fix enrollment logic, add financial aid, fix class distribution
Tests / PHPUnit (push) Failing after 1m6s

This commit is contained in:
root
2026-08-15 15:07:16 -04:00
parent c12bb59372
commit 4603d9ced2
95 changed files with 6892 additions and 1295 deletions
+214 -49
View File
@@ -914,28 +914,37 @@ class StudentController extends BaseController
}
$total = count($cands);
if ($sectionCount * $minPerSection > $total) {
$msg = 'Insufficient students: ' . $sectionCount . ' sections require at least ' . ($sectionCount * $minPerSection) . ' students, but only ' . $total . ' are available.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
}
if ($maxPerSection !== null && $total > $sectionCount * $maxPerSection) {
$msg = 'Capacity exceeded: ' . $sectionCount . ' sections can hold at most ' . ($sectionCount * $maxPerSection) . ' students, but ' . $total . ' must be assigned.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
}
// Fetch lettered sections for this class
$baseSection = $this->sectionForDistribution($classSectionId, $year);
// Fetch lettered sections for this class. The requested section count is a max:
// if the class cannot split into 2+ sections, keep the assignment on the base grade.
$letters = $this->letterSectionsForDistribution($classId, $year);
if (empty($letters)) {
$msg = 'No lettered sections found for the selected class.';
$availableSectionCount = count($letters);
if (!$baseSection || (int)($baseSection['class_id'] ?? 0) !== $classId) {
$msg = 'No base grade found for the selected class.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
}
if (count($letters) < $sectionCount) {
$msg = 'Not enough sections available. Needed: ' . $sectionCount . ', available: ' . count($letters);
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
$actualSectionCount = min($sectionCount, $availableSectionCount);
if ($minPerSection > 0) {
$actualSectionCount = min($actualSectionCount, max(1, intdiv($total, $minPerSection)));
}
if ($maxPerSection !== null) {
$minimumNeededForCapacity = (int)ceil($total / $maxPerSection);
$capacitySectionCount = max(1, $availableSectionCount);
if ($minimumNeededForCapacity > $capacitySectionCount || $minimumNeededForCapacity > $sectionCount) {
$msg = 'Capacity exceeded: available sections can hold at most ' . ($capacitySectionCount * $maxPerSection) . ' students, but ' . $total . ' must be assigned.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
}
$actualSectionCount = max($actualSectionCount, $minimumNeededForCapacity);
}
$letters = array_slice($letters, 0, $sectionCount);
if ($actualSectionCount < 2) {
$letters = [$baseSection];
} else {
$letters = array_slice($letters, 0, $actualSectionCount);
}
$buckets = $this->buildBalancedDistribution($cands, $letters, $minPerSection, $maxPerSection);
$draftModel = new StudentSectionDistributionDraftModel();
@@ -946,21 +955,11 @@ class StudentController extends BaseController
$draftIdByStudentId = [];
$this->db->transStart();
$studentIdsToReplace = array_values(array_unique(array_map(
static fn(array $student): int => (int)($student['student_id'] ?? 0),
$cands
)));
if (!empty($studentIdsToReplace)) {
$draftModel->where('school_year', $year)
->whereIn('student_id', $studentIdsToReplace)
->where('status', 'pending')
->delete();
}
foreach ($buckets as $b) {
$secId = (int)$b['class_section_id'];
foreach ($b['assigned'] as $student) {
$sid = (int)$student['student_id'];
$draftId = (int)$draftModel->insert([
$draftId = $this->upsertDistributionDraft($draftModel, [
'student_id' => $sid,
'class_id' => $classId,
'class_section_id' => $secId,
@@ -971,7 +970,6 @@ class StudentController extends BaseController
'status' => 'pending',
'batch_key' => $batchKey,
'created_by' => $updatedBy,
'created_at' => $now,
'updated_at' => $now,
]);
if ($draftId > 0) {
@@ -1026,6 +1024,7 @@ class StudentController extends BaseController
'age_at_reference' => $student['age_at_reference'] ?? null,
'gender' => (string)($student['gender'] ?? ''),
'last_year_class_section' => (string)($student['last_year_class_section'] ?? ''),
'previous_final_score' => $student['previous_final_score'] ?? null,
'class_id' => $classId,
'class_section_id' => $secId,
'class_section_name' => $nameById[$secId] ?? (string)$secId,
@@ -1182,12 +1181,7 @@ class StudentController extends BaseController
$now = utc_now();
$this->db->transStart();
$draftModel->where('student_id', $studentId)
->where('school_year', $year)
->where('status', 'pending')
->delete();
$draftId = (int)$draftModel->insert([
$draftId = $this->upsertDistributionDraft($draftModel, [
'student_id' => $studentId,
'class_id' => $targetClassId,
'class_section_id' => $targetSectionId,
@@ -1198,7 +1192,6 @@ class StudentController extends BaseController
'status' => 'pending',
'batch_key' => sha1($year . ':' . $studentId . ':' . microtime(true)),
'created_by' => $updatedBy,
'created_at' => $now,
'updated_at' => $now,
]);
@@ -1286,7 +1279,8 @@ class StudentController extends BaseController
return $this->mergeDistributionCandidates(
$out,
$this->decisionDistributionCandidates($classId, $year),
$this->currentYearDistributionCandidates($classId, $year)
$this->currentYearDistributionCandidates($classId, $year),
$this->registeredKgDistributionCandidates($classId, $year)
);
}
@@ -1302,7 +1296,7 @@ class StudentController extends BaseController
$builder = $this->db->table('student_class sc')
->select('0 AS promotion_queue_id, sc.student_id, sc.school_year AS school_year_from, cs.class_id AS source_class_id, cs.class_section_name AS source_class_name, students.firstname, students.lastname, students.gender, students.age, students.dob, students.registration_grade', false)
->join('students', 'students.id = sc.student_id', 'inner')
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
->join('classSection cs', 'cs.class_section_id = sc.class_section_id AND cs.school_year = sc.school_year', 'left')
->where('sc.school_year', $year)
->where('sc.class_section_id IS NOT NULL', null, false);
@@ -1325,7 +1319,7 @@ class StudentController extends BaseController
$builder = $this->db->table('enrollments e')
->select('0 AS promotion_queue_id, e.student_id, e.school_year AS school_year_from, cs.class_id AS source_class_id, cs.class_section_name AS source_class_name, students.firstname, students.lastname, students.gender, students.age, students.dob, students.registration_grade', false)
->join('students', 'students.id = e.student_id', 'inner')
->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left')
->join('classSection cs', 'cs.class_section_id = e.class_section_id AND cs.school_year = e.school_year', 'left')
->where('e.school_year', $year)
->whereIn('e.enrollment_status', ['admission under review', 'payment pending', 'enrolled'])
->groupStart()
@@ -1413,6 +1407,40 @@ class StudentController extends BaseController
return $out;
}
private function upsertDistributionDraft(StudentSectionDistributionDraftModel $draftModel, array $data): int
{
$studentId = (int)($data['student_id'] ?? 0);
$year = (string)($data['school_year'] ?? '');
if ($studentId <= 0 || $year === '') {
return 0;
}
$existing = $draftModel
->where('student_id', $studentId)
->where('school_year', $year)
->first();
if ($existing) {
$draftId = (int)($existing['id'] ?? 0);
if ($draftId <= 0) {
return 0;
}
unset($data['created_at']);
$data['status'] = 'pending';
$data['applied_at'] = null;
$draftModel->update($draftId, $data);
return $draftId;
}
if (empty($data['created_at'])) {
$data['created_at'] = $data['updated_at'] ?? utc_now();
}
return (int)$draftModel->insert($data);
}
private function kgDistributionCandidates(int $classId, string $year): array
{
if ($classId <= 0 || $year === '' || ! $this->db->tableExists('enrollments') || ! $this->db->tableExists('students')) {
@@ -1422,7 +1450,7 @@ class StudentController extends BaseController
$builder = $this->db->table('enrollments e')
->select('0 AS promotion_queue_id, e.student_id, e.school_year AS school_year_from, cs.class_id AS source_class_id, cs.class_section_name AS source_class_name, students.firstname, students.lastname, students.gender, students.age, students.dob, students.registration_grade', false)
->join('students', 'students.id = e.student_id', 'inner')
->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left')
->join('classSection cs', 'cs.class_section_id = e.class_section_id AND cs.school_year = e.school_year', 'left')
->where('e.school_year', $year)
->whereIn('e.enrollment_status', ['admission under review', 'payment pending', 'enrolled'])
->groupStart()
@@ -1515,7 +1543,12 @@ class StudentController extends BaseController
if ($this->db->fieldExists('school_year', 'students')) {
$builder->where('school_year', $year);
} elseif ($this->db->fieldExists('year_of_registration', 'students') && preg_match('/^(\d{4})/', $year, $matches)) {
$builder->where('year_of_registration', (int)$matches[1]);
$registrationYears = [(int)$matches[1]];
$previousYear = $this->previousSchoolYearName($year);
if ($previousYear !== null && preg_match('/^(\d{4})/', $previousYear, $previousMatches)) {
$registrationYears[] = (int)$previousMatches[1];
}
$builder->whereIn('year_of_registration', array_values(array_unique($registrationYears)));
}
if ($this->db->fieldExists('is_active', 'students')) {
@@ -1537,6 +1570,10 @@ class StudentController extends BaseController
continue;
}
if (!$this->studentHasOnlyKgPriorPlacement($studentId, $year)) {
continue;
}
$ageAtReference = $this->distributionAgeAtReference($row['dob'] ?? null, $year);
$targetClassId = $this->distributionTargetClassIdForStudent(
$classId,
@@ -1565,6 +1602,72 @@ class StudentController extends BaseController
return $out;
}
private function studentHasOnlyKgPriorPlacement(int $studentId, string $targetSchoolYear): bool
{
$previousYear = $this->previousSchoolYearName($targetSchoolYear);
if ($studentId <= 0 || $previousYear === null) {
return true;
}
$baseNames = [];
if ($this->db->tableExists('student_class')) {
$builder = $this->db->table('student_class sc')
->select('cs.class_section_name')
->join('classSection cs', 'cs.class_section_id = sc.class_section_id AND cs.school_year = sc.school_year', 'left')
->where('sc.student_id', $studentId)
->where('sc.school_year', $previousYear)
->where('sc.class_section_id IS NOT NULL', null, false);
if ($this->db->fieldExists('is_event_only', 'student_class')) {
$builder->groupStart()
->where('sc.is_event_only', 0)
->orWhere('sc.is_event_only', null)
->groupEnd();
}
foreach ($builder->get()->getResultArray() as $row) {
$baseName = $this->baseClassNameForDistribution((string)($row['class_section_name'] ?? ''));
if ($baseName !== '') {
$baseNames[$baseName] = true;
}
}
}
if ($this->db->tableExists('enrollments')) {
$rows = $this->db->table('enrollments e')
->select('cs.class_section_name')
->join('classSection cs', 'cs.class_section_id = e.class_section_id AND cs.school_year = e.school_year', 'left')
->where('e.student_id', $studentId)
->where('e.school_year', $previousYear)
->where('e.class_section_id IS NOT NULL', null, false)
->whereIn('e.enrollment_status', ['admission under review', 'payment pending', 'enrolled'])
->groupStart()
->where('e.is_withdrawn', 0)
->orWhere('e.is_withdrawn', null)
->groupEnd()
->get()
->getResultArray();
foreach ($rows as $row) {
$baseName = $this->baseClassNameForDistribution((string)($row['class_section_name'] ?? ''));
if ($baseName !== '') {
$baseNames[$baseName] = true;
}
}
}
if (empty($baseNames)) {
return true;
}
return count($baseNames) === 1 && isset($baseNames['KG']);
}
private function baseClassNameForDistribution(string $classSectionName): string
{
return strtoupper(trim(preg_replace('/-.+$/', '', $classSectionName) ?? ''));
}
private function isDistributionKgClass(int $classId, string $year): bool
{
if ($classId <= 0) {
@@ -1722,7 +1825,8 @@ class StudentController extends BaseController
$targetClassId = $this->targetClassIdFromDecision(
(string)($row['class_section_name'] ?? ''),
(string)($row['decision'] ?? '')
(string)($row['decision'] ?? ''),
$targetSchoolYear
);
$ageAtReference = $this->distributionAgeAtReference($row['dob'] ?? null, $targetSchoolYear);
$targetClassId = $this->distributionTargetClassIdForStudent(
@@ -1754,7 +1858,7 @@ class StudentController extends BaseController
return $out;
}
private function targetClassIdFromDecision(string $classSectionName, string $decision): ?int
private function targetClassIdFromDecision(string $classSectionName, string $decision, string $targetSchoolYear = ''): ?int
{
$baseName = strtoupper(trim(preg_replace('/-.+$/', '', $classSectionName) ?? ''));
if ($baseName === '') {
@@ -1767,17 +1871,30 @@ class StudentController extends BaseController
$targetBaseName = '1';
} elseif (ctype_digit($baseName)) {
$level = (int)$baseName;
$targetBaseName = $level >= 9 ? 'YOUTH' : (string)($level + 1);
$targetBaseName = $level >= 10 ? 'YOUTH' : (string)($level + 1);
} elseif ($baseName === 'YOUTH') {
$targetBaseName = 'YOUTH';
}
}
$row = $this->classSectionModel
$query = $this->classSectionModel
->select('class_id')
->where('UPPER(class_section_name)', $targetBaseName)
->where("class_section_name NOT LIKE '%-%'", null, false)
->first();
->orderBy('id', 'DESC');
if ($targetSchoolYear !== '' && $this->db->fieldExists('school_year', 'classSection')) {
$query->where('school_year', $targetSchoolYear);
}
$row = $query->first();
if (!$row && $targetSchoolYear !== '' && $this->db->fieldExists('school_year', 'classSection')) {
$row = $this->classSectionModel
->select('class_id')
->where('UPPER(class_section_name)', $targetBaseName)
->where("class_section_name NOT LIKE '%-%'", null, false)
->first();
}
return $row ? (int)$row['class_id'] : null;
}
@@ -1884,7 +2001,7 @@ class StudentController extends BaseController
if ($this->db->tableExists('student_class')) {
$builder = $this->db->table('student_class sc')
->select('cs.class_section_name')
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
->join('classSection cs', 'cs.class_section_id = sc.class_section_id AND cs.school_year = sc.school_year', 'left')
->where('sc.student_id', $studentId)
->where('sc.school_year', $previousYear)
->where('sc.class_section_id IS NOT NULL', null, false);
@@ -1911,7 +2028,7 @@ class StudentController extends BaseController
if (empty($names) && $this->db->tableExists('enrollments')) {
$rows = $this->db->table('enrollments e')
->select('cs.class_section_name')
->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left')
->join('classSection cs', 'cs.class_section_id = e.class_section_id AND cs.school_year = e.school_year', 'left')
->where('e.student_id', $studentId)
->where('e.school_year', $previousYear)
->where('e.class_section_id IS NOT NULL', null, false)
@@ -2236,6 +2353,7 @@ class StudentController extends BaseController
'age_at_reference' => $student['age_at_reference'] ?? null,
'gender' => (string)($student['gender'] ?? ''),
'last_year_class_section' => (string)($student['last_year_class_section'] ?? ''),
'previous_final_score' => $student['previous_final_score'] ?? null,
'class_id' => $classId,
'class_section_id' => 0,
'class_section_name' => $className,
@@ -2277,7 +2395,26 @@ class StudentController extends BaseController
}
$timezone = new \DateTimeZone((string)(config('School')->attendance['timezone'] ?? user_timezone()));
$reference = new \DateTimeImmutable($matches[1] . '-09-01', $timezone);
$reference = null;
$configuredReference = '';
try {
$configuredReference = trim((string)($this->configModel ? $this->configModel->getConfig('date_age_reference') : ''));
} catch (\Throwable $e) {
$configuredReference = '';
}
if ($configuredReference !== '') {
$candidate = \DateTimeImmutable::createFromFormat('!Y-m-d', $configuredReference, $timezone);
$errors = \DateTimeImmutable::getLastErrors();
$hasErrors = is_array($errors) && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0);
if ($candidate !== false && !$hasErrors && $candidate->format('Y') === $matches[1]) {
$reference = $candidate;
}
}
if ($reference === null) {
$reference = new \DateTimeImmutable($matches[1] . '-09-01', $timezone);
}
return $reference->setTime(0, 0, 0);
}
@@ -2312,6 +2449,10 @@ class StudentController extends BaseController
return $this->distributionBaseClassIdByName('KG', $schoolYear);
}
if ($defaultClassId === null && $ageAtReference === 6) {
return $this->distributionBaseClassIdByName('1', $schoolYear);
}
if ($this->isDistributionKgSource($defaultClassId, $sourceClassName, $schoolYear)) {
if ($ageAtReference !== null && $ageAtReference < 6) {
return $this->distributionBaseClassIdByName('KG', $schoolYear) ?? $defaultClassId;
@@ -2474,8 +2615,8 @@ class StudentController extends BaseController
}
$builder = $this->db->table('student_section_distribution_drafts d')
->select('d.id AS draft_id, d.class_id, d.class_section_id, d.previous_school_year, cs.class_section_name, students.firstname, students.lastname, students.gender, students.dob, d.student_id')
->join('classSection cs', 'cs.class_section_id = d.class_section_id', 'left')
->select('d.id AS draft_id, d.class_id, d.class_section_id, d.previous_school_year, d.previous_final_score, cs.class_section_name, students.firstname, students.lastname, students.gender, students.dob, d.student_id')
->join('classSection cs', 'cs.class_section_id = d.class_section_id AND cs.school_year = d.school_year', 'left')
->join('students', 'students.id = d.student_id', 'left')
->where('d.class_id', $classId)
->where('d.school_year', $year)
@@ -2511,6 +2652,10 @@ class StudentController extends BaseController
'class_section_id' => $sectionId,
'class_section_name' => (string)($row['class_section_name'] ?? $sectionId),
'total' => 0,
'male' => 0,
'female' => 0,
'score_total' => 0.0,
'score_count' => 0,
'student_names' => [],
'student_assignments' => [],
];
@@ -2520,6 +2665,16 @@ class StudentController extends BaseController
if ($name === '') {
$name = 'Student #' . $studentId;
}
$gender = strtolower((string)($row['gender'] ?? ''));
if ($gender === 'female') {
$sections[$sectionId]['female']++;
} else {
$sections[$sectionId]['male']++;
}
if (is_numeric($row['previous_final_score'] ?? null)) {
$sections[$sectionId]['score_total'] += (float)$row['previous_final_score'];
$sections[$sectionId]['score_count']++;
}
$sections[$sectionId]['student_names'][] = $name;
$sections[$sectionId]['student_assignments'][] = [
'draft_id' => (int)($row['draft_id'] ?? 0),
@@ -2527,6 +2682,7 @@ class StudentController extends BaseController
'student_name' => $name,
'age_at_reference' => $this->distributionAgeAtReference($row['dob'] ?? null, $year),
'gender' => (string)($row['gender'] ?? ''),
'previous_final_score' => is_numeric($row['previous_final_score'] ?? null) ? (float)$row['previous_final_score'] : null,
'last_year_class_section' => $this->distributionPreviousClassSectionName(
$studentId,
$year,
@@ -2538,6 +2694,15 @@ class StudentController extends BaseController
$sections[$sectionId]['total']++;
}
foreach ($sections as &$section) {
$scoreCount = (int)($section['score_count'] ?? 0);
$section['average_score'] = $scoreCount > 0
? round((float)$section['score_total'] / $scoreCount, 2)
: null;
unset($section['score_total'], $section['score_count']);
}
unset($section);
return array_values($sections);
}