fix enrollment for the new school-year
Tests / PHPUnit (push) Successful in 1m26s

This commit is contained in:
root
2026-08-07 23:43:31 -04:00
parent b6f3b14e7b
commit 1ef2800f12
66 changed files with 8997 additions and 498 deletions
+347 -39
View File
@@ -33,6 +33,7 @@ use App\Models\TeacherSubmissionNotificationHistoryModel;
use App\Models\ExamDraftModel;
use App\Models\HomeworkModel;
use App\Services\SemesterRangeService;
use App\Support\Enrollment\DeliberationDecision;
use CodeIgniter\Events\Events;
@@ -572,14 +573,14 @@ class AdministratorController extends BaseController
// USERS (phone col: cellphone)
$uCols = ['firstname', 'lastname', 'email', 'cellphone', 'school_id', 'city', 'state'];
$uQB = $db->table('users')
->select('id, firstname, lastname, email, cellphone, school_id, city, state, semester');
->select('id, firstname, lastname, email, cellphone, school_id, city, state');
$applyMultiTokenLike($uQB, $uCols, $tokens, ['cellphone']);
$users = $uQB->limit(150)->get()->getResultArray();
// STUDENTS (no phone column to search)
$sCols = ['firstname', 'lastname', 'school_id', 'rfid_tag', 'dob', 'gender'];
$sQB = $db->table('students')
->select('id, parent_id, school_id, firstname, lastname, dob, gender, semester, rfid_tag');
->select('id, parent_id, school_id, firstname, lastname, dob, gender, rfid_tag');
$applyMultiTokenLike($sQB, $sCols, $tokens, []);
$students = $sQB->limit(150)->get()->getResultArray();
@@ -2061,6 +2062,7 @@ class AdministratorController extends BaseController
{
$db = db_connect();
$isPg = ($db->getPlatform() === 'Postgre'); // 'MySQLi', 'Postgre', 'SQLSRV', ...
$selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
// In MySQL, avoid truncation of long lists
if (!$isPg) {
@@ -2133,20 +2135,23 @@ class AdministratorController extends BaseController
->getResultArray();
}
// === Inject class_section_name from student_class via your method and replace grade ===
// === Inject current-year class_section_name from student_class and replace grade ===
foreach ($students as $i => $row) {
$sid = (int) ($row['id'] ?? 0);
if ($sid > 0) {
// Fetch class_section_name for this student
// Assumes your method signature: getClassSectionNameByStudentId(int $studentId): ?string
$classSectionName = (string) ($this->studentClassModel->getClassSectionNameByStudentId($sid) ?? '');
$classSectionName = (string) ($this->studentClassModel->getClassSectionNameByStudentId($sid, $selectedYear) ?? '');
// Expose it explicitly and replace original grade if present
$students[$i]['class_section_name'] = $classSectionName;
} else {
// Keep keys consistent even if id missing
$students[$i]['class_section_name'] = '';
}
$studentYear = trim((string) ($row['school_year'] ?? ''));
$students[$i]['age'] = $this->calculateAgeAsOfSchoolYearStartYear(
$row['dob'] ?? null,
$studentYear !== '' ? $studentYear : $selectedYear
);
}
// === end injection ===
@@ -2154,9 +2159,45 @@ class AdministratorController extends BaseController
'students' => $students,
'gradeOptions' => ['K', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'Youth'],
'genderOptions' => ['Male', 'Female', 'Other'],
'selectedYear' => $selectedYear,
]);
}
private function calculateAgeAsOfSchoolYearStartYear(?string $dob, string $schoolYear): ?int
{
$dob = trim((string) $dob);
$schoolYear = trim($schoolYear);
if ($dob === '' || ! preg_match('/^(\d{4})/', $schoolYear, $matches)) {
return null;
}
try {
$timezone = new \DateTimeZone((string) (config('School')->attendance['timezone'] ?? user_timezone()));
$birthDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $timezone);
$errors = \DateTimeImmutable::getLastErrors();
$hasParseErrors = is_array($errors)
&& (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0);
if ($birthDate === false || $hasParseErrors) {
return null;
}
$schoolYearStartYearCutoff = new \DateTimeImmutable($matches[1] . '-09-01', $timezone);
if ($birthDate > $schoolYearStartYearCutoff) {
return null;
}
return $birthDate->diff($schoolYearStartYearCutoff)->y;
} catch (\Throwable $e) {
log_message('warning', 'Unable to calculate school-year age from DOB: {message}', [
'message' => $e->getMessage(),
]);
return null;
}
}
public function parentProfiles()
@@ -2330,28 +2371,19 @@ class AdministratorController extends BaseController
$schoolYearContext = $this->resolveSchoolYearContext();
$selectedYear = $schoolYearContext->yearName();
$this->syncReviewDecisionEnrollments($selectedYear);
$students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear);
$selectedStartYear = $this->getSchoolYearStartYear((string)$selectedYear);
$removedPriorIds = [];
if ($selectedStartYear !== null) {
$removedRows = $this->db->table('enrollments')
->select('student_id, school_year')
->where('is_withdrawn', 1)
->get()->getResultArray();
foreach ($removedRows as $row) {
$rowYear = $this->getSchoolYearStartYear((string)($row['school_year'] ?? ''));
if ($rowYear !== null && $rowYear < $selectedStartYear) {
$removedPriorIds[(int)($row['student_id'] ?? 0)] = true;
}
}
}
$removedPriorStatuses = $this->removedPriorYearStudentStatuses($selectedYear);
$returningStudentIds = $this->priorYearStudentIds($selectedYear);
foreach ($students as &$s) {
// ===== Ensure IDs needed by the modal =====
$s['student_id'] = (int)($s['id'] ?? 0);
$s['removed_previous_year'] = isset($removedPriorIds[$s['student_id']]) ? 'Yes' : 'No';
$priorRemovedStatus = $removedPriorStatuses[$s['student_id']] ?? null;
$s['removed_previous_year'] = $priorRemovedStatus !== null ? 'Yes' : 'No';
$s['prior_removed_status'] = $priorRemovedStatus;
// Prefer parent_id; fallback to secondparent_user_id if present
if (empty($s['parent_id']) && !empty($s['secondparent_user_id'])) {
@@ -2389,7 +2421,9 @@ class AdministratorController extends BaseController
// ===== Admission override =====
// Enrollment status for selected year
$statusForYear = $this->enrollmentModel->getEnrollmentStatus((int)$s['student_id'], $selectedYear);
if (!empty($statusForYear)) {
if (!empty($priorRemovedStatus)) {
$s['enrollment_status'] = $priorRemovedStatus;
} elseif (!empty($statusForYear)) {
$s['enrollment_status'] = $statusForYear;
} elseif (($s['admission_status'] ?? null) === 'denied') {
$s['enrollment_status'] = 'denied';
@@ -2401,6 +2435,8 @@ class AdministratorController extends BaseController
// ===== Class section name for the selected year =====
$name = $this->studentClassModel->getClassSectionsByStudentId((int)$s['student_id'], $selectedYear);
$s['class_section'] = $name ?: 'Class not Assigned';
$calculatedAge = $this->calculateAgeAsOfSchoolYearStartYear($s['dob'] ?? null, $selectedYear);
$s['age'] = $calculatedAge ?? ($s['age'] ?? null);
// ===== Sortable registration date (for data-order in view) =====
$s['registration_date_order'] = !empty($s['registration_date'])
@@ -2445,27 +2481,18 @@ class AdministratorController extends BaseController
try {
$selectedYear = $this->currentSchoolYearName((string)($this->schoolYear ?? ''));
$this->syncReviewDecisionEnrollments($selectedYear);
$students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear);
$selectedStartYear = $this->getSchoolYearStartYear((string)$selectedYear);
$removedPriorIds = [];
if ($selectedStartYear !== null) {
$removedRows = $this->db->table('enrollments')
->select('student_id, school_year')
->where('is_withdrawn', 1)
->get()->getResultArray();
foreach ($removedRows as $row) {
$rowYear = $this->getSchoolYearStartYear((string)($row['school_year'] ?? ''));
if ($rowYear !== null && $rowYear < $selectedStartYear) {
$removedPriorIds[(int)($row['student_id'] ?? 0)] = true;
}
}
}
$removedPriorStatuses = $this->removedPriorYearStudentStatuses($selectedYear);
$returningStudentIds = $this->priorYearStudentIds($selectedYear);
foreach ($students as &$s) {
$s['student_id'] = (int)($s['id'] ?? 0);
$s['removed_previous_year'] = isset($removedPriorIds[$s['student_id']]) ? 'Yes' : 'No';
$priorRemovedStatus = $removedPriorStatuses[$s['student_id']] ?? null;
$s['removed_previous_year'] = $priorRemovedStatus !== null ? 'Yes' : 'No';
$s['prior_removed_status'] = $priorRemovedStatus;
if (empty($s['parent_id']) && !empty($s['secondparent_user_id'])) {
$s['parent_id'] = (int)$s['secondparent_user_id'];
@@ -2490,7 +2517,9 @@ class AdministratorController extends BaseController
$s['new_student'] = $s['is_new'] === 1 ? 'Yes' : 'No';
$statusForYear = $this->enrollmentModel->getEnrollmentStatus((int)$s['student_id'], $selectedYear);
if (!empty($statusForYear)) {
if (!empty($priorRemovedStatus)) {
$s['enrollment_status'] = $priorRemovedStatus;
} elseif (!empty($statusForYear)) {
$s['enrollment_status'] = $statusForYear;
} elseif (($s['admission_status'] ?? null) === 'denied') {
$s['enrollment_status'] = 'denied';
@@ -2501,6 +2530,8 @@ class AdministratorController extends BaseController
$className = $this->studentClassModel->getClassSectionsByStudentId((int)$s['student_id'], $selectedYear);
$s['class_section'] = $className ?: 'Class not Assigned';
$calculatedAge = $this->calculateAgeAsOfSchoolYearStartYear($s['dob'] ?? null, $selectedYear);
$s['age'] = $calculatedAge ?? ($s['age'] ?? null);
$s['registration_date_order'] = !empty($s['registration_date'])
? date('Y-m-d', strtotime($s['registration_date']))
@@ -2536,6 +2567,172 @@ class AdministratorController extends BaseController
}
}
private function syncReviewDecisionEnrollments(string $selectedYear): void
{
$selectedYear = trim($selectedYear);
$sourceYear = $this->getPreviousSchoolYear($selectedYear);
if ($selectedYear === '' || $sourceYear === '' || ! $this->db->tableExists('enrollments')) {
return;
}
$studentIds = $this->sourceYearStudentIds($sourceYear);
if ($studentIds === []) {
return;
}
$transitionService = service('enrollmentTransition');
$now = utc_now();
foreach ($studentIds as $studentId) {
try {
$evaluation = $transitionService->evaluate((int) $studentId, $sourceYear, $selectedYear, 'parent');
} catch (\Throwable $e) {
log_message('error', 'Review & Decision enrollment sync evaluation failed for student {studentId}: {message}', [
'studentId' => $studentId,
'message' => $e->getMessage(),
]);
continue;
}
if (! $this->needsReviewDecisionEnrollment($evaluation)) {
continue;
}
$student = $this->studentModel->find((int) $studentId);
if (! is_array($student)) {
continue;
}
$parentId = (int) ($student['parent_id'] ?? ($student['secondparent_user_id'] ?? 0));
if ($parentId <= 0) {
log_message('warning', 'Review & Decision enrollment sync skipped student {studentId}: no parent ID.', [
'studentId' => $studentId,
]);
continue;
}
$existing = $this->db->table('enrollments')
->select('id, enrollment_status')
->where('student_id', (int) $studentId)
->where('school_year', $selectedYear)
->orderBy('updated_at', 'DESC')
->orderBy('id', 'DESC')
->limit(1)
->get()
->getRowArray();
if ($existing !== null) {
$existingStatus = (string) ($existing['enrollment_status'] ?? '');
if ($existingStatus === 'review & decision' || ! in_array($existingStatus, ['', 'admission under review'], true)) {
continue;
}
}
$payload = [
'student_id' => (int) $studentId,
'parent_id' => $parentId,
'school_year' => $selectedYear,
'semester' => (string) $this->semester,
'source_school_year' => $sourceYear,
'deliberation_decision' => $evaluation['deliberation_decision'] ?? null,
'source_grade_id' => $evaluation['source_grade_id'] ?? null,
'assigned_grade_id' => $evaluation['assigned_grade_id'] ?? null,
'source_class_section_id' => $evaluation['source_class_section_id'] ?? null,
'assigned_class_section_id' => $evaluation['assigned_class_section_id'] ?? null,
'placement_status' => $evaluation['placement_status'] ?? 'not_created',
'age_reference_date' => $evaluation['age_reference_date'] ?? null,
'age_on_reference_date' => $evaluation['age_on_reference_date'] ?? null,
'adult_student' => ! empty($evaluation['adult_student']) ? 1 : 0,
'parent_enrollment_allowed' => ! empty($evaluation['parent_enrollment_allowed']) ? 1 : 0,
'student_self_enrollment_allowed' => ! empty($evaluation['student_self_enrollment_allowed']) ? 1 : 0,
'exception_required' => 1,
'exception_reason' => implode(', ', array_filter(array_column($evaluation['flags'] ?? [], 'flag_type'))) ?: implode(' ', array_map('strval', $evaluation['blockers'] ?? [])),
'enrollment_date' => local_date(utc_now(), 'Y-m-d'),
'enrollment_status' => 'review & decision',
'admission_status' => 'pending',
'is_withdrawn' => 0,
'updated_at' => $now,
];
$payload = $this->filterEnrollmentPayloadByColumns($payload);
if ($existing !== null) {
$this->db->table('enrollments')
->where('id', (int) $existing['id'])
->update($payload);
} else {
$payload['created_at'] = $now;
$this->db->table('enrollments')->insert($this->filterEnrollmentPayloadByColumns($payload));
}
}
}
private function needsReviewDecisionEnrollment(array $evaluation): bool
{
$decision = (string) ($evaluation['deliberation_decision'] ?? '');
if (in_array($decision, [
DeliberationDecision::EXPELLED,
DeliberationDecision::WITHDRAWN,
DeliberationDecision::DEFERRED_DECISION,
], true)) {
return true;
}
$hasSourceAssignment = (int) ($evaluation['source_class_section_id'] ?? 0) > 0
|| (int) ($evaluation['source_grade_id'] ?? 0) > 0;
return $decision === ''
&& $hasSourceAssignment
&& array_filter($evaluation['blockers'] ?? []) !== [];
}
private function sourceYearStudentIds(string $sourceYear): array
{
$studentIds = [];
foreach (['student_class', 'enrollments', 'student_decisions'] as $table) {
if (! $this->db->tableExists($table) || ! $this->db->fieldExists('student_id', $table)) {
continue;
}
$yearColumn = match ($table) {
'student_class', 'student_decisions' => 'school_year',
default => 'school_year',
};
if (! $this->db->fieldExists($yearColumn, $table)) {
continue;
}
$rows = $this->db->table($table)
->select('student_id')
->where($yearColumn, $sourceYear)
->where('student_id IS NOT NULL', null, false)
->get()
->getResultArray();
foreach ($rows as $row) {
$studentId = (int) ($row['student_id'] ?? 0);
if ($studentId > 0) {
$studentIds[$studentId] = true;
}
}
}
return array_keys($studentIds);
}
private function filterEnrollmentPayloadByColumns(array $payload): array
{
foreach (array_keys($payload) as $column) {
if (! $this->db->fieldExists($column, 'enrollments')) {
unset($payload[$column]);
}
}
return $payload;
}
private function enrollmentClassOptions(string $selectedYear): array
{
$select = ['id', 'class_section_id', 'class_section_name'];
@@ -2572,6 +2769,115 @@ class AdministratorController extends BaseController
->findAll();
}
private function removedPriorYearStudentStatuses(string $selectedYear): array
{
$selectedStartYear = $this->getSchoolYearStartYear($selectedYear);
if ($selectedStartYear === null || ! $this->db->tableExists('enrollments')) {
return [];
}
$select = ['student_id', 'school_year'];
$hasIsWithdrawn = $this->db->fieldExists('is_withdrawn', 'enrollments');
$hasEnrollmentStatus = $this->db->fieldExists('enrollment_status', 'enrollments');
$hasAdmissionStatus = $this->db->fieldExists('admission_status', 'enrollments');
if ($hasIsWithdrawn) {
$select[] = 'is_withdrawn';
}
if ($hasEnrollmentStatus) {
$select[] = 'enrollment_status';
}
if ($hasAdmissionStatus) {
$select[] = 'admission_status';
}
if (! $hasIsWithdrawn && ! $hasEnrollmentStatus && ! $hasAdmissionStatus) {
return [];
}
$builder = $this->db->table('enrollments')
->select(implode(', ', $select))
->where('student_id IS NOT NULL', null, false)
->where('school_year IS NOT NULL', null, false)
->groupStart();
$hasRemovalCondition = false;
if ($hasIsWithdrawn) {
$builder->where('is_withdrawn', 1);
$hasRemovalCondition = true;
}
if ($hasEnrollmentStatus) {
if ($hasRemovalCondition) {
$builder->orWhereIn('enrollment_status', ['withdrawn', 'widthran', 'denied']);
} else {
$builder->whereIn('enrollment_status', ['withdrawn', 'widthran', 'denied']);
}
$hasRemovalCondition = true;
}
if ($hasAdmissionStatus) {
if ($hasRemovalCondition) {
$builder->orWhere('admission_status', 'denied');
} else {
$builder->where('admission_status', 'denied');
}
$hasRemovalCondition = true;
}
$builder->groupEnd();
if (! $hasRemovalCondition) {
return [];
}
$removedPriorStatuses = [];
foreach ($builder->get()->getResultArray() as $row) {
$rowYear = $this->getSchoolYearStartYear((string)($row['school_year'] ?? ''));
$studentId = (int)($row['student_id'] ?? 0);
if ($studentId <= 0 || $rowYear === null || $rowYear >= $selectedStartYear) {
continue;
}
$status = $this->priorRemovedEnrollmentStatus($row);
if ($status === null) {
continue;
}
if (
!isset($removedPriorStatuses[$studentId])
|| $rowYear > (int)$removedPriorStatuses[$studentId]['year']
) {
$removedPriorStatuses[$studentId] = [
'year' => $rowYear,
'status' => $status,
];
}
}
$statusByStudentId = [];
foreach ($removedPriorStatuses as $studentId => $row) {
$statusByStudentId[(int)$studentId] = (string)$row['status'];
}
return $statusByStudentId;
}
private function priorRemovedEnrollmentStatus(array $row): ?string
{
$enrollmentStatus = strtolower(trim((string)($row['enrollment_status'] ?? '')));
$admissionStatus = strtolower(trim((string)($row['admission_status'] ?? '')));
if ($enrollmentStatus === 'denied' || $admissionStatus === 'denied') {
return 'denied';
}
if (in_array($enrollmentStatus, ['withdrawn', 'widthran'], true) || (int)($row['is_withdrawn'] ?? 0) === 1) {
return 'withdrawn';
}
return null;
}
private function priorYearStudentIds(string $selectedYear): array
{
$selectedStartYear = $this->getSchoolYearStartYear($selectedYear);
@@ -2674,6 +2980,7 @@ class AdministratorController extends BaseController
$validStatuses = [
'admission under review',
'review & decision',
'payment pending',
'enrolled',
'withdraw under review',
@@ -2921,6 +3228,7 @@ class AdministratorController extends BaseController
// === AFTER COMMIT: fire specific events, batched per parent/status ===
$eventMap = [
'admission under review' => 'admissionUnderReview',
'review & decision' => 'admissionUnderReview',
'payment pending' => 'paymentPending',
'enrolled' => 'studentEnrolled',
'withdraw under review' => 'withdrawUnderReview',