*/ private const BLOCKING_BALANCE_BEHAVIORS = [ 'submission_blocked_until_payment', 'admin_approval_required', ]; /** @var array */ private array $resolvedInvoiceBalanceCache = []; private ?InvoiceLedgerService $invoiceLedgerService = null; public function __construct( private readonly BaseConnection $db, private readonly ?EnrollmentStatusService $statusService = null, ) { } public function evaluateForParent( int $parentId, int $studentId, string $sourceSchoolYear, string $targetSchoolYear, string $actorRole = 'parent', ?DateTimeInterface $now = null ): array { $evaluation = $this->evaluate($studentId, $sourceSchoolYear, $targetSchoolYear, $actorRole, $now); $this->ensureDecisionFields($evaluation); $student = $this->student($studentId); if ($parentId <= 0 || $student === null || (int) ($student['parent_id'] ?? 0) !== $parentId) { $this->addBlocker($evaluation, 'STUDENT_NOT_LINKED', 'This student is not linked to the signed-in parent account.'); } if ($this->schoolYearByName($targetSchoolYear) === null) { $this->addBlocker($evaluation, 'TARGET_YEAR_NOT_FOUND', 'Target school year configuration was not found.'); } if ( empty($evaluation['first_enrollment']) && ($sourceSchoolYear === '' || $this->sourceAssignment($studentId, $sourceSchoolYear) === null) ) { $this->addBlocker($evaluation, 'SOURCE_YEAR_NOT_FOUND', 'Student does not belong to the closing school year.'); } $existing = $this->controllingEnrollment($studentId, $targetSchoolYear); if ($this->activeEnrollmentBlocksDuplicate($existing)) { $this->addBlocker($evaluation, 'ALREADY_ENROLLED', EnrollmentEligibility::ALREADY_ENROLLED_MESSAGE); } elseif ($this->deniedOrWithdrawnEnrollmentBlocksStandardEligibility($existing)) { $code = strtolower((string) ($existing['admission_status'] ?? '')) === 'denied' || strtolower((string) ($existing['enrollment_status'] ?? '')) === 'denied' ? 'DENIED' : 'WITHDRAWN'; $this->addBlocker($evaluation, $code, 'Student has a target-year enrollment status that requires administration review.'); } $this->applyHouseholdLastNameRule($evaluation, $parentId, $sourceSchoolYear); $this->applyFinancialRule($evaluation, $parentId, $sourceSchoolYear, $targetSchoolYear); $this->applyCarriedForwardLastNameException($evaluation, $parentId, $studentId, $sourceSchoolYear, $targetSchoolYear); $this->deriveAcademicRuleCodes($evaluation); $this->applyScopedException($evaluation, $parentId, $studentId, $sourceSchoolYear, $targetSchoolYear); $this->finalizeParentDecision($evaluation); $this->assignPrimaryBlockerFields($evaluation); $this->appendFlagsFromRuleCodes($evaluation); return $evaluation; } /** * @return list */ public function statusesRequiringEnrollmentEligibility(): array { return [ 'admission under review', 'payment pending', 'enrolled', 'waitlist', ]; } public function evaluateEnrollmentAdvance( int $parentId, int $studentId, string $targetSchoolYear, string $actorRole = 'admin' ): array { $sourceSchoolYear = $this->previousSchoolYearName($targetSchoolYear); if ($sourceSchoolYear === null) { return [ 'student_id' => $studentId, 'target_school_year' => $targetSchoolYear, 'can_enroll' => false, 'primary_block_reason' => 'SOURCE_YEAR_NOT_FOUND', 'primary_parent_message' => 'Source school year could not be resolved for eligibility.', 'blocking_rule_codes' => ['SOURCE_YEAR_NOT_FOUND'], ]; } return $this->evaluateForParent($parentId, $studentId, $sourceSchoolYear, $targetSchoolYear, $actorRole); } /** * Admin operations (class assignment, status advance) may proceed when the student * is already enrolled for the target year, or when an admin exception authorizes them. * Adult-student blocking remains non-overridable. */ public function adminMayAdvanceEnrollment(array $evaluation): bool { if (in_array('ADULT_STUDENT_PARENT_BLOCKED', array_map('strval', $evaluation['blocking_rule_codes'] ?? []), true)) { return false; } if (($evaluation['can_enroll'] ?? false) === true) { return true; } $decision = (string) ($evaluation['decision'] ?? ''); if ($decision === self::DECISION_ALREADY_ENROLLED || $decision === self::DECISION_EXCEPTION_ELIGIBLE) { return true; } return ($evaluation['admin_exception'] ?? null) !== null; } public function logEnrollmentBlock( array $evaluation, string $context, ?int $parentId = null, ?int $performedBy = null ): void { $payload = [ 'context' => $context, 'student_id' => (int) ($evaluation['student_id'] ?? 0), 'parent_id' => $parentId ?? ($evaluation['parent_id'] ?? null), 'school_year' => (string) ($evaluation['target_school_year'] ?? ''), 'enrollment_status' => $evaluation['enrollment_status'] ?? null, 'academic_decision' => $evaluation['deliberation_decision'] ?? null, 'age' => $evaluation['age_on_reference_date'] ?? null, 'adult_student' => (bool) ($evaluation['adult_student'] ?? false), 'blocking_rules' => array_values(array_map('strval', $evaluation['blocking_rule_codes'] ?? [])), 'primary_block_reason' => $evaluation['primary_block_reason'] ?? null, 'exception_ids' => isset($evaluation['admin_exception']['id']) ? [(int) $evaluation['admin_exception']['id']] : [], 'financial_summary' => $evaluation['financial_summary'] ?? null, 'timestamp' => date('Y-m-d H:i:s'), ]; log_message('warning', 'Enrollment blocked [{context}] student={student_id} parent={parent_id} reason={primary_block_reason}', [ 'context' => $context, 'student_id' => $payload['student_id'], 'parent_id' => $payload['parent_id'], 'primary_block_reason' => $payload['primary_block_reason'] ?? 'unknown', ]); if ($payload['student_id'] <= 0 || ! $this->db->tableExists('enrollment_transition_audits')) { return; } $this->db->table('enrollment_transition_audits')->insert([ 'student_id' => $payload['student_id'], 'school_year' => $payload['school_year'] !== '' ? $payload['school_year'] : date('Y'), 'source_school_year' => $evaluation['source_school_year'] ?? null, 'action' => 'enrollment_blocked', 'performed_by' => $performedBy, 'original_values_json' => null, 'new_values_json' => json_encode($payload, JSON_UNESCAPED_SLASHES), 'reason' => $context, 'created_at' => date('Y-m-d H:i:s'), ]); } public function markExceptionUsed(int $exceptionId, int $enrollmentId): void { if ($exceptionId <= 0 || $enrollmentId <= 0 || ! $this->db->tableExists('enrollment_exceptions')) { return; } $this->db->table('enrollment_exceptions') ->where('id', $exceptionId) ->where('status', 'active') ->update([ 'status' => 'used', 'used_at' => date('Y-m-d H:i:s'), 'enrollment_id' => $enrollmentId, 'updated_at' => date('Y-m-d H:i:s'), ]); } public function auditEnrollmentDecision( int $studentId, string $targetSchoolYear, ?string $sourceSchoolYear, string $action, ?int $performedBy, ?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' => $targetSchoolYear, 'source_school_year' => $sourceSchoolYear, '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' => $reason, 'created_at' => date('Y-m-d H:i:s'), ]); } public function evaluate( int $studentId, string $sourceSchoolYear, string $targetSchoolYear, string $actorRole = 'parent', ?DateTimeInterface $now = null ): array { $now = $now !== null ? DateTimeImmutable::createFromInterface($now) : new DateTimeImmutable('now'); $targetYear = $this->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); $rawDecision = trim((string) ($decisionRow['deliberation_decision_standard'] ?? $decisionRow['decision'] ?? '')); $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; } $sourceEnrollment = $this->db->tableExists('enrollments') ? $this->controllingEnrollment($studentId, $sourceSchoolYear) : null; if (is_array($sourceEnrollment)) { $student['enrollment_status'] = $sourceEnrollment['enrollment_status'] ?? ($student['enrollment_status'] ?? null); $student['is_withdrawn'] = $sourceEnrollment['is_withdrawn'] ?? ($student['is_withdrawn'] ?? 0); } $age = EnrollmentEligibility::ageOnSeptemberFirst($student['dob'] ?? null, $targetSchoolYear); $result['age_on_reference_date'] = $age; $result['adult_student'] = $age !== null && $age >= EnrollmentEligibility::ADULT_STUDENT_MIN_AGE; if ($decision === DeliberationDecision::EXPELLED) { $result['blockers'][] = EnrollmentEligibility::EXPELLED_MESSAGE; $result['flags'][] = $this->flag('RESTRICTED_ADMINISTRATIVE_REVIEW', 'high', ['rule_code' => 'EXPELLED']); return $result; } if ($result['adult_student']) { $this->applyAdultStudentBlock($result, $student, $age); return $result; } if ($decision === DeliberationDecision::WITHDRAWN || EnrollmentEligibility::isMarkedWithdrawn($student)) { $result['blockers'][] = EnrollmentEligibility::WITHDRAWN_MESSAGE; $result['flags'][] = $this->flag('WITHDRAWAL_REVIEW_REQUIRED', 'normal', ['rule_code' => 'WITHDRAWN']); return $result; } if ($sourceAssignment === null) { if ($this->isFirstEnrollmentStudent($studentId, $student, $targetSchoolYear)) { $result = array_replace($result, $this->firstEnrollmentPlacement($student, $targetSchoolYear)); $result['first_enrollment'] = true; $result['decision_label'] = 'New student registration'; $result['academic_eligible'] = true; $result['parent_enrollment_allowed'] = true; $result['student_self_enrollment_allowed'] = false; $this->applyRegistrationWindow($result, $targetYear, $now, $actorRole); $this->applyAgeRules($result, $targetSchoolYear, $age); if ($result['blockers'] !== [] && $actorRole === 'parent') { $result['parent_enrollment_allowed'] = false; } return $result; } $result['blockers'][] = 'Student does not belong to the closing school year.'; return $result; } if ($decision === DeliberationDecision::DEFERRED_DECISION) { $result['blockers'][] = EnrollmentEligibility::DEFERRED_MESSAGE; $result['flags'][] = $this->flag('DEFERRED_DELIBERATION', 'high', ['rule_code' => 'DEFERRED_DECISION']); return $result; } if (($decisionRow === null || $decision === null) && $sourceAssignment !== null) { $kgDecision = $this->decisionForKgWithoutFinalDecision($sourceAssignment, $age, $rawDecision); if ($kgDecision !== null) { $decision = $kgDecision; $result['deliberation_decision'] = $decision; $result['decision_label'] = 'KG age placement'; $result['kg_missing_decision_eligible'] = true; $result['warnings'][] = EnrollmentEligibility::KG_MISSING_DECISION_ELIGIBLE_MESSAGE; } } if ($decision === null) { $result['blockers'][] = EnrollmentEligibility::MISSING_DECISION_MESSAGE; $result['flags'][] = $this->flag('DEFERRED_DELIBERATION', 'high', [ 'rule_code' => $decisionRow === null ? 'NO_FINAL_DECISION' : 'UNRECOGNIZED_DECISION', 'reason' => 'Missing or unrecognized final deliberation decision.', ]); 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, ]); } $result['parent_enrollment_allowed'] = $result['academic_eligible']; $result['student_self_enrollment_allowed'] = false; $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); $student = $this->student($studentId); $parentId = $parentId ?? (is_numeric($student['parent_id'] ?? null) ? (int) $student['parent_id'] : 0); if ($parentId > 0) { $evaluation = $this->evaluateForParent($parentId, $studentId, $sourceSchoolYear, $targetSchoolYear, $actorRole); } $this->ensureDecisionFields($evaluation); $this->assignPrimaryBlockerFields($evaluation); if (($evaluation['can_enroll'] ?? false) !== true) { $this->writeFlags($evaluation, $performedBy); $this->logEnrollmentBlock($evaluation, 'apply_initial_transition', $parentId > 0 ? $parentId : null, $performedBy); $this->audit($evaluation, 'transition_evaluation_blocked', $performedBy, null, $evaluation); return $evaluation; } $this->db->transStart(); $original = $this->controllingEnrollment($studentId, $targetSchoolYear); $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) { $payload['id'] = (int) $original['id']; } else { $payload['created_at'] = date('Y-m-d H:i:s'); } (new EnrollmentStatusService($this->db))->upsertStatus( $payload, $performedBy, 'initial_transition_applied' ); service('studentYearStatus')->upsert($studentId, $targetSchoolYear, false); 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) { $sourceSectionId = (int) ($sourceAssignment['class_section_id'] ?? 0); $targetSection = $this->matchingSection($sourceSectionName, $sourceClassId, $targetSchoolYear); if ($targetSection === null && $sourceSectionId > 0) { $derivedSectionId = $this->repeatClassBaseSectionId($sourceSectionId); if ($derivedSectionId > 0) { $targetSection = $this->sectionByClassSectionId($derivedSectionId, $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 ? 'manual_class_required' : 'same_class_assigned', '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 decisionForKgWithoutFinalDecision(array $sourceAssignment, ?int $age, string $rawDecision): ?string { if ($rawDecision !== '' || $this->classBaseName((string) ($sourceAssignment['class_name'] ?? $sourceAssignment['class_section_name'] ?? '')) !== 'KG') { return null; } return $age !== null && $age >= 6 ? DeliberationDecision::PASSED : DeliberationDecision::REPEAT_CLASS; } 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'; if ($actorRole !== 'admin') { $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 { $select = ['id', 'firstname', 'lastname', 'dob', 'parent_id', 'registration_grade']; if ($this->db->fieldExists('school_year', 'students')) { $select[] = 'school_year'; } return $this->db->table('students') ->select(implode(', ', $select)) ->where('id', $studentId) ->limit(1) ->get() ->getRowArray() ?: null; } private function isFirstEnrollmentStudent(int $studentId, array $student, string $targetSchoolYear): bool { if ($studentId <= 0 || $targetSchoolYear === '') { return false; } if ($this->db->tableExists('student_year_status')) { $row = $this->db->table('student_year_status') ->select('is_new') ->where('student_id', $studentId) ->where('school_year', $targetSchoolYear) ->limit(1) ->get() ->getRowArray(); if (is_array($row)) { return (int) ($row['is_new'] ?? 1) === 1; } } return trim((string) ($student['school_year'] ?? '')) === $targetSchoolYear; } 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 firstEnrollmentPlacement(array $student, string $targetSchoolYear): array { $grade = $this->classBaseName((string) ($student['registration_grade'] ?? '')); $targetClass = $grade !== '' ? $this->classByName($grade, $targetSchoolYear) : null; $targetSection = is_array($targetClass) ? $this->baseSectionForClass((int) ($targetClass['id'] ?? 0), $targetSchoolYear) : null; return [ 'assigned_grade_id' => $targetClass['id'] ?? null, 'assigned_grade_name' => $targetClass['class_name'] ?? ($grade !== '' ? $grade : null), 'assigned_class_section_id' => $targetSection['class_section_id'] ?? null, 'placement_status' => $targetSection === null ? 'manual_class_required' : 'same_class_assigned', 'flags' => [], ]; } private function baseSectionForClass(int $classId, string $targetSchoolYear): ?array { if ($classId <= 0 || ! $this->db->tableExists('classSection')) { return null; } $builder = $this->db->table('classSection') ->select('class_section_id, class_id, class_section_name') ->where('class_id', $classId) ->where("class_section_name NOT LIKE '%-%'", null, false) ->orderBy('id', 'ASC'); if ($this->db->fieldExists('school_year', 'classSection')) { $builder->where('school_year', $targetSchoolYear); } return $builder->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; } /** * Repeat-class students keep the same grade. Section codes use the units digit for * lettered sections (e.g. 41 = 4-A, 40 = grade 4 base). Zeroing that digit yields the * base grade section to assign before redistribution. */ private function repeatClassBaseSectionId(int $sourceClassSectionId): int { if ($sourceClassSectionId <= 0) { return 0; } if ($sourceClassSectionId < 10) { return $sourceClassSectionId; } return intdiv($sourceClassSectionId, 10) * 10; } private function sectionByClassSectionId(int $classSectionId, string $targetSchoolYear): ?array { if ($classSectionId <= 0) { return null; } $builder = $this->db->table('classSection') ->select('class_section_id, class_id, class_section_name') ->where('class_section_id', $classSectionId) ->orderBy('id', 'DESC'); if ($this->db->fieldExists('school_year', 'classSection')) { $builder->where('school_year', $targetSchoolYear); } return $builder->limit(1)->get()->getRowArray() ?: null; } private function syncRepeatClassReassignmentFlags( string $targetSchoolYear, string $sourceSchoolYear, ?int $performedBy ): int { if (! $this->db->tableExists('enrollment_flags')) { return 0; } $openFlags = $this->db->table('enrollment_flags') ->where('school_year', $targetSchoolYear) ->where('flag_type', 'CLASS_REASSIGNMENT_REQUIRED') ->where('status', 'open') ->get() ->getResultArray(); $resolved = 0; foreach ($openFlags as $flagRow) { $studentId = (int) ($flagRow['student_id'] ?? 0); if ($studentId <= 0) { continue; } $sourceAssignment = $this->sourceAssignment($studentId, $sourceSchoolYear); $sourceSectionId = (int) ($sourceAssignment['class_section_id'] ?? 0); if ($sourceSectionId <= 0) { continue; } $derivedSectionId = $this->repeatClassBaseSectionId($sourceSectionId); $targetSection = $derivedSectionId > 0 ? $this->sectionByClassSectionId($derivedSectionId, $targetSchoolYear) : null; if ($targetSection === null) { continue; } $targetSectionId = (int) ($targetSection['class_section_id'] ?? 0); if ($targetSectionId <= 0) { continue; } $this->applyRepeatClassSectionAssignment($studentId, $targetSchoolYear, $targetSection, $performedBy); $this->db->table('enrollment_flags') ->where('id', (int) ($flagRow['id'] ?? 0)) ->update([ 'status' => 'resolved', 'resolved_at' => date('Y-m-d H:i:s'), 'resolution_notes' => 'Automatically assigned to base grade section ' . $targetSectionId . ' derived from previous year section ' . $sourceSectionId . '.', ]); $resolved++; } return $resolved; } private function applyRepeatClassSectionAssignment( int $studentId, string $targetSchoolYear, array $targetSection, ?int $performedBy ): void { $sectionId = (int) ($targetSection['class_section_id'] ?? 0); if ($sectionId <= 0) { return; } $this->upsertStudentClass($studentId, $sectionId, $targetSchoolYear, $performedBy); if (! $this->db->tableExists('enrollments')) { return; } $enrollment = $this->db->table('enrollments') ->where('student_id', $studentId) ->where('school_year', $targetSchoolYear) ->orderBy('updated_at', 'DESC') ->orderBy('id', 'DESC') ->limit(1) ->get() ->getRowArray(); if ($enrollment === null) { return; } $this->db->table('enrollments') ->where('id', (int) ($enrollment['id'] ?? 0)) ->update([ 'class_section_id' => $sectionId, 'assigned_class_section_id' => $sectionId, 'assigned_grade_id' => (int) ($targetSection['class_id'] ?? 0) ?: null, 'placement_status' => 'same_class_assigned', 'updated_at' => date('Y-m-d H:i:s'), ]); } private function assignPrimaryBlockerFields(array &$evaluation): void { if (($evaluation['can_enroll'] ?? false) === true) { $evaluation['primary_block_reason'] = null; $evaluation['primary_parent_message'] = null; return; } $blockers = array_values(array_unique(array_merge( array_map('strval', $evaluation['blocking_rule_codes'] ?? []), array_map('strval', $evaluation['review_rule_codes'] ?? []) ))); $primary = EnrollmentEligibility::selectPrimaryBlocker($blockers); $evaluation['primary_block_reason'] = $primary; $evaluation['primary_parent_message'] = $primary !== null ? EnrollmentEligibility::messageForRuleCode($primary) : null; } private function latestEnrollment(int $studentId, string $schoolYear): ?array { return $this->controllingEnrollment($studentId, $schoolYear); } 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); } } public function syncDashboardBlockageFlags(string $targetSchoolYear, ?int $performedBy = null): int { if (! $this->db->tableExists('enrollment_flags')) { return 0; } $sourceSchoolYear = $this->previousSchoolYearName($targetSchoolYear); if ($sourceSchoolYear === null) { return 0; } $students = $this->sourceYearStudents($sourceSchoolYear); if ($students === []) { return 0; } $enrolledIds = $this->activeTargetEnrollmentStudentIds($targetSchoolYear); $targetReviewCodes = $this->targetEnrollmentReviewCodesByStudent($targetSchoolYear); $bypassCodesByStudent = $this->activeBypassCodesByStudent($targetSchoolYear); $written = 0; foreach ($students as $student) { $studentId = (int) ($student['student_id'] ?? 0); if ($studentId <= 0) { continue; } $alreadyEnrolled = isset($enrolledIds[$studentId]); $age = EnrollmentEligibility::ageOnSeptemberFirst($student['dob'] ?? null, $targetSchoolYear); $isAdult = $age !== null && $age >= EnrollmentEligibility::ADULT_STUDENT_MIN_AGE; $flags = []; if (! $alreadyEnrolled && $isAdult) { $flags[] = $this->flag('ADULT_STUDENT_ACTION_REQUIRED', 'normal', [ 'age_on_reference_date' => $age, 'rule_code' => 'ADULT_STUDENT_PARENT_BLOCKED', ]); } elseif (! $alreadyEnrolled && isset($targetReviewCodes[$studentId])) { $review = $targetReviewCodes[$studentId]; $code = (string) ($review['rule_code'] ?? 'WITHDRAWN'); $flags[] = $this->flag( $code === 'DENIED' ? 'RESTRICTED_ADMINISTRATIVE_REVIEW' : 'WITHDRAWAL_REVIEW_REQUIRED', $code === 'DENIED' ? 'high' : 'normal', [ 'rule_code' => $code, 'enrollment_status' => $review['enrollment_status'] ?? null, 'is_withdrawn' => $review['is_withdrawn'] ?? null, ] ); } else { $parentId = (int) ($student['parent_id'] ?? 0); $evaluation = $parentId > 0 ? $this->evaluateForParent($parentId, $studentId, $sourceSchoolYear, $targetSchoolYear, 'admin') : $this->evaluate($studentId, $sourceSchoolYear, $targetSchoolYear, 'admin'); if ($alreadyEnrolled) { $flags = array_values(array_filter( $evaluation['flags'] ?? [], static fn (array $flag): bool => ($flag['flag_type'] ?? '') === 'PENDING_MAKE_UP_EXAM_PROMOTION' )); } else { $flags = $evaluation['flags'] ?? []; } } foreach ($flags as $flag) { if ($this->flagCoveredByException((string) ($flag['flag_type'] ?? ''), $bypassCodesByStudent[$studentId] ?? [])) { continue; } if ($this->upsertOpenFlag($studentId, $targetSchoolYear, $sourceSchoolYear, $flag, $performedBy)) { $written++; } } if ($isAdult) { $this->retireWithdrawnFlagsForAdultStudent($studentId, $targetSchoolYear); } } $written += $this->syncSiblingLastNameFlags($students, $enrolledIds, $bypassCodesByStudent, $targetSchoolYear, $sourceSchoolYear, $performedBy); $written += $this->syncFinancialFlags($students, $enrolledIds, $bypassCodesByStudent, $targetSchoolYear, $sourceSchoolYear, $performedBy); $written += $this->syncRepeatClassReassignmentFlags($targetSchoolYear, $sourceSchoolYear, $performedBy); $this->retireClassReassignmentFlags($targetSchoolYear); $this->retireClassCapacityExceptionFlags($targetSchoolYear); return $written; } private function writeFlags(array $evaluation, ?int $performedBy): void { foreach ($evaluation['flags'] ?? [] as $flag) { $this->upsertOpenFlag( (int) $evaluation['student_id'], (string) $evaluation['target_school_year'], (string) $evaluation['source_school_year'], $flag, $performedBy ); } } private function upsertOpenFlag( int $studentId, string $schoolYear, string $sourceSchoolYear, array $flag, ?int $performedBy ): bool { if ($studentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('enrollment_flags')) { return false; } $flagType = (string) ($flag['flag_type'] ?? ''); if ($flagType === '' || in_array($flagType, ['CLASS_CAPACITY_EXCEPTION_REQUIRED', 'CLASS_REASSIGNMENT_REQUIRED'], true)) { return false; } $existing = $this->db->table('enrollment_flags') ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->where('flag_type', $flagType) ->where('status', 'open') ->limit(1) ->get() ->getRowArray(); if ($existing !== null) { return false; } $this->db->table('enrollment_flags')->insert([ 'flag_type' => $flagType, 'student_id' => $studentId, 'school_year' => $schoolYear, 'source_school_year' => $sourceSchoolYear !== '' ? $sourceSchoolYear : null, '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'), ]); return true; } private function retireWithdrawnFlagsForAdultStudent(int $studentId, string $targetSchoolYear): void { if ($studentId <= 0 || $targetSchoolYear === '' || ! $this->db->tableExists('enrollment_flags')) { return; } $this->db->table('enrollment_flags') ->where('student_id', $studentId) ->where('school_year', $targetSchoolYear) ->where('flag_type', 'WITHDRAWAL_REVIEW_REQUIRED') ->where('status', 'open') ->update([ 'status' => 'resolved', 'resolved_at' => date('Y-m-d H:i:s'), 'resolution_notes' => 'Adult-student age rule takes priority over withdrawal review.', ]); } private function retireClassReassignmentFlags(string $targetSchoolYear): void { if (! $this->db->tableExists('enrollment_flags')) { return; } $builder = $this->db->table('enrollment_flags') ->where('flag_type', 'CLASS_REASSIGNMENT_REQUIRED') ->where('status', 'open'); if ($targetSchoolYear !== '') { $builder->where('school_year', $targetSchoolYear); } $builder->update([ 'status' => 'resolved', 'resolved_at' => date('Y-m-d H:i:s'), 'resolution_notes' => 'Class reassignment flags are no longer used for registration emails.', ]); } private function retireClassCapacityExceptionFlags(string $targetSchoolYear): void { if (! $this->db->tableExists('enrollment_flags')) { return; } $builder = $this->db->table('enrollment_flags') ->where('flag_type', 'CLASS_CAPACITY_EXCEPTION_REQUIRED') ->where('status', 'open'); if ($targetSchoolYear !== '') { $builder->where('school_year', $targetSchoolYear); } $builder->update([ 'status' => 'resolved', 'resolved_at' => date('Y-m-d H:i:s'), 'resolution_notes' => 'Class capacity exceptions are no longer used. Class assignment is handled by section autogeneration or enrollment management.', ]); } private function activeBypassCodesByStudent(string $schoolYear): array { if ($schoolYear === '' || ! $this->db->tableExists('enrollment_exceptions')) { return []; } $now = date('Y-m-d H:i:s'); $rows = $this->db->table('enrollment_exceptions') ->select('student_id, bypassed_rule_codes_json, reason_code') ->where('school_year', $schoolYear) ->where('status', 'active') ->groupStart() ->where('starts_at IS NULL', null, false) ->orWhere('starts_at <=', $now) ->groupEnd() ->groupStart() ->where('expires_at IS NULL', null, false) ->orWhere('expires_at >=', $now) ->groupEnd() ->get() ->getResultArray(); $byStudent = []; foreach ($rows as $row) { $studentId = (int) ($row['student_id'] ?? 0); if ($studentId <= 0) { continue; } $codes = json_decode((string) ($row['bypassed_rule_codes_json'] ?? ''), true); $codes = is_array($codes) ? array_values(array_filter(array_map('strval', $codes))) : []; $reason = strtoupper(trim((string) ($row['reason_code'] ?? ''))); if ($reason !== '') { $codes[] = $reason; } $byStudent[$studentId] = array_values(array_unique(array_merge($byStudent[$studentId] ?? [], $codes))); } return $byStudent; } private function flagCoveredByException(string $flagType, array $bypassCodes): bool { if ($flagType === '' || $bypassCodes === []) { return false; } $bypassCodes = array_map(static fn ($code): string => strtoupper(trim((string) $code)), $bypassCodes); $mapped = match ($flagType) { 'AGE_EXCEPTION_REQUIRED' => ['AGE_RULE_BLOCKED', 'AGE_EXCEPTION_REQUIRED'], 'LATE_REGISTRATION_EXCEPTION' => ['REGISTRATION_CLOSED', 'LATE_REGISTRATION_EXCEPTION'], 'FINANCIAL_REVIEW_REQUIRED' => ['OUTSTANDING_BALANCE_BLOCKED', 'FINANCE_APPROVAL_REQUIRED', 'FINANCIAL_REVIEW_REQUIRED'], 'RESTRICTED_ADMINISTRATIVE_REVIEW' => ['EXPELLED', 'DENIED', 'RESTRICTED_ADMINISTRATIVE_REVIEW'], 'WITHDRAWAL_REVIEW_REQUIRED' => ['WITHDRAWN', 'WITHDRAWAL_REVIEW_REQUIRED'], 'DEFERRED_DELIBERATION' => ['NO_FINAL_DECISION', 'UNRECOGNIZED_DECISION', 'DEFERRED_DECISION', 'DEFERRED_DELIBERATION'], 'SIBLING_LAST_NAME_MISMATCH' => ['SIBLING_LAST_NAME_MISMATCH', 'SIBLING_LAST_NAME_REVIEWED'], 'ADULT_STUDENT_ACTION_REQUIRED' => ['ADULT_STUDENT_PARENT_BLOCKED', 'ADULT_STUDENT_ACTION_REQUIRED'], default => [$flagType], }; return array_intersect($mapped, $bypassCodes) !== []; } private function previousSchoolYearName(string $schoolYear): ?string { return preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches) ? ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1) : null; } private function sourceYearStudents(string $sourceSchoolYear): array { $byId = []; if ($this->db->tableExists('student_class')) { $rows = $this->db->table('student_class sc') ->select('sc.student_id, s.parent_id, s.firstname, s.lastname, s.dob, cs.class_section_name') ->join('students s', 's.id = sc.student_id', 'left') ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') ->where('sc.school_year', $sourceSchoolYear) ->get() ->getResultArray(); foreach ($rows as $row) { $studentId = (int) ($row['student_id'] ?? 0); if ($studentId > 0) { $byId[$studentId] = $row; } } } if ($this->db->tableExists('enrollments')) { $rows = $this->db->table('enrollments e') ->select('e.student_id, s.parent_id, s.firstname, s.lastname, s.dob, cs.class_section_name') ->join('students s', 's.id = e.student_id', 'left') ->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left') ->where('e.school_year', $sourceSchoolYear) ->get() ->getResultArray(); foreach ($rows as $row) { $studentId = (int) ($row['student_id'] ?? 0); if ($studentId > 0 && ! isset($byId[$studentId])) { $byId[$studentId] = $row; } } } return array_values($byId); } private function latestDecisionsByStudent(string $sourceSchoolYear): array { if (! $this->db->tableExists('student_decisions')) { return []; } $select = ['student_id', 'decision', 'source', 'notes', 'class_section_name', 'updated_at', 'id']; if ($this->db->fieldExists('deliberation_decision_standard', 'student_decisions')) { $select[] = 'deliberation_decision_standard'; } $rows = $this->db->table('student_decisions') ->select($select) ->where('school_year', $sourceSchoolYear) ->orderBy('updated_at', 'DESC') ->orderBy('id', 'DESC') ->get() ->getResultArray(); $latest = []; foreach ($rows as $row) { $studentId = (int) ($row['student_id'] ?? 0); if ($studentId > 0 && ! isset($latest[$studentId])) { $latest[$studentId] = $row; } } return $latest; } private function activeTargetEnrollmentStudentIds(string $targetSchoolYear): array { if (! $this->db->tableExists('enrollments')) { return []; } $select = ['student_id', 'enrollment_status', 'admission_status']; if ($this->db->fieldExists('is_withdrawn', 'enrollments')) { $select[] = 'is_withdrawn'; } $rows = $this->db->table('enrollments') ->select(implode(', ', $select)) ->where('school_year', $targetSchoolYear) ->orderBy('updated_at', 'DESC') ->orderBy('id', 'DESC') ->get() ->getResultArray(); $ids = []; foreach ($rows as $row) { $studentId = (int) ($row['student_id'] ?? 0); if ($studentId <= 0 || isset($ids[$studentId])) { continue; } if ($this->activeEnrollmentBlocksDuplicate($row)) { $ids[$studentId] = true; } } return $ids; } /** * @return array */ private function targetEnrollmentReviewCodesByStudent(string $targetSchoolYear): array { if (! $this->db->tableExists('enrollments')) { return []; } $select = ['student_id', 'enrollment_status', 'admission_status']; if ($this->db->fieldExists('is_withdrawn', 'enrollments')) { $select[] = 'is_withdrawn'; } $rows = $this->db->table('enrollments') ->select(implode(', ', $select)) ->where('school_year', $targetSchoolYear) ->orderBy('updated_at', 'DESC') ->orderBy('id', 'DESC') ->get() ->getResultArray(); $reviewCodes = []; foreach ($rows as $row) { $studentId = (int) ($row['student_id'] ?? 0); if ($studentId <= 0 || isset($reviewCodes[$studentId]) || ! $this->deniedOrWithdrawnEnrollmentBlocksStandardEligibility($row)) { continue; } $status = strtolower(trim((string) ($row['enrollment_status'] ?? ''))); $admission = strtolower(trim((string) ($row['admission_status'] ?? ''))); $reviewCodes[$studentId] = [ 'rule_code' => $admission === 'denied' || $status === 'denied' ? 'DENIED' : 'WITHDRAWN', 'enrollment_status' => (string) ($row['enrollment_status'] ?? ''), 'is_withdrawn' => (int) ($row['is_withdrawn'] ?? 0), ]; } return $reviewCodes; } private function syncSiblingLastNameFlags( array $students, array $enrolledIds, array $bypassCodesByStudent, string $targetSchoolYear, string $sourceSchoolYear, ?int $performedBy ): int { $parentIds = []; $studentsByParent = []; foreach ($students as $student) { $parentId = (int) ($student['parent_id'] ?? 0); $studentId = (int) ($student['student_id'] ?? 0); if ($parentId <= 0 || $studentId <= 0) { continue; } $parentIds[$parentId] = $parentId; $studentsByParent[$parentId][] = $studentId; } if ($parentIds === [] || ! $this->db->tableExists('students')) { return 0; } $linked = $this->db->table('students') ->select('id, parent_id, lastname') ->whereIn('parent_id', array_values($parentIds)) ->get() ->getResultArray(); $namesByParent = []; foreach ($linked as $row) { $parentId = (int) ($row['parent_id'] ?? 0); $namesByParent[$parentId][] = $this->normalizeLastName($row['lastname'] ?? null); } $mismatchedParents = []; foreach ($namesByParent as $parentId => $names) { if (count($names) <= 1) { continue; } $unique = array_values(array_unique($names)); if (in_array('', $unique, true) || count($unique) > 1) { $mismatchedParents[$parentId] = true; } } $written = 0; foreach ($mismatchedParents as $parentId => $_) { foreach ($studentsByParent[$parentId] ?? [] as $studentId) { if (isset($enrolledIds[$studentId])) { continue; } if ($this->flagCoveredByException('SIBLING_LAST_NAME_MISMATCH', $bypassCodesByStudent[$studentId] ?? [])) { continue; } if ($this->upsertOpenFlag($studentId, $targetSchoolYear, $sourceSchoolYear, $this->flag('SIBLING_LAST_NAME_MISMATCH', 'high'), $performedBy)) { $written++; } } } return $written; } private function syncFinancialFlags( array $students, array $enrolledIds, array $bypassCodesByStudent, string $targetSchoolYear, string $sourceSchoolYear, ?int $performedBy ): int { $target = $this->schoolYearByName($targetSchoolYear) ?? []; $behavior = (string) ($target['carry_over_balance_behavior'] ?? 'submission_blocked_until_payment'); if (! in_array($behavior, ['submission_blocked_until_payment', 'admin_approval_required'], true)) { return 0; } if (! $this->db->tableExists('invoices')) { return 0; } $parentIds = []; $studentsByParent = []; foreach ($students as $student) { $parentId = (int) ($student['parent_id'] ?? 0); $studentId = (int) ($student['student_id'] ?? 0); if ($parentId <= 0 || $studentId <= 0) { continue; } $parentIds[$parentId] = $parentId; $studentsByParent[$parentId][] = $studentId; } if ($parentIds === []) { return 0; } $flagType = $behavior === 'admin_approval_required' ? 'FINANCIAL_REVIEW_REQUIRED' : 'FINANCIAL_REVIEW_REQUIRED'; $written = 0; foreach (array_values($parentIds) as $parentId) { $summary = $this->getEnrollmentFinancialSummary($parentId, $sourceSchoolYear, $targetSchoolYear); $balance = (float) ($summary['carry_forward_balance'] ?? 0.0); if ($balance <= 0.0) { $this->resolveOpenFinancialReviewFlagsForParent($parentId, $targetSchoolYear, $studentsByParent[$parentId] ?? []); continue; } foreach ($studentsByParent[$parentId] ?? [] as $studentId) { if (isset($enrolledIds[$studentId])) { continue; } if ($this->flagCoveredByException($flagType, $bypassCodesByStudent[$studentId] ?? [])) { continue; } if ($this->upsertOpenFlag( $studentId, $targetSchoolYear, $sourceSchoolYear, $this->flag($flagType, 'high', ['carry_over_balance' => $balance]), $performedBy )) { $written++; } } } return $written; } public function resolveOpenFinancialReviewFlagsForParent(int $parentId, string $schoolYear, array $studentIds = []): int { if ($parentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('enrollment_flags')) { return 0; } if ($studentIds === []) { $studentIds = array_values(array_filter(array_map( static fn (array $row): int => (int) ($row['id'] ?? 0), $this->linkedStudentsForParent($parentId) ))); } $studentIds = array_values(array_unique(array_filter(array_map('intval', $studentIds)))); if ($studentIds === []) { return 0; } $this->db->table('enrollment_flags') ->whereIn('student_id', $studentIds) ->where('school_year', $schoolYear) ->where('flag_type', 'FINANCIAL_REVIEW_REQUIRED') ->where('status', 'open') ->update([ 'status' => 'resolved', 'resolved_at' => date('Y-m-d H:i:s'), 'resolution_notes' => 'Automatically resolved because no outstanding carry-forward balance remains.', ]); return (int) $this->db->affectedRows(); } public function syncParentFinancialReviewFlags( int $parentId, string $sourceSchoolYear, string $targetSchoolYear, array $studentIds = [] ): void { $summary = $this->getEnrollmentFinancialSummary($parentId, $sourceSchoolYear, $targetSchoolYear); if ((float) ($summary['carry_forward_balance'] ?? 0.0) > 0.0) { return; } $this->resolveOpenFinancialReviewFlagsForParent($parentId, $targetSchoolYear, $studentIds); } /** * Refresh stored invoice balances after payment so enrollment and finance stay aligned. */ public function reconcileCarryForwardInvoicesAfterSourcePayment(int $parentId, string $schoolYear): int { return $this->refreshParentInvoiceBalances($parentId, $schoolYear); } public function refreshParentInvoiceBalances(int $parentId, string $schoolYear): int { if ($parentId <= 0 || ! $this->db->tableExists('invoices')) { return 0; } $ledger = $this->invoiceLedger(); $recalculated = 0; $invoiceIds = []; if ($schoolYear !== '') { $rows = $this->db->table('invoices') ->select('id') ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->get() ->getResultArray(); foreach ($rows as $row) { $invoiceIds[(int) ($row['id'] ?? 0)] = (int) ($row['id'] ?? 0); } } $carryForwardRows = $this->db->table('invoices') ->select('id, invoice_number, description') ->where('parent_id', $parentId) ->get() ->getResultArray(); $sourceToken = $schoolYear !== '' ? (preg_replace('/[^0-9A-Za-z]/', '', $schoolYear) ?? '') : ''; foreach ($carryForwardRows as $row) { if (! $this->isCarryForwardInvoiceRow($row)) { continue; } if ($schoolYear === '') { $invoiceIds[(int) ($row['id'] ?? 0)] = (int) ($row['id'] ?? 0); continue; } $invoiceNumber = (string) ($row['invoice_number'] ?? ''); $description = (string) ($row['description'] ?? ''); if ( ($sourceToken !== '' && str_contains($invoiceNumber, $sourceToken)) || str_contains($description, $schoolYear) ) { $invoiceIds[(int) ($row['id'] ?? 0)] = (int) ($row['id'] ?? 0); } } foreach (array_values(array_filter($invoiceIds)) as $invoiceId) { if ($invoiceId <= 0) { continue; } try { $ledger->recalculateInvoice($invoiceId); unset($this->resolvedInvoiceBalanceCache[$invoiceId]); $recalculated++; } catch (\Throwable) { // Leave enrollment logic to fall back to stored balances for this invoice. } } return $recalculated; } private function appendFlagsFromRuleCodes(array &$evaluation): void { $existing = []; foreach ($evaluation['flags'] ?? [] as $flag) { $type = (string) ($flag['flag_type'] ?? ''); if ($type !== '') { $existing[$type] = true; } } $codes = array_values(array_unique(array_merge( array_map('strval', $evaluation['blocking_rule_codes'] ?? []), array_map('strval', $evaluation['review_rule_codes'] ?? []) ))); $map = [ 'NO_FINAL_DECISION' => ['DEFERRED_DELIBERATION', 'high'], 'UNRECOGNIZED_DECISION' => ['DEFERRED_DELIBERATION', 'high'], 'DEFERRED_DECISION' => ['DEFERRED_DELIBERATION', 'high'], 'EXPELLED' => ['RESTRICTED_ADMINISTRATIVE_REVIEW', 'high'], 'WITHDRAWN' => ['WITHDRAWAL_REVIEW_REQUIRED', 'normal'], 'DENIED' => ['RESTRICTED_ADMINISTRATIVE_REVIEW', 'high'], 'SIBLING_LAST_NAME_MISMATCH' => ['SIBLING_LAST_NAME_MISMATCH', 'high'], 'OUTSTANDING_BALANCE_BLOCKED' => ['FINANCIAL_REVIEW_REQUIRED', 'high'], 'FINANCE_APPROVAL_REQUIRED' => ['FINANCIAL_REVIEW_REQUIRED', 'high'], 'AGE_RULE_BLOCKED' => ['AGE_EXCEPTION_REQUIRED', 'normal'], 'ADULT_STUDENT_PARENT_BLOCKED' => ['ADULT_STUDENT_ACTION_REQUIRED', 'normal'], 'EXIT_REQUIRED' => ['COMPLETION_OR_EXIT_PROCESS_REQUIRED', 'normal'], 'REGISTRATION_CLOSED' => ['LATE_REGISTRATION_EXCEPTION', 'normal'], ]; foreach ($codes as $code) { if (! isset($map[$code])) { continue; } [$type, $priority] = $map[$code]; if (isset($existing[$type])) { continue; } $evaluation['flags'][] = $this->flag($type, $priority, ['rule_code' => $code]); $existing[$type] = true; } } private function audit(array $evaluation, string $action, ?int $performedBy, ?array $original, array $new): void { if (! $this->db->tableExists('enrollment_transition_audits')) { return; } $snapshot = [ 'payload' => $new, 'decision' => $evaluation['decision'] ?? null, 'rule_codes' => $evaluation['rule_codes'] ?? [], 'blocking_rule_codes' => $evaluation['blocking_rule_codes'] ?? [], 'review_rule_codes' => $evaluation['review_rule_codes'] ?? [], 'warning_rule_codes' => $evaluation['warning_rule_codes'] ?? [], 'financial_summary' => $evaluation['financial_summary'] ?? null, 'exception_id' => $evaluation['admin_exception']['id'] ?? null, 'parent_id' => $evaluation['parent_id'] ?? ($new['parent_id'] ?? null), ]; $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($snapshot, JSON_UNESCAPED_SLASHES), 'reason' => implode(' ', $evaluation['blockers'] ?? []) ?: implode(' ', array_map('strval', $evaluation['rule_codes'] ?? [])), 'created_at' => date('Y-m-d H:i:s'), ]); } private function applyAdultStudentBlock(array &$result, array $student, ?int $age): void { $result['age_on_reference_date'] = $age; $result['adult_student'] = true; $result['parent_enrollment_allowed'] = false; $result['student_self_enrollment_allowed'] = false; $result['academic_eligible'] = false; $result['flags'][] = $this->flag('ADULT_STUDENT_ACTION_REQUIRED', 'normal', [ 'age_on_reference_date' => $age, 'rule_code' => 'ADULT_STUDENT_PARENT_BLOCKED', ]); $name = $this->studentName($student); $result['blockers'][] = $name === 'The student' ? EnrollmentEligibility::ADULT_STUDENT_MESSAGE : str_replace('This student', $name, EnrollmentEligibility::ADULT_STUDENT_MESSAGE); } 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'; } private function ensureDecisionFields(array &$evaluation): void { $evaluation['rule_codes'] = array_values(array_unique(array_map('strval', $evaluation['rule_codes'] ?? []))); $evaluation['blocking_rule_codes'] = array_values(array_unique(array_map('strval', $evaluation['blocking_rule_codes'] ?? []))); $evaluation['warning_rule_codes'] = array_values(array_unique(array_map('strval', $evaluation['warning_rule_codes'] ?? []))); $evaluation['review_rule_codes'] = array_values(array_unique(array_map('strval', $evaluation['review_rule_codes'] ?? []))); $evaluation['admin_exception'] = $evaluation['admin_exception'] ?? null; $evaluation['decision'] = $evaluation['decision'] ?? self::DECISION_REVIEW_REQUIRED; $evaluation['can_enroll'] = (bool) ($evaluation['parent_enrollment_allowed'] ?? false); foreach ($evaluation['blockers'] ?? [] as $message) { $this->addDerivedBlockerCode($evaluation, (string) $message); } foreach ($evaluation['warnings'] ?? [] as $message) { $this->addRuleCode($evaluation, $this->ruleCodeFromMessage((string) $message), 'warning'); } } private function deriveAcademicRuleCodes(array &$evaluation): void { $decision = (string) ($evaluation['deliberation_decision'] ?? ''); if (in_array($decision, [ DeliberationDecision::PASSED, DeliberationDecision::REPEAT_CLASS, DeliberationDecision::MAKE_UP_EXAM, ], true)) { $this->addRuleCode($evaluation, $decision, $decision === DeliberationDecision::MAKE_UP_EXAM ? 'warning' : null); } if (($evaluation['placement_status'] ?? '') === 'exit_required') { $this->addRuleCode($evaluation, 'EXIT_REQUIRED', 'blocking'); } foreach ($evaluation['flags'] ?? [] as $flag) { $flagType = (string) ($flag['flag_type'] ?? ''); if ($flagType !== '') { $this->addRuleCode($evaluation, $flagType, 'warning'); } } } private function addDerivedBlockerCode(array &$evaluation, string $message): void { $code = $this->ruleCodeFromMessage($message); $this->addRuleCode($evaluation, $code, $this->reviewCode($code) ? 'review' : 'blocking'); } private function ruleCodeFromMessage(string $message): string { $lower = strtolower($message); return match (true) { str_contains($lower, 'expelled') => 'EXPELLED', str_contains($lower, 'withdrawn') => 'WITHDRAWN', str_contains($lower, 'deferred') => 'DEFERRED_DECISION', str_contains($lower, 'no final deliberation') || str_contains($lower, 'no final academic') || str_contains($lower, 'no final decision') => 'NO_FINAL_DECISION', str_contains($lower, 'unrecognized') => 'UNRECOGNIZED_DECISION', str_contains($lower, '18 years old') || str_contains($lower, 'adult-student') || str_contains($lower, 'over the allowed age') => 'ADULT_STUDENT_PARENT_BLOCKED', str_contains($lower, 'registration for the new school year has not opened') => 'REGISTRATION_NOT_OPEN', str_contains($lower, 'registration deadline') => 'REGISTRATION_CLOSED', str_contains($lower, 'age rule') => 'AGE_RULE_BLOCKED', str_contains($lower, 'closing school year') => 'SOURCE_YEAR_NOT_FOUND', str_contains($lower, 'target school year') => 'TARGET_YEAR_NOT_FOUND', str_contains($lower, 'highest available grade') || str_contains($lower, 'exit process') => 'EXIT_REQUIRED', default => 'REVIEW_REQUIRED', }; } private function reviewCode(string $code): bool { return in_array($code, [ 'NO_FINAL_DECISION', 'UNRECOGNIZED_DECISION', 'DEFERRED_DECISION', 'REVIEW_REQUIRED', ], true); } private function addBlocker(array &$evaluation, string $code, string $message): void { $blockers = array_values(array_filter(array_map('strval', $evaluation['blockers'] ?? []))); if (! in_array($message, $blockers, true)) { $blockers[] = $message; } $evaluation['blockers'] = $blockers; $this->addRuleCode($evaluation, $code, $this->reviewCode($code) ? 'review' : 'blocking'); $evaluation['parent_enrollment_allowed'] = false; $evaluation['can_enroll'] = false; } private function addRuleCode(array &$evaluation, string $code, ?string $bucket = null): void { $code = strtoupper(trim($code)); if ($code === '') { return; } $evaluation['rule_codes'] = array_values(array_unique(array_merge($evaluation['rule_codes'] ?? [], [$code]))); if ($bucket !== null) { $field = $bucket . '_rule_codes'; $evaluation[$field] = array_values(array_unique(array_merge($evaluation[$field] ?? [], [$code]))); } } private function removeRuleCode(array &$evaluation, string $code): void { $code = strtoupper(trim($code)); if ($code === '') { return; } foreach (['rule_codes', 'blocking_rule_codes', 'warning_rule_codes', 'review_rule_codes'] as $field) { $evaluation[$field] = array_values(array_filter( array_map('strval', $evaluation[$field] ?? []), static fn (string $existing): bool => $existing !== $code )); } $lastNameMessages = [ 'Linked student last-name data must be reviewed by administration before re-enrollment.', 'Linked siblings have different last names. Please contact administration to review the family record.', ]; $evaluation['blockers'] = array_values(array_filter( array_map('strval', $evaluation['blockers'] ?? []), static fn (string $message): bool => ! in_array($message, $lastNameMessages, true) )); } private function activeEnrollmentBlocksDuplicate(?array $enrollment): bool { if ($enrollment === null) { return false; } if ((int) ($enrollment['is_withdrawn'] ?? 0) === 1) { return false; } $status = strtolower(trim((string) ($enrollment['enrollment_status'] ?? ''))); $admission = strtolower(trim((string) ($enrollment['admission_status'] ?? ''))); return $admission === 'accepted' || in_array($status, [ 'admission under review', 'review & decision', 'payment pending', 'enrolled', 'waitlist', 'withdraw under review', 'refund pending', ], true); } private function deniedOrWithdrawnEnrollmentBlocksStandardEligibility(?array $enrollment): bool { if ($enrollment === null) { return false; } $status = strtolower(trim((string) ($enrollment['enrollment_status'] ?? ''))); $admission = strtolower(trim((string) ($enrollment['admission_status'] ?? ''))); return $admission === 'denied' || $status === 'denied' || EnrollmentEligibility::isMarkedWithdrawn($enrollment); } private function applyHouseholdLastNameRule(array &$evaluation, int $parentId, ?string $sourceSchoolYear = null): void { if (! empty($evaluation['first_enrollment'])) { $evaluation['family_name_check_ok'] = true; return; } $students = $this->linkedStudentsForParent($parentId); $sourceSchoolYear = trim((string) $sourceSchoolYear); if ($sourceSchoolYear !== '') { $students = array_values(array_filter( $students, fn (array $student): bool => $this->sourceAssignment((int) ($student['id'] ?? 0), $sourceSchoolYear) !== null )); } if (count($students) <= 1) { $evaluation['family_name_check_ok'] = true; return; } $normalized = []; foreach ($students as $student) { $lastName = $this->normalizeLastName($student['lastname'] ?? null); if ($lastName === '') { $this->addBlocker($evaluation, 'SIBLING_LAST_NAME_MISMATCH', 'Linked student last-name data must be reviewed by administration before re-enrollment.'); $evaluation['family_name_check_ok'] = false; return; } $normalized[$lastName] = true; } $ok = count($normalized) === 1; $evaluation['family_name_check_ok'] = $ok; if (! $ok) { $this->addBlocker($evaluation, 'SIBLING_LAST_NAME_MISMATCH', 'Linked siblings have different last names. Please contact administration to review the family record.'); } } private function applyCarriedForwardLastNameException( array &$evaluation, int $parentId, int $studentId, string $sourceSchoolYear, string $targetSchoolYear ): void { if (! in_array('SIBLING_LAST_NAME_MISMATCH', $evaluation['blocking_rule_codes'] ?? [], true)) { return; } $financialBlockers = array_values(array_intersect( $evaluation['blocking_rule_codes'] ?? [], ['OUTSTANDING_BALANCE_BLOCKED', 'FINANCE_APPROVAL_REQUIRED'] )); if ($financialBlockers !== []) { return; } $prior = $this->priorLastNameException($parentId, $studentId, $targetSchoolYear, $sourceSchoolYear); if ($prior === null) { return; } $approvedStudentIds = $this->exceptionFamilyStudentIds($prior); $currentStudentIds = $this->linkedStudentIds($parentId); sort($currentStudentIds); if ($approvedStudentIds !== [] && $approvedStudentIds !== $currentStudentIds) { $evaluation['last_name_exception_carry_forward'] = [ 'eligible' => false, 'prior_exception_id' => (int) ($prior['id'] ?? 0), 'prior_school_year' => (string) ($prior['school_year'] ?? ''), 'approved_student_ids' => $approvedStudentIds, 'reason' => 'NEW_STUDENT_ADDED', ]; return; } $this->removeRuleCode($evaluation, 'SIBLING_LAST_NAME_MISMATCH'); $this->addRuleCode($evaluation, 'LAST_NAME_EXCEPTION_CARRIED_FORWARD', 'warning'); $evaluation['warnings'] = array_values(array_unique(array_merge( array_map('strval', $evaluation['warnings'] ?? []), ['A previous last-name exception still applies for this student.'] ))); $evaluation['family_name_check_ok'] = true; $evaluation['last_name_exception_carry_forward'] = [ 'eligible' => true, 'prior_exception_id' => (int) ($prior['id'] ?? 0), 'prior_school_year' => (string) ($prior['school_year'] ?? ''), 'approved_student_ids' => $approvedStudentIds !== [] ? $approvedStudentIds : [(int) ($prior['student_id'] ?? $studentId)], ]; } public function linkedStudentIds(int $parentId): array { return array_values(array_unique(array_filter(array_map( static fn (array $student): int => (int) ($student['id'] ?? 0), $this->linkedStudentsForParent($parentId) ), static fn (int $id): bool => $id > 0))); } private function priorLastNameException(int $parentId, int $studentId, string $targetSchoolYear, string $sourceSchoolYear): ?array { if ($parentId <= 0 || $studentId <= 0 || ! $this->db->tableExists('enrollment_exceptions')) { return null; } $rows = $this->db->table('enrollment_exceptions') ->where('parent_id', $parentId) ->where('student_id', $studentId) ->whereIn('status', ['active', 'used']) ->orderBy('id', 'DESC') ->get() ->getResultArray(); $sourceMatch = null; $otherPrior = null; foreach ($rows as $row) { if ((int) ($row['student_id'] ?? 0) !== $studentId) { continue; } $year = (string) ($row['school_year'] ?? ''); if ($year === '' || $year === $targetSchoolYear || ! $this->exceptionBypassesLastName($row)) { continue; } if ($year === $sourceSchoolYear && $sourceMatch === null) { $sourceMatch = $row; } elseif ($otherPrior === null) { $otherPrior = $row; } } return $sourceMatch ?? $otherPrior; } private function exceptionBypassesLastName(array $exception): bool { $reason = strtoupper(trim((string) ($exception['reason_code'] ?? ''))); if (in_array($reason, ['SIBLING_LAST_NAME_MISMATCH', 'SIBLING_LAST_NAME_REVIEWED'], true) || str_contains($reason, 'LAST_NAME') ) { return true; } $codes = json_decode((string) ($exception['bypassed_rule_codes_json'] ?? ''), true); if (! is_array($codes)) { return false; } $codes = array_map(static fn ($code): string => strtoupper(trim((string) $code)), $codes); return in_array('SIBLING_LAST_NAME_MISMATCH', $codes, true); } /** * @return list */ private function exceptionFamilyStudentIds(array $exception): array { $ids = json_decode((string) ($exception['family_student_ids_json'] ?? ''), true); if (! is_array($ids)) { return []; } $ids = array_values(array_unique(array_filter(array_map( static fn ($id): int => (int) $id, $ids ), static fn (int $id): bool => $id > 0))); sort($ids); return $ids; } private function linkedStudentsForParent(int $parentId): array { if ($parentId <= 0 || ! $this->db->tableExists('students')) { return []; } return $this->db->table('students') ->select('id, firstname, lastname, parent_id') ->where('parent_id', $parentId) ->orderBy('lastname', 'ASC') ->orderBy('firstname', 'ASC') ->get() ->getResultArray(); } private function normalizeLastName(mixed $value): string { $value = strtolower(trim((string) $value)); $value = str_replace(['’', '`', '´'], "'", $value); $value = preg_replace('/[^\p{L}\p{N}\s]+/u', '', $value) ?? ''; $value = preg_replace('/\s+/u', ' ', $value) ?? ''; return trim($value); } private function controllingEnrollment(int $studentId, string $schoolYear): ?array { return $this->enrollmentStatusService()->controllingEnrollment($studentId, $schoolYear); } private function enrollmentStatusService(): EnrollmentStatusService { return $this->statusService ?? new EnrollmentStatusService($this->db); } /** * @return array */ public function getEnrollmentFinancialSummary(int $parentId, string $sourceSchoolYear, string $targetSchoolYear, float $tuitionDue = 0.0): array { $target = $this->schoolYearByName($targetSchoolYear) ?? []; $targetBalances = $this->invoiceBalancesByType($parentId, $targetSchoolYear); $carryForward = round((float) ($targetBalances['carry_forward'] ?? 0.0), 2); $sourcePositiveOutstanding = $sourceSchoolYear !== '' ? $this->positiveOutstandingBalance($parentId, $sourceSchoolYear) : 0.0; $hasTargetYearCarryForwardInvoices = $this->parentHasTargetYearCarryForwardInvoices($parentId, $targetSchoolYear); if ($sourcePositiveOutstanding <= 0.0) { // Previous-year invoices are settled (including discounted/paid accounts). // Ignore stale carry-forward invoices that may still show an opening balance. $carryForward = 0.0; } elseif ($carryForward <= 0.0 && $sourceSchoolYear !== '' && ! $hasTargetYearCarryForwardInvoices) { $carryForward = round($sourcePositiveOutstanding, 2); } $currentYearBalance = round((float) ($targetBalances['current_year'] ?? 0.0), 2); $totalExistingBalance = round(max(0.0, $carryForward) + max(0.0, $currentYearBalance), 2); $tuitionDue = round(max(0.0, $tuitionDue), 2); $behavior = (string) ($target['carry_over_balance_behavior'] ?? 'submission_blocked_until_payment'); return [ 'currency' => '$', 'source_school_year' => $sourceSchoolYear, 'target_school_year' => $targetSchoolYear, 'carry_forward_balance' => $carryForward, 'carry_over_balance' => $carryForward, 'current_year_balance' => $currentYearBalance, 'current_balance' => $currentYearBalance, 'total_existing_balance' => $totalExistingBalance, 'tuition_due_at_registration' => $tuitionDue, 'total_enrollment_due' => round($totalExistingBalance + $tuitionDue, 2), 'amount_due' => round($totalExistingBalance + $tuitionDue, 2), 'balance_behavior' => $behavior, 'payment_plan_available' => (bool) ($target['payment_plan_available'] ?? false), 'evaluated_at' => date('Y-m-d H:i:s'), ]; } private function applyFinancialRule(array &$evaluation, int $parentId, string $sourceSchoolYear, string $targetSchoolYear): void { $summary = $this->getEnrollmentFinancialSummary($parentId, $sourceSchoolYear, $targetSchoolYear); $evaluation['financial_summary'] = $summary; $balance = (float) ($summary['carry_forward_balance'] ?? 0.0); $behavior = (string) ($summary['balance_behavior'] ?? 'submission_blocked_until_payment'); if ($balance <= 0.0 || ! in_array($behavior, self::BLOCKING_BALANCE_BEHAVIORS, true)) { return; } if ($behavior === 'admin_approval_required') { $this->addBlocker($evaluation, 'FINANCE_APPROVAL_REQUIRED', EnrollmentEligibility::FINANCE_APPROVAL_PORTAL_MESSAGE); return; } $this->addBlocker($evaluation, 'OUTSTANDING_BALANCE_BLOCKED', EnrollmentEligibility::BALANCE_PORTAL_MESSAGE); } /** * @return array{carry_forward: float, current_year: float} */ private function invoiceBalancesByType(int $parentId, string $schoolYear): array { if ($parentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('invoices')) { return ['carry_forward' => 0.0, 'current_year' => 0.0]; } $rows = $this->db->table('invoices') ->select('id, invoice_number, semester, description, balance, status') ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->get() ->getResultArray(); $carryForward = 0.0; $currentYear = 0.0; foreach ($rows as $row) { $balance = round($this->enrollmentOutstandingBalance($row), 2); if ($balance <= 0.0) { continue; } if ($this->isCarryForwardInvoiceRow($row)) { $carryForward += $balance; } else { $currentYear += $balance; } } return [ 'carry_forward' => round($carryForward, 2), 'current_year' => round($currentYear, 2), ]; } private function parentHasTargetYearCarryForwardInvoices(int $parentId, string $targetSchoolYear): bool { if ($parentId <= 0 || $targetSchoolYear === '' || ! $this->db->tableExists('invoices')) { return false; } $rows = $this->db->table('invoices') ->select('invoice_number, semester, description') ->where('parent_id', $parentId) ->where('school_year', $targetSchoolYear) ->get() ->getResultArray(); foreach ($rows as $row) { if ($this->isCarryForwardInvoiceRow($row)) { return true; } } return false; } /** * @param array $invoice */ private function isCarryForwardInvoiceRow(array $invoice): bool { $invoiceNumber = (string) ($invoice['invoice_number'] ?? ''); if (str_starts_with($invoiceNumber, 'CF-')) { return true; } if (strcasecmp((string) ($invoice['semester'] ?? ''), 'Opening Balance') === 0) { return true; } $description = strtolower((string) ($invoice['description'] ?? '')); return str_contains($description, 'carried over') || str_contains($description, 'carry-forward') || str_contains($description, 'carry over') || str_contains($description, 'previous school year'); } private function financialSummary(int $parentId, string $sourceSchoolYear, string $targetSchoolYear): array { return $this->getEnrollmentFinancialSummary($parentId, $sourceSchoolYear, $targetSchoolYear); } private function invoiceBalanceForParent(int $parentId, string $schoolYear): float { return $this->positiveOutstandingBalance($parentId, $schoolYear); } private function positiveOutstandingBalance(int $parentId, string $schoolYear): float { if ($parentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('invoices')) { return 0.0; } $rows = $this->db->table('invoices') ->select('id, invoice_number, semester, description, balance, status') ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->get() ->getResultArray(); $total = 0.0; foreach ($rows as $row) { $total += round($this->enrollmentOutstandingBalance($row), 2); } return round($total, 2); } /** * Enrollment should not block on invoices already settled in the billing UI, and * should not inflate balances when ledger recalculation omits legacy discount rows. * * @param array $invoice */ private function enrollmentOutstandingBalance(array $invoice): float { $storedBalance = round((float) ($invoice['balance'] ?? 0.0), 2); if ($storedBalance <= 0.0) { return 0.0; } $normalizedStatus = FinancialStatus::normalizeInvoiceStatus((string) ($invoice['status'] ?? '')); if (in_array($normalizedStatus, [ FinancialStatus::INVOICE_PAID, FinancialStatus::INVOICE_CREDITED, FinancialStatus::INVOICE_OVERPAID, FinancialStatus::INVOICE_VOIDED, FinancialStatus::INVOICE_CANCELLED, ], true)) { return 0.0; } $ledgerBalance = max(0.0, round($this->resolvedInvoiceBalance($invoice), 2)); return min($storedBalance, $ledgerBalance); } private function isSettledInvoiceStatus(string $status): bool { $normalizedStatus = FinancialStatus::normalizeInvoiceStatus($status); return in_array($normalizedStatus, [ FinancialStatus::INVOICE_PAID, FinancialStatus::INVOICE_CREDITED, FinancialStatus::INVOICE_OVERPAID, FinancialStatus::INVOICE_VOIDED, FinancialStatus::INVOICE_CANCELLED, ], true); } /** * @param array $invoice */ private function resolvedInvoiceBalance(array $invoice): float { $invoiceId = (int) ($invoice['id'] ?? 0); if ($invoiceId <= 0) { return round((float) ($invoice['balance'] ?? 0.0), 2); } if (! array_key_exists($invoiceId, $this->resolvedInvoiceBalanceCache)) { try { $calculation = $this->invoiceLedger()->calculateInvoice($invoiceId); $this->resolvedInvoiceBalanceCache[$invoiceId] = round((float) ($calculation['balance'] ?? 0.0), 2); } catch (\Throwable) { $this->resolvedInvoiceBalanceCache[$invoiceId] = round((float) ($invoice['balance'] ?? 0.0), 2); } } return $this->resolvedInvoiceBalanceCache[$invoiceId]; } private function invoiceLedger(): InvoiceLedgerService { return $this->invoiceLedgerService ??= new InvoiceLedgerService(); } private function applyScopedException(array &$evaluation, int $parentId, int $studentId, string $sourceSchoolYear, string $targetSchoolYear): void { $blockingCodes = array_values(array_unique(array_map('strval', $evaluation['blocking_rule_codes'] ?? []))); $reviewCodes = array_values(array_unique(array_map('strval', $evaluation['review_rule_codes'] ?? []))); $failedCodes = array_values(array_unique(array_merge($blockingCodes, $reviewCodes))); if ($failedCodes === []) { return; } $nonOverridable = array_values(array_intersect($failedCodes, self::NON_OVERRIDABLE_RULE_CODES)); if ($nonOverridable !== []) { return; } $exception = $this->applicableException($parentId, $studentId, $targetSchoolYear); if ($exception === null) { return; } $overridableFailed = array_values(array_diff($failedCodes, self::NON_OVERRIDABLE_RULE_CODES)); $recordedCodes = json_decode((string) ($exception['bypassed_rule_codes_json'] ?? ''), true); $recordedCodes = is_array($recordedCodes) ? array_values(array_filter(array_map('strval', $recordedCodes))) : []; $applicableCodes = array_values(array_intersect($overridableFailed, $recordedCodes)); if ($applicableCodes === []) { return; } $evaluation['admin_exception'] = [ 'id' => (int) $exception['id'], 'reason_code' => (string) ($exception['reason_code'] ?? ''), 'source_school_year' => $sourceSchoolYear, 'status' => (string) ($exception['status'] ?? ''), 'bypassed_rule_codes' => $applicableCodes, ]; $evaluation['decision'] = self::DECISION_EXCEPTION_ELIGIBLE; $evaluation['parent_enrollment_allowed'] = true; $evaluation['can_enroll'] = true; } /** * Active exceptions authorize enrollment. Used exceptions remain authoritative for the * same school year so later admin steps (class assignment, status advances) stay allowed. */ private function applicableException(int $parentId, int $studentId, string $schoolYear): ?array { if ($parentId <= 0 || $studentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('enrollment_exceptions')) { return null; } $now = date('Y-m-d H:i:s'); $rows = $this->db->table('enrollment_exceptions') ->where('parent_id', $parentId) ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->whereIn('status', ['active', 'used']) ->orderBy('created_at', 'DESC') ->orderBy('id', 'DESC') ->get() ->getResultArray(); foreach ($rows as $row) { $status = strtolower(trim((string) ($row['status'] ?? ''))); if ($status === 'used') { return $row; } if ($status !== 'active') { continue; } $startsAt = trim((string) ($row['starts_at'] ?? '')); $expiresAt = trim((string) ($row['expires_at'] ?? '')); if ($startsAt !== '' && $startsAt > $now) { continue; } if ($expiresAt !== '' && $expiresAt < $now) { continue; } return $row; } return null; } private function activeException(int $parentId, int $studentId, string $schoolYear): ?array { return $this->applicableException($parentId, $studentId, $schoolYear); } private function finalizeParentDecision(array &$evaluation): void { if (($evaluation['admin_exception'] ?? null) !== null && ! in_array('ALREADY_ENROLLED', $evaluation['blocking_rule_codes'] ?? [], true)) { $evaluation['decision'] = self::DECISION_EXCEPTION_ELIGIBLE; $evaluation['parent_enrollment_allowed'] = true; $evaluation['can_enroll'] = true; return; } if (in_array('ALREADY_ENROLLED', $evaluation['blocking_rule_codes'] ?? [], true)) { $evaluation['decision'] = self::DECISION_ALREADY_ENROLLED; $evaluation['parent_enrollment_allowed'] = false; $evaluation['can_enroll'] = false; return; } if (($evaluation['blocking_rule_codes'] ?? []) !== []) { $evaluation['decision'] = self::DECISION_INELIGIBLE; $evaluation['parent_enrollment_allowed'] = false; $evaluation['can_enroll'] = false; return; } if (($evaluation['review_rule_codes'] ?? []) !== []) { $evaluation['decision'] = self::DECISION_REVIEW_REQUIRED; $evaluation['parent_enrollment_allowed'] = false; $evaluation['can_enroll'] = false; return; } if (! empty($evaluation['warnings']) || ($evaluation['warning_rule_codes'] ?? []) !== []) { $evaluation['decision'] = self::DECISION_ELIGIBLE_WITH_WARNING; } else { $evaluation['decision'] = self::DECISION_ELIGIBLE; } $evaluation['parent_enrollment_allowed'] = true; $evaluation['can_enroll'] = true; } }