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
+491 -18
View File
@@ -46,9 +46,35 @@ class AssignmentController extends BaseController
// Apply school year filter (default to current config) but avoid semester filtering so the full year is visible
$selectedSemester = (string)($this->request->getGet('semester') ?? $this->semester ?? '');
$year = (string)($this->schoolYear ?? '');
$year = trim((string)($this->request->getGet('schoolYear') ?? $this->request->getGet('school_year') ?? ''));
if ($year === '') {
$year = $this->currentSchoolYearName((string)($this->schoolYear ?? ''));
}
$this->applyPendingDistributionDraftsForEnrolledStudents($year);
$this->repairMissingEnrollmentClassAssignments($year);
$distributedSectionIds = array_values(array_unique(array_merge(
$this->distributedClassSectionIds($year),
$this->enrollmentAssignedClassSectionIds($year)
)));
$classSectionsAll = [];
if (!empty($distributedSectionIds)) {
$classSectionsAll = $this->classSectionModel
->select('id, class_section_id, class_section_name, school_year')
->where('school_year', $year)
->whereIn('class_section_id', $distributedSectionIds)
->orderBy('class_section_name', 'ASC')
->findAll();
}
$classSectionById = [];
foreach ($classSectionsAll as $section) {
$sectionId = (int)($section['class_section_id'] ?? 0);
if ($sectionId > 0) {
$classSectionById[$sectionId] = $section;
}
}
$tcQ = $this->teacherClassModel;
if ($year !== '') {
@@ -73,20 +99,26 @@ class AssignmentController extends BaseController
$studentsBySection[$sc['class_section_id']][] = $sc;
}
$allSectionIds = array_unique(array_merge(array_keys($teacherBySection), array_keys($studentsBySection)));
$allSectionIds = array_unique(array_merge(array_keys($classSectionById), array_keys($teacherBySection), array_keys($studentsBySection)));
$distributedSectionSet = array_fill_keys($distributedSectionIds, true);
foreach ($allSectionIds as $classSectionId) {
if (!isset($distributedSectionSet[(int)$classSectionId])) {
continue;
}
$teacherClasses = $teacherBySection[$classSectionId] ?? [];
$studentClasses = $studentsBySection[$classSectionId] ?? [];
$hasTeacher = !empty($teacherClasses);
$hasStudents = !empty($studentClasses);
$hasClassSection = isset($classSectionById[(int)$classSectionId]);
if (!$hasTeacher && !$hasStudents) {
if (!$hasClassSection && !$hasTeacher && !$hasStudents) {
continue;
}
$classSectionName = (string) ($this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? '');
$classSectionName = (string) ($classSectionById[(int)$classSectionId]['class_section_name'] ?? $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? '');
$mainTeachers = [];
$teacherAssistants = [];
@@ -176,12 +208,19 @@ class AssignmentController extends BaseController
$schoolYearsList = [];
try {
$db = Database::connect();
$yearsQuery = $db->table('teacher_class')
$yearsQuery = $db->table('classSection')
->select('DISTINCT school_year', false)
->where('school_year IS NOT NULL', null, false)
->orderBy('school_year', 'DESC')
->get()
->getResultArray();
$studentYearsQuery = $db->table('student_class')
->select('DISTINCT school_year', false)
->where('school_year IS NOT NULL', null, false)
->orderBy('school_year', 'DESC')
->get()
->getResultArray();
$yearsQuery = array_merge($yearsQuery, $studentYearsQuery);
foreach ($yearsQuery as $row) {
$val = (string)($row['school_year'] ?? '');
if ($val !== '' && !in_array($val, $schoolYearsList, true)) {
@@ -196,7 +235,7 @@ class AssignmentController extends BaseController
}
// Sort sections
usort($data['classSections'], fn($a, $b) => strcmp((string) $a['class_section_name'], (string) $b['class_section_name']));
usort($data['classSections'], fn($a, $b) => strnatcasecmp((string) $a['class_section_name'], (string) $b['class_section_name']));
$data['schoolYears'] = $schoolYearsList;
$data['schoolYear'] = $year;
@@ -206,6 +245,205 @@ class AssignmentController extends BaseController
return view('administrator/class_assignment', $data);
}
private function distributedClassSectionIds(string $year): array
{
$year = trim($year);
if ($year === '') {
return [];
}
try {
$db = Database::connect();
if (! $db->tableExists('student_section_distribution_drafts')) {
return [];
}
$baseRows = $db->table('classSection')
->select('class_id, class_section_id, class_section_name')
->where('school_year', $year)
->where("class_section_name NOT LIKE '%-%'", null, false)
->orderBy('class_id', 'ASC')
->get()
->getResultArray();
$draftRows = $db->table('student_section_distribution_drafts d')
->select('d.class_id, d.class_section_id, COALESCE(cs.class_section_name, d.class_section_id) AS class_section_name', false)
->join(
'classSection cs',
'cs.class_section_id = d.class_section_id AND cs.school_year = d.school_year',
'left',
false
)
->where('d.school_year', $year)
->where('d.class_section_id >', 0)
->whereIn('status', ['pending', 'applied'])
->get()
->getResultArray();
$draftsByClassId = [];
foreach ($draftRows as $row) {
$classId = (int)($row['class_id'] ?? 0);
$sectionId = (int)($row['class_section_id'] ?? 0);
if ($classId <= 0 || $sectionId <= 0) {
continue;
}
$draftsByClassId[$classId][$sectionId] = [
'class_section_id' => $sectionId,
'class_section_name' => (string)($row['class_section_name'] ?? ''),
];
}
$allowed = [];
$seenClassIds = [];
foreach ($baseRows as $row) {
$classId = (int)($row['class_id'] ?? 0);
$baseSectionId = (int)($row['class_section_id'] ?? 0);
$baseName = trim((string)($row['class_section_name'] ?? ''));
if ($classId <= 0 || $baseSectionId <= 0 || $baseName === '') {
continue;
}
$normalized = strtolower($baseName);
$isAutoDistributeBase = $normalized === 'youth'
|| (ctype_digit($normalized) && (int)$normalized >= 1 && (int)$normalized <= 10);
if (!$isAutoDistributeBase) {
continue;
}
$seenClassIds[$classId] = true;
$classDrafts = $draftsByClassId[$classId] ?? [];
$hasBaseDraft = false;
$letteredDraftIds = [];
foreach ($classDrafts as $draft) {
$draftSectionId = (int)($draft['class_section_id'] ?? 0);
$draftName = trim((string)($draft['class_section_name'] ?? ''));
if ($draftSectionId === $baseSectionId || $draftName === '' || strpos($draftName, '-') === false) {
$hasBaseDraft = true;
continue;
}
$letteredDraftIds[] = $draftSectionId;
}
if ($hasBaseDraft || empty($letteredDraftIds)) {
$allowed[] = $baseSectionId;
continue;
}
foreach ($letteredDraftIds as $draftSectionId) {
$allowed[] = $draftSectionId;
}
}
foreach ($draftsByClassId as $classId => $classDrafts) {
if (isset($seenClassIds[$classId])) {
continue;
}
foreach ($classDrafts as $draft) {
$sectionId = (int)($draft['class_section_id'] ?? 0);
if ($sectionId > 0) {
$allowed[] = $sectionId;
}
}
}
return array_values(array_unique(array_filter(
$allowed,
static fn(int $sectionId): bool => $sectionId > 0
)));
} catch (\Throwable $e) {
log_message('error', 'distributedClassSectionIds failed: ' . $e->getMessage());
return [];
}
}
private function enrollmentAssignedClassSectionIds(string $year): array
{
$year = trim($year);
if ($year === '') {
return [];
}
try {
$db = Database::connect();
$allowedStatuses = ['admission under review', 'review & decision', 'payment pending', 'enrolled'];
$ids = [];
if ($db->tableExists('enrollments')) {
$builder = $db->table('enrollments e')
->select('e.class_section_id')
->join('students s', 's.id = e.student_id', 'inner')
->where('e.school_year', $year)
->whereIn('e.enrollment_status', $allowedStatuses)
->where('e.class_section_id IS NOT NULL', null, false)
->where('e.class_section_id >', 0);
if ($db->fieldExists('is_active', 'students')) {
$builder->where('s.is_active', 1);
}
if ($db->fieldExists('is_withdrawn', 'enrollments')) {
$builder->groupStart()
->where('e.is_withdrawn', 0)
->orWhere('e.is_withdrawn', null)
->groupEnd();
}
foreach ($builder->groupBy('e.class_section_id')->get()->getResultArray() as $row) {
$sectionId = (int)($row['class_section_id'] ?? 0);
if ($sectionId > 0) {
$ids[] = $sectionId;
}
}
}
if ($db->tableExists('student_class') && $db->tableExists('enrollments')) {
$builder = $db->table('student_class sc')
->select('sc.class_section_id')
->join(
'enrollments e',
'e.student_id = sc.student_id AND e.school_year = sc.school_year',
'inner',
false
)
->join('students s', 's.id = sc.student_id', 'inner')
->where('sc.school_year', $year)
->whereIn('e.enrollment_status', $allowedStatuses)
->where('sc.class_section_id IS NOT NULL', null, false)
->where('sc.class_section_id >', 0);
if ($db->fieldExists('is_active', 'students')) {
$builder->where('s.is_active', 1);
}
if ($db->fieldExists('is_event_only', 'student_class')) {
$builder->groupStart()
->where('sc.is_event_only', 0)
->orWhere('sc.is_event_only', null)
->groupEnd();
}
if ($db->fieldExists('is_withdrawn', 'enrollments')) {
$builder->groupStart()
->where('e.is_withdrawn', 0)
->orWhere('e.is_withdrawn', null)
->groupEnd();
}
foreach ($builder->groupBy('sc.class_section_id')->get()->getResultArray() as $row) {
$sectionId = (int)($row['class_section_id'] ?? 0);
if ($sectionId > 0) {
$ids[] = $sectionId;
}
}
}
return array_values(array_unique($ids));
} catch (\Throwable $e) {
log_message('error', 'enrollmentAssignedClassSectionIds failed: ' . $e->getMessage());
return [];
}
}
private function applyPendingDistributionDraftsForEnrolledStudents(string $year): void
{
if ($year === '') {
@@ -227,7 +465,7 @@ class AssignmentController extends BaseController
)
->where('d.school_year', $year)
->where('d.status', 'pending')
->whereIn('e.enrollment_status', ['payment pending', 'enrolled'])
->whereIn('e.enrollment_status', ['admission under review', 'payment pending', 'enrolled'])
->groupBy('d.id, d.student_id, d.class_section_id')
->get()
->getResultArray();
@@ -274,10 +512,12 @@ class AssignmentController extends BaseController
$db->table('enrollments')
->where('student_id', $studentId)
->where('school_year', $year)
->whereIn('enrollment_status', ['payment pending', 'enrolled'])
->whereIn('enrollment_status', ['admission under review', 'payment pending', 'enrolled'])
->update([
'class_section_id' => $sectionId,
'updated_at' => $now,
'class_section_id' => $sectionId,
'assigned_class_section_id' => $sectionId,
'placement_status' => 'automatic_distribution_applied',
'updated_at' => $now,
]);
$db->table('student_section_distribution_drafts')
@@ -294,6 +534,188 @@ class AssignmentController extends BaseController
}
}
private function repairMissingEnrollmentClassAssignments(string $year): void
{
if ($year === '') {
return;
}
try {
$db = Database::connect();
$this->ensureClassSectionsForYear($db, $year);
if (! $db->tableExists('enrollments') || ! $db->tableExists('student_class')) {
return;
}
$previousYear = $this->previousSchoolYearName($year);
if ($previousYear === null) {
return;
}
$rows = $db->table('enrollments e')
->select('e.student_id, e.school_year, e.enrollment_status, e.class_section_id, sc.id AS student_class_id')
->join('student_class sc', 'sc.student_id = e.student_id AND sc.school_year = e.school_year', 'left')
->where('e.school_year', $year)
->whereIn('e.enrollment_status', ['admission under review', 'payment pending', 'enrolled'])
->groupStart()
->where('e.class_section_id', null)
->orWhere('e.class_section_id', 0)
->orWhere('sc.id', null)
->groupEnd()
->groupBy('e.student_id, e.school_year, e.enrollment_status, e.class_section_id, sc.id')
->get()
->getResultArray();
foreach ($rows as $row) {
$studentId = (int)($row['student_id'] ?? 0);
if ($studentId <= 0) {
continue;
}
$evaluation = service('enrollmentTransition')->evaluate($studentId, $previousYear, $year, 'admin');
if (!($evaluation['academic_eligible'] ?? false) || ($evaluation['blockers'] ?? []) !== []) {
continue;
}
$targetSectionId = (int)($evaluation['assigned_class_section_id'] ?? 0);
if ($targetSectionId <= 0 && (int)($evaluation['assigned_grade_id'] ?? 0) > 0) {
$base = $this->baseSectionForClassYear((int)$evaluation['assigned_grade_id'], $year);
$targetSectionId = (int)($base['class_section_id'] ?? 0);
}
if ($targetSectionId <= 0) {
continue;
}
$placementStatus = match ((string)($evaluation['placement_status'] ?? '')) {
'automatic_distribution_pending' => 'base_section_pending_distribution',
'same_class_assigned', 'temporary_same_grade' => (string)$evaluation['placement_status'],
default => 'manual_class_assigned',
};
$existing = $db->table('student_class')
->select('id')
->where('student_id', $studentId)
->where('school_year', $year)
->orderBy('id', 'DESC')
->limit(1)
->get()
->getRowArray();
$studentClassPayload = [
'student_id' => $studentId,
'class_section_id' => $targetSectionId,
'school_year' => $year,
'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null,
'updated_at' => utc_now(),
];
if ($existing !== null) {
$db->table('student_class')->where('id', (int)$existing['id'])->update($studentClassPayload);
} else {
$studentClassPayload['created_at'] = utc_now();
$db->table('student_class')->insert($studentClassPayload);
}
$db->table('enrollments')
->where('student_id', $studentId)
->where('school_year', $year)
->whereIn('enrollment_status', ['admission under review', 'payment pending', 'enrolled'])
->update([
'class_section_id' => $targetSectionId,
'assigned_class_section_id' => $targetSectionId,
'placement_status' => $placementStatus,
'updated_at' => utc_now(),
]);
}
} catch (\Throwable $e) {
log_message('error', 'repairMissingEnrollmentClassAssignments failed: ' . $e->getMessage());
}
}
private function baseSectionForClassYear(int $classId, string $schoolYear): ?array
{
$db = Database::connect();
$this->ensureClassSectionsForYear($db, $schoolYear);
if ($classId <= 0 || $schoolYear === '' || ! $db->tableExists('classSection')) {
return null;
}
$builder = $db->table('classSection')
->select('class_section_id, class_section_name, class_id')
->where('class_id', $classId)
->where("class_section_name NOT LIKE '%-%'", null, false)
->orderBy('id', 'ASC')
->limit(1);
if ($db->fieldExists('school_year', 'classSection')) {
$builder->where('school_year', $schoolYear);
}
$row = $builder->get()->getRowArray();
return $row !== null && (int)($row['class_section_id'] ?? 0) > 0 ? $row : null;
}
private function ensureClassSectionsForYear($db, string $targetSchoolYear): void
{
$targetSchoolYear = trim($targetSchoolYear);
if ($targetSchoolYear === '' || ! $db->tableExists('classSection') || ! $db->fieldExists('school_year', 'classSection')) {
return;
}
if ($db->table('classSection')->where('school_year', $targetSchoolYear)->countAllResults() > 0) {
return;
}
$sourceSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
if ($sourceSchoolYear === null) {
return;
}
$sourceRows = $db->table('classSection')
->select('class_id, class_section_id, class_section_name')
->where('school_year', $sourceSchoolYear)
->orderBy('id', 'ASC')
->get()
->getResultArray();
$now = utc_now();
foreach ($sourceRows as $row) {
$classSectionId = (int)($row['class_section_id'] ?? 0);
if ($classSectionId <= 0) {
continue;
}
$exists = $db->table('classSection')
->where('school_year', $targetSchoolYear)
->where('class_section_id', $classSectionId)
->countAllResults();
if ($exists > 0) {
continue;
}
$db->table('classSection')->insert([
'class_id' => (int)($row['class_id'] ?? 0),
'class_section_id' => $classSectionId,
'class_section_name' => (string)($row['class_section_name'] ?? ''),
'school_year' => $targetSchoolYear,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
private function previousSchoolYearName(string $schoolYear): ?string
{
$schoolYear = trim($schoolYear);
if (! preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $matches)) {
return null;
}
return ((int)$matches[1] - 1) . '-' . ((int)$matches[2] - 1);
}
public function save()
@@ -316,8 +738,46 @@ class AssignmentController extends BaseController
// API: JSON payload for Classes List page
public function classAssignmentData()
{
$teacherClassesAll = $this->teacherClassModel->findAll();
$studentClassesAll = $this->studentClassModel->findAll();
$year = trim((string)($this->request->getGet('schoolYear') ?? $this->request->getGet('school_year') ?? ''));
if ($year === '') {
$year = $this->currentSchoolYearName((string)($this->schoolYear ?? ''));
}
$this->applyPendingDistributionDraftsForEnrolledStudents($year);
$this->repairMissingEnrollmentClassAssignments($year);
$distributedSectionIds = array_values(array_unique(array_merge(
$this->distributedClassSectionIds($year),
$this->enrollmentAssignedClassSectionIds($year)
)));
$classSectionsAll = [];
if (!empty($distributedSectionIds)) {
$classSectionsAll = $this->classSectionModel
->select('id, class_section_id, class_section_name, school_year')
->where('school_year', $year)
->whereIn('class_section_id', $distributedSectionIds)
->orderBy('class_section_name', 'ASC')
->findAll();
}
$classSectionById = [];
foreach ($classSectionsAll as $section) {
$sectionId = (int)($section['class_section_id'] ?? 0);
if ($sectionId > 0) {
$classSectionById[$sectionId] = $section;
}
}
$tcQ = $this->teacherClassModel;
if ($year !== '') {
$tcQ = $tcQ->where('school_year', $year);
}
$teacherClassesAll = $tcQ->findAll();
$scQ = $this->studentClassModel->active();
if ($year !== '') {
$scQ = $scQ->where('student_class.school_year', $year);
}
$studentClassesAll = $scQ->findAll();
// Group by section
$teacherBySection = [];
@@ -331,16 +791,22 @@ class AssignmentController extends BaseController
if ($secId) $studentsBySection[$secId][] = $sc;
}
$allSectionIds = array_values(array_unique(array_merge(array_keys($teacherBySection), array_keys($studentsBySection))));
$allSectionIds = array_values(array_unique(array_merge(array_keys($classSectionById), array_keys($teacherBySection), array_keys($studentsBySection))));
$distributedSectionSet = array_fill_keys($distributedSectionIds, true);
$classSections = [];
foreach ($allSectionIds as $classSectionId) {
if (!isset($distributedSectionSet[(int)$classSectionId])) {
continue;
}
$hasTeacher = !empty($teacherBySection[$classSectionId]);
$hasStudents = !empty($studentsBySection[$classSectionId]);
if (!$hasTeacher && !$hasStudents) continue;
$hasClassSection = isset($classSectionById[(int)$classSectionId]);
if (!$hasClassSection && !$hasTeacher && !$hasStudents) continue;
$classSectionName = (string) ($this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? '');
$classSectionName = (string) ($classSectionById[(int)$classSectionId]['class_section_name'] ?? $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? '');
$mainTeachers = [];
$teacherAssistants = [];
@@ -374,12 +840,18 @@ class AssignmentController extends BaseController
// Load students for the section
$students = [];
foreach ($this->studentClassModel->active()->where('student_class.class_section_id', $classSectionId)->findAll() as $studentClass) {
$seenStudentIds = [];
foreach ($studentsBySection[$classSectionId] ?? [] as $studentClass) {
$sid = (int)($studentClass['student_id'] ?? 0);
if ($sid <= 0 || isset($seenStudentIds[$sid])) {
continue;
}
$stu = $this->studentModel
->where('id', (int)$studentClass['student_id'])
->where('id', $sid)
->where('is_active', 1)
->first();
if (!$stu) continue;
$students[] = [
'id' => (int)$stu['id'],
'firstname' => (string)($stu['firstname'] ?? ''),
@@ -391,6 +863,7 @@ class AssignmentController extends BaseController
'tuition_paid' => (bool)($stu['tuition_paid'] ?? false),
'school_id' => (string)($stu['school_id'] ?? ''),
];
$seenStudentIds[$sid] = true;
}
$classSections[] = [
@@ -406,7 +879,7 @@ class AssignmentController extends BaseController
}
// Sort by class_section_name
usort($classSections, fn($a, $b) => strcmp((string)($a['class_section_name'] ?? ''), (string)($b['class_section_name'] ?? '')));
usort($classSections, fn($a, $b) => strnatcasecmp((string)($a['class_section_name'] ?? ''), (string)($b['class_section_name'] ?? '')));
return $this->response->setJSON([
'classSections' => $classSections,