db = \Config\Database::connect(); } public function dashboard() { $schoolYear = trim((string) ($this->request->getGet('school_year') ?? $this->currentSchoolYearName((string) ($this->schoolYear ?? '')))); $status = trim((string) ($this->request->getGet('status') ?? 'open')); $flagType = trim((string) ($this->request->getGet('flag_type') ?? '')); $assignedTo = trim((string) ($this->request->getGet('assigned_to') ?? '')); $exceptionParentId = (int) ($this->request->getGet('exception_parent_id') ?? 0); $canManageExceptions = $this->canManageEnrollmentExceptions(); service('enrollmentTransition')->syncDashboardBlockageFlags($schoolYear); $flags = $this->enrollmentFlags($schoolYear, $status, $flagType, $assignedTo); $openFlags = ($status === 'open' && $flagType === '' && $assignedTo === '') ? $flags : $this->enrollmentFlags($schoolYear, 'open', '', ''); return view('administrator/enrollment_admin_dashboard', [ 'flags' => $flags, 'schoolYear' => $schoolYear, 'status' => $status, 'flagType' => $flagType, 'assignedTo' => $assignedTo, 'flagTypes' => $this->flagTypes(), 'schoolYears' => $this->schoolYears(), 'classSections' => $this->classSections($schoolYear), 'admins' => $this->adminUsers(), 'auditRows' => $this->auditRows($schoolYear), 'activeExceptions' => $canManageExceptions ? $this->enrollmentExceptions($schoolYear) : [], 'exceptionNeeded' => $this->exceptionNeededFromFlags($openFlags), 'canManageEnrollmentExceptions' => $canManageExceptions, 'exceptionFamilies' => $this->exceptionFamilies(), 'exceptionReasonCodes' => $this->exceptionReasonCodes(), 'exceptionSelectedParentId' => $exceptionParentId, 'exceptionPreview' => $this->exceptionPreview($exceptionParentId, $schoolYear), 'launchState' => $this->launchState($schoolYear), 'previewParentId' => $this->firstParentWithStudents(), 'emailExamples' => service('enrollmentRegistrationEmail')->previewExamplesForSchoolYear($schoolYear), 'openFlagCount' => $this->openFlagCount($schoolYear), ]); } public function approveLaunch() { $schoolYear = trim((string) ($this->request->getPost('school_year') ?? '')); if ($schoolYear === '') { return redirect()->back()->with('error', 'School year is required.'); } if (! $this->launchConfigurationComplete($schoolYear, $missing)) { return redirect()->back()->with('error', 'Registration launch is not ready: ' . implode(', ', $missing)); } $this->db->table('school_years') ->where('name', $schoolYear) ->update([ 'registration_launch_approved_at' => date('Y-m-d H:i:s'), 'registration_launch_approved_by' => $this->userId(), 'registration_email_template_version' => \App\Services\EnrollmentRegistrationEmailService::TEMPLATE_VERSION, 'updated_at' => date('Y-m-d H:i:s'), ]); return redirect()->back()->with('success', 'Registration launch approved for ' . $schoolYear . '.'); } public function sendRegistrationEmails() { $schoolYear = trim((string) ($this->request->getPost('school_year') ?? '')); if ($schoolYear === '') { return redirect()->back()->with('error', 'School year is required.'); } if (! $this->launchConfigurationComplete($schoolYear, $missing)) { return redirect()->back()->with('error', 'Registration launch is not ready: ' . implode(', ', $missing)); } $failedOnly = (bool) $this->request->getPost('failed_only'); $force = ! $failedOnly && (bool) $this->request->getPost('force_resend'); $result = service('enrollmentRegistrationEmail')->sendForSchoolYearName($schoolYear, $force, $failedOnly); $message = sprintf( $failedOnly ? 'Failed registration emails retried for %s: %d sent, %d failed, %d skipped.' : 'Registration emails processed for %s: %d sent, %d failed, %d skipped.', $schoolYear, (int) ($result['sent'] ?? 0), (int) ($result['failed'] ?? 0), (int) ($result['skipped'] ?? 0) ); $details = array_filter(array_map('strval', $result['messages'] ?? [])); if ($details !== []) { $message .= ' ' . implode(' ', $details); } return redirect()->back()->with(((int) ($result['failed'] ?? 0) > 0) ? 'error' : 'success', $message); } public function previewEmail() { $schoolYear = trim((string) ($this->request->getGet('school_year') ?? '')); $parentId = (int) ($this->request->getGet('parent_id') ?? 0); if ($schoolYear === '' || $parentId <= 0) { return redirect()->back()->with('error', 'School year and parent are required for preview.'); } $message = service('enrollmentRegistrationEmail')->previewForParent($schoolYear, $parentId); if ($message === null) { return redirect()->back()->with('error', 'No preview email could be generated for that parent.'); } return view('administrator/enrollment_email_preview', [ 'schoolYear' => $schoolYear, 'parentId' => $parentId, 'subject' => $message['subject'], 'body' => $message['body'], ]); } public function resolveFlag(int $id) { try { $flag = $this->requireFlag($id); $notes = trim((string) ($this->request->getPost('resolution_notes') ?? '')); if ($notes === '') { return redirect()->back()->with('error', 'Resolution notes are required.'); } $this->resolveFlagRow($flag, $notes, 'flag_resolved'); return redirect()->back()->with('success', 'Enrollment flag resolved.'); } catch (Throwable $e) { return redirect()->back()->with('error', $e->getMessage()); } } public function assignClass(int $id) { try { $flag = $this->requireFlag($id); $sectionId = (int) ($this->request->getPost('class_section_id') ?? 0); $notes = trim((string) ($this->request->getPost('resolution_notes') ?? '')); if ($sectionId <= 0) { return redirect()->back()->with('error', 'Select a target class section.'); } if ($notes === '') { return redirect()->back()->with('error', 'Resolution notes are required.'); } $this->applyClassSection((int) $flag['student_id'], (string) $flag['school_year'], $sectionId, 'manual_class_assignment'); $this->resolveFlagRow($flag, $notes, 'manual_class_assignment', ['class_section_id' => $sectionId]); return redirect()->back()->with('success', 'Class assignment applied.'); } catch (Throwable $e) { return redirect()->back()->with('error', $e->getMessage()); } } public function confirmMakeupPromotion(int $id) { try { $flag = $this->requireFlag($id); if ((string) ($flag['flag_type'] ?? '') !== 'PENDING_MAKE_UP_EXAM_PROMOTION') { return redirect()->back()->with('error', 'This flag is not a make-up exam promotion flag.'); } $sectionId = (int) ($this->request->getPost('class_section_id') ?? 0); $examResult = trim((string) ($this->request->getPost('exam_result') ?? '')); $notes = trim((string) ($this->request->getPost('resolution_notes') ?? '')); if (! in_array($examResult, ['passed', 'failed'], true)) { return redirect()->back()->with('error', 'Select whether the make-up exam was passed or failed.'); } if ($notes === '') { return redirect()->back()->with('error', 'Resolution notes are required.'); } if ($examResult === 'passed') { if ($sectionId <= 0) { return redirect()->back()->with('error', 'Select the promoted class section.'); } $this->applyClassSection((int) $flag['student_id'], (string) $flag['school_year'], $sectionId, 'make_up_exam_promotion_completed', 'promotion_completed'); $this->resolveFlagRow($flag, $notes, 'make_up_exam_promotion_completed', [ 'exam_result' => $examResult, 'class_section_id' => $sectionId, ]); } else { $this->updateEnrollmentPlacementStatus((int) $flag['student_id'], (string) $flag['school_year'], 'no_promotion_required'); $this->resolveFlagRow($flag, $notes, 'make_up_exam_no_promotion_required', [ 'exam_result' => $examResult, ]); } return redirect()->back()->with('success', 'Make-up exam follow-up resolved.'); } catch (Throwable $e) { return redirect()->back()->with('error', $e->getMessage()); } } public function approveException(int $id) { try { if (! $this->canManageEnrollmentExceptions()) { return redirect()->back()->with('error', 'You do not have permission to manage enrollment exceptions.'); } $flag = $this->requireFlag($id); $reason = trim((string) ($this->request->getPost('reason') ?? '')); if ($reason === '') { return redirect()->back()->with('error', 'Approval reason is required.'); } $parentId = $this->parentIdForStudent((int) $flag['student_id'], (string) $flag['school_year']); if ($parentId <= 0) { return redirect()->back()->with('error', 'Unable to create exception because no linked parent was found.'); } $ruleCodes = $this->ruleCodesForFlag((string) $flag['flag_type']); if ( (string) $flag['flag_type'] === 'FINANCIAL_REVIEW_REQUIRED' && (string) ($this->request->getPost('allow_current_year_installments') ?? '') === '1' ) { $ruleCodes[] = 'CURRENT_YEAR_INSTALLMENT_OVERRIDE'; $ruleCodes = array_values(array_unique($ruleCodes)); } $now = date('Y-m-d H:i:s'); $this->db->transStart(); if ($this->db->tableExists('enrollment_exceptions')) { $existing = $this->db->table('enrollment_exceptions') ->where('parent_id', $parentId) ->where('student_id', (int) $flag['student_id']) ->where('school_year', (string) $flag['school_year']) ->where('status', 'active') ->limit(1) ->get() ->getRowArray(); $payload = [ 'parent_id' => $parentId, 'student_id' => (int) $flag['student_id'], 'school_year' => (string) $flag['school_year'], 'source_school_year' => $flag['source_school_year'] ?? null, 'status' => 'active', 'reason_code' => (string) $flag['flag_type'], 'reason_note' => $reason, 'bypassed_rule_codes_json' => json_encode($ruleCodes, JSON_UNESCAPED_SLASHES), 'family_student_ids_json' => json_encode([(int) $flag['student_id']], JSON_UNESCAPED_SLASHES), 'created_by' => $this->userId(), 'approved_by' => $this->userId(), 'starts_at' => $now, 'expires_at' => null, 'updated_at' => $now, ]; if ($existing !== null) { $this->db->table('enrollment_exceptions')->where('id', (int) $existing['id'])->update($payload); } else { $payload['created_at'] = $now; $this->db->table('enrollment_exceptions')->insert($payload); } } $this->db->table('enrollments') ->where('student_id', (int) $flag['student_id']) ->where('school_year', (string) $flag['school_year']) ->update([ 'exception_required' => 0, 'exception_reason' => $reason, 'updated_at' => $now, ]); $this->resolveFlagRow($flag, $reason, 'enrollment_exception_approved'); $this->db->transComplete(); if ($this->db->transStatus() === false) { return redirect()->back()->with('error', 'Unable to approve enrollment exception.'); } return redirect()->back()->with('success', 'Enrollment exception approved.'); } catch (Throwable $e) { return redirect()->back()->with('error', $e->getMessage()); } } public function createException() { try { if (! $this->canManageEnrollmentExceptions()) { return redirect()->back()->with('error', 'You do not have permission to manage enrollment exceptions.'); } if (! $this->db->tableExists('enrollment_exceptions')) { return redirect()->back()->with('error', 'Enrollment exception storage is not available. Run migrations first.'); } $parentId = (int) ($this->request->getPost('parent_id') ?? 0); $studentIds = $this->request->getPost('student_ids') ?? []; if (! is_array($studentIds)) { $studentIds = [$this->request->getPost('student_id') ?? 0]; } $studentIds = array_values(array_unique(array_filter(array_map('intval', $studentIds), static fn (int $id): bool => $id > 0))); $schoolYear = trim((string) ($this->request->getPost('school_year') ?? '')); $reasonCode = trim((string) ($this->request->getPost('reason_code') ?? '')); $reasonNote = trim((string) ($this->request->getPost('reason_note') ?? '')); $postedCodesByStudent = $this->request->getPost('bypassed_rule_codes_by_student') ?? []; $postedCodesByStudent = is_array($postedCodesByStudent) ? $postedCodesByStudent : []; if ($parentId <= 0 || $studentIds === [] || $schoolYear === '' || $reasonCode === '' || $reasonNote === '') { return redirect()->back()->withInput()->with('error', 'Parent, at least one student, school year, reason code, and note are required.'); } $sourceSchoolYear = $this->previousSchoolYearName($schoolYear); if ($sourceSchoolYear === null) { return redirect()->back()->withInput()->with('error', 'Unable to determine the source school year.'); } $now = date('Y-m-d H:i:s'); $transitionService = service('enrollmentTransition'); $saved = 0; $this->db->transStart(); foreach ($studentIds as $studentId) { if (! $this->studentLinkedToParent($studentId, $parentId)) { $this->db->transRollback(); return redirect()->back()->withInput()->with('error', 'Selected student is not linked to the selected parent.'); } $evaluation = $transitionService->evaluateForParent($parentId, $studentId, $sourceSchoolYear, $schoolYear, 'admin'); $failedCodes = array_values(array_unique(array_filter(array_map('strval', array_merge( $evaluation['blocking_rule_codes'] ?? [], $evaluation['review_rule_codes'] ?? [] ))))); $postedCodes = $postedCodesByStudent[(string) $studentId] ?? $postedCodesByStudent[$studentId] ?? []; $postedCodes = is_array($postedCodes) ? array_values(array_filter(array_map('strval', $postedCodes))) : []; $postedCodes = array_values(array_unique(array_map(static fn (string $code): string => strtoupper(trim($code)), $postedCodes))); $failedCodes = array_values(array_unique(array_map(static fn (string $code): string => strtoupper(trim($code)), $failedCodes))); $allowedCodes = $failedCodes; $allowedCodes[] = 'CURRENT_YEAR_INSTALLMENT_OVERRIDE'; $ruleCodes = $postedCodes !== [] ? array_values(array_intersect($postedCodes, $allowedCodes)) : $failedCodes; $ruleCodes = array_values(array_filter($ruleCodes, static fn (string $code): bool => $code !== '')); $nonOverridable = array_values(array_intersect($ruleCodes, ['ADULT_STUDENT_PARENT_BLOCKED'])); if ($nonOverridable !== []) { $this->db->transRollback(); return redirect()->back()->withInput()->with('error', 'These rule(s) cannot be bypassed: ' . implode(', ', $nonOverridable)); } if ($ruleCodes === []) { $this->db->transRollback(); return redirect()->back()->withInput()->with('error', 'No failed eligibility rule codes were selected for one or more selected students.'); } $existing = $this->db->table('enrollment_exceptions') ->where('parent_id', $parentId) ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->where('status', 'active') ->limit(1) ->get() ->getRowArray(); $payload = [ 'parent_id' => $parentId, 'student_id' => $studentId, 'school_year' => $schoolYear, 'source_school_year' => $sourceSchoolYear, 'status' => 'active', 'reason_code' => $reasonCode, 'reason_note' => $reasonNote, 'bypassed_rule_codes_json' => json_encode($ruleCodes, JSON_UNESCAPED_SLASHES), 'family_student_ids_json' => json_encode([$studentId], JSON_UNESCAPED_SLASHES), 'created_by' => $this->userId(), 'approved_by' => $this->userId(), 'starts_at' => $now, 'expires_at' => null, 'updated_at' => $now, ]; if ($existing !== null) { $this->db->table('enrollment_exceptions')->where('id', (int) $existing['id'])->update($payload); $exceptionId = (int) $existing['id']; } else { $payload['created_at'] = $now; $this->db->table('enrollment_exceptions')->insert($payload); $exceptionId = (int) $this->db->insertID(); } $this->audit($studentId, $schoolYear, $sourceSchoolYear, 'enrollment_exception_created', $existing, array_merge($payload, [ 'id' => $exceptionId, 'evaluation_decision' => $evaluation['decision'] ?? null, ]), $reasonNote); $this->resolveCoveredFlags($studentId, $schoolYear, $ruleCodes, $reasonNote); $saved++; } $this->db->transComplete(); if ($this->db->transStatus() === false) { return redirect()->back()->withInput()->with('error', 'Unable to save enrollment exception.'); } return redirect()->to(site_url('administrator/enrollment-admin?school_year=' . rawurlencode($schoolYear) . '&exception_parent_id=' . $parentId)) ->with('success', $saved . ' enrollment exception' . ($saved === 1 ? '' : 's') . ' saved.'); } catch (Throwable $e) { return redirect()->back()->withInput()->with('error', $e->getMessage()); } } public function revokeException(int $id) { if (! $this->canManageEnrollmentExceptions()) { return redirect()->back()->with('error', 'You do not have permission to manage enrollment exceptions.'); } if ($id <= 0 || ! $this->db->tableExists('enrollment_exceptions')) { return redirect()->back()->with('error', 'Enrollment exception was not found.'); } $reason = trim((string) ($this->request->getPost('revocation_reason') ?? '')); if ($reason === '') { return redirect()->back()->with('error', 'Revocation reason is required.'); } $exception = $this->db->table('enrollment_exceptions')->where('id', $id)->limit(1)->get()->getRowArray(); if ($exception === null || (string) ($exception['status'] ?? '') !== 'active') { return redirect()->back()->with('error', 'Only active exceptions can be revoked.'); } $this->db->table('enrollment_exceptions')->where('id', $id)->update([ 'status' => 'revoked', 'revoked_at' => date('Y-m-d H:i:s'), 'revoked_by' => $this->userId(), 'revocation_reason' => $reason, 'updated_at' => date('Y-m-d H:i:s'), ]); $this->audit( (int) $exception['student_id'], (string) $exception['school_year'], (string) ($exception['source_school_year'] ?? ''), 'enrollment_exception_revoked', $exception, ['status' => 'revoked', 'revocation_reason' => $reason], $reason ); return redirect()->back()->with('success', 'Enrollment exception revoked.'); } private function enrollmentFlags(string $schoolYear, string $status, string $flagType, string $assignedTo): array { if (! $this->db->tableExists('enrollment_flags')) { return []; } $builder = $this->db->table('enrollment_flags ef') ->select('ef.*') ->select('s.firstname, s.lastname, s.school_id, s.parent_id') ->select('u.firstname AS assignee_firstname, u.lastname AS assignee_lastname') ->select('p.firstname AS parent_firstname, p.lastname AS parent_lastname') ->join('students s', 's.id = ef.student_id', 'left') ->join('users u', 'u.id = ef.assigned_to', 'left') ->join('users p', 'p.id = s.parent_id', 'left') ->orderBy('ef.created_at', 'DESC') ->orderBy('ef.id', 'DESC'); if ($schoolYear !== '') { $builder->where('ef.school_year', $schoolYear); } if ($status !== '') { $builder->where('ef.status', $status); } if ($flagType !== '') { $builder->where('ef.flag_type', $flagType); } if (is_numeric($assignedTo) && (int) $assignedTo > 0) { $builder->where('ef.assigned_to', (int) $assignedTo); } $ignoredFlagTypes = ['CLASS_CAPACITY_EXCEPTION_REQUIRED', 'CLASS_REASSIGNMENT_REQUIRED']; $builder->whereNotIn('ef.flag_type', $ignoredFlagTypes); $rows = $builder->get()->getResultArray(); $rows = array_values(array_filter( $rows, static fn (array $row): bool => ! in_array((string) ($row['flag_type'] ?? ''), $ignoredFlagTypes, true) )); foreach ($rows as &$row) { $row['student_name'] = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Student #' . (int) ($row['student_id'] ?? 0); $row['assignee_name'] = trim((string) ($row['assignee_firstname'] ?? '') . ' ' . (string) ($row['assignee_lastname'] ?? '')); $row['parent_name'] = trim((string) ($row['parent_firstname'] ?? '') . ' ' . (string) ($row['parent_lastname'] ?? '')) ?: ((int) ($row['parent_id'] ?? 0) > 0 ? 'Parent #' . (int) $row['parent_id'] : ''); $row['details'] = json_decode((string) ($row['details_json'] ?? ''), true) ?: []; if (! isset($row['details']['rule_code'])) { $ruleCode = $this->ruleCodeForExistingFlag($row); if ($ruleCode !== null) { $row['details']['rule_code'] = $ruleCode; } } } unset($row); return $rows; } private function ruleCodeForExistingFlag(array $flag): ?string { $type = strtoupper(trim((string) ($flag['flag_type'] ?? ''))); if (! in_array($type, ['DEFERRED_DELIBERATION', 'RESTRICTED_ADMINISTRATIVE_REVIEW', 'WITHDRAWAL_REVIEW_REQUIRED', 'PENDING_MAKE_UP_EXAM_PROMOTION'], true)) { return null; } $studentId = (int) ($flag['student_id'] ?? 0); $sourceSchoolYear = trim((string) ($flag['source_school_year'] ?? '')); if ($studentId <= 0 || $sourceSchoolYear === '' || ! $this->db->tableExists('student_decisions')) { return $type === 'WITHDRAWAL_REVIEW_REQUIRED' ? 'WITHDRAWN' : null; } $select = ['decision']; if ($this->db->fieldExists('deliberation_decision_standard', 'student_decisions')) { $select[] = 'deliberation_decision_standard'; } $row = $this->db->table('student_decisions') ->select($select) ->where('student_id', $studentId) ->where('school_year', $sourceSchoolYear) ->orderBy('updated_at', 'DESC') ->orderBy('id', 'DESC') ->limit(1) ->get() ->getRowArray(); if ($row === null) { return match ($type) { 'DEFERRED_DELIBERATION' => 'NO_FINAL_DECISION', 'WITHDRAWAL_REVIEW_REQUIRED' => 'WITHDRAWN', default => null, }; } $decision = DeliberationDecision::normalize($row['deliberation_decision_standard'] ?? null) ?? DeliberationDecision::normalize($row['decision'] ?? null); return match ($decision) { DeliberationDecision::DEFERRED_DECISION => 'DEFERRED_DECISION', DeliberationDecision::EXPELLED => 'EXPELLED', DeliberationDecision::WITHDRAWN => 'WITHDRAWN', DeliberationDecision::MAKE_UP_EXAM => 'MAKE_UP_EXAM', null => $type === 'DEFERRED_DELIBERATION' ? 'UNRECOGNIZED_DECISION' : null, default => null, }; } private function enrollmentFollowups(string $schoolYear, array $flags = []): array { if (! $this->db->tableExists('enrollments')) { return $this->followupsFromFlags($flags, []); } $fields = $this->db->getFieldNames('enrollments'); $select = [ 'e.id', 'e.student_id', 'e.school_year', 'e.enrollment_status', 'e.class_section_id', 'e.updated_at', 's.firstname', 's.lastname', 's.school_id', 'cs.class_section_name', ]; foreach ([ 'deliberation_decision', 'placement_status', 'exception_required', 'exception_reason', 'source_school_year', 'assigned_class_section_id', 'age_on_reference_date', ] as $field) { if (in_array($field, $fields, true)) { $select[] = 'e.' . $field; } } $builder = $this->db->table('enrollments e') ->select(implode(', ', $select)) ->join('students s', 's.id = e.student_id', 'left') ->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left') ->orderBy('e.updated_at', 'DESC') ->orderBy('e.id', 'DESC') ->limit(200); if ($schoolYear !== '') { $builder->where('e.school_year', $schoolYear); } $builder->groupStart() ->whereIn('e.enrollment_status', [ 'review & decision', 'admission under review', 'waitlist', 'denied', 'Review & Decision', 'Admission Under Review', 'Waitlist', 'Denied', ]); if (in_array('placement_status', $fields, true)) { $builder->orWhereIn('e.placement_status', [ 'temporary_same_grade', 'temporary_manual_class_required', 'manual_class_required', 'exit_required', 'automatic_distribution_pending', ]); } if (in_array('exception_required', $fields, true)) { $builder->orWhere('e.exception_required', 1); } if (in_array('deliberation_decision', $fields, true)) { $builder->orWhereIn('e.deliberation_decision', [ 'make_up_exam', 'MAKE_UP_EXAM', 'repeat_class', 'REPEAT_CLASS', 'deferred', 'DEFERRED', 'DEFERRED_DECISION', 'expelled', 'EXPELLED', 'withdrawn', 'WITHDRAWN', ]); } $rows = $builder->groupEnd()->get()->getResultArray(); foreach ($rows as &$row) { $row['student_name'] = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Student #' . (int) ($row['student_id'] ?? 0); } unset($row); return $this->followupsFromFlags($flags, $rows); } private function followupsFromFlags(array $flags, array $rows): array { $seen = []; foreach ($rows as $row) { $seen[(int) ($row['student_id'] ?? 0)] = true; } $followupTypes = [ 'PENDING_MAKE_UP_EXAM_PROMOTION' => 'temporary_same_grade', 'COMPLETION_OR_EXIT_PROCESS_REQUIRED' => 'exit_required', ]; foreach ($flags as $flag) { $type = (string) ($flag['flag_type'] ?? ''); $studentId = (int) ($flag['student_id'] ?? 0); if ($studentId <= 0 || isset($seen[$studentId]) || ! isset($followupTypes[$type])) { continue; } $seen[$studentId] = true; $rows[] = [ 'id' => 0, 'student_id' => $studentId, 'student_name' => (string) ($flag['student_name'] ?? ''), 'school_id' => (string) ($flag['school_id'] ?? ''), 'enrollment_status' => 'not enrolled', 'deliberation_decision' => $type === 'PENDING_MAKE_UP_EXAM_PROMOTION' ? 'MAKE_UP_EXAM' : '', 'placement_status' => $followupTypes[$type], 'class_section_name' => '', 'exception_required' => 0, 'exception_reason' => $type, 'updated_at' => $flag['created_at'] ?? null, ]; } return $rows; } private function exceptionNeededFromFlags(array $flags): array { $exceptionTypes = [ 'AGE_EXCEPTION_REQUIRED', 'LATE_REGISTRATION_EXCEPTION', 'FINANCIAL_REVIEW_REQUIRED', 'SIBLING_LAST_NAME_MISMATCH', 'DEFERRED_DELIBERATION', 'WITHDRAWAL_REVIEW_REQUIRED', 'RESTRICTED_ADMINISTRATIVE_REVIEW', ]; $needed = []; foreach ($flags as $flag) { if (($flag['status'] ?? 'open') !== 'open') { continue; } if (! in_array((string) ($flag['flag_type'] ?? ''), $exceptionTypes, true)) { continue; } $needed[] = $flag; } return $needed; } private function requireFlag(int $id): array { if ($id <= 0 || ! $this->db->tableExists('enrollment_flags')) { throw new \RuntimeException('Enrollment flag was not found.'); } $flag = $this->db->table('enrollment_flags')->where('id', $id)->limit(1)->get()->getRowArray(); if ($flag === null) { throw new \RuntimeException('Enrollment flag was not found.'); } return $flag; } private function resolveFlagRow(array $flag, string $notes, string $auditAction, array $metadata = []): void { $this->db->transStart(); $original = $flag; $this->db->table('enrollment_flags') ->where('id', (int) $flag['id']) ->update([ 'status' => 'resolved', 'resolved_at' => date('Y-m-d H:i:s'), 'resolution_notes' => $notes, ]); $this->audit((int) $flag['student_id'], (string) $flag['school_year'], (string) ($flag['source_school_year'] ?? ''), $auditAction, $original, array_merge($metadata, [ 'flag_id' => (int) $flag['id'], 'flag_type' => (string) $flag['flag_type'], 'resolution_notes' => $notes, ]), $notes); $this->db->transComplete(); if ($this->db->transStatus() === false) { throw new \RuntimeException('Unable to resolve enrollment flag.'); } } private function resolveCoveredFlags(int $studentId, string $schoolYear, array $ruleCodes, string $notes): void { if ($studentId <= 0 || $schoolYear === '' || $ruleCodes === [] || ! $this->db->tableExists('enrollment_flags')) { return; } $ruleCodes = array_values(array_unique(array_map(static fn (string $code): string => strtoupper(trim($code)), $ruleCodes))); $flags = $this->db->table('enrollment_flags') ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->where('status', 'open') ->get() ->getResultArray(); foreach ($flags as $flag) { $flagType = strtoupper(trim((string) ($flag['flag_type'] ?? ''))); $mapped = array_map('strtoupper', $this->ruleCodesForFlag($flagType)); if (in_array($flagType, $ruleCodes, true) || array_intersect($mapped, $ruleCodes) !== []) { $this->resolveFlagRow($flag, $notes, 'enrollment_exception_created'); } } } private function applyClassSection(int $studentId, string $schoolYear, int $sectionId, string $auditAction, string $placementStatus = 'manual_class_assigned'): void { $section = $this->db->table('classSection') ->select('class_section_id, class_id, class_section_name') ->where('class_section_id', $sectionId) ->orderBy('id', 'DESC') ->limit(1) ->get() ->getRowArray(); if ($section === null) { throw new \RuntimeException('Selected class section was not found.'); } $originalEnrollment = service('enrollmentStatus')->controllingEnrollment($studentId, $schoolYear); $payload = [ 'class_section_id' => $sectionId, 'assigned_class_section_id' => $sectionId, 'assigned_grade_id' => (int) ($section['class_id'] ?? 0) ?: null, 'placement_status' => $placementStatus, 'updated_at' => date('Y-m-d H:i:s'), ]; $this->db->table('enrollments') ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->update($payload); $studentClass = $this->db->table('student_class') ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->orderBy('id', 'DESC') ->limit(1) ->get() ->getRowArray(); $studentClassPayload = [ 'student_id' => $studentId, 'class_section_id' => $sectionId, 'school_year' => $schoolYear, 'updated_by' => $this->userId(), 'updated_at' => date('Y-m-d H:i:s'), ]; if ($studentClass !== null) { $this->db->table('student_class')->where('id', (int) $studentClass['id'])->update($studentClassPayload); } else { $studentClassPayload['created_at'] = date('Y-m-d H:i:s'); $this->db->table('student_class')->insert($studentClassPayload); } $this->audit($studentId, $schoolYear, (string) ($originalEnrollment['source_school_year'] ?? ''), $auditAction, $originalEnrollment, $payload, 'Class section assigned by administrator.'); $parentId = (int) ($originalEnrollment['parent_id'] ?? 0); if ($parentId <= 0) { $student = $this->db->table('students')->select('parent_id')->where('id', $studentId)->limit(1)->get()->getRowArray(); $parentId = (int) ($student['parent_id'] ?? 0); } if ($parentId > 0) { try { (new InvoiceController())->generateInvoice( (string) $parentId, $schoolYear, (string) ($originalEnrollment['semester'] ?? getSemester()) ); } catch (\Throwable $e) { log_message('error', 'Invoice refresh after enrollment admin class assignment failed for parent {parent_id}: {error}', [ 'parent_id' => $parentId, 'error' => $e->getMessage(), ]); } } } private function updateEnrollmentPlacementStatus(int $studentId, string $schoolYear, string $placementStatus): void { $this->db->table('enrollments') ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->update([ 'placement_status' => $placementStatus, 'updated_at' => date('Y-m-d H:i:s'), ]); } private function latestEnrollment(int $studentId, string $schoolYear): ?array { return service('enrollmentStatus')->controllingEnrollment($studentId, $schoolYear); } private function audit(int $studentId, string $schoolYear, string $sourceSchoolYear, string $action, ?array $original, array $new, string $reason): void { if (! $this->db->tableExists('enrollment_transition_audits')) { return; } $this->db->table('enrollment_transition_audits')->insert([ 'student_id' => $studentId, 'school_year' => $schoolYear, 'source_school_year' => $sourceSchoolYear !== '' ? $sourceSchoolYear : null, 'action' => $action, 'performed_by' => $this->userId(), 'original_values_json' => $original !== null ? json_encode($original, JSON_UNESCAPED_SLASHES) : null, 'new_values_json' => json_encode($new, JSON_UNESCAPED_SLASHES), 'reason' => $reason, 'created_at' => date('Y-m-d H:i:s'), ]); } private function auditRows(string $schoolYear): array { if (! $this->db->tableExists('enrollment_transition_audits')) { return []; } $builder = $this->db->table('enrollment_transition_audits eta') ->select('eta.*') ->select('s.firstname, s.lastname') ->select('u.firstname AS user_firstname, u.lastname AS user_lastname') ->join('students s', 's.id = eta.student_id', 'left') ->join('users u', 'u.id = eta.performed_by', 'left') ->orderBy('eta.created_at', 'DESC') ->limit(50); if ($schoolYear !== '') { $builder->where('eta.school_year', $schoolYear); } $rows = $builder->get()->getResultArray(); foreach ($rows as &$row) { $row['student_name'] = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Student #' . (int) ($row['student_id'] ?? 0); $row['performed_by_name'] = trim((string) ($row['user_firstname'] ?? '') . ' ' . (string) ($row['user_lastname'] ?? '')) ?: ((int) ($row['performed_by'] ?? 0) > 0 ? 'User #' . (int) $row['performed_by'] : ''); } unset($row); return $rows; } private function enrollmentExceptions(string $schoolYear): array { if (! $this->db->tableExists('enrollment_exceptions')) { return []; } $builder = $this->db->table('enrollment_exceptions ee') ->select('ee.*') ->select('s.firstname, s.lastname, s.school_id') ->select('u.firstname AS parent_firstname, u.lastname AS parent_lastname') ->select('admin.firstname AS admin_firstname, admin.lastname AS admin_lastname') ->join('students s', 's.id = ee.student_id', 'left') ->join('users u', 'u.id = ee.parent_id', 'left') ->join('users admin', 'admin.id = ee.created_by', 'left') ->orderBy('ee.created_at', 'DESC') ->orderBy('ee.id', 'DESC') ->limit(100); if ($schoolYear !== '') { $builder->where('ee.school_year', $schoolYear); } $rows = $builder->get()->getResultArray(); foreach ($rows as &$row) { $row['student_name'] = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Student #' . (int) ($row['student_id'] ?? 0); $row['parent_name'] = trim((string) ($row['parent_firstname'] ?? '') . ' ' . (string) ($row['parent_lastname'] ?? '')) ?: 'Parent #' . (int) ($row['parent_id'] ?? 0); $row['created_by_name'] = trim((string) ($row['admin_firstname'] ?? '') . ' ' . (string) ($row['admin_lastname'] ?? '')) ?: ((int) ($row['created_by'] ?? 0) > 0 ? 'User #' . (int) $row['created_by'] : ''); $decoded = json_decode((string) ($row['bypassed_rule_codes_json'] ?? ''), true); $row['bypassed_rule_codes'] = is_array($decoded) ? array_values(array_map('strval', $decoded)) : []; } unset($row); return $rows; } private function exceptionFamilies(): array { if (! $this->db->tableExists('students') || ! $this->db->tableExists('users')) { return []; } $rows = $this->db->table('students s') ->select('s.id AS student_id, s.firstname AS student_firstname, s.lastname AS student_lastname, s.school_id, s.parent_id') ->select('u.firstname AS parent_firstname, u.lastname AS parent_lastname, u.email AS parent_email') ->join('users u', 'u.id = s.parent_id', 'left') ->where('s.parent_id >', 0) ->orderBy('u.lastname', 'ASC') ->orderBy('u.firstname', 'ASC') ->orderBy('s.lastname', 'ASC') ->orderBy('s.firstname', 'ASC') ->limit(1000) ->get() ->getResultArray(); $families = []; foreach ($rows as $row) { $parentId = (int) ($row['parent_id'] ?? 0); if ($parentId <= 0) { continue; } if (! isset($families[$parentId])) { $parentName = trim((string) ($row['parent_firstname'] ?? '') . ' ' . (string) ($row['parent_lastname'] ?? '')); $families[$parentId] = [ 'parent_id' => $parentId, 'parent_name' => $parentName !== '' ? $parentName : 'Parent #' . $parentId, 'parent_email' => (string) ($row['parent_email'] ?? ''), 'students' => [], ]; } $studentId = (int) ($row['student_id'] ?? 0); $studentName = trim((string) ($row['student_firstname'] ?? '') . ' ' . (string) ($row['student_lastname'] ?? '')); $families[$parentId]['students'][] = [ 'student_id' => $studentId, 'student_name' => $studentName !== '' ? $studentName : 'Student #' . $studentId, 'school_id' => (string) ($row['school_id'] ?? ''), ]; } $families = array_values($families); usort($families, static function (array $left, array $right): int { return strnatcasecmp( (string) ($left['parent_name'] ?? ''), (string) ($right['parent_name'] ?? '') ); }); return $families; } private function exceptionReasonCodes(): array { return [ 'ADMIN_REVIEW_APPROVED' => 'Administrative review approved', 'FINANCE_APPROVAL_REQUIRED' => 'Finance approval', 'SIBLING_LAST_NAME_REVIEWED' => 'Sibling last name reviewed', 'AGE_EXCEPTION_APPROVED' => 'Age exception approved', 'LATE_REGISTRATION_APPROVED' => 'Late registration approved', 'ACADEMIC_STATUS_EXCEPTION' => 'Academic status exception', 'MANUAL_PLACEMENT_EXCEPTION' => 'Manual placement exception', ]; } private function exceptionPreview(int $parentId, string $schoolYear): ?array { if ($parentId <= 0 || $schoolYear === '') { return null; } $sourceSchoolYear = $this->previousSchoolYearName($schoolYear); if ($sourceSchoolYear === null) { return [ 'error' => 'Unable to determine the previous school year for ' . $schoolYear . '.', ]; } $parent = $this->db->table('users u') ->select('u.firstname, u.lastname, u.email') ->where('u.id', $parentId) ->limit(1) ->get() ->getRowArray() ?: []; $studentRows = $this->db->table('students s') ->select('s.id, s.firstname, s.lastname, s.school_id') ->where('s.parent_id', $parentId) ->orderBy('s.lastname', 'ASC') ->orderBy('s.firstname', 'ASC') ->get() ->getResultArray(); $students = []; foreach ($studentRows as $student) { $linkedStudentId = (int) ($student['id'] ?? 0); if ($linkedStudentId <= 0) { continue; } $students[] = [ 'student_id' => $linkedStudentId, 'student_name' => trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')) ?: 'Student #' . $linkedStudentId, 'school_id' => (string) ($student['school_id'] ?? ''), 'evaluation' => service('enrollmentTransition')->evaluateForParent($parentId, $linkedStudentId, $sourceSchoolYear, $schoolYear, 'admin'), ]; } return [ 'parent_id' => $parentId, 'school_year' => $schoolYear, 'source_school_year' => $sourceSchoolYear, 'parent_name' => trim((string) ($parent['firstname'] ?? '') . ' ' . (string) ($parent['lastname'] ?? '')) ?: 'Parent #' . $parentId, 'parent_email' => (string) ($parent['email'] ?? ''), 'students' => $students, ]; } private function studentLinkedToParent(int $studentId, int $parentId): bool { if ($studentId <= 0 || $parentId <= 0 || ! $this->db->tableExists('students')) { return false; } return $this->db->table('students') ->where('id', $studentId) ->where('parent_id', $parentId) ->countAllResults() > 0; } private function previousSchoolYearName(string $schoolYear): ?string { $schoolYear = trim($schoolYear); if (! preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $matches)) { return null; } return ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1); } private function parentIdForStudent(int $studentId, string $schoolYear): int { if ($studentId <= 0) { return 0; } if ($schoolYear !== '' && $this->db->tableExists('enrollments')) { $row = $this->db->table('enrollments') ->select('parent_id') ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->orderBy('updated_at', 'DESC') ->orderBy('id', 'DESC') ->limit(1) ->get() ->getRowArray(); if ((int) ($row['parent_id'] ?? 0) > 0) { return (int) $row['parent_id']; } } $student = $this->db->table('students') ->select('parent_id') ->where('id', $studentId) ->limit(1) ->get() ->getRowArray(); return (int) ($student['parent_id'] ?? 0); } private function ruleCodesForFlag(string $flagType): array { return match ($flagType) { 'AGE_EXCEPTION_REQUIRED' => ['AGE_RULE_BLOCKED'], 'LATE_REGISTRATION_EXCEPTION' => ['REGISTRATION_CLOSED'], 'FINANCIAL_REVIEW_REQUIRED' => ['OUTSTANDING_BALANCE_BLOCKED', 'FINANCE_APPROVAL_REQUIRED'], 'RESTRICTED_ADMINISTRATIVE_REVIEW' => ['EXPELLED'], 'WITHDRAWAL_REVIEW_REQUIRED' => ['WITHDRAWN'], 'DEFERRED_DELIBERATION' => ['NO_FINAL_DECISION', 'UNRECOGNIZED_DECISION', 'DEFERRED_DECISION'], 'SIBLING_LAST_NAME_MISMATCH' => ['SIBLING_LAST_NAME_MISMATCH'], default => [$flagType], }; } private function canManageEnrollmentExceptions(): bool { $userId = $this->userId(); if ($userId <= 0) { return false; } if (! $this->db->tableExists('permissions') || ! $this->db->tableExists('role_permissions') || ! $this->db->tableExists('user_roles')) { return $this->isAdministratorSessionRole(); } $rows = $this->db->table('user_roles ur') ->select('rp.*') ->join('role_permissions rp', 'rp.role_id = ur.role_id') ->join('permissions p', 'p.id = rp.permission_id') ->where('ur.user_id', $userId) ->where('LOWER(p.name)', 'enrollment.exception.manage') ->get() ->getResultArray(); foreach ($rows as $row) { if (! empty($row['can_create']) || ! empty($row['can_update']) || ! empty($row['can_delete'])) { return true; } } return false; } private function isAdministratorSessionRole(): bool { $roles = array_map(static fn ($role): string => strtolower(trim((string) $role)), (array) session()->get('roles')); $activeRole = strtolower(trim((string) session()->get('role'))); if ($activeRole !== '') { $roles[] = $activeRole; } return (bool) array_intersect(array_unique($roles), ['administrator', 'principal']); } private function launchState(string $schoolYear): array { if ($schoolYear === '' || ! $this->db->tableExists('school_years')) { return ['approved' => false, 'approved_at' => null, 'missing' => ['school year']]; } $row = $this->db->table('school_years')->where('name', $schoolYear)->limit(1)->get()->getRowArray(); $this->launchConfigurationComplete($schoolYear, $missing); return [ 'approved' => ! empty($row['registration_launch_approved_at'] ?? null), 'approved_at' => $row['registration_launch_approved_at'] ?? null, 'missing' => $missing, ]; } private function launchConfigurationComplete(string $schoolYear, ?array &$missing = null): bool { $missing = []; $row = $this->db->table('school_years')->where('name', $schoolYear)->limit(1)->get()->getRowArray(); if ($row === null) { $missing[] = 'school year record'; return false; } foreach (['registration_starts_on' => 'registration opening date', 'registration_ends_on' => 'registration deadline'] as $field => $label) { if (empty($row[$field])) { $missing[] = $label; } } if (! $this->db->tableExists('email_templates')) { $missing[] = 'email templates table'; } else { $fields = $this->db->getFieldNames('email_templates'); $keyField = in_array('code', $fields, true) ? 'code' : 'template_key'; $template = $this->db->table('email_templates') ->where($keyField, 'registration_opening') ->where('is_active', 1) ->countAllResults(); if ($template <= 0) { $missing[] = 'approved registration email template'; } } return $missing === []; } private function firstParentWithStudents(): ?int { if (! $this->db->tableExists('students')) { return null; } $row = $this->db->table('students') ->select('parent_id') ->where('parent_id IS NOT NULL', null, false) ->orderBy('parent_id', 'ASC') ->limit(1) ->get() ->getRowArray(); return is_numeric($row['parent_id'] ?? null) ? (int) $row['parent_id'] : null; } private function openFlagCount(string $schoolYear): int { if (! $this->db->tableExists('enrollment_flags')) { return 0; } $builder = $this->db->table('enrollment_flags') ->where('status', 'open') ->whereNotIn('flag_type', ['CLASS_CAPACITY_EXCEPTION_REQUIRED', 'CLASS_REASSIGNMENT_REQUIRED']); if ($schoolYear !== '') { $builder->where('school_year', $schoolYear); } return $builder->countAllResults(); } private function flagTypes(): array { if (! $this->db->tableExists('enrollment_flags')) { return []; } $types = array_column( $this->db->table('enrollment_flags') ->select('flag_type') ->whereNotIn('flag_type', ['CLASS_CAPACITY_EXCEPTION_REQUIRED', 'CLASS_REASSIGNMENT_REQUIRED']) ->distinct() ->orderBy('flag_type') ->get() ->getResultArray(), 'flag_type' ); return array_values(array_filter( $types, static fn ($type): bool => ! in_array((string) $type, ['CLASS_CAPACITY_EXCEPTION_REQUIRED', 'CLASS_REASSIGNMENT_REQUIRED'], true) )); } private function schoolYears(): array { if (! $this->db->tableExists('school_years')) { return []; } return $this->db->table('school_years')->select('name')->orderBy('name', 'DESC')->get()->getResultArray(); } private function classSections(string $schoolYear): array { if (! $this->db->tableExists('classSection')) { return []; } $builder = $this->db->table('classSection')->select('class_section_id, class_section_name')->orderBy('class_section_name', 'ASC'); if ($schoolYear !== '' && $this->db->fieldExists('school_year', 'classSection')) { $builder->where('school_year', $schoolYear); } return $builder->get()->getResultArray(); } private function adminUsers(): array { if (! $this->db->tableExists('users')) { return []; } return $this->db->table('users') ->select('id, firstname, lastname, user_type') ->whereIn('user_type', ['administrator', 'admin', 'principal', 'administrative staff']) ->orderBy('lastname', 'ASC') ->get() ->getResultArray(); } private function userId(): ?int { $id = session('user_id') ?? session('id'); return is_numeric($id) ? (int) $id : null; } }