Files
alrahma_sunday_school/app/Services/EnrollmentTransitionService.php
T
root 0a31aa1393
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Successful in 1m16s
fix is_active student flag logic and make moving with enrollment status
2026-08-20 03:11:37 -04:00

1758 lines
71 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\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';
private const NON_OVERRIDABLE_RULE_CODES = [
'STUDENT_NOT_LINKED',
'SOURCE_YEAR_NOT_FOUND',
'TARGET_YEAR_NOT_FOUND',
'ALREADY_ENROLLED',
];
public function __construct(private readonly BaseConnection $db)
{
}
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 ($sourceSchoolYear === '' || $this->sourceAssignment($studentId, $sourceSchoolYear) === null) {
$this->addBlocker($evaluation, 'SOURCE_YEAR_NOT_FOUND', 'Student does not belong to the closing school year.');
}
$existing = $this->latestEnrollment($studentId, $targetSchoolYear);
if ($this->activeEnrollmentBlocksDuplicate($existing)) {
$this->addBlocker($evaluation, 'ALREADY_ENROLLED', 'Student already has an active enrollment for the selected school year.');
} 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);
$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->appendFlagsFromRuleCodes($evaluation);
return $evaluation;
}
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);
$result = [
'student_id' => $studentId,
'source_school_year' => $sourceSchoolYear,
'target_school_year' => $targetSchoolYear,
'deliberation_decision' => $decision,
'decision_label' => DeliberationDecision::display($decisionRow['decision'] ?? ''),
'source_grade_id' => $sourceAssignment['class_id'] ?? null,
'source_grade_name' => $sourceAssignment['class_name'] ?? null,
'source_class_section_id' => $sourceAssignment['class_section_id'] ?? null,
'source_class_section_name' => $sourceAssignment['class_section_name'] ?? null,
'assigned_grade_id' => null,
'assigned_class_section_id' => null,
'placement_status' => 'not_created',
'academic_eligible' => false,
'parent_enrollment_allowed' => false,
'student_self_enrollment_allowed' => false,
'administrative_enrollment_allowed' => true,
'age_reference_date' => $this->ageReferenceDate($targetSchoolYear),
'age_on_reference_date' => null,
'adult_student' => false,
'registration_window_status' => 'unknown',
'blockers' => [],
'warnings' => [],
'flags' => [],
];
if ($student === null) {
$result['blockers'][] = 'Student record was not found.';
return $result;
}
if ($sourceAssignment === null) {
$result['blockers'][] = 'Student does not belong to the closing school year.';
return $result;
}
if ($decisionRow === null || $decision === null) {
$result['blockers'][] = EnrollmentEligibility::MISSING_DECISION_MESSAGE;
$result['flags'][] = $this->flag('DEFERRED_DELIBERATION', 'high', [
'reason' => 'Missing or unrecognized final deliberation decision.',
]);
return $result;
}
if ($decision === DeliberationDecision::EXPELLED) {
$result['blockers'][] = EnrollmentEligibility::EXPELLED_MESSAGE;
$result['flags'][] = $this->flag('RESTRICTED_ADMINISTRATIVE_REVIEW', 'high');
return $result;
}
if ($decision === DeliberationDecision::WITHDRAWN) {
$result['blockers'][] = EnrollmentEligibility::WITHDRAWN_MESSAGE;
$result['flags'][] = $this->flag('WITHDRAWAL_REVIEW_REQUIRED', 'normal');
return $result;
}
if ($decision === DeliberationDecision::DEFERRED_DECISION) {
$result['blockers'][] = EnrollmentEligibility::DEFERRED_MESSAGE;
$result['flags'][] = $this->flag('DEFERRED_DELIBERATION', 'high');
return $result;
}
$placement = $this->placement($decision, $sourceAssignment, $targetSchoolYear);
$result = array_replace($result, $placement);
$result['academic_eligible'] = $placement['placement_status'] !== 'exit_required';
if ($placement['placement_status'] === 'exit_required') {
$result['blockers'][] = 'The student has passed the highest available grade and must follow the school completion or exit process.';
$result['flags'][] = $this->flag('COMPLETION_OR_EXIT_PROCESS_REQUIRED', 'normal', [
'source_class' => $sourceAssignment['class_section_name'] ?? null,
]);
}
$age = EnrollmentEligibility::ageOnSeptemberFirst($student['dob'] ?? null, $targetSchoolYear);
$result['age_on_reference_date'] = $age;
$result['adult_student'] = $age !== null && $age >= 18;
$result['parent_enrollment_allowed'] = $result['academic_eligible'];
$result['student_self_enrollment_allowed'] = $result['academic_eligible'] && (bool) ($targetYear['adult_student_registration_enabled'] ?? false);
if ($result['adult_student']) {
$result['parent_enrollment_allowed'] = false;
$result['flags'][] = $this->flag('ADULT_STUDENT_ACTION_REQUIRED', 'normal', [
'age_on_reference_date' => $age,
]);
if ($actorRole === 'parent') {
$result['blockers'][] = str_replace('The student', $this->studentName($student), EnrollmentEligibility::ADULT_STUDENT_MESSAGE);
}
}
$this->applyRegistrationWindow($result, $targetYear, $now, $actorRole);
$this->applyAgeRules($result, $targetSchoolYear, $age);
if ($result['blockers'] === [] && $result['academic_eligible']) {
if ($actorRole === 'parent') {
$result['parent_enrollment_allowed'] = $result['parent_enrollment_allowed'] && ! $result['adult_student'];
}
} elseif ($actorRole === 'parent') {
$result['parent_enrollment_allowed'] = false;
} elseif ($actorRole === 'student') {
$result['student_self_enrollment_allowed'] = false;
}
return $result;
}
public function applyInitialTransition(
int $studentId,
string $sourceSchoolYear,
string $targetSchoolYear,
?int $parentId,
?int $performedBy = null,
string $actorRole = 'admin'
): array {
$evaluation = $this->evaluate($studentId, $sourceSchoolYear, $targetSchoolYear, $actorRole);
if (! $evaluation['academic_eligible'] || $evaluation['blockers'] !== []) {
$this->writeFlags($evaluation, $performedBy);
$this->audit($evaluation, 'transition_evaluation_blocked', $performedBy, null, $evaluation);
return $evaluation;
}
$this->db->transStart();
$original = $this->latestEnrollment($studentId, $targetSchoolYear);
$student = $this->student($studentId);
$parentId = $parentId ?? (is_numeric($student['parent_id'] ?? null) ? (int) $student['parent_id'] : null);
$payload = [
'student_id' => $studentId,
'parent_id' => $parentId,
'school_year' => $targetSchoolYear,
'source_school_year' => $sourceSchoolYear,
'deliberation_decision' => $evaluation['deliberation_decision'],
'source_grade_id' => $evaluation['source_grade_id'],
'assigned_grade_id' => $evaluation['assigned_grade_id'],
'source_class_section_id' => $evaluation['source_class_section_id'],
'assigned_class_section_id' => $evaluation['assigned_class_section_id'],
'class_section_id' => $evaluation['assigned_class_section_id'],
'placement_status' => $evaluation['placement_status'],
'age_reference_date' => $evaluation['age_reference_date'],
'age_on_reference_date' => $evaluation['age_on_reference_date'],
'adult_student' => $evaluation['adult_student'] ? 1 : 0,
'parent_enrollment_allowed' => $evaluation['parent_enrollment_allowed'] ? 1 : 0,
'student_self_enrollment_allowed' => $evaluation['student_self_enrollment_allowed'] ? 1 : 0,
'exception_required' => $evaluation['flags'] !== [] ? 1 : 0,
'exception_reason' => $evaluation['flags'] !== [] ? implode(', ', array_column($evaluation['flags'], 'flag_type')) : null,
'enrollment_date' => date('Y-m-d'),
'enrollment_status' => 'admission under review',
'admission_status' => 'pending',
'updated_at' => date('Y-m-d H:i:s'),
];
if ($original !== null) {
$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'
);
if ((int) ($evaluation['assigned_class_section_id'] ?? 0) > 0) {
$this->upsertStudentClass($studentId, (int) $evaluation['assigned_class_section_id'], $targetSchoolYear, $performedBy);
}
$this->writeFlags($evaluation, $performedBy);
$this->audit($evaluation, 'initial_transition_applied', $performedBy, $original, $payload);
$this->db->transComplete();
if ($this->db->transStatus() === false) {
throw new RuntimeException('Unable to apply enrollment transition.');
}
return $evaluation;
}
private function placement(string $decision, array $sourceAssignment, string $targetSchoolYear): array
{
$sourceClassId = (int) ($sourceAssignment['class_id'] ?? 0);
$sourceClassName = trim((string) ($sourceAssignment['class_name'] ?? $sourceAssignment['class_section_name'] ?? ''));
$sourceSectionName = trim((string) ($sourceAssignment['class_section_name'] ?? ''));
if ($decision === DeliberationDecision::PASSED) {
$targetClass = $this->nextClass($sourceClassName, $targetSchoolYear);
return [
'assigned_grade_id' => $targetClass['id'] ?? null,
'assigned_grade_name' => $targetClass['class_name'] ?? null,
'assigned_class_section_id' => null,
'placement_status' => $targetClass === null ? 'exit_required' : 'automatic_distribution_pending',
'flags' => [],
];
}
if ($decision === DeliberationDecision::REPEAT_CLASS) {
$targetSection = $this->matchingSection($sourceSectionName, $sourceClassId, $targetSchoolYear);
$flags = [];
if ($targetSection === null) {
$flags[] = $this->flag('CLASS_REASSIGNMENT_REQUIRED', 'normal', [
'previous_class_section_name' => $sourceSectionName,
]);
} elseif ($this->sectionAtCapacity((int) $targetSection['class_section_id'])) {
$flags[] = $this->flag('CLASS_CAPACITY_EXCEPTION_REQUIRED', 'normal', [
'class_section_id' => (int) $targetSection['class_section_id'],
]);
}
return [
'assigned_grade_id' => $targetSection['class_id'] ?? $this->sameClassInTargetYear($sourceClassName, $targetSchoolYear)['id'] ?? ($sourceClassId ?: null),
'assigned_grade_name' => $this->classNameForId((int) ($targetSection['class_id'] ?? 0)) ?? $sourceClassName,
'assigned_class_section_id' => $targetSection['class_section_id'] ?? null,
'placement_status' => $targetSection === null ? 'manual_class_required' : 'same_class_assigned',
'flags' => $flags,
];
}
if ($decision === DeliberationDecision::MAKE_UP_EXAM) {
$targetSection = $this->matchingSection($sourceSectionName, $sourceClassId, $targetSchoolYear);
return [
'assigned_grade_id' => $targetSection['class_id'] ?? $this->sameClassInTargetYear($sourceClassName, $targetSchoolYear)['id'] ?? ($sourceClassId ?: null),
'assigned_grade_name' => $this->classNameForId((int) ($targetSection['class_id'] ?? 0)) ?? $sourceClassName,
'assigned_class_section_id' => $targetSection['class_section_id'] ?? null,
'placement_status' => $targetSection === null ? 'temporary_manual_class_required' : 'temporary_same_grade',
'flags' => [
$this->flag('PENDING_MAKE_UP_EXAM_PROMOTION', 'high', [
'current_grade_id' => $sourceClassId ?: null,
'expected_promoted_grade_id' => $this->nextClass($sourceClassName, $targetSchoolYear)['id'] ?? null,
'current_class_section_id' => $targetSection['class_section_id'] ?? null,
]),
],
];
}
return ['assigned_grade_id' => null, 'assigned_class_section_id' => null, 'placement_status' => 'not_created', 'flags' => []];
}
private function applyRegistrationWindow(array &$result, ?array $targetYear, DateTimeImmutable $now, string $actorRole): void
{
if ($targetYear === null) {
$result['registration_window_status'] = 'missing_school_year';
$result['blockers'][] = 'Target school year configuration was not found.';
return;
}
$opensAt = $this->dateTimeFromYear($targetYear, 'registration_opens_at', 'registration_starts_on', false);
$deadlineAt = $this->dateTimeFromYear($targetYear, 'registration_deadline_at', 'registration_ends_on', true);
if ($opensAt !== null && $now < $opensAt) {
$result['registration_window_status'] = 'not_open';
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
{
return $this->db->table('students')
->select('id, firstname, lastname, dob, parent_id')
->where('id', $studentId)
->limit(1)
->get()
->getRowArray() ?: null;
}
private function schoolYearByName(string $schoolYear): ?array
{
if (! $this->db->tableExists('school_years')) {
return null;
}
return $this->db->table('school_years')->where('name', $schoolYear)->limit(1)->get()->getRowArray() ?: null;
}
private function nextClass(string $sourceClassName, string $targetSchoolYear): ?array
{
$base = $this->classBaseName($sourceClassName);
$target = match (true) {
$base === 'KG' || str_contains($base, 'KINDERGARTEN') => '1',
ctype_digit($base) => (string) ((int) $base + 1),
$base === 'YOUTH' => 'YOUTH',
default => '',
};
if ($target === '') {
return null;
}
$targetClass = $this->classByName($target, $targetSchoolYear);
if ($targetClass !== null) {
return $targetClass;
}
return ctype_digit($base) && (int) $base >= 9
? $this->classByName('YOUTH', $targetSchoolYear)
: null;
}
private function sameClassInTargetYear(string $sourceClassName, string $targetSchoolYear): ?array
{
$base = $this->classBaseName($sourceClassName);
if ($base === '') {
return null;
}
if (str_contains($base, 'KINDERGARTEN')) {
$base = 'KG';
}
return $this->classByName($base, $targetSchoolYear);
}
private function classBaseName(string $className): string
{
$base = strtoupper(trim((string) preg_replace('/-.+$/', '', $className)));
$base = preg_replace('/\b(CLASS|GRADE)\b/i', '', $base) ?? $base;
$base = trim(preg_replace('/\s+/', ' ', $base) ?? $base);
if (str_contains($base, 'KINDERGARTEN')) {
return 'KG';
}
if (preg_match('/\d+/', $base, $matches) === 1) {
return (string) (int) $matches[0];
}
return $base;
}
private function classByName(string $className, string $schoolYear): ?array
{
$builder = $this->db->table('classes')->where('UPPER(class_name)', strtoupper($className));
if ($this->db->fieldExists('school_year', 'classes')) {
$builder->where('school_year', $schoolYear);
}
$row = $builder->orderBy('id', 'DESC')->limit(1)->get()->getRowArray();
if ($row !== null || ! $this->db->fieldExists('school_year', 'classes')) {
return $row ?: null;
}
return $this->db->table('classes')
->where('UPPER(class_name)', strtoupper($className))
->orderBy('id', 'DESC')
->limit(1)
->get()
->getRowArray() ?: null;
}
private function classNameForId(int $classId): ?string
{
if ($classId <= 0 || ! $this->db->tableExists('classes')) {
return null;
}
$row = $this->db->table('classes')
->select('class_name')
->where('id', $classId)
->limit(1)
->get()
->getRowArray();
$name = trim((string) ($row['class_name'] ?? ''));
return $name !== '' ? $name : null;
}
private function matchingSection(string $sourceSectionName, int $sourceClassId, string $targetSchoolYear): ?array
{
$builder = $this->db->table('classSection')->select('class_section_id, class_id, class_section_name')->orderBy('id', 'DESC');
if ($sourceSectionName !== '') {
$builder->where('class_section_name', $sourceSectionName);
} else {
$builder->where('class_id', $sourceClassId)->where("class_section_name NOT LIKE '%-%'", null, false);
}
if ($this->db->fieldExists('school_year', 'classSection')) {
$builder->where('school_year', $targetSchoolYear);
}
return $builder->limit(1)->get()->getRowArray() ?: null;
}
private function sectionAtCapacity(int $classSectionId): bool
{
$section = $this->db->table('classSection cs')
->select('cs.class_section_id, c.capacity')
->join('classes c', 'c.id = cs.class_id', 'left')
->where('cs.class_section_id', $classSectionId)
->orderBy('cs.id', 'DESC')
->limit(1)
->get()
->getRowArray();
$capacity = is_numeric($section['capacity'] ?? null) ? (int) $section['capacity'] : 0;
if ($capacity <= 0) {
return false;
}
$count = $this->db->table('student_class')->where('class_section_id', $classSectionId)->countAllResults();
return $count >= $capacity;
}
private function latestEnrollment(int $studentId, string $schoolYear): ?array
{
if (! $this->db->tableExists('enrollments')) {
return null;
}
return $this->db->table('enrollments')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->orderBy('updated_at', 'DESC')
->orderBy('id', 'DESC')
->limit(1)
->get()
->getRowArray() ?: null;
}
private function upsertStudentClass(int $studentId, int $classSectionId, string $schoolYear, ?int $performedBy): void
{
$existing = $this->db->table('student_class')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->orderBy('id', 'DESC')
->limit(1)
->get()
->getRowArray();
$payload = [
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'school_year' => $schoolYear,
'updated_by' => $performedBy,
'updated_at' => date('Y-m-d H:i:s'),
];
if ($existing !== null) {
$this->db->table('student_class')->where('id', (int) $existing['id'])->update($payload);
} else {
$payload['created_at'] = date('Y-m-d H:i:s');
$this->db->table('student_class')->insert($payload);
}
}
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;
}
$decisions = $this->latestDecisionsByStudent($sourceSchoolYear);
$enrolledIds = $this->activeTargetEnrollmentStudentIds($targetSchoolYear);
$bypassCodesByStudent = $this->activeBypassCodesByStudent($targetSchoolYear);
$written = 0;
foreach ($students as $student) {
$studentId = (int) ($student['student_id'] ?? 0);
if ($studentId <= 0) {
continue;
}
$decisionRow = $decisions[$studentId] ?? null;
$decision = DeliberationDecision::normalize($decisionRow['deliberation_decision_standard'] ?? null)
?? DeliberationDecision::normalize($decisionRow['decision'] ?? null);
$alreadyEnrolled = isset($enrolledIds[$studentId]);
$flags = [];
if ($decisionRow === null || $decision === null) {
if (! $alreadyEnrolled) {
$flags[] = $this->flag('DEFERRED_DELIBERATION', 'high', [
'reason' => 'Missing or unrecognized final deliberation decision.',
]);
}
} elseif ($decision === DeliberationDecision::EXPELLED && ! $alreadyEnrolled) {
$flags[] = $this->flag('RESTRICTED_ADMINISTRATIVE_REVIEW', 'high');
} elseif ($decision === DeliberationDecision::WITHDRAWN && ! $alreadyEnrolled) {
$flags[] = $this->flag('WITHDRAWAL_REVIEW_REQUIRED', 'normal');
} elseif ($decision === DeliberationDecision::DEFERRED_DECISION && ! $alreadyEnrolled) {
$flags[] = $this->flag('DEFERRED_DELIBERATION', 'high');
} elseif ($decision === DeliberationDecision::MAKE_UP_EXAM) {
$flags[] = $this->flag('PENDING_MAKE_UP_EXAM_PROMOTION', 'high', [
'current_class_section_name' => $student['class_section_name'] ?? ($decisionRow['class_section_name'] ?? null),
]);
}
$age = EnrollmentEligibility::ageOnSeptemberFirst($student['dob'] ?? null, $targetSchoolYear);
if ($age !== null && $age >= 18 && ! $alreadyEnrolled) {
$flags[] = $this->flag('ADULT_STUDENT_ACTION_REQUIRED', 'normal', [
'age_on_reference_date' => $age,
]);
}
foreach ($flags as $flag) {
if ($this->flagCoveredByException((string) ($flag['flag_type'] ?? ''), $bypassCodesByStudent[$studentId] ?? [])) {
continue;
}
if ($this->upsertOpenFlag($studentId, $targetSchoolYear, $sourceSchoolYear, $flag, $performedBy)) {
$written++;
}
}
}
$written += $this->syncSiblingLastNameFlags($students, $enrolledIds, $bypassCodesByStudent, $targetSchoolYear, $sourceSchoolYear, $performedBy);
$written += $this->syncFinancialFlags($students, $enrolledIds, $bypassCodesByStudent, $targetSchoolYear, $sourceSchoolYear, $performedBy);
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 === '') {
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 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'],
'CLASS_CAPACITY_EXCEPTION_REQUIRED' => ['CLASS_CAPACITY_EXCEPTION_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;
}
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;
}
$balances = $this->db->table('invoices')
->select('parent_id, COALESCE(SUM(balance), 0) AS balance', false)
->where('school_year', $sourceSchoolYear)
->whereIn('parent_id', array_values($parentIds))
->groupBy('parent_id')
->get()
->getResultArray();
$flagType = $behavior === 'admin_approval_required' ? 'FINANCIAL_REVIEW_REQUIRED' : 'FINANCIAL_REVIEW_REQUIRED';
$written = 0;
foreach ($balances as $row) {
if ((float) ($row['balance'] ?? 0) <= 0.0) {
continue;
}
$parentId = (int) ($row['parent_id'] ?? 0);
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' => (float) $row['balance']]),
$performedBy
)) {
$written++;
}
}
}
return $written;
}
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 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') => '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'
|| in_array($status, ['denied', 'withdrawn', 'widthran'], true)
|| (int) ($enrollment['is_withdrawn'] ?? 0) === 1;
}
private function applyHouseholdLastNameRule(array &$evaluation, int $parentId): 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 applyFinancialRule(array &$evaluation, int $parentId, string $sourceSchoolYear, string $targetSchoolYear): void
{
$summary = $this->financialSummary($parentId, $sourceSchoolYear, $targetSchoolYear);
$evaluation['financial_summary'] = $summary;
$balance = (float) ($summary['carry_over_balance'] ?? 0.0);
$behavior = (string) ($summary['balance_behavior'] ?? 'submission_blocked_until_payment');
if ($balance <= 0.0) {
return;
}
if ($behavior === 'admin_approval_required') {
$this->addBlocker($evaluation, 'FINANCE_APPROVAL_REQUIRED', 'Registration requires administrative financial approval because there is a previous-year balance.');
return;
}
$this->addBlocker($evaluation, 'OUTSTANDING_BALANCE_BLOCKED', 'Registration cannot be submitted until the previous-year balance is paid.');
}
private function financialSummary(int $parentId, string $sourceSchoolYear, string $targetSchoolYear): array
{
$target = $this->schoolYearByName($targetSchoolYear) ?? [];
$carryOver = $this->invoiceBalanceForParent($parentId, $sourceSchoolYear);
$current = $this->invoiceBalanceForParent($parentId, $targetSchoolYear);
$registrationFee = round((float) ($target['registration_fee'] ?? 0), 2);
$tuitionDue = round((float) ($target['tuition_due_at_registration'] ?? 0), 2);
$mandatory = round((float) ($target['mandatory_fees'] ?? 0), 2);
return [
'currency' => '$',
'source_school_year' => $sourceSchoolYear,
'target_school_year' => $targetSchoolYear,
'carry_over_balance' => round($carryOver, 2),
'current_balance' => round($current, 2),
'registration_fee' => $registrationFee,
'tuition_due_at_registration' => $tuitionDue,
'mandatory_fees' => $mandatory,
'amount_due' => round(max(0.0, $carryOver) + $registrationFee + $tuitionDue + $mandatory, 2),
'balance_behavior' => (string) ($target['carry_over_balance_behavior'] ?? 'submission_blocked_until_payment'),
'evaluated_at' => date('Y-m-d H:i:s'),
];
}
private function invoiceBalanceForParent(int $parentId, string $schoolYear): float
{
if ($parentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('invoices')) {
return 0.0;
}
$row = $this->db->table('invoices')
->select('COALESCE(SUM(balance), 0) AS balance', false)
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->get()
->getRowArray();
return round((float) ($row['balance'] ?? 0), 2);
}
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;
}
if (in_array('REGISTRATION_CLOSED', $failedCodes, true)) {
$targetYear = $this->schoolYearByName($targetSchoolYear);
if ((int) ($targetYear['administrative_exceptions_permitted'] ?? 0) !== 1) {
return;
}
}
$exception = $this->activeException($parentId, $studentId, $targetSchoolYear);
if ($exception === null) {
return;
}
$allowedCodes = json_decode((string) ($exception['bypassed_rule_codes_json'] ?? ''), true);
$allowedCodes = is_array($allowedCodes) ? array_values(array_filter(array_map('strval', $allowedCodes))) : [];
if ($allowedCodes === [] || array_diff($failedCodes, $allowedCodes) !== []) {
return;
}
$evaluation['admin_exception'] = [
'id' => (int) $exception['id'],
'reason_code' => (string) ($exception['reason_code'] ?? ''),
'source_school_year' => $sourceSchoolYear,
'bypassed_rule_codes' => $failedCodes,
];
$evaluation['decision'] = self::DECISION_EXCEPTION_ELIGIBLE;
$evaluation['parent_enrollment_allowed'] = true;
$evaluation['can_enroll'] = true;
}
private function activeException(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');
return $this->db->table('enrollment_exceptions')
->where('parent_id', $parentId)
->where('student_id', $studentId)
->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()
->orderBy('created_at', 'DESC')
->orderBy('id', 'DESC')
->limit(1)
->get()
->getRowArray() ?: null;
}
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;
}
}