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
+283 -83
View File
@@ -92,15 +92,6 @@ class ParentController extends BaseController
$this->maxEmergency = (int) $this->configModel->getConfig('max_emergency') ?? 0;
helper(['url', 'form']);
if (!session()->get('is_logged_in')) {
return redirect()->to('/login');
}
// Add more role-specific checks if needed
if (session()->get('role') !== 'parent') {
return redirect()->to('/access_denied');
}
}
public function index()
@@ -252,9 +243,6 @@ class ParentController extends BaseController
public function enrollClasses()
{
try {
// Log session data for debugging
log_message('info', 'Session Data: ' . print_r(session()->get(), true));
// Get deadlines and school year from config
if (!$this->schoolYear) {
log_message('error', 'Current school year not found in configuration.');
@@ -373,7 +361,7 @@ class ParentController extends BaseController
if ($isEditable) {
$student['transition_evaluation'] = $previousSchoolYear !== null
? $this->transitionEvaluationForStudent((int) $studentId, $previousSchoolYear, $selectedYear)
? $this->transitionEvaluationForStudent((int) $parentId, (int) $studentId, $previousSchoolYear, $selectedYear)
: null;
$student['enrollment_eligibility_message'] = $this->eligibilityMessageFromTransition(
$student,
@@ -382,11 +370,13 @@ class ParentController extends BaseController
);
$student['expected_placement_label'] = $this->expectedPlacementLabel($student['transition_evaluation']);
$student['required_action_label'] = $this->requiredActionLabel($student['transition_evaluation']);
$student['parent_enrollment_state'] = $this->parentEnrollmentState($student);
} else {
$student['transition_evaluation'] = $this->readonlyEnrollmentEvaluation($student['previous_year_decision']);
$student['enrollment_eligibility_message'] = ['message' => '', 'blocking' => false, 'level' => 'info'];
$student['expected_placement_label'] = (string) ($student['class_section'] ?? 'Class not Assigned');
$student['required_action_label'] = 'Read-only closed school year.';
$student['parent_enrollment_state'] = $this->parentEnrollmentState($student);
}
}
@@ -416,8 +406,6 @@ class ParentController extends BaseController
public function enrollClassesHandler()
{
// Call enrollClasses() function at the start of this method
$this->enrollClasses();
$refundService = new FeeCalculationService();
// Retrieve enrollment and withdrawal data from the POST request
@@ -460,25 +448,70 @@ class ParentController extends BaseController
if (!empty($enroll)) {
$selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
$blockingDecisionMessages = $this->blockedEnrollmentDecisionMessages(array_map('intval', (array) $enroll), $selectedYear);
if ($blockingDecisionMessages !== []) {
return redirect()->back()->withInput()->with('error', implode(' ', $blockingDecisionMessages));
$previousSchoolYear = $this->previousSchoolYearName($selectedYear);
if ($previousSchoolYear === null) {
return redirect()->back()->withInput()->with('error', 'Enrollment cannot be submitted because the closing school year could not be determined.');
}
$financialBlockers = $this->financialSubmissionBlockers((int) $parentId, $selectedYear);
if ($financialBlockers !== []) {
return redirect()->back()->withInput()->with('error', implode(' ', $financialBlockers));
$parent = $this->userModel->find((int) $parentId);
if (! is_array($parent) || ($parent['user_type'] ?? '') !== 'primary') {
return redirect()->back()->withInput()->with('error', 'Only primary parents can enroll students.');
}
$transitionService = service('enrollmentTransition');
$submittedStudentIds = array_values(array_unique(array_filter(array_map('intval', (array) $enroll), static fn (int $id): bool => $id > 0)));
$evaluations = [];
$errors = [];
foreach ($submittedStudentIds as $studentId) {
try {
$evaluation = $transitionService->evaluateForParent((int) $parentId, $studentId, $previousSchoolYear, $selectedYear, 'parent');
} catch (Throwable $e) {
log_message('error', 'Parent enrollment eligibility evaluation failed for student {studentId}: {message}', [
'studentId' => $studentId,
'message' => $e->getMessage(),
]);
$errors[] = 'Student ID ' . $studentId . ': enrollment eligibility could not be evaluated. Please contact administration.';
continue;
}
$studentInfo = $this->studentModel->find($studentId);
if (! is_array($studentInfo)) {
$errors[] = 'Student ID ' . $studentId . ': student record was not found.';
continue;
}
$studentName = trim((string) ($studentInfo['firstname'] ?? '') . ' ' . (string) ($studentInfo['lastname'] ?? '')) ?: 'Student ID ' . $studentId;
if (empty($evaluation['can_enroll'])) {
$messages = array_values(array_filter(array_map('trim', array_map('strval', $evaluation['blockers'] ?? []))));
$codes = array_values(array_filter(array_map('strval', array_merge($evaluation['blocking_rule_codes'] ?? [], $evaluation['review_rule_codes'] ?? []))));
$errors[] = $studentName . ': ' . ($messages !== [] ? implode(' ', $messages) : 'Enrollment is not currently allowed' . ($codes !== [] ? ' (' . implode(', ', $codes) . ')' : '') . '.');
continue;
}
if (! $this->studentModel->getStudentSchoolIdByStudentId($studentId)) {
$errors[] = $studentName . ': Student school ID not found.';
continue;
}
$evaluations[$studentId] = $evaluation;
}
if ($errors !== []) {
return redirect()->back()->withInput()->with('error', implode(' ', $errors));
}
$this->db->transStart();
foreach ($enroll as $studentId) {
$studentId = (int) $studentId;
if (! isset($evaluations[$studentId])) {
continue;
}
$evaluation = $evaluations[$studentId];
// Get student full name (supports both string return or array with firstname/lastname)
$studentInfo = $this->studentModel->getFullNameById($studentId);
if (empty($studentName)) {
$studentName = "Student ID $studentId";
log_message('warning', "Name for student ID $studentId not found in students table.");
}
// Save student info into $studentData
$studentData[$studentId] = $studentInfo; // raw return from getFullName()
@@ -490,49 +523,57 @@ class ParentController extends BaseController
->get()
->getRowArray();
$studentSchoolId = $this->studentModel->getStudentSchoolIdByStudentId($studentId);
if (!$studentSchoolId) {
return redirect()->back()->with('error', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']}: Student school ID not found.");
}
$studentName = trim((string) ($studentData[$studentId]['firstname'] ?? '') . ' ' . (string) ($studentData[$studentId]['lastname'] ?? '')) ?: 'Student ID ' . $studentId;
if ($existingEnrollment) {
$isReturningReEnrollment = $this->isReturningReEnrollmentStudent((int)$studentId, $selectedYear);
$targetEnrollmentStatus = $isReturningReEnrollment ? 'payment pending' : 'admission under review';
$targetAdmissionStatus = $isReturningReEnrollment ? 'accepted' : 'pending';
$update = $this->enrollmentPayloadFromEvaluation($evaluation, [
'is_withdrawn' => 0,
'withdrawal_date' => null,
'enrollment_status' => $targetEnrollmentStatus,
'admission_status' => $targetAdmissionStatus,
'updated_at' => utc_now(),
]);
if ($existingEnrollment['is_withdrawn'] == 1) {
// Reactivate the enrollment if the student was previously withdrawn
$this->enrollmentModel->where('id', $existingEnrollment['id'])->update([
'is_withdrawn' => 0,
'withdrawal_date' => null,
'enrollment_status' => $targetEnrollmentStatus,
'admission_status' => $targetAdmissionStatus,
'updated_at' => utc_now()
]);
$this->enrollmentModel->where('id', $existingEnrollment['id'])->update($update);
log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) has been re-enrolled in enrollment ID {$existingEnrollment['id']}.");
// Apply promotion-based class placement for the upcoming year
$this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment);
} else {
$currentStatus = (string) ($existingEnrollment['enrollment_status'] ?? '');
$update = [
'updated_at' => utc_now(),
];
if ($currentStatus === 'enrolled') {
$update['admission_status'] = 'accepted';
} else {
$update['enrollment_status'] = $targetEnrollmentStatus;
$update['admission_status'] = $targetAdmissionStatus;
}
$this->enrollmentModel->where('id', $existingEnrollment['id'])->update($update);
log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) is already actively enrolled.");
$this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment);
}
$enrollmentId = (int) $existingEnrollment['id'];
if (! empty($evaluation['admin_exception']['id'])) {
$transitionService->markExceptionUsed((int) $evaluation['admin_exception']['id'], $enrollmentId);
}
$transitionService->auditEnrollmentDecision(
$studentId,
$selectedYear,
$previousSchoolYear,
! empty($evaluation['admin_exception']) ? 'parent_enrollment_submitted_with_exception' : 'parent_enrollment_submitted',
(int) $parentId,
$existingEnrollment,
$this->enrollmentAuditPayload($update, $evaluation),
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';
// If no enrollment record exists, insert a new enrollment record
$result = $this->enrollmentModel->insert([
$payload = $this->enrollmentPayloadFromEvaluation($evaluation, [
'student_id' => $studentId,
'parent_id' => $parentId,
'school_year' => $selectedYear,
@@ -543,16 +584,38 @@ class ParentController extends BaseController
'admission_status' => $targetAdmissionStatus,
'created_at' => utc_now()
]);
$result = $this->enrollmentModel->insert($payload, true);
if (!$result) {
dd($this->enrollmentModel->errors());
$this->db->transRollback();
return redirect()->back()->withInput()->with('error', $studentName . ': Unable to save enrollment.');
} else {
log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) has been newly enrolled.");
// Apply promotion-based class placement for the upcoming year
$this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment);
}
$enrollmentId = (int) $result;
if (! empty($evaluation['admin_exception']['id'])) {
$transitionService->markExceptionUsed((int) $evaluation['admin_exception']['id'], $enrollmentId);
}
$transitionService->auditEnrollmentDecision(
$studentId,
$selectedYear,
$previousSchoolYear,
! empty($evaluation['admin_exception']) ? 'parent_enrollment_submitted_with_exception' : 'parent_enrollment_submitted',
(int) $parentId,
null,
$this->enrollmentAuditPayload($payload, $evaluation),
implode(' ', array_map('strval', $evaluation['rule_codes'] ?? []))
);
}
}
$this->db->transComplete();
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
@@ -652,6 +715,74 @@ class ParentController extends BaseController
}
}
private function enrollmentPayloadFromEvaluation(array $evaluation, array $base): array
{
$payload = array_merge($base, [
'source_school_year' => $evaluation['source_school_year'] ?? null,
'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,
'class_section_id' => $evaluation['assigned_class_section_id'] ?? ($base['class_section_id'] ?? null),
'placement_status' => $evaluation['placement_status'] ?? null,
'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' => ! empty($evaluation['admin_exception']) || ! empty($evaluation['flags']) ? 1 : 0,
'exception_reason' => $this->exceptionReasonFromEvaluation($evaluation),
'registration_submitted_at' => utc_now(),
]);
return $this->filterEnrollmentPayloadByColumns($payload);
}
private function exceptionReasonFromEvaluation(array $evaluation): ?string
{
if (! empty($evaluation['admin_exception'])) {
return 'Admin exception: ' . (string) ($evaluation['admin_exception']['reason_code'] ?? 'approved');
}
$codes = array_values(array_filter(array_map('strval', array_merge(
$evaluation['blocking_rule_codes'] ?? [],
$evaluation['review_rule_codes'] ?? [],
$evaluation['warning_rule_codes'] ?? []
))));
return $codes !== [] ? implode(', ', array_unique($codes)) : null;
}
private function enrollmentAuditPayload(array $payload, array $evaluation): array
{
return [
'enrollment' => $payload,
'eligibility' => [
'decision' => $evaluation['decision'] ?? null,
'can_enroll' => ! empty($evaluation['can_enroll']),
'rule_codes' => $evaluation['rule_codes'] ?? [],
'blocking_rule_codes' => $evaluation['blocking_rule_codes'] ?? [],
'review_rule_codes' => $evaluation['review_rule_codes'] ?? [],
'warning_rule_codes' => $evaluation['warning_rule_codes'] ?? [],
'admin_exception' => $evaluation['admin_exception'] ?? null,
'financial_summary' => $evaluation['financial_summary'] ?? null,
'last_name_exception_carry_forward' => $evaluation['last_name_exception_carry_forward'] ?? null,
],
];
}
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 hasAcceptedPolicyForYear(int $parentId, string $schoolYear): bool
{
if ($parentId <= 0 || $schoolYear === '') {
@@ -1243,7 +1374,8 @@ class ParentController extends BaseController
try {
$studentName = $this->studentNameForEnrollmentMessage($studentId);
$evaluation = service('enrollmentTransition')->evaluate($studentId, $previousSchoolYear, $targetSchoolYear, 'parent');
$parentId = (int) session()->get('user_id');
$evaluation = service('enrollmentTransition')->evaluateForParent($parentId, $studentId, $previousSchoolYear, $targetSchoolYear, 'parent');
foreach ($evaluation['blockers'] ?? [] as $blocker) {
$blocker = trim((string) $blocker);
if ($blocker !== '') {
@@ -1268,10 +1400,10 @@ class ParentController extends BaseController
return EnrollmentEligibility::parentDecisionMessage($student, $decisionRow, $targetSchoolYear, $fallMakeupExamOn);
}
private function transitionEvaluationForStudent(int $studentId, string $previousSchoolYear, string $selectedYear): ?array
private function transitionEvaluationForStudent(int $parentId, int $studentId, string $previousSchoolYear, string $selectedYear): ?array
{
try {
return service('enrollmentTransition')->evaluate($studentId, $previousSchoolYear, $selectedYear, 'parent');
return service('enrollmentTransition')->evaluateForParent($parentId, $studentId, $previousSchoolYear, $selectedYear, 'parent');
} catch (\Throwable $e) {
log_message('error', 'Enrollment transition evaluation failed for student ' . $studentId . ': ' . $e->getMessage());
@@ -1317,6 +1449,14 @@ class ParentController extends BaseController
);
}
if (! empty($evaluation['can_enroll']) && ! empty($evaluation['admin_exception'])) {
return [
'message' => 'Enrollment has been authorized by administration.',
'blocking' => false,
'level' => 'info',
];
}
$blockers = array_values(array_filter(array_map('trim', array_map('strval', $evaluation['blockers'] ?? []))));
if ($blockers !== []) {
$name = $this->studentNameFromRow($student);
@@ -1409,31 +1549,80 @@ class ParentController extends BaseController
private function requiredActionLabel(?array $evaluation): string
{
if ($evaluation === null) {
return 'Complete re-enrollment before the registration deadline.';
return 'Contact administration';
}
if (($evaluation['blockers'] ?? []) !== []) {
if (($evaluation['adult_student'] ?? false) && ! ($evaluation['parent_enrollment_allowed'] ?? false)) {
return 'Student must complete the authorized adult-student process or contact administration.';
$state = $this->parentEnrollmentStateFromEvaluation($evaluation, null);
return match ($state) {
'Enroll' => 'Complete re-enrollment before the registration deadline.',
'Eligible with follow-up' => 'Complete re-enrollment and follow the listed next step.',
'Already submitted' => 'Already submitted',
'Action needed' => 'Action needed: pay the previous-year balance or contact administration.',
'Under review' => 'Under review. Contact the school administration.',
default => 'Contact administration',
};
}
private function parentEnrollmentState(array $student): string
{
$status = strtolower(trim((string) ($student['enrollment_status'] ?? '')));
if (in_array($status, [
'admission under review',
'review & decision',
'payment pending',
'enrolled',
'waitlist',
'withdraw under review',
], true)) {
return 'Already submitted';
}
return $this->parentEnrollmentStateFromEvaluation(
is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : null,
$status
);
}
private function parentEnrollmentStateFromEvaluation(?array $evaluation, ?string $enrollmentStatus): string
{
if ($evaluation === null) {
return 'Contact administration';
}
$decision = (string) ($evaluation['decision'] ?? '');
$codes = array_map('strval', $evaluation['blocking_rule_codes'] ?? []);
if ($decision === 'ALREADY_ENROLLED' || $enrollmentStatus === 'already enrolled') {
return 'Already submitted';
}
if (! empty($evaluation['can_enroll']) || $decision === 'EXCEPTION_ELIGIBLE' || $decision === 'ELIGIBLE') {
if ($decision === 'ELIGIBLE_WITH_WARNING' || ($evaluation['warning_rule_codes'] ?? []) !== []) {
return 'Eligible with follow-up';
}
$decision = (string) ($evaluation['deliberation_decision'] ?? '');
if (in_array($decision, [
DeliberationDecision::EXPELLED,
DeliberationDecision::WITHDRAWN,
DeliberationDecision::DEFERRED_DECISION,
], true)) {
return 'Contact the school administration.';
}
return 'Review the eligibility message above.';
return 'Enroll';
}
if (($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM) {
return 'Complete re-enrollment and follow make-up exam instructions.';
if ($decision === 'ELIGIBLE_WITH_WARNING') {
return 'Eligible with follow-up';
}
return 'Complete re-enrollment before the registration deadline.';
if (in_array('OUTSTANDING_BALANCE_BLOCKED', $codes, true)
|| in_array('FINANCE_APPROVAL_REQUIRED', $codes, true)
|| in_array('SIBLING_LAST_NAME_MISMATCH', $codes, true)
) {
return 'Action needed';
}
if ($decision === 'REVIEW_REQUIRED' || in_array((string) ($evaluation['deliberation_decision'] ?? ''), [
DeliberationDecision::EXPELLED,
DeliberationDecision::WITHDRAWN,
DeliberationDecision::DEFERRED_DECISION,
], true)) {
return 'Under review';
}
return 'Contact administration';
}
private function familyFinancialSummary(int $parentId, ?string $previousSchoolYear, string $selectedYear): array
@@ -1444,7 +1633,7 @@ class ParentController extends BaseController
$registrationFee = round((float) ($schoolYearConfig['registration_fee'] ?? 0), 2);
$tuitionDue = round((float) ($schoolYearConfig['tuition_due_at_registration'] ?? 0), 2);
$mandatoryFees = round((float) ($schoolYearConfig['mandatory_fees'] ?? 0), 2);
$behavior = (string) ($schoolYearConfig['carry_over_balance_behavior'] ?? 'information_only');
$behavior = (string) ($schoolYearConfig['carry_over_balance_behavior'] ?? 'submission_blocked_until_payment');
$amountDue = max(0.0, $carryOver) + $registrationFee + $tuitionDue + $mandatoryFees;
return [
@@ -1461,21 +1650,6 @@ class ParentController extends BaseController
];
}
private function financialSubmissionBlockers(int $parentId, string $selectedYear): array
{
$previousSchoolYear = $this->previousSchoolYearName($selectedYear);
$summary = $this->familyFinancialSummary($parentId, $previousSchoolYear, $selectedYear);
if (($summary['carry_over_balance'] ?? 0.0) <= 0.0) {
return [];
}
return match ((string) ($summary['balance_behavior'] ?? 'information_only')) {
'submission_blocked_until_payment' => ['Registration cannot be submitted until the previous-year balance is paid.'],
'admin_approval_required' => ['Registration requires administrative financial approval because there is a previous-year balance.'],
default => [],
};
}
private function financialPolicyMessage(string $behavior, string $configured): string
{
$configured = trim($configured);
@@ -1488,7 +1662,7 @@ class ParentController extends BaseController
'submission_allowed_confirmation_blocked' => 'Registration may be submitted, but it will not be confirmed until the balance is settled.',
'submission_blocked_until_payment' => 'The balance must be paid before registration can be submitted.',
'admin_approval_required' => 'Please contact the finance office to arrange an approved exception.',
default => 'The balance is shown for information and does not currently block registration.',
default => 'The previous-year balance must be paid before registration can be submitted.',
};
}
@@ -1784,6 +1958,10 @@ class ParentController extends BaseController
public function profile($id)
{
if (! $this->canAccessUserRecord((int) $id)) {
return redirect()->to('/access_denied');
}
// Fetch the user's data based on the given ID
$user = $this->userModel->find($id);
@@ -1798,6 +1976,10 @@ class ParentController extends BaseController
public function updateProfile($id)
{
if (! $this->canAccessUserRecord((int) $id)) {
return redirect()->to('/access_denied');
}
$user = $this->userModel->find($id);
// Step 1: Check if user exists
@@ -2937,4 +3119,22 @@ $existing = $this->studentModel
return redirect()->back()->with('success', 'Participation updated');
}
private function canAccessUserRecord(int $id): bool
{
$userId = (int) (session()->get('user_id') ?? 0);
if ($userId <= 0 || $id <= 0) {
return false;
}
if ($userId === $id) {
return true;
}
$roles = array_map(
static fn ($role): string => strtolower(trim((string) $role)),
array_filter(array_merge((array) session()->get('roles'), [session()->get('role')]))
);
return (bool) array_intersect($roles, ['administrator', 'administrative staff', 'principal', 'admin']);
}
}