Files
alrahma_sunday_school/app/Services/EnrollmentTransitionService.php
T
root 9676261d65
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Successful in 1m19s
fix registration enrollment of students
2026-08-26 21:36:03 -04:00

2609 lines
103 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services;
use App\Libraries\FinancialStatus;
use App\Libraries\InvoiceLedgerService;
use App\Support\Enrollment\DeliberationDecision;
use App\Support\Enrollment\EnrollmentEligibility;
use CodeIgniter\Database\BaseConnection;
use DateTimeImmutable;
use DateTimeInterface;
use RuntimeException;
final class EnrollmentTransitionService
{
private const DECISION_ELIGIBLE = 'ELIGIBLE';
private const DECISION_ELIGIBLE_WITH_WARNING = 'ELIGIBLE_WITH_WARNING';
private const DECISION_INELIGIBLE = 'INELIGIBLE';
private const DECISION_REVIEW_REQUIRED = 'REVIEW_REQUIRED';
private const DECISION_EXCEPTION_ELIGIBLE = 'EXCEPTION_ELIGIBLE';
private const DECISION_ALREADY_ENROLLED = 'ALREADY_ENROLLED';
/** Admin exceptions may override every eligibility rule except adult-student age. */
private const NON_OVERRIDABLE_RULE_CODES = [
'ADULT_STUDENT_PARENT_BLOCKED',
];
/** @var list<string> */
private const BLOCKING_BALANCE_BEHAVIORS = [
'submission_blocked_until_payment',
'admin_approval_required',
];
/** @var array<int, float> */
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<string>
*/
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;
}
}
if ($this->db->fieldExists('school_year', 'students')) {
return trim((string) ($student['school_year'] ?? '')) === $targetSchoolYear;
}
return ! $this->studentHasPriorSchoolHistory($studentId, $targetSchoolYear);
}
private function studentHasPriorSchoolHistory(int $studentId, string $targetSchoolYear): bool
{
if ($studentId <= 0) {
return false;
}
foreach (['student_class', 'enrollments', 'student_decisions'] as $table) {
if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) {
continue;
}
$count = $this->db->table($table)
->where('student_id', $studentId)
->where('school_year !=', $targetSchoolYear)
->countAllResults();
if ($count > 0) {
return true;
}
}
return false;
}
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<int, array{rule_code: string, enrollment_status: string, is_withdrawn: int}>
*/
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
{
$students = $this->linkedStudentsForParent($parentId);
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<int>
*/
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<string, mixed>
*/
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<string, mixed> $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<string, mixed> $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<string, mixed> $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;
}
}