diff --git a/app/Controllers/View/EnrollmentAdminController.php b/app/Controllers/View/EnrollmentAdminController.php index 6c3053a..57981f0 100644 --- a/app/Controllers/View/EnrollmentAdminController.php +++ b/app/Controllers/View/EnrollmentAdminController.php @@ -484,12 +484,13 @@ class EnrollmentAdminController extends BaseController if (is_numeric($assignedTo) && (int) $assignedTo > 0) { $builder->where('ef.assigned_to', (int) $assignedTo); } - $builder->where('ef.flag_type !=', 'CLASS_CAPACITY_EXCEPTION_REQUIRED'); + $ignoredFlagTypes = ['CLASS_CAPACITY_EXCEPTION_REQUIRED', 'CLASS_REASSIGNMENT_REQUIRED']; + $builder->whereNotIn('ef.flag_type', $ignoredFlagTypes); $rows = $builder->get()->getResultArray(); $rows = array_values(array_filter( $rows, - static fn (array $row): bool => (string) ($row['flag_type'] ?? '') !== 'CLASS_CAPACITY_EXCEPTION_REQUIRED' + static fn (array $row): bool => ! in_array((string) ($row['flag_type'] ?? ''), $ignoredFlagTypes, true) )); foreach ($rows as &$row) { $row['student_name'] = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Student #' . (int) ($row['student_id'] ?? 0); @@ -663,7 +664,6 @@ class EnrollmentAdminController extends BaseController $followupTypes = [ 'PENDING_MAKE_UP_EXAM_PROMOTION' => 'temporary_same_grade', - 'CLASS_REASSIGNMENT_REQUIRED' => 'manual_class_required', 'COMPLETION_OR_EXIT_PROCESS_REQUIRED' => 'exit_required', ]; @@ -1251,7 +1251,7 @@ class EnrollmentAdminController extends BaseController $builder = $this->db->table('enrollment_flags') ->where('status', 'open') - ->where('flag_type !=', 'CLASS_CAPACITY_EXCEPTION_REQUIRED'); + ->whereNotIn('flag_type', ['CLASS_CAPACITY_EXCEPTION_REQUIRED', 'CLASS_REASSIGNMENT_REQUIRED']); if ($schoolYear !== '') { $builder->where('school_year', $schoolYear); } @@ -1268,7 +1268,7 @@ class EnrollmentAdminController extends BaseController $types = array_column( $this->db->table('enrollment_flags') ->select('flag_type') - ->where('flag_type !=', 'CLASS_CAPACITY_EXCEPTION_REQUIRED') + ->whereNotIn('flag_type', ['CLASS_CAPACITY_EXCEPTION_REQUIRED', 'CLASS_REASSIGNMENT_REQUIRED']) ->distinct() ->orderBy('flag_type') ->get() @@ -1276,7 +1276,10 @@ class EnrollmentAdminController extends BaseController 'flag_type' ); - return array_values(array_filter($types, static fn ($type): bool => (string) $type !== 'CLASS_CAPACITY_EXCEPTION_REQUIRED')); + return array_values(array_filter( + $types, + static fn ($type): bool => ! in_array((string) $type, ['CLASS_CAPACITY_EXCEPTION_REQUIRED', 'CLASS_REASSIGNMENT_REQUIRED'], true) + )); } private function schoolYears(): array diff --git a/app/Controllers/View/InvoiceController.php b/app/Controllers/View/InvoiceController.php index 119a29a..d3a1ccc 100644 --- a/app/Controllers/View/InvoiceController.php +++ b/app/Controllers/View/InvoiceController.php @@ -730,12 +730,19 @@ class InvoiceController extends ResourceController $grade = 'N/A'; if ($studentClass && isset($studentClass['class_section_id'])) { - $classSection = $this->db->table('classSection') - ->where('class_section_id', $studentClass['class_section_id']) - ->get() - ->getRowArray(); - if ($classSection && isset($classSection['class_section_name'])) { - $grade = $classSection['class_section_name']; + $classSectionBuilder = $this->db->table('classSection') + ->select('classSection.class_section_id, classSection.class_section_name, classSection.class_id, classes.class_name') + ->join('classes', 'classes.id = classSection.class_id', 'left') + ->where('classSection.class_section_id', $studentClass['class_section_id']); + if ($this->db->fieldExists('school_year', 'classSection')) { + $classSectionBuilder->orderBy('classSection.school_year = ' . $this->db->escape($schoolYear), 'DESC', false); + } + $classSectionBuilder->orderBy('classSection.id', 'DESC'); + $classSection = $classSectionBuilder->get()->getRowArray(); + if ($classSection) { + $grade = $this->invoiceManagementGradeLabel($classSection, $student); + } elseif ($this->isKindergarten((string) ($student['registration_grade'] ?? ''))) { + $grade = 'KG'; } } @@ -771,6 +778,24 @@ class InvoiceController extends ResourceController ]; } + private function invoiceManagementGradeLabel(array $classSection, array $student): string + { + $registrationGrade = trim((string) ($student['registration_grade'] ?? '')); + $classId = (int) ($classSection['class_id'] ?? 0); + $className = trim((string) ($classSection['class_name'] ?? '')); + $sectionName = trim((string) ($classSection['class_section_name'] ?? '')); + + if ($classId === 13 || $this->isKindergarten($className) || $this->isKindergarten($registrationGrade)) { + return 'KG'; + } + + if ($sectionName === '13') { + return 'KG'; + } + + return $sectionName !== '' ? $sectionName : ($className !== '' ? $className : 'N/A'); + } + /** * @return list> */ diff --git a/app/Controllers/View/ParentController.php b/app/Controllers/View/ParentController.php index 815b5bf..b0551e1 100644 --- a/app/Controllers/View/ParentController.php +++ b/app/Controllers/View/ParentController.php @@ -2897,12 +2897,17 @@ class ParentController extends BaseController $lnKey = mb_strtolower(trim(preg_replace('/\s+/', ' ', $lastName)), 'UTF-8'); $dobStr = $dobObj->format('Y-m-d'); -$existing = $this->studentModel - ->where('school_year', $schoolYear) - ->where('dob', $dobStr) - ->where('firstname', $firstName) // already normalized above - ->where('lastname', $lastName) // already normalized above - ->first(); + $existingBuilder = $this->studentModel + ->where('parent_id', (int) $parentId) + ->where('dob', $dobStr) + ->where('firstname', $firstName) // already normalized above + ->where('lastname', $lastName); // already normalized above + + if ($this->db->fieldExists('school_year', 'students')) { + $existingBuilder->where('school_year', $schoolYear); + } + + $existing = $existingBuilder->first(); if (!$studentId && $existing) { session()->setFlashdata( diff --git a/app/Models/StudentModel.php b/app/Models/StudentModel.php index fd5a70a..d9d31e6 100644 --- a/app/Models/StudentModel.php +++ b/app/Models/StudentModel.php @@ -22,6 +22,8 @@ class StudentModel extends Model 'tuition_paid', 'year_of_registration', 'rfid_tag', + 'semester', + 'school_year', 'is_active' ]; protected $useTimestamps = false; diff --git a/app/Services/EnrollmentRegistrationEmailService.php b/app/Services/EnrollmentRegistrationEmailService.php index b051e4c..89faa9c 100644 --- a/app/Services/EnrollmentRegistrationEmailService.php +++ b/app/Services/EnrollmentRegistrationEmailService.php @@ -4,6 +4,7 @@ namespace App\Services; use App\Models\ConfigurationModel; use App\Support\Enrollment\DeliberationDecision; +use App\Support\Enrollment\EnrollmentEligibility; use CodeIgniter\Database\BaseConnection; use DateTimeInterface; @@ -132,6 +133,7 @@ final class EnrollmentRegistrationEmailService $studentSummaries[] = [ 'name' => trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')) ?: 'Student #' . $studentId, 'adult_student' => ! empty($evaluation['adult_student']), + 'issue_code' => $this->studentIssueCode($evaluation), ]; } @@ -293,6 +295,13 @@ final class EnrollmentRegistrationEmailService private function decisionMessage(string $name, array $evaluation, string $opens, string $deadline, array $schoolYear = []): string { + if (! empty($evaluation['kg_missing_decision_eligible']) && ! $this->hasNonKgMissingDecisionBlocker($evaluation)) { + $placement = $this->placementText($evaluation); + $placementText = $placement !== '' ? ' The student placement for the new school year is: ' . $placement . '.' : ''; + + return 'KG students may complete registration now. ' . $name . ' is eligible for re-enrollment even though no final deliberation decision is recorded.' . $placementText; + } + if (($evaluation['can_enroll'] ?? false) === false && ! empty($evaluation['primary_parent_message'])) { return (string) $evaluation['primary_parent_message']; } @@ -518,6 +527,18 @@ final class EnrollmentRegistrationEmailService : 'Eligible'; } + if (! empty($evaluation['kg_missing_decision_eligible']) && ! $this->hasNonKgMissingDecisionBlocker($evaluation)) { + return 'Eligible'; + } + + if ( + strtoupper(trim((string) ($evaluation['primary_block_reason'] ?? ''))) === 'ALREADY_ENROLLED' + || in_array('ALREADY_ENROLLED', array_map('strval', $evaluation['blocking_rule_codes'] ?? []), true) + || strtoupper(trim((string) ($evaluation['decision'] ?? ''))) === 'ALREADY_ENROLLED' + ) { + return EnrollmentEligibility::alreadyEnrolledTitle($evaluation['enrollment_status'] ?? null); + } + if (! empty($evaluation['adult_student'])) { return 'Not Eligible'; } @@ -529,6 +550,53 @@ final class EnrollmentRegistrationEmailService return 'Not Eligible'; } + private function hasNonKgMissingDecisionBlocker(array $evaluation): bool + { + $ignoredCodes = ['NO_FINAL_DECISION', 'UNRECOGNIZED_DECISION']; + $blockingCodes = array_values(array_filter(array_map( + static fn ($code): string => strtoupper(trim((string) $code)), + $evaluation['blocking_rule_codes'] ?? [] + ))); + $reviewCodes = array_values(array_filter(array_map( + static fn ($code): string => strtoupper(trim((string) $code)), + $evaluation['review_rule_codes'] ?? [] + ))); + $codes = array_values(array_unique(array_merge($blockingCodes, $reviewCodes))); + + return array_values(array_diff($codes, $ignoredCodes)) !== []; + } + + private function studentIssueCode(array $evaluation): string + { + if (! empty($evaluation['adult_student'])) { + return 'ADULT_STUDENT_PARENT_BLOCKED'; + } + + foreach (($evaluation['flags'] ?? []) as $flag) { + if (! is_array($flag)) { + continue; + } + + $details = is_array($flag['details'] ?? null) ? $flag['details'] : []; + $ruleCode = strtoupper(trim((string) ($details['rule_code'] ?? ''))); + if ($ruleCode !== '') { + return $ruleCode; + } + + $flagType = strtoupper(trim((string) ($flag['flag_type'] ?? ''))); + if ($flagType !== '') { + return $flagType; + } + } + + $primaryBlockReason = strtoupper(trim((string) ($evaluation['primary_block_reason'] ?? ''))); + if ($primaryBlockReason !== '') { + return $primaryBlockReason; + } + + return ''; + } + private function placementText(array $evaluation): string { $placement = match ((string) ($evaluation['placement_status'] ?? '')) { @@ -623,6 +691,10 @@ final class EnrollmentRegistrationEmailService return $fallback; } + if (in_array(strtoupper($grade), ['KG', 'K', 'KINDERGARTEN'], true)) { + return 'KG'; + } + return preg_match('/^grade\b/i', $grade) === 1 ? $grade : 'Grade ' . $grade; } } diff --git a/app/Services/EnrollmentTransitionService.php b/app/Services/EnrollmentTransitionService.php index 0dd6cbc..6b28193 100644 --- a/app/Services/EnrollmentTransitionService.php +++ b/app/Services/EnrollmentTransitionService.php @@ -62,7 +62,10 @@ final class EnrollmentTransitionService $this->addBlocker($evaluation, 'TARGET_YEAR_NOT_FOUND', 'Target school year configuration was not found.'); } - if ($sourceSchoolYear === '' || $this->sourceAssignment($studentId, $sourceSchoolYear) === null) { + 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.'); } @@ -77,7 +80,7 @@ final class EnrollmentTransitionService $this->addBlocker($evaluation, $code, 'Student has a target-year enrollment status that requires administration review.'); } - $this->applyHouseholdLastNameRule($evaluation, $parentId); + $this->applyHouseholdLastNameRule($evaluation, $parentId, $sourceSchoolYear); $this->applyFinancialRule($evaluation, $parentId, $sourceSchoolYear, $targetSchoolYear); $this->applyCarriedForwardLastNameException($evaluation, $parentId, $studentId, $sourceSchoolYear, $targetSchoolYear); $this->deriveAcademicRuleCodes($evaluation); @@ -250,6 +253,7 @@ final class EnrollmentTransitionService $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, @@ -312,6 +316,24 @@ final class EnrollmentTransitionService } 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; } @@ -322,7 +344,18 @@ final class EnrollmentTransitionService return $result; } - if ($decisionRow === null || $decision === null) { + 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', @@ -472,20 +505,12 @@ final class EnrollmentTransitionService } } - $flags = []; - if ($targetSection === null) { - $flags[] = $this->flag('CLASS_REASSIGNMENT_REQUIRED', 'normal', [ - 'previous_class_section_name' => $sourceSectionName, - 'previous_class_section_id' => $sourceSectionId > 0 ? $sourceSectionId : null, - ]); - } - 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, + 'flags' => [], ]; } @@ -509,6 +534,17 @@ final class EnrollmentTransitionService 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) { @@ -638,14 +674,42 @@ final class EnrollmentTransitionService 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('id, firstname, lastname, dob, parent_id') + ->select(implode(', ', $select)) ->where('id', $studentId) ->limit(1) ->get() ->getRowArray() ?: null; } + private function isFirstEnrollmentStudent(int $studentId, array $student, string $targetSchoolYear): bool + { + if ($studentId <= 0 || $targetSchoolYear === '') { + return false; + } + + if ($this->db->tableExists('student_year_status')) { + $row = $this->db->table('student_year_status') + ->select('is_new') + ->where('student_id', $studentId) + ->where('school_year', $targetSchoolYear) + ->limit(1) + ->get() + ->getRowArray(); + + if (is_array($row)) { + return (int) ($row['is_new'] ?? 1) === 1; + } + } + + return trim((string) ($student['school_year'] ?? '')) === $targetSchoolYear; + } + private function schoolYearByName(string $schoolYear): ?array { if (! $this->db->tableExists('school_years')) { @@ -730,6 +794,42 @@ final class EnrollmentTransitionService ->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')) { @@ -1033,6 +1133,7 @@ final class EnrollmentTransitionService $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; @@ -1063,7 +1164,7 @@ final class EnrollmentTransitionService } $flagType = (string) ($flag['flag_type'] ?? ''); - if ($flagType === '' || $flagType === 'CLASS_CAPACITY_EXCEPTION_REQUIRED') { + if ($flagType === '' || in_array($flagType, ['CLASS_CAPACITY_EXCEPTION_REQUIRED', 'CLASS_REASSIGNMENT_REQUIRED'], true)) { return false; } @@ -1113,6 +1214,26 @@ final class EnrollmentTransitionService ]); } + 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')) { @@ -1886,9 +2007,22 @@ final class EnrollmentTransitionService || EnrollmentEligibility::isMarkedWithdrawn($enrollment); } - private function applyHouseholdLastNameRule(array &$evaluation, int $parentId): void + private function applyHouseholdLastNameRule(array &$evaluation, int $parentId, ?string $sourceSchoolYear = null): void { + if (! empty($evaluation['first_enrollment'])) { + $evaluation['family_name_check_ok'] = true; + return; + } + $students = $this->linkedStudentsForParent($parentId); + $sourceSchoolYear = trim((string) $sourceSchoolYear); + if ($sourceSchoolYear !== '') { + $students = array_values(array_filter( + $students, + fn (array $student): bool => $this->sourceAssignment((int) ($student['id'] ?? 0), $sourceSchoolYear) !== null + )); + } + if (count($students) <= 1) { $evaluation['family_name_check_ok'] = true; return; diff --git a/app/Support/Enrollment/EnrollmentEligibility.php b/app/Support/Enrollment/EnrollmentEligibility.php index 6aa6946..9f1444c 100644 --- a/app/Support/Enrollment/EnrollmentEligibility.php +++ b/app/Support/Enrollment/EnrollmentEligibility.php @@ -7,6 +7,7 @@ final class EnrollmentEligibility public const EXPELLED_MESSAGE = 'Re-enrollment is not available because the final deliberation decision is expelled. Please contact the school administration for further information.'; public const WITHDRAWN_MESSAGE = 'Re-enrollment is not available because the final deliberation decision is withdrawn. Please contact the school administration if this status needs to be reviewed.'; public const DEFERRED_MESSAGE = 'Re-enrollment cannot currently be completed because the final deliberation decision is deferred. Please contact the school administration for the next required step.'; + public const KG_MISSING_DECISION_ELIGIBLE_MESSAGE = 'KG students may complete registration now. Their new-year grade placement will be based on the school age-placement rule.'; public const MISSING_DECISION_MESSAGE = 'Re-enrollment cannot currently be completed because no final deliberation decision is recorded for the student. Registration will become available after the school records a final decision.'; public const ADULT_STUDENT_MESSAGE = 'This student is over the allowed age for enrollment or re-enrollment at the school. Please contact school administration.'; public const ADULT_STUDENT_PARENT_PORTAL_MESSAGE = self::ADULT_STUDENT_MESSAGE; diff --git a/app/Views/administrator/enrollment_admin_dashboard.php b/app/Views/administrator/enrollment_admin_dashboard.php index 20c8522..d5f4540 100644 --- a/app/Views/administrator/enrollment_admin_dashboard.php +++ b/app/Views/administrator/enrollment_admin_dashboard.php @@ -49,17 +49,22 @@ text-align: left; } .enrollment-admin .issue-badge.adult-student, + .enrollment-admin .email-student-name.adult-student, .enrollment-admin .adult-student-name { background-color: #b02a37; color: #fff; } - .enrollment-admin .adult-student-name { + .enrollment-admin .adult-student-name, + .enrollment-admin .email-student-name { display: inline-block; border-radius: .25rem; font-weight: 600; padding: .15rem .35rem; margin: .1rem .15rem .1rem 0; } + .enrollment-admin .email-student-name.bg-dark { + color: #fff; + } endSection() ?> @@ -766,7 +771,10 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes : $student): ?> 0 ? ', ' : '' ?> - + + + + diff --git a/app/Views/administrator/student_profiles.php b/app/Views/administrator/student_profiles.php index 38a16de..f5d43e6 100644 --- a/app/Views/administrator/student_profiles.php +++ b/app/Views/administrator/student_profiles.php @@ -124,8 +124,15 @@ $selectedYear = trim((string)($selectedYear ?? '')); } } - // Input-safe values - $dobVal = $dobDisp; + // Browser date inputs require ISO dates even when the table displays local mm-dd-YYYY. + $dobVal = ''; + if (!empty($student['dob'])) { + try { + $dobVal = (new DateTime($student['dob']))->format('Y-m-d'); + } catch (\Throwable $e) { + $dobVal = ''; + } + } $regDateLocal = ''; if (!empty($student['registration_date'])) { try { diff --git a/tests/app/Services/EnrollmentRegistrationEmailServiceTest.php b/tests/app/Services/EnrollmentRegistrationEmailServiceTest.php index 8453727..7663702 100644 --- a/tests/app/Services/EnrollmentRegistrationEmailServiceTest.php +++ b/tests/app/Services/EnrollmentRegistrationEmailServiceTest.php @@ -85,6 +85,51 @@ final class EnrollmentRegistrationEmailServiceTest extends TestCase $this->assertSame('', $action); } + public function testKgMissingDecisionEligibleMessageAllowsRegistration(): void + { + $service = $this->service(); + + $message = $this->invoke($service, 'decisionMessage', ['Student Name', [ + 'can_enroll' => false, + 'kg_missing_decision_eligible' => true, + 'deliberation_decision' => DeliberationDecision::PASSED, + 'placement_status' => 'automatic_distribution_pending', + 'assigned_grade_name' => '1', + 'blocking_rule_codes' => ['NO_FINAL_DECISION'], + 'blockers' => ['Re-enrollment cannot currently be completed because no final deliberation decision is recorded for the student. Registration will become available after the school records a final decision.'], + ], 'August 1, 2026', 'August 31, 2026']); + $status = $this->invoke($service, 'registrationStatus', [[ + 'can_enroll' => false, + 'kg_missing_decision_eligible' => true, + 'deliberation_decision' => DeliberationDecision::PASSED, + 'blocking_rule_codes' => ['NO_FINAL_DECISION'], + 'blockers' => ['Re-enrollment cannot currently be completed because no final deliberation decision is recorded for the student. Registration will become available after the school records a final decision.'], + ]]); + + $this->assertSame('Eligible', $status); + $this->assertStringContainsString('KG students may complete registration now.', $message); + $this->assertStringContainsString('Student Name is eligible for re-enrollment even though no final deliberation decision is recorded.', $message); + $this->assertStringContainsString('Grade 1', $message); + $this->assertStringNotContainsString('cannot currently be completed', $message); + } + + public function testAlreadyEnrolledStatusDoesNotRenderAsNotEligible(): void + { + $service = $this->service(); + + $status = $this->invoke($service, 'registrationStatus', [[ + 'can_enroll' => false, + 'deliberation_decision' => DeliberationDecision::PASSED, + 'decision' => 'ALREADY_ENROLLED', + 'primary_block_reason' => 'ALREADY_ENROLLED', + 'blocking_rule_codes' => ['ALREADY_ENROLLED'], + 'blockers' => ['This student is already enrolled for the selected school year.'], + 'enrollment_status' => 'enrolled', + ]]); + + $this->assertSame('Already Enrolled', $status); + } + public function testMakeupExamDecisionMessageUsesSchoolYearDateBeforeConfig(): void { $configModel = $this->createMock(ConfigurationModel::class); @@ -231,6 +276,33 @@ final class EnrollmentRegistrationEmailServiceTest extends TestCase $this->assertStringContainsString('parent/enroll_classes', $html); } + public function testStudentIssueCodeUsesFlagRuleCode(): void + { + $service = $this->service(); + + $code = $this->invoke($service, 'studentIssueCode', [[ + 'flags' => [[ + 'flag_type' => 'DEFERRED_DELIBERATION', + 'details' => ['rule_code' => 'NO_FINAL_DECISION'], + ]], + ]]); + + $this->assertSame('NO_FINAL_DECISION', $code); + } + + public function testStudentIssueCodeDoesNotColorNormalDecision(): void + { + $service = $this->service(); + + $code = $this->invoke($service, 'studentIssueCode', [[ + 'can_enroll' => true, + 'deliberation_decision' => DeliberationDecision::PASSED, + 'flags' => [], + ]]); + + $this->assertSame('', $code); + } + public function testParentReEnrollmentEligibilityUsesTransitionAllowedFlag(): void { $service = $this->service(); diff --git a/tests/app/Services/EnrollmentTransitionServiceTest.php b/tests/app/Services/EnrollmentTransitionServiceTest.php index aebf0a2..aa4cb17 100644 --- a/tests/app/Services/EnrollmentTransitionServiceTest.php +++ b/tests/app/Services/EnrollmentTransitionServiceTest.php @@ -3,6 +3,7 @@ namespace Tests\App\Services; use App\Services\EnrollmentTransitionService; +use App\Support\Enrollment\DeliberationDecision; use CodeIgniter\Database\BaseBuilder; use CodeIgniter\Database\BaseConnection; use PHPUnit\Framework\TestCase; @@ -27,6 +28,71 @@ final class EnrollmentTransitionServiceTest extends TestCase $this->assertSame(10, (int) $result['id']); } + public function testKgMissingDecisionUsesAgePlacementDecision(): void + { + $service = new EnrollmentTransitionService($this->createMock(BaseConnection::class)); + + $this->assertSame( + DeliberationDecision::PASSED, + $this->invoke($service, 'decisionForKgWithoutFinalDecision', [['class_name' => 'KG'], 6, '']) + ); + $this->assertSame( + DeliberationDecision::REPEAT_CLASS, + $this->invoke($service, 'decisionForKgWithoutFinalDecision', [['class_name' => 'Kindergarten'], 5, '']) + ); + $this->assertNull($this->invoke($service, 'decisionForKgWithoutFinalDecision', [['class_name' => 'KG'], 6, 'Needs review'])); + $this->assertNull($this->invoke($service, 'decisionForKgWithoutFinalDecision', [['class_name' => '1'], 6, ''])); + } + + public function testExplicitNewStudentStatusMarksFirstEnrollmentCandidate(): void + { + $statusBuilder = $this->builderReturning(['is_new' => 1]); + $statusBuilder->method('where')->willReturnSelf(); + + $db = $this->createMock(BaseConnection::class); + $db->method('tableExists')->with('student_year_status')->willReturn(true); + $db->method('table')->with('student_year_status')->willReturn($statusBuilder); + + $service = new EnrollmentTransitionService($db); + + $this->assertTrue($this->invoke($service, 'isFirstEnrollmentStudent', [ + 42, + ['school_year' => '2026-2027'], + '2026-2027', + ])); + } + + public function testFirstEnrollmentPlacementUsesRegistrationGradeBaseSection(): void + { + $classBuilder = $this->builderReturning(['id' => 3, 'class_name' => '3']); + $classBuilder->method('where')->willReturnSelf(); + + $sectionBuilder = $this->builderReturning([ + 'class_section_id' => 30, + 'class_id' => 3, + 'class_section_name' => '3', + ]); + $sectionBuilder->method('where')->willReturnSelf(); + + $db = $this->createMock(BaseConnection::class); + $db->method('tableExists')->willReturnCallback(static fn (string $table): bool => $table === 'classSection'); + $db->method('fieldExists')->willReturn(false); + $db->method('table')->willReturnCallback(static function (string $table) use ($classBuilder, $sectionBuilder) { + return $table === 'classes' ? $classBuilder : $sectionBuilder; + }); + + $service = new EnrollmentTransitionService($db); + + $placement = $this->invoke($service, 'firstEnrollmentPlacement', [ + ['registration_grade' => 'Grade 3'], + '2026-2027', + ]); + + $this->assertSame(3, (int) $placement['assigned_grade_id']); + $this->assertSame(30, (int) $placement['assigned_class_section_id']); + $this->assertSame('same_class_assigned', $placement['placement_status']); + } + public function testClassLookupFallsBackToGlobalRowsWhenSchoolYearSpecificRowIsMissing(): void { $firstBuilder = $this->builderReturning(null); @@ -145,6 +211,66 @@ final class EnrollmentTransitionServiceTest extends TestCase $this->assertContains('SIBLING_LAST_NAME_MISMATCH', $evaluation['blocking_rule_codes']); } + public function testNewStudentDoesNotMakeReturningSiblingsFailLastNameRule(): void + { + $studentBuilder = $this->createMock(BaseBuilder::class); + $studentBuilder->method('select')->willReturnSelf(); + $studentBuilder->method('where')->willReturnSelf(); + $studentBuilder->method('orderBy')->willReturnSelf(); + $studentBuilder->method('get')->willReturn(new class { + public function getResultArray(): array + { + return [ + ['id' => 1, 'firstname' => 'Ibrahim', 'lastname' => 'Khalid', 'parent_id' => 9], + ['id' => 2, 'firstname' => 'Eesa', 'lastname' => 'Khalid', 'parent_id' => 9], + ['id' => 3, 'firstname' => 'Lamia', 'lastname' => 'Khalid', 'parent_id' => 9], + ['id' => 4, 'firstname' => 'New', 'lastname' => 'Khalid', 'parent_id' => 9], + ]; + } + }); + + $assignmentBuilder = $this->createMock(BaseBuilder::class); + $assignmentBuilder->method('select')->willReturnSelf(); + $assignmentBuilder->method('join')->willReturnSelf(); + $assignmentBuilder->method('where')->willReturnSelf(); + $assignmentBuilder->method('orderBy')->willReturnSelf(); + $assignmentBuilder->method('limit')->willReturnSelf(); + $assignmentBuilder->method('get')->willReturnCallback(new class { + private int $calls = 0; + + public function __invoke(): object + { + $this->calls++; + $hasSourceAssignment = $this->calls <= 3; + + return new class($hasSourceAssignment) { + public function __construct(private readonly bool $hasSourceAssignment) + { + } + + public function getRowArray(): ?array + { + return $this->hasSourceAssignment ? ['class_section_id' => 10] : null; + } + }; + } + }); + + $db = $this->createMock(BaseConnection::class); + $db->method('tableExists')->willReturnCallback(static fn (string $table): bool => in_array($table, ['students', 'student_class'], true)); + $db->method('table')->willReturnCallback(static function (string $table) use ($studentBuilder, $assignmentBuilder) { + return $table === 'students' ? $studentBuilder : $assignmentBuilder; + }); + + $service = new EnrollmentTransitionService($db); + $evaluation = []; + + $this->invoke($service, 'applyHouseholdLastNameRule', [&$evaluation, 9, '2025-2026']); + + $this->assertTrue($evaluation['family_name_check_ok']); + $this->assertArrayNotHasKey('blocking_rule_codes', $evaluation); + } + public function testPreviousYearBalanceBlocksEnrollment(): void { $service = $this->serviceWithFinance(125.50, 'submission_blocked_until_payment'); @@ -343,6 +469,29 @@ final class EnrollmentTransitionServiceTest extends TestCase $this->assertArrayNotHasKey('parent_enrollment_allowed', $evaluation); } + public function testClassReassignmentFlagIsIgnored(): void + { + $db = $this->createMock(BaseConnection::class); + $db->method('tableExists')->with('enrollment_flags')->willReturn(true); + $db->expects($this->never())->method('table'); + + $service = new EnrollmentTransitionService($db); + + $written = $this->invoke($service, 'upsertOpenFlag', [ + 123, + '2026-2027', + '2025-2026', + [ + 'flag_type' => 'CLASS_REASSIGNMENT_REQUIRED', + 'priority' => 'normal', + 'details' => ['reason' => 'repeat class'], + ], + null, + ]); + + $this->assertFalse($written); + } + public function testPreviousLastNameExceptionDoesNotCarryForwardWhenNewStudentIsAdded(): void { $service = $this->serviceWithLastNameCarryForward(