fix school year and enrollment steps
Tests / PHPUnit (push) Failing after 58s

This commit is contained in:
root
2026-08-15 21:15:05 -04:00
parent 4603d9ced2
commit 3cbfc40934
18 changed files with 1142 additions and 163 deletions
@@ -62,7 +62,7 @@ class SchoolYearClosingController extends BaseController
}
service('schoolYearClosing')->start($id, $targetId, $this->userId());
return redirect()->to('/administrator/school-years/' . $id . '/closing/preview')->with('success', 'End-year process started.');
return redirect()->to('/administrator/school-years/' . $id . '/closing/preview?close_flow=1')->with('success', 'End-year process started.');
} catch (Throwable $e) {
return redirect()->back()->with('error', $e->getMessage());
}
@@ -72,7 +72,7 @@ class SchoolYearClosingController extends BaseController
{
try {
service('schoolYearClosing')->execute($id, $this->userId());
return redirect()->to('/administrator/school-years/' . $id . '/closing/preview')->with('success', 'Carry-forward confirmed.');
return redirect()->to('/administrator/school-years/' . $id . '/closing/preview?close_flow=1')->with('success', 'Carry-forward confirmed.');
} catch (Throwable $e) {
return redirect()->back()->with('error', $e->getMessage());
}
@@ -82,7 +82,7 @@ class SchoolYearClosingController extends BaseController
{
try {
service('schoolYearClosing')->complete($id, $this->userId());
return redirect()->to('/administrator/school-years')->with('success', 'School year ended and closed.');
return redirect()->to('/administrator/school-years/' . $id . '/closing/preview?close_flow=1')->with('success', 'School year ended and closed.');
} catch (Throwable $e) {
return redirect()->back()->with('error', $e->getMessage());
}
@@ -52,6 +52,7 @@ class ParentReportCardController extends BaseController
$studentIds = array_values(array_filter(array_map(static fn ($s) => (int) ($s['id'] ?? 0), $students)));
$ackMap = [];
$reportAvailableMap = $this->reportAvailabilityMap($studentIds, $schoolYear, $semester);
if (! empty($studentIds)) {
$rows = $this->ackModel
->where('parent_id', $parentId)
@@ -67,6 +68,7 @@ class ParentReportCardController extends BaseController
return view('parent/report_cards', [
'students' => $students,
'ackMap' => $ackMap,
'reportAvailableMap' => $reportAvailableMap,
'schoolYear' => $schoolYear,
'semester' => $semester,
'isEditable' => ! $schoolYearContext->isReadonly(),
@@ -89,6 +91,11 @@ class ParentReportCardController extends BaseController
$schoolYear = trim($schoolYearContext->yearName());
$semester = trim((string) ($this->request->getGet('semester') ?? $this->configModel->getConfig('semester') ?? ''));
if (! $this->reportExists((int) $studentId, $schoolYear, $semester)) {
return redirect()->to(site_url('parent/report-cards'))
->with('error', 'No report card exists for the selected school year and semester.');
}
if (! $schoolYearContext->isReadonly()) {
$this->touchAcknowledgement($parentId, (int) $studentId, $schoolYear, $semester, [
'viewed_at' => date('Y-m-d H:i:s'),
@@ -136,6 +143,12 @@ class ParentReportCardController extends BaseController
$schoolYear = trim($schoolYearContext->yearName());
$semester = trim((string) ($this->configModel->getConfig('semester') ?? ''));
if (! $this->reportExists((int) $studentId, $schoolYear, $semester)) {
return redirect()->to(site_url('parent/report-cards'))
->with('error', 'No report card exists for the selected school year and semester.');
}
$now = date('Y-m-d H:i:s');
$this->touchAcknowledgement($parentId, (int) $studentId, $schoolYear, $semester, [
'viewed_at' => $now,
@@ -173,6 +186,80 @@ class ParentReportCardController extends BaseController
return $parentId ?: null;
}
protected function reportAvailabilityMap(array $studentIds, string $schoolYear, string $semester): array
{
$studentIds = array_values(array_filter(array_map('intval', $studentIds), static fn ($id) => $id > 0));
if (empty($studentIds) || $schoolYear === '') {
return [];
}
$builder = $this->db->table('semester_scores')
->select('student_id')
->whereIn('student_id', $studentIds)
->where('school_year', $schoolYear);
$semesterVariants = $this->semesterVariants($semester);
if (! empty($semesterVariants)) {
$builder->whereIn('semester', $semesterVariants);
}
$rows = $builder
->groupBy('student_id')
->get()
->getResultArray();
$map = [];
foreach ($rows as $row) {
$sid = (int) ($row['student_id'] ?? 0);
if ($sid > 0) {
$map[$sid] = true;
}
}
return $map;
}
protected function reportExists(int $studentId, string $schoolYear, string $semester): bool
{
if ($studentId <= 0 || $schoolYear === '') {
return false;
}
$builder = $this->db->table('semester_scores')
->select('id')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->limit(1);
$semesterVariants = $this->semesterVariants($semester);
if (! empty($semesterVariants)) {
$builder->whereIn('semester', $semesterVariants);
}
return (bool) $builder->get()->getRowArray();
}
protected function semesterVariants(string $semester): array
{
$raw = trim($semester);
if ($raw === '') {
return [];
}
$normalized = strtolower($raw);
if ($normalized === 'fall' || $normalized === 'first' || str_contains($normalized, 'fall') || str_contains($normalized, '1')) {
$values = ['Fall', 'fall', 'First', 'first', 'Semester 1', 'semester 1', 'Semester1', 'semester1', 'Sem 1', 'sem 1', '1', '01', 'S1', 's1'];
} elseif ($normalized === 'spring' || $normalized === 'second' || str_contains($normalized, 'spring') || str_contains($normalized, '2')) {
$values = ['Spring', 'spring', 'Second', 'second', 'Semester 2', 'semester 2', 'Semester2', 'semester2', 'Sem 2', 'sem 2', '2', '02', 'S2', 's2'];
} else {
$values = [$raw];
}
$values[] = $raw;
return array_values(array_unique($values));
}
protected function touchAcknowledgement(
int $parentId,
int $studentId,
+172 -6
View File
@@ -391,7 +391,8 @@ class ParentController extends BaseController
'lastDayOfRegistration' => $this->lastDayOfRegistration,
'schoolStartDate' => $this->schoolStartDate,
'hasAcceptedSchoolPolicy' => $this->hasAcceptedPolicyForYear((int) $parentId, $selectedYear),
'familyFinancialSummary' => $this->familyFinancialSummary((int) $parentId, $previousSchoolYear, $selectedYear),
'familyFinancialSummary' => $this->familyFinancialSummary((int) $parentId, $previousSchoolYear, $selectedYear, $students),
'enrollmentFeeSchedule' => $this->enrollmentFeeSchedule($selectedYear),
]);
} catch (Exception $e) {
log_message('error', 'An error occurred in enrollClasses: ' . $e->getMessage());
@@ -462,6 +463,12 @@ class ParentController extends BaseController
$submittedStudentIds = array_values(array_unique(array_filter(array_map('intval', (array) $enroll), static fn (int $id): bool => $id > 0)));
$evaluations = [];
$errors = [];
$submittedEnrollmentContexts = [];
$studentInfoErrors = $this->updateEnrollmentStudentInfo($submittedStudentIds, (int) $parentId, $selectedYear);
if ($studentInfoErrors !== []) {
return redirect()->back()->withInput()->with('error', implode(' ', $studentInfoErrors));
}
foreach ($submittedStudentIds as $studentId) {
try {
@@ -524,8 +531,10 @@ class ParentController extends BaseController
->getRowArray();
$studentName = trim((string) ($studentData[$studentId]['firstname'] ?? '') . ' ' . (string) ($studentData[$studentId]['lastname'] ?? '')) ?: 'Student ID ' . $studentId;
$isReturningReEnrollment = $this->isReturningReEnrollmentStudent((int)$studentId, $selectedYear);
$submittedEnrollmentContexts[$studentId] = $isReturningReEnrollment ? 're_enrollment' : 'first_enrollment';
if ($existingEnrollment) {
$isReturningReEnrollment = $this->isReturningReEnrollmentStudent((int)$studentId, $selectedYear);
$targetEnrollmentStatus = $isReturningReEnrollment ? 'payment pending' : 'admission under review';
$targetAdmissionStatus = $isReturningReEnrollment ? 'accepted' : 'pending';
@@ -568,7 +577,6 @@ class ParentController extends BaseController
implode(' ', array_map('strval', $evaluation['rule_codes'] ?? []))
);
} else {
$isReturningReEnrollment = $this->isReturningReEnrollmentStudent((int)$studentId, $selectedYear);
$targetEnrollmentStatus = $isReturningReEnrollment ? 'payment pending' : 'admission under review';
$targetAdmissionStatus = $isReturningReEnrollment ? 'accepted' : 'pending';
@@ -616,6 +624,7 @@ class ParentController extends BaseController
if (! $this->db->transStatus()) {
return redirect()->back()->withInput()->with('error', 'A database error occurred while submitting enrollment.');
}
}
// $studentData now holds info for all students processed
@@ -710,6 +719,8 @@ class ParentController extends BaseController
return redirect()->to('/parent/enroll_classes');
} else {
// Redirect to enrollment success page if there are enrollments
$contextValues = array_values(array_unique($submittedEnrollmentContexts ?? []));
$parentData['enrollment_context'] = count($contextValues) === 1 ? $contextValues[0] : 'mixed';
Events::trigger('admissionUnderReview', $parentData, $studentData); //send notification for enrolled student
return redirect()->to('/parent/enroll_classes');
}
@@ -739,6 +750,115 @@ class ParentController extends BaseController
return $this->filterEnrollmentPayloadByColumns($payload);
}
private function updateEnrollmentStudentInfo(array $studentIds, int $parentId, string $schoolYear): array
{
$studentInfo = $this->request->getPost('student_info');
if (! is_array($studentInfo)) {
return [];
}
$submittedIds = array_fill_keys(array_map('intval', $studentIds), true);
$errors = [];
$updates = [];
foreach ($studentInfo as $studentId => $fields) {
$studentId = (int) $studentId;
if ($studentId <= 0 || ! isset($submittedIds[$studentId]) || ! is_array($fields)) {
continue;
}
$existing = $this->studentModel
->where('id', $studentId)
->where('parent_id', $parentId)
->first();
if (! is_array($existing)) {
$errors[] = 'Student ID ' . $studentId . ': student record was not found.';
continue;
}
$firstName = $this->normalizeEnrollmentStudentName((string) ($fields['firstname'] ?? ''));
$lastName = $this->normalizeEnrollmentStudentName((string) ($fields['lastname'] ?? ''));
$dob = trim((string) ($fields['dob'] ?? ''));
$gender = trim((string) ($fields['gender'] ?? ''));
$registrationGrade = trim((string) ($fields['registration_grade'] ?? ''));
$photoConsent = (string) ($fields['photo_consent'] ?? '');
$studentLabel = trim($firstName . ' ' . $lastName) ?: 'Student ID ' . $studentId;
try {
$this->validateNames($firstName);
$this->validateNames($lastName);
} catch (InvalidArgumentException $e) {
$errors[] = $studentLabel . ': ' . $e->getMessage();
continue;
}
if (! in_array($gender, ['Male', 'Female'], true)) {
$errors[] = $studentLabel . ': gender is required.';
continue;
}
if ($photoConsent !== '0' && $photoConsent !== '1') {
$errors[] = $studentLabel . ': photo consent is required.';
continue;
}
if ($registrationGrade === '' || mb_strlen($registrationGrade) > 50) {
$errors[] = $studentLabel . ': registration grade is required.';
continue;
}
$dobObj = \DateTimeImmutable::createFromFormat('!Y-m-d', $dob, new \DateTimeZone('UTC'));
$dobErrors = \DateTimeImmutable::getLastErrors();
$dobWarningCount = is_array($dobErrors) ? (int) ($dobErrors['warning_count'] ?? 0) : 0;
$dobErrorCount = is_array($dobErrors) ? (int) ($dobErrors['error_count'] ?? 0) : 0;
if ($dobObj === false || $dobWarningCount > 0 || $dobErrorCount > 0) {
$errors[] = $studentLabel . ': date of birth must use YYYY-MM-DD format.';
continue;
}
$validation = $this->validateDobAge(
$dob,
$this->registrationMinimumAgeDeadline($schoolYear),
5,
18,
$this->schoolYearAgeDeadline($schoolYear)
);
if (! $validation['isValid']) {
$errors[] = $studentLabel . ': ' . $validation['message'] . '.';
continue;
}
$updates[$studentId] = [
'firstname' => $firstName,
'lastname' => $lastName,
'dob' => $dobObj->format('Y-m-d'),
'age' => $this->calculateAgeAsOfSchoolYearStartYear($dobObj->format('Y-m-d'), $schoolYear),
'gender' => $gender,
'registration_grade' => $registrationGrade,
'photo_consent' => (int) $photoConsent,
];
}
if ($errors !== []) {
return $errors;
}
foreach ($updates as $studentId => $payload) {
if (! $this->studentModel->update($studentId, $payload)) {
$errors[] = 'Student ID ' . $studentId . ': student information could not be updated.';
}
}
return $errors;
}
private function normalizeEnrollmentStudentName(string $name): string
{
$name = trim(preg_replace('/\s+/', ' ', $name) ?? '');
return mb_convert_case($name, MB_CASE_TITLE, 'UTF-8');
}
private function exceptionReasonFromEvaluation(array $evaluation): ?string
{
if (! empty($evaluation['admin_exception'])) {
@@ -1625,16 +1745,16 @@ class ParentController extends BaseController
return 'Contact administration';
}
private function familyFinancialSummary(int $parentId, ?string $previousSchoolYear, string $selectedYear): array
private function familyFinancialSummary(int $parentId, ?string $previousSchoolYear, string $selectedYear, array $students = []): array
{
$schoolYearConfig = $this->schoolYearConfig($selectedYear);
$carryOver = $previousSchoolYear !== null ? $this->invoiceBalanceForParent($parentId, $previousSchoolYear) : 0.0;
$currentBalance = $this->invoiceBalanceForParent($parentId, $selectedYear);
$registrationFee = round((float) ($schoolYearConfig['registration_fee'] ?? 0), 2);
$tuitionDue = round((float) ($schoolYearConfig['tuition_due_at_registration'] ?? 0), 2);
$tuitionDue = $this->enrollmentTuitionDue($students);
$mandatoryFees = round((float) ($schoolYearConfig['mandatory_fees'] ?? 0), 2);
$behavior = (string) ($schoolYearConfig['carry_over_balance_behavior'] ?? 'submission_blocked_until_payment');
$amountDue = max(0.0, $carryOver) + $registrationFee + $tuitionDue + $mandatoryFees;
$amountDue = max(0.0, $carryOver) + max(0.0, $currentBalance) + $registrationFee + $tuitionDue + $mandatoryFees;
return [
'currency' => '$',
@@ -1650,6 +1770,52 @@ class ParentController extends BaseController
];
}
private function enrollmentTuitionDue(array $students): float
{
$tuitionStudents = [];
foreach ($students as $student) {
$status = strtolower(trim((string) ($student['enrollment_status'] ?? '')));
$eligibilityMessage = is_array($student['enrollment_eligibility_message'] ?? null)
? $student['enrollment_eligibility_message']
: ['blocking' => false];
if ($status !== 'not enrolled' || ! empty($eligibilityMessage['blocking'])) {
continue;
}
$evaluation = is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : [];
$tuitionStudents[] = [
'student_id' => (int) ($student['id'] ?? $student['student_id'] ?? 0),
'class_section_id' => (int) (
$evaluation['assigned_class_section_id']
?? $student['class_section_id']
?? 0
),
];
}
if ($tuitionStudents === []) {
return 0.0;
}
return (new FeeCalculationService())->calculateEnrollmentTuition($tuitionStudents);
}
private function enrollmentFeeSchedule(string $selectedYear): array
{
$schoolYearConfig = $this->schoolYearConfig($selectedYear);
return [
'currency' => '$',
'first_student_fee' => round((float) ($this->configModel->getConfig('first_student_fee') ?? 380), 2),
'second_student_fee' => round((float) ($this->configModel->getConfig('second_student_fee') ?? 280), 2),
'registration_fee' => round((float) ($schoolYearConfig['registration_fee'] ?? 0), 2),
'tuition_due_at_registration' => round((float) ($schoolYearConfig['tuition_due_at_registration'] ?? 0), 2),
'mandatory_fees' => round((float) ($schoolYearConfig['mandatory_fees'] ?? 0), 2),
];
}
private function financialPolicyMessage(string $behavior, string $configured): string
{
$configured = trim($configured);
@@ -35,6 +35,7 @@ class ParentFinancialAidController extends BaseController
'students' => $students,
'requests' => $requests,
'openRequest' => $model->openRequestForParent($parentId, $schoolYear),
'existingRequest' => $model->requestForParentYear($parentId, $schoolYear),
]);
}
@@ -47,8 +48,8 @@ class ParentFinancialAidController extends BaseController
$schoolYear = (string) ((new ConfigurationModel())->getConfig('school_year') ?? '');
$model = new FinancialAidRequestModel();
if ($model->openRequestForParent($parentId, $schoolYear) !== null) {
return redirect()->back()->with('error', 'You already have an open financial aid request for this school year.');
if ($model->requestForParentYear($parentId, $schoolYear) !== null) {
return redirect()->back()->with('error', 'You can submit only one financial aid application per school year.');
}
$studentIds = array_values(array_unique(array_filter(array_map('intval', (array) $this->request->getPost('student_ids')))));
+22 -14
View File
@@ -1224,12 +1224,6 @@ $scoresEndY = $pdf->GetY();
public function generateSingleReport($studentId)
{
// Prevent Debug Toolbar from corrupting PDF
if (ENVIRONMENT !== 'production') {
// Turn off toolbar manually by unsetting the collector
unset(service('toolbar')->collectors['CodeIgniter\Debug\Toolbar\Collectors\Timers']);
}
$download = $this->request->getGet('download') === '1';
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
$semester = $this->request->getGet('semester') ?? $this->semester;
@@ -1237,7 +1231,7 @@ $scoresEndY = $pdf->GetY();
$data = $this->prepareStudentReportData((int)$studentId, (string)$schoolYear, (string)$semester);
if (!$data) {
return "Student not found or missing scores.";
return $this->missingReportResponse((int)$studentId, (string)$schoolYear, (string)$semester);
}
$data['report_date'] = $reportDate['iso'];
$data['report_date_display'] = $reportDate['display'];
@@ -1264,12 +1258,6 @@ $scoresEndY = $pdf->GetY();
public function generateClassReport($classSectionId)
{
// Disable toolbar output to prevent PDF corruption
if (ENVIRONMENT !== 'production') {
unset(service('toolbar')->collectors['CodeIgniter\Debug\Toolbar\Collectors\Timers']);
}
$download = $this->request->getGet('download') === '1';
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
$semester = $this->request->getGet('semester') ?? $this->semester;
@@ -1290,10 +1278,11 @@ $scoresEndY = $pdf->GetY();
->findAll();
if (!$scores) {
return "No students found for this class section.";
return $this->missingReportResponse(null, (string)$schoolYear, (string)$semester, (int)$classSectionId);
}
$pdf = new \FPDF('P', 'mm', 'Letter');
$generatedCount = 0;
foreach ($scores as $score) {
$studentId = $score['student_id'];
@@ -1304,9 +1293,14 @@ $scoresEndY = $pdf->GetY();
$data['report_date'] = $reportDate['iso'];
$data['report_date_display'] = $reportDate['display'];
$this->formatReportPDF($pdf, $data);
$generatedCount++;
}
}
if ($generatedCount === 0) {
return $this->missingReportResponse(null, (string)$schoolYear, (string)$semester, (int)$classSectionId);
}
$filename = 'ClassReport_Section_' . $classSectionId . '.pdf';
// Clean any existing output buffer
@@ -1324,6 +1318,20 @@ $scoresEndY = $pdf->GetY();
->setBody($pdfContent);
}
protected function missingReportResponse(?int $studentId, string $schoolYear, string $semester, ?int $classSectionId = null)
{
$target = $studentId !== null
? 'student #' . $studentId
: 'class section #' . (int)$classSectionId;
$period = trim($schoolYear . ($semester !== '' ? ' ' . $semester : ''));
$message = 'No report card exists for ' . $target . ($period !== '' ? ' for ' . $period : '') . '.';
return $this->response
->setStatusCode(404)
->setHeader('Content-Type', 'text/plain; charset=UTF-8')
->setBody($message . "\nPlease select a school year and semester that has saved scores before viewing or signing the report card.");
}
protected function normalizeSemester(?string $input): string
{