schoolYearByName($targetSchoolYear); $sourceAssignment = $this->sourceAssignment($studentId, $sourceSchoolYear); $student = $this->student($studentId); $decisionRow = $this->decisionRow($studentId, $sourceSchoolYear); $decision = DeliberationDecision::normalize($decisionRow['deliberation_decision_standard'] ?? null) ?? DeliberationDecision::normalize($decisionRow['decision'] ?? null); $result = [ 'student_id' => $studentId, 'source_school_year' => $sourceSchoolYear, 'target_school_year' => $targetSchoolYear, 'deliberation_decision' => $decision, 'decision_label' => DeliberationDecision::display($decisionRow['decision'] ?? ''), 'source_grade_id' => $sourceAssignment['class_id'] ?? null, 'source_grade_name' => $sourceAssignment['class_name'] ?? null, 'source_class_section_id' => $sourceAssignment['class_section_id'] ?? null, 'source_class_section_name' => $sourceAssignment['class_section_name'] ?? null, 'assigned_grade_id' => null, 'assigned_class_section_id' => null, 'placement_status' => 'not_created', 'academic_eligible' => false, 'parent_enrollment_allowed' => false, 'student_self_enrollment_allowed' => false, 'administrative_enrollment_allowed' => true, 'age_reference_date' => $this->ageReferenceDate($targetSchoolYear), 'age_on_reference_date' => null, 'adult_student' => false, 'registration_window_status' => 'unknown', 'blockers' => [], 'warnings' => [], 'flags' => [], ]; if ($student === null) { $result['blockers'][] = 'Student record was not found.'; return $result; } if ($sourceAssignment === null) { $result['blockers'][] = 'Student does not belong to the closing school year.'; return $result; } if ($decisionRow === null || $decision === null) { $result['blockers'][] = EnrollmentEligibility::MISSING_DECISION_MESSAGE; $result['flags'][] = $this->flag('DEFERRED_DELIBERATION', 'high', [ 'reason' => 'Missing or unrecognized final deliberation decision.', ]); return $result; } if ($decision === DeliberationDecision::EXPELLED) { $result['blockers'][] = EnrollmentEligibility::EXPELLED_MESSAGE; $result['flags'][] = $this->flag('RESTRICTED_ADMINISTRATIVE_REVIEW', 'high'); return $result; } if ($decision === DeliberationDecision::WITHDRAWN) { $result['blockers'][] = EnrollmentEligibility::WITHDRAWN_MESSAGE; $result['flags'][] = $this->flag('WITHDRAWAL_REVIEW_REQUIRED', 'normal'); return $result; } if ($decision === DeliberationDecision::DEFERRED_DECISION) { $result['blockers'][] = EnrollmentEligibility::DEFERRED_MESSAGE; $result['flags'][] = $this->flag('DEFERRED_DELIBERATION', 'high'); return $result; } $placement = $this->placement($decision, $sourceAssignment, $targetSchoolYear); $result = array_replace($result, $placement); $result['academic_eligible'] = $placement['placement_status'] !== 'exit_required'; if ($placement['placement_status'] === 'exit_required') { $result['blockers'][] = 'The student has passed the highest available grade and must follow the school completion or exit process.'; $result['flags'][] = $this->flag('COMPLETION_OR_EXIT_PROCESS_REQUIRED', 'normal', [ 'source_class' => $sourceAssignment['class_section_name'] ?? null, ]); } $age = EnrollmentEligibility::ageOnSeptemberFirst($student['dob'] ?? null, $targetSchoolYear); $result['age_on_reference_date'] = $age; $result['adult_student'] = $age !== null && $age >= 18; $result['parent_enrollment_allowed'] = $result['academic_eligible']; $result['student_self_enrollment_allowed'] = $result['academic_eligible'] && (bool) ($targetYear['adult_student_registration_enabled'] ?? false); if ($result['adult_student']) { $result['parent_enrollment_allowed'] = false; $result['flags'][] = $this->flag('ADULT_STUDENT_ACTION_REQUIRED', 'normal', [ 'age_on_reference_date' => $age, ]); if ($actorRole === 'parent') { $result['blockers'][] = str_replace('The student', $this->studentName($student), EnrollmentEligibility::ADULT_STUDENT_MESSAGE); } } $this->applyRegistrationWindow($result, $targetYear, $now, $actorRole); $this->applyAgeRules($result, $targetSchoolYear, $age); if ($result['blockers'] === [] && $result['academic_eligible']) { if ($actorRole === 'parent') { $result['parent_enrollment_allowed'] = $result['parent_enrollment_allowed'] && ! $result['adult_student']; } } elseif ($actorRole === 'parent') { $result['parent_enrollment_allowed'] = false; } elseif ($actorRole === 'student') { $result['student_self_enrollment_allowed'] = false; } return $result; } public function applyInitialTransition( int $studentId, string $sourceSchoolYear, string $targetSchoolYear, ?int $parentId, ?int $performedBy = null, string $actorRole = 'admin' ): array { $evaluation = $this->evaluate($studentId, $sourceSchoolYear, $targetSchoolYear, $actorRole); if (! $evaluation['academic_eligible'] || $evaluation['blockers'] !== []) { $this->writeFlags($evaluation, $performedBy); $this->audit($evaluation, 'transition_evaluation_blocked', $performedBy, null, $evaluation); return $evaluation; } $this->db->transStart(); $original = $this->latestEnrollment($studentId, $targetSchoolYear); $student = $this->student($studentId); $parentId = $parentId ?? (is_numeric($student['parent_id'] ?? null) ? (int) $student['parent_id'] : null); $payload = [ 'student_id' => $studentId, 'parent_id' => $parentId, 'school_year' => $targetSchoolYear, 'source_school_year' => $sourceSchoolYear, 'deliberation_decision' => $evaluation['deliberation_decision'], 'source_grade_id' => $evaluation['source_grade_id'], 'assigned_grade_id' => $evaluation['assigned_grade_id'], 'source_class_section_id' => $evaluation['source_class_section_id'], 'assigned_class_section_id' => $evaluation['assigned_class_section_id'], 'class_section_id' => $evaluation['assigned_class_section_id'], 'placement_status' => $evaluation['placement_status'], 'age_reference_date' => $evaluation['age_reference_date'], 'age_on_reference_date' => $evaluation['age_on_reference_date'], 'adult_student' => $evaluation['adult_student'] ? 1 : 0, 'parent_enrollment_allowed' => $evaluation['parent_enrollment_allowed'] ? 1 : 0, 'student_self_enrollment_allowed' => $evaluation['student_self_enrollment_allowed'] ? 1 : 0, 'exception_required' => $evaluation['flags'] !== [] ? 1 : 0, 'exception_reason' => $evaluation['flags'] !== [] ? implode(', ', array_column($evaluation['flags'], 'flag_type')) : null, 'enrollment_date' => date('Y-m-d'), 'enrollment_status' => 'admission under review', 'admission_status' => 'pending', 'updated_at' => date('Y-m-d H:i:s'), ]; if ($original !== null) { $this->db->table('enrollments')->where('id', (int) $original['id'])->update($payload); } else { $payload['created_at'] = date('Y-m-d H:i:s'); $this->db->table('enrollments')->insert($payload); } if ((int) ($evaluation['assigned_class_section_id'] ?? 0) > 0) { $this->upsertStudentClass($studentId, (int) $evaluation['assigned_class_section_id'], $targetSchoolYear, $performedBy); } $this->writeFlags($evaluation, $performedBy); $this->audit($evaluation, 'initial_transition_applied', $performedBy, $original, $payload); $this->db->transComplete(); if ($this->db->transStatus() === false) { throw new RuntimeException('Unable to apply enrollment transition.'); } return $evaluation; } private function placement(string $decision, array $sourceAssignment, string $targetSchoolYear): array { $sourceClassId = (int) ($sourceAssignment['class_id'] ?? 0); $sourceClassName = trim((string) ($sourceAssignment['class_name'] ?? $sourceAssignment['class_section_name'] ?? '')); $sourceSectionName = trim((string) ($sourceAssignment['class_section_name'] ?? '')); if ($decision === DeliberationDecision::PASSED) { $targetClass = $this->nextClass($sourceClassName, $targetSchoolYear); return [ 'assigned_grade_id' => $targetClass['id'] ?? null, 'assigned_grade_name' => $targetClass['class_name'] ?? null, 'assigned_class_section_id' => null, 'placement_status' => $targetClass === null ? 'exit_required' : 'automatic_distribution_pending', 'flags' => [], ]; } if ($decision === DeliberationDecision::REPEAT_CLASS) { $targetSection = $this->matchingSection($sourceSectionName, $sourceClassId, $targetSchoolYear); $flags = []; if ($targetSection === null) { $flags[] = $this->flag('CLASS_REASSIGNMENT_REQUIRED', 'normal', [ 'previous_class_section_name' => $sourceSectionName, ]); } elseif ($this->sectionAtCapacity((int) $targetSection['class_section_id'])) { $flags[] = $this->flag('CLASS_CAPACITY_EXCEPTION_REQUIRED', 'normal', [ 'class_section_id' => (int) $targetSection['class_section_id'], ]); } return [ 'assigned_grade_id' => $targetSection['class_id'] ?? $this->sameClassInTargetYear($sourceClassName, $targetSchoolYear)['id'] ?? ($sourceClassId ?: null), 'assigned_grade_name' => $this->classNameForId((int) ($targetSection['class_id'] ?? 0)) ?? $sourceClassName, 'assigned_class_section_id' => $targetSection['class_section_id'] ?? null, 'placement_status' => $targetSection === null ? 'manual_class_required' : 'same_class_assigned', 'flags' => $flags, ]; } if ($decision === DeliberationDecision::MAKE_UP_EXAM) { $targetSection = $this->matchingSection($sourceSectionName, $sourceClassId, $targetSchoolYear); return [ 'assigned_grade_id' => $targetSection['class_id'] ?? $this->sameClassInTargetYear($sourceClassName, $targetSchoolYear)['id'] ?? ($sourceClassId ?: null), 'assigned_grade_name' => $this->classNameForId((int) ($targetSection['class_id'] ?? 0)) ?? $sourceClassName, 'assigned_class_section_id' => $targetSection['class_section_id'] ?? null, 'placement_status' => $targetSection === null ? 'temporary_manual_class_required' : 'temporary_same_grade', 'flags' => [ $this->flag('PENDING_MAKE_UP_EXAM_PROMOTION', 'high', [ 'current_grade_id' => $sourceClassId ?: null, 'expected_promoted_grade_id' => $this->nextClass($sourceClassName, $targetSchoolYear)['id'] ?? null, 'current_class_section_id' => $targetSection['class_section_id'] ?? null, ]), ], ]; } return ['assigned_grade_id' => null, 'assigned_class_section_id' => null, 'placement_status' => 'not_created', 'flags' => []]; } private function applyRegistrationWindow(array &$result, ?array $targetYear, DateTimeImmutable $now, string $actorRole): void { if ($targetYear === null) { $result['registration_window_status'] = 'missing_school_year'; $result['blockers'][] = 'Target school year configuration was not found.'; return; } $opensAt = $this->dateTimeFromYear($targetYear, 'registration_opens_at', 'registration_starts_on', false); $deadlineAt = $this->dateTimeFromYear($targetYear, 'registration_deadline_at', 'registration_ends_on', true); if ($opensAt !== null && $now < $opensAt) { $result['registration_window_status'] = 'not_open'; $result['blockers'][] = 'Registration for the new school year has not opened yet. Registration will be available starting on ' . $opensAt->format('F j, Y g:i A') . '.'; return; } if ($deadlineAt !== null && $now > $deadlineAt && (int) ($targetYear['late_registration_blocked'] ?? 1) === 1 && $actorRole !== 'admin') { $result['registration_window_status'] = 'closed'; $result['blockers'][] = 'The registration deadline was ' . $deadlineAt->format('F j, Y g:i A') . '. Online registration is no longer available. Please contact the school administration if you believe an exception applies.'; $result['flags'][] = $this->flag('LATE_REGISTRATION_EXCEPTION', 'normal'); return; } $result['registration_window_status'] = 'open'; } private function applyAgeRules(array &$result, string $targetSchoolYear, ?int $age): void { if ($age === null || ! $this->db->tableExists('enrollment_age_rules')) { return; } $builder = $this->db->table('enrollment_age_rules') ->where('school_year', $targetSchoolYear) ->groupStart() ->where('grade_class_id', null) ->orWhere('grade_class_id', $result['assigned_grade_id']) ->groupEnd(); foreach ($builder->get()->getResultArray() as $rule) { $min = is_numeric($rule['minimum_age'] ?? null) ? (int) $rule['minimum_age'] : null; $max = is_numeric($rule['maximum_age'] ?? null) ? (int) $rule['maximum_age'] : null; $violated = ($min !== null && $age < $min) || ($max !== null && $age > $max); if (! $violated) { continue; } $message = 'Student age does not satisfy a configured age rule for the target placement.'; if (($rule['behavior'] ?? 'blocking') === 'warning') { $result['warnings'][] = $message; } else { $result['blockers'][] = $message; $result['flags'][] = $this->flag('AGE_EXCEPTION_REQUIRED', 'normal', [ 'age_rule_id' => (int) $rule['id'], 'age_on_reference_date' => $age, ]); } } } private function sourceAssignment(int $studentId, string $sourceSchoolYear): ?array { if (! $this->db->tableExists('student_class')) { return $this->sourceEnrollmentAssignment($studentId, $sourceSchoolYear); } $assignment = $this->db->table('student_class sc') ->select('sc.class_section_id, cs.class_section_name, cs.class_id, c.class_name') ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') ->join('classes c', 'c.id = cs.class_id', 'left') ->where('sc.student_id', $studentId) ->where('sc.school_year', $sourceSchoolYear) ->where('sc.class_section_id IS NOT NULL', null, false) ->orderBy('sc.updated_at', 'DESC') ->orderBy('sc.id', 'DESC') ->limit(1) ->get() ->getRowArray(); return $assignment ?: $this->sourceEnrollmentAssignment($studentId, $sourceSchoolYear); } private function sourceEnrollmentAssignment(int $studentId, string $sourceSchoolYear): ?array { if (! $this->db->tableExists('enrollments')) { return null; } return $this->db->table('enrollments e') ->select('e.class_section_id, cs.class_section_name, cs.class_id, c.class_name') ->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left') ->join('classes c', 'c.id = cs.class_id', 'left') ->where('e.student_id', $studentId) ->where('e.school_year', $sourceSchoolYear) ->where('e.class_section_id IS NOT NULL', null, false) ->orderBy('e.updated_at', 'DESC') ->orderBy('e.id', 'DESC') ->limit(1) ->get() ->getRowArray() ?: null; } private function decisionRow(int $studentId, string $sourceSchoolYear): ?array { if (! $this->db->tableExists('student_decisions')) { return null; } $select = ['decision', 'source', 'notes', 'class_section_name']; if ($this->db->fieldExists('deliberation_decision_standard', 'student_decisions')) { $select[] = 'deliberation_decision_standard'; } return $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() ?: null; } private function student(int $studentId): ?array { return $this->db->table('students') ->select('id, firstname, lastname, dob, parent_id') ->where('id', $studentId) ->limit(1) ->get() ->getRowArray() ?: null; } private function schoolYearByName(string $schoolYear): ?array { if (! $this->db->tableExists('school_years')) { return null; } return $this->db->table('school_years')->where('name', $schoolYear)->limit(1)->get()->getRowArray() ?: null; } private function nextClass(string $sourceClassName, string $targetSchoolYear): ?array { $base = $this->classBaseName($sourceClassName); $target = match (true) { $base === 'KG' || str_contains($base, 'KINDERGARTEN') => '1', ctype_digit($base) => (string) ((int) $base + 1), $base === 'YOUTH' => 'YOUTH', default => '', }; if ($target === '') { return null; } $targetClass = $this->classByName($target, $targetSchoolYear); if ($targetClass !== null) { return $targetClass; } return ctype_digit($base) && (int) $base >= 9 ? $this->classByName('YOUTH', $targetSchoolYear) : null; } private function sameClassInTargetYear(string $sourceClassName, string $targetSchoolYear): ?array { $base = $this->classBaseName($sourceClassName); if ($base === '') { return null; } if (str_contains($base, 'KINDERGARTEN')) { $base = 'KG'; } return $this->classByName($base, $targetSchoolYear); } private function classBaseName(string $className): string { $base = strtoupper(trim((string) preg_replace('/-.+$/', '', $className))); $base = preg_replace('/\b(CLASS|GRADE)\b/i', '', $base) ?? $base; $base = trim(preg_replace('/\s+/', ' ', $base) ?? $base); if (str_contains($base, 'KINDERGARTEN')) { return 'KG'; } if (preg_match('/\d+/', $base, $matches) === 1) { return (string) (int) $matches[0]; } return $base; } private function classByName(string $className, string $schoolYear): ?array { $builder = $this->db->table('classes')->where('UPPER(class_name)', strtoupper($className)); if ($this->db->fieldExists('school_year', 'classes')) { $builder->where('school_year', $schoolYear); } $row = $builder->orderBy('id', 'DESC')->limit(1)->get()->getRowArray(); if ($row !== null || ! $this->db->fieldExists('school_year', 'classes')) { return $row ?: null; } return $this->db->table('classes') ->where('UPPER(class_name)', strtoupper($className)) ->orderBy('id', 'DESC') ->limit(1) ->get() ->getRowArray() ?: null; } private function classNameForId(int $classId): ?string { if ($classId <= 0 || ! $this->db->tableExists('classes')) { return null; } $row = $this->db->table('classes') ->select('class_name') ->where('id', $classId) ->limit(1) ->get() ->getRowArray(); $name = trim((string) ($row['class_name'] ?? '')); return $name !== '' ? $name : null; } private function matchingSection(string $sourceSectionName, int $sourceClassId, string $targetSchoolYear): ?array { $builder = $this->db->table('classSection')->select('class_section_id, class_id, class_section_name')->orderBy('id', 'DESC'); if ($sourceSectionName !== '') { $builder->where('class_section_name', $sourceSectionName); } else { $builder->where('class_id', $sourceClassId)->where("class_section_name NOT LIKE '%-%'", null, false); } if ($this->db->fieldExists('school_year', 'classSection')) { $builder->where('school_year', $targetSchoolYear); } return $builder->limit(1)->get()->getRowArray() ?: null; } private function sectionAtCapacity(int $classSectionId): bool { $section = $this->db->table('classSection cs') ->select('cs.class_section_id, c.capacity') ->join('classes c', 'c.id = cs.class_id', 'left') ->where('cs.class_section_id', $classSectionId) ->orderBy('cs.id', 'DESC') ->limit(1) ->get() ->getRowArray(); $capacity = is_numeric($section['capacity'] ?? null) ? (int) $section['capacity'] : 0; if ($capacity <= 0) { return false; } $count = $this->db->table('student_class')->where('class_section_id', $classSectionId)->countAllResults(); return $count >= $capacity; } private function latestEnrollment(int $studentId, string $schoolYear): ?array { if (! $this->db->tableExists('enrollments')) { return null; } return $this->db->table('enrollments') ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->orderBy('updated_at', 'DESC') ->orderBy('id', 'DESC') ->limit(1) ->get() ->getRowArray() ?: null; } private function upsertStudentClass(int $studentId, int $classSectionId, string $schoolYear, ?int $performedBy): void { $existing = $this->db->table('student_class') ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->orderBy('id', 'DESC') ->limit(1) ->get() ->getRowArray(); $payload = [ 'student_id' => $studentId, 'class_section_id' => $classSectionId, 'school_year' => $schoolYear, 'updated_by' => $performedBy, 'updated_at' => date('Y-m-d H:i:s'), ]; if ($existing !== null) { $this->db->table('student_class')->where('id', (int) $existing['id'])->update($payload); } else { $payload['created_at'] = date('Y-m-d H:i:s'); $this->db->table('student_class')->insert($payload); } } private function writeFlags(array $evaluation, ?int $performedBy): void { if (! $this->db->tableExists('enrollment_flags')) { return; } foreach ($evaluation['flags'] as $flag) { $existing = $this->db->table('enrollment_flags') ->where('student_id', (int) $evaluation['student_id']) ->where('school_year', (string) $evaluation['target_school_year']) ->where('flag_type', (string) $flag['flag_type']) ->where('status', 'open') ->limit(1) ->get() ->getRowArray(); if ($existing !== null) { continue; } $this->db->table('enrollment_flags')->insert([ 'flag_type' => $flag['flag_type'], 'student_id' => (int) $evaluation['student_id'], 'school_year' => (string) $evaluation['target_school_year'], 'source_school_year' => (string) $evaluation['source_school_year'], 'status' => 'open', 'priority' => $flag['priority'] ?? 'normal', 'assigned_to' => $performedBy, 'details_json' => json_encode($flag['details'] ?? [], JSON_UNESCAPED_SLASHES), 'created_at' => date('Y-m-d H:i:s'), ]); } } private function audit(array $evaluation, string $action, ?int $performedBy, ?array $original, array $new): void { if (! $this->db->tableExists('enrollment_transition_audits')) { return; } $this->db->table('enrollment_transition_audits')->insert([ 'student_id' => (int) $evaluation['student_id'], 'school_year' => (string) $evaluation['target_school_year'], 'source_school_year' => (string) $evaluation['source_school_year'], 'action' => $action, 'performed_by' => $performedBy, 'original_values_json' => $original !== null ? json_encode($original, JSON_UNESCAPED_SLASHES) : null, 'new_values_json' => json_encode($new, JSON_UNESCAPED_SLASHES), 'reason' => implode(' ', $evaluation['blockers'] ?? []), 'created_at' => date('Y-m-d H:i:s'), ]); } private function flag(string $type, string $priority, array $details = []): array { return ['flag_type' => $type, 'priority' => $priority, 'details' => $details]; } private function ageReferenceDate(string $schoolYear): string { return preg_match('/^(\d{4})/', $schoolYear, $matches) ? $matches[1] . '-09-01' : date('Y') . '-09-01'; } private function dateTimeFromYear(array $year, string $dateTimeField, string $dateField, bool $endOfDay): ?DateTimeImmutable { $value = trim((string) ($year[$dateTimeField] ?? '')); if ($value === '') { $date = trim((string) ($year[$dateField] ?? '')); $value = $date !== '' ? $date . ($endOfDay ? ' 23:59:59' : ' 00:00:00') : ''; } if ($value === '') { return null; } try { return new DateTimeImmutable($value); } catch (\Throwable) { return null; } } private function studentName(array $student): string { $name = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')); return $name !== '' ? $name : 'The student'; } }