db = $db; $this->studentModel = $studentModel; $this->enrollmentModel = $enrollmentModel; $this->studentClassModel = $studentClassModel; $this->classSectionModel = $classSectionModel; $this->userModel = $userModel; $this->invoiceModel = $invoiceModel; $this->refundModel = $refundModel; } public function buildRoster(string $selectedYear, string $semester): array { $this->schoolYear = $selectedYear; $this->semester = $semester; $this->syncReviewDecisionEnrollments($selectedYear); $students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear); $removedPriorStatuses = $this->removedPriorYearStudentStatuses($selectedYear); service('studentYearStatus')->attachToStudents($students, $selectedYear); foreach ($students as &$s) { // ===== Ensure IDs needed by the modal ===== $s['student_id'] = (int)($s['id'] ?? 0); $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'])) { $s['parent_id'] = (int)$s['secondparent_user_id']; } else { $s['parent_id'] = (int)($s['parent_id'] ?? 0); } // ===== Parent display + sort keys (keep existing behavior) ===== $pf = trim((string)($s['parent_firstname'] ?? '')); $pl = trim((string)($s['parent_lastname'] ?? '')); // Fallback: if only a single full name exists if ($pf === '' && $pl === '' && !empty($s['parent_fullname'])) { $parts = preg_split('/\s+/', trim((string)$s['parent_fullname']), 2); $pf = $parts[0] ?? ''; $pl = $parts[1] ?? ''; } $s['parent_label'] = trim($pf . ' ' . $pl); $s['parent_sort'] = trim(($pl !== '' ? $pl : $pf) . ' ' . $pf); if ($s['parent_label'] === '') { $s['parent_label'] = 'Unknown Parent'; $s['parent_sort'] = 'ZZZ Unknown Parent'; } // ===== New-student flags (year-scoped) ===== $s['is_new'] = (int) ($s['is_new'] ?? 1) === 1 ? 1 : 0; $s['new_student'] = $s['is_new'] === 1 ? 'Yes' : 'No'; // ===== Admission override ===== // Enrollment status for selected year $statusForYear = $this->enrollmentModel->getEnrollmentStatus((int)$s['student_id'], $selectedYear); if (!empty($priorRemovedStatus)) { $s['enrollment_status'] = $priorRemovedStatus; } elseif (!empty($statusForYear)) { $s['enrollment_status'] = $statusForYear; } elseif (($s['admission_status'] ?? null) === 'denied') { $s['enrollment_status'] = 'denied'; } else { $s['enrollment_status'] = 'admission under review'; $s['admission_status'] = 'pending'; } // ===== Class section name for the selected year ===== $name = $this->studentClassModel->getClassSectionsByStudentId((int)$s['student_id'], $selectedYear); $s['class_section'] = $name ?: 'Class not Assigned'; $calculatedAge = EnrollmentEligibility::ageOnSeptemberFirst($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']) ? date('Y-m-d', strtotime($s['registration_date'])) : ''; } unset($s); // break reference // ===== Sort by parent, then student (lastname, firstname) ===== usort($students, function (array $a, array $b) { $pa = $a['parent_sort'] ?? ''; $pb = $b['parent_sort'] ?? ''; if (strcasecmp($pa, $pb) === 0) { $la = $a['lastname'] ?? ''; $lb = $b['lastname'] ?? ''; $cmp = strcasecmp($la, $lb); if ($cmp !== 0) return $cmp; return strcasecmp($a['firstname'] ?? '', $b['firstname'] ?? ''); } return strcasecmp($pa, $pb); }); $classes = $this->enrollmentClassOptions((string)$selectedYear); return [ 'students' => $students, 'classes' => $classes, 'selectedYear' => $selectedYear, ]; } /** * Show only newly registered students, with contact modal support. * * Route example: * $routes->get('admin/enrollment/new-students', 'EnrollmentController::showNewStudents', ['filter' => 'auth:view_new_students']); */ public function newStudents(string $schoolYear): array { $rows = $this->studentModel->getStudentsWithParentsAndEmergency($schoolYear, 1); $newStudents = []; foreach ($rows as $r) { $r['new_student'] = 'Yes'; $classSection = $this->studentClassModel->getClassSectionsByStudentId($r['id'], $schoolYear); $enrollmentstatus = $this->enrollmentModel->getEnrollmentStatus($r['id'], $schoolYear); // robust default $r['class_section'] = (isset($classSection) && trim((string)$classSection) !== '') ? $classSection : 'Class not Assigned'; $r['is_new'] = (int) ($r['is_new'] ?? 1) === 1 ? 1 : 0; $r['new_student'] = $r['is_new'] === 1 ? "Yes" : "No"; $r['modalIdContact'] = 'contact_' . (int)($r['id'] ?? 0); $r['enrollment_status'] = $enrollmentstatus; // ✅ Format registration_date for display if (!empty($r['registration_date'])) { try { $r['registration_date'] = (new \DateTime($r['registration_date']))->format('Y-m-d'); } catch (\Throwable $e) { $r['registration_date'] = ''; } } // Age comes directly from DB (already stored in students.age) $newStudents[] = $r; } return [ 'new_students' => $newStudents, 'total_new' => count($newStudents), ]; } //update enrollment status public function updateStatuses(?array $enrollmentStatuses, string $schoolYear, string $semester, ?int $performedBy): array { $enrollmentStatusService = \Config\Services::enrollmentStatus(false); $performedBy = $performedBy ?: ((int) (session()->get('user_id') ?? 0) ?: null); $this->schoolYear = $schoolYear; $this->semester = $semester; if (empty($enrollmentStatuses)) { return ['ok' => false, 'message' => 'No enrollment statuses were submitted.']; } $this->db->transStart(); try { $errors = []; // For batching emails: parent -> status -> [students...] $groupsByParentStatus = []; // [parent_id][status][] = ['student_id'=>, 'student_name'=>] $parentInfo = []; // [parent_id] = ['user_id','email','firstname','lastname'] $withdrawalPreviewEnrollmentIds = []; // enrollment_id => parent_id $refundAmountByParent = []; // parent_id => preview amount for notification context $validStatuses = EnrollmentStatusService::VALID_STATUSES; $transitionService = service('enrollmentTransition'); $advanceStatuses = $transitionService->statusesRequiringEnrollmentEligibility(); foreach ($enrollmentStatuses as $studentId => $newEnrollmentStatus) { if (!in_array($newEnrollmentStatus, $validStatuses, true)) { $errors[] = "Invalid enrollment status '$newEnrollmentStatus' for student ID $studentId."; continue; } // Map admission_status based on the desired new status (needed for create-or-update) if ($newEnrollmentStatus === 'denied') { $admissionStatus = 'denied'; } elseif (in_array($newEnrollmentStatus, ['enrolled', 'payment pending'], true)) { $admissionStatus = 'accepted'; } else { $admissionStatus = 'pending'; } // Current enrollment row $enrollmentRow = $this->db->table('enrollments') ->where('student_id', $studentId) ->where('school_year', $this->schoolYear) ->get() ->getRowArray(); // If no enrollment found for this student/year, create one so invoice generation has data if (!$enrollmentRow) { $stu = $this->studentModel->find((int)$studentId) ?? []; $parentId = (int)($stu['parent_id'] ?? ($stu['secondparent_user_id'] ?? 0)); if (!$parentId) { $errors[] = "No parent ID found for student ID $studentId."; continue; } if (in_array($newEnrollmentStatus, $advanceStatuses, true)) { $advance = $transitionService->evaluateEnrollmentAdvance($parentId, (int) $studentId, $this->schoolYear, 'admin'); if (! $transitionService->adminMayAdvanceEnrollment($advance)) { $transitionService->logEnrollmentBlock($advance, 'admin_updateStatuses_create', $parentId, $performedBy); $errors[] = "Student ID $studentId cannot be advanced to '$newEnrollmentStatus': " . (string) ($advance['primary_parent_message'] ?? 'Enrollment eligibility check failed.'); continue; } } $isWithdrawn = in_array($newEnrollmentStatus, ['withdrawn', 'refund pending', 'withdraw under review'], true) ? 1 : 0; $result = $enrollmentStatusService->upsertStatus([ 'student_id' => (int)$studentId, 'parent_id' => $parentId, 'school_year' => (string)$this->schoolYear, 'semester' => (string)$this->semester, 'enrollment_date' => local_date(utc_now(), 'Y-m-d'), 'is_withdrawn' => $isWithdrawn, 'enrollment_status' => $newEnrollmentStatus, 'admission_status' => $admissionStatus, 'created_at' => utc_now(), 'updated_at' => utc_now(), ], $performedBy, 'admin_enrollment_withdrawal_handler'); if ((int) ($result['id'] ?? 0) <= 0) { $errors[] = "Failed to create enrollment for student ID $studentId."; continue; } // Group this newly-created change for notifications $studentRow = $this->studentModel->find($studentId) ?? []; $studentName = trim(($studentRow['firstname'] ?? '') . ' ' . ($studentRow['lastname'] ?? '')) ?: "Student #{$studentId}"; if (!isset($parentInfo[$parentId])) { $p = $this->userModel->find($parentId) ?? []; $parentInfo[$parentId] = [ 'user_id' => $p['id'] ?? $parentId, 'email' => $p['email'] ?? null, 'firstname' => $p['firstname'] ?? '', 'lastname' => $p['lastname'] ?? '', ]; } $groupsByParentStatus[$parentId][$newEnrollmentStatus][] = [ 'student_id' => (int) $studentId, 'student_name' => $studentName, ]; if ($newEnrollmentStatus === 'refund pending') { $withdrawalPreviewEnrollmentIds[(int) $result['id']] = $parentId; } log_message('info', "Created enrollment for student ID {$studentId} with status {$newEnrollmentStatus} and admission {$admissionStatus}."); if (in_array($newEnrollmentStatus, ['payment pending', 'enrolled'], true)) { $this->applyDistributionDraftToStudentClass((int)$studentId, (string)$this->schoolYear); } continue; // go to next student } $oldStatus = $enrollmentRow['enrollment_status'] ?? null; $parentId = $enrollmentRow['parent_id'] ?? null; if (!$parentId) { $errors[] = "No parent ID found for student ID $studentId."; continue; } // admissionStatus computed above if ($oldStatus === $newEnrollmentStatus) { $enrollmentStatusService->upsertStatus([ 'id' => (int) $enrollmentRow['id'], 'student_id' => (int) $studentId, 'parent_id' => (int) $parentId, 'school_year' => (string) $this->schoolYear, 'semester' => (string) ($enrollmentRow['semester'] ?? $this->semester), 'enrollment_status' => $newEnrollmentStatus, 'admission_status' => $admissionStatus, 'updated_at' => utc_now(), ], $performedBy, 'admin_enrollment_status_repair'); log_message('debug', "No status change for student {$studentId} ({$oldStatus}); repaired activity flag."); if (in_array($newEnrollmentStatus, ['payment pending', 'enrolled'], true)) { $this->applyDistributionDraftToStudentClass((int)$studentId, (string)$this->schoolYear); } continue; } if ($oldStatus !== $newEnrollmentStatus && in_array($newEnrollmentStatus, $advanceStatuses, true)) { $advance = $transitionService->evaluateEnrollmentAdvance((int) $parentId, (int) $studentId, $this->schoolYear, 'admin'); if (! $transitionService->adminMayAdvanceEnrollment($advance)) { $transitionService->logEnrollmentBlock($advance, 'admin_updateStatuses', (int) $parentId, $performedBy); $errors[] = "Student ID $studentId cannot be advanced to '$newEnrollmentStatus': " . (string) ($advance['primary_parent_message'] ?? 'Enrollment eligibility check failed.'); continue; } } $result = $enrollmentStatusService->upsertStatus([ 'id' => (int) $enrollmentRow['id'], 'student_id' => (int) $studentId, 'parent_id' => (int) $parentId, 'school_year' => (string) $this->schoolYear, 'semester' => (string) ($enrollmentRow['semester'] ?? $this->semester), 'enrollment_status' => $newEnrollmentStatus, 'admission_status' => $admissionStatus, 'updated_at' => utc_now(), ], $performedBy, 'admin_enrollment_withdrawal_handler'); if ((int) ($result['id'] ?? 0) <= 0) { $errors[] = "Failed to update enrollment for student ID $studentId."; continue; } log_message('info', "Updated enrollment for student ID $studentId: {$oldStatus} → {$newEnrollmentStatus} (admission: {$admissionStatus})"); if (in_array($newEnrollmentStatus, ['payment pending', 'enrolled'], true)) { $this->applyDistributionDraftToStudentClass((int)$studentId, (string)$this->schoolYear); } // Student name $studentRow = $this->studentModel->find($studentId); $studentName = trim(($studentRow['firstname'] ?? '') . ' ' . ($studentRow['lastname'] ?? '')) ?: "Student #{$studentId}"; // Cache parent info once if (!isset($parentInfo[$parentId])) { $p = $this->userModel->find($parentId) ?? []; $parentInfo[$parentId] = [ 'user_id' => $p['id'] ?? $parentId, // assuming parent_id == user_id 'email' => $p['email'] ?? null, 'firstname' => $p['firstname'] ?? '', 'lastname' => $p['lastname'] ?? '', ]; } // Group by parent & status for minimal emails $groupsByParentStatus[$parentId][$newEnrollmentStatus][] = [ 'student_id' => (int) $studentId, 'student_name' => $studentName, ]; // Mark for refund calc if ($newEnrollmentStatus === 'refund pending') { $withdrawalPreviewEnrollmentIds[(int) $enrollmentRow['id']] = (int) $parentId; } } $this->db->transComplete(); if (!$this->db->transStatus()) { return ['ok' => false, 'message' => 'A database error occurred. Changes were rolled back.']; } foreach ($withdrawalPreviewEnrollmentIds as $enrollmentId => $pid) { try { $calculation = service('withdrawalFinancial')->preview((int) $enrollmentId, $performedBy); $refundAmountByParent[(int) $pid] = ((int) ($calculation['new_refund_request_cents'] ?? 0)) / 100; } catch (\Throwable $e) { $errors[] = 'Withdrawal calculation preview failed for enrollment #' . (int) $enrollmentId . ': ' . $e->getMessage(); } } // === 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', 'refund pending' => 'refundPending', 'withdrawn' => 'withdrawn', 'denied' => 'denied', 'waitlist' => 'waitlist', ]; foreach ($groupsByParentStatus as $pid => $byStatus) { // Common parent data $p = $parentInfo[$pid] ?? ['user_id' => $pid, 'email' => null, 'firstname' => '', 'lastname' => '']; // Fetch invoice once for this parent (for payment pending email data if needed) $invoice = $this->invoiceModel->where('parent_id', $pid) ->where('school_year', $this->schoolYear) ->orderBy('created_at', 'DESC') ->first(); foreach ($byStatus as $status => $studentsArr) { if (empty($eventMap[$status])) { continue; // unknown mapping } // Build second arg: student list $studentData = []; foreach ($studentsArr as $s) { $studentData[] = ['name' => $s['student_name'], 'student_id' => $s['student_id']]; } // Parent payload (first arg) $parentData = [ 'user_id' => $p['user_id'], 'email' => $p['email'], 'firstname' => $p['firstname'], 'lastname' => $p['lastname'], 'school_year' => $this->schoolYear, 'portalLink' => base_url('/login'), ]; // Enrich with status-specific fields if ($status === 'payment pending') { if ($invoice) { $parentData['amount'] = (float) ($invoice['balance'] ?? $invoice['amount_due'] ?? $invoice['total_amount'] ?? 0); $parentData['due_date'] = $invoice['due_date'] ?? null; } } elseif ($status === 'refund pending') { $parentData['amount'] = $refundAmountByParent[$pid] ?? null; } $eventName = $eventMap[$status]; log_message('info', "Triggering event '{$eventName}' for parent {$pid} with " . count($studentData) . " student(s)."); Events::trigger($eventName, $parentData, $studentData); } } // === Server-side safety net: generate/update invoices for parents whose statuses require it === try { $needsInvoiceFor = ['payment pending', 'enrolled']; $invCtl = new InvoiceController(); foreach ($groupsByParentStatus as $pid => $byStatus) { $statuses = array_keys($byStatus); $requires = array_intersect($statuses, $needsInvoiceFor); if (!empty($requires)) { // Best-effort; ignore response object try { $invCtl->generateInvoice((string)$pid); } catch (\Throwable $e) { log_message('error', 'Invoice fallback generation failed for parent {pid}: {err}', ['pid' => $pid, 'err' => $e->getMessage()]); } } } } catch (\Throwable $e) { log_message('error', 'Invoice fallback block error: ' . $e->getMessage()); } if (!empty($errors)) { return ['ok' => false, 'message' => implode(' ', $errors)]; } return ['ok' => true, 'message' => 'Enrollment statuses updated and notifications sent.']; } catch (\Throwable $e) { $this->db->transRollback(); log_message('error', 'Enrollment withdrawal error: ' . $e->getMessage()); return ['ok' => false, 'message' => 'An unexpected error occurred while processing enrollments.']; } } private function getPreviousSchoolYear(string $schoolYear): string { $schoolYear = trim($schoolYear); if ($schoolYear === '') { return ''; } if (preg_match('/^(\d{4})\s*-\s*(\d{4})$/', $schoolYear, $m)) { return ((int)$m[1] - 1) . '-' . ((int)$m[2] - 1); } if (preg_match('/^(\d{4})\s*-\s*(\d{2})$/', $schoolYear, $m)) { $start = (int)$m[1] - 1; $end = (int)$m[2] - 1; if ($end < 0) { $end += 100; } return sprintf('%04d-%02d', $start, $end); } if (preg_match('/^\d{4}$/', $schoolYear)) { return (string)((int)$schoolYear - 1); } return ''; } private function getSchoolYearStartYear(string $schoolYear): ?int { $schoolYear = trim($schoolYear); if ($schoolYear === '') { return null; } if (preg_match('/^(\d{4})\s*-\s*(\d{4})$/', $schoolYear, $m)) { return (int)$m[1]; } if (preg_match('/^(\d{4})\s*-\s*(\d{2})$/', $schoolYear, $m)) { return (int)$m[1]; } if (preg_match('/^\d{4}$/', $schoolYear)) { return (int)$schoolYear; } return null; } public 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) { $payload['id'] = (int) $existing['id']; } else { $payload['created_at'] = $now; } \Config\Services::enrollmentStatus(false)->upsertStatus( $this->filterEnrollmentPayloadByColumns($payload), (int) (session()->get('user_id') ?? 0) ?: null, 'admin_review_decision_enrollment' ); } } 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']; $hasSchoolYear = $this->db->fieldExists('school_year', 'classSection'); $hasSemester = $this->db->fieldExists('semester', 'classSection'); if ($hasSchoolYear) { $select[] = 'school_year'; } if ($hasSemester) { $select[] = 'semester'; } $query = $this->classSectionModel ->select(implode(', ', $select)) ->orderBy('class_section_name', 'ASC'); if ($hasSchoolYear && $selectedYear !== '') { $query->where('school_year', $selectedYear); } if ($hasSemester) { $query->where('semester', (string)$this->semester); } $classes = $query->findAll(); if (! empty($classes) || (! $hasSchoolYear && ! $hasSemester)) { return $classes; } return $this->classSectionModel ->select('id, class_section_id, class_section_name') ->orderBy('class_section_name', 'ASC') ->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->orWhere('is_withdrawn', 1); $hasRemovalCondition = true; } if ($hasEnrollmentStatus) { $builder->orWhereIn('enrollment_status', ['withdrawn', 'denied']); $hasRemovalCondition = true; } if ($hasAdmissionStatus) { $builder->orWhere('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 ($enrollmentStatus === 'withdrawn' || (int)($row['is_withdrawn'] ?? 0) === 1) { return 'withdrawn'; } return null; } private function priorYearStudentIds(string $selectedYear): array { $selectedStartYear = $this->getSchoolYearStartYear($selectedYear); if ($selectedStartYear === null) { return []; } $studentIds = []; foreach (['enrollments', 'student_class'] as $table) { if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) { continue; } $rows = $this->db->table($table) ->select('student_id, school_year') ->where('student_id IS NOT NULL', null, false) ->where('school_year IS NOT NULL', null, false) ->get() ->getResultArray(); foreach ($rows as $row) { $rowStartYear = $this->getSchoolYearStartYear((string) ($row['school_year'] ?? '')); $studentId = (int) ($row['student_id'] ?? 0); if ($studentId > 0 && $rowStartYear !== null && $rowStartYear < $selectedStartYear) { $studentIds[$studentId] = true; } } } return $studentIds; } private function applyDistributionDraftToStudentClass(int $studentId, string $year): void { try { $draftModel = new StudentSectionDistributionDraftModel(); $draft = $draftModel->where('student_id', $studentId) ->where('school_year', $year) ->where('status', 'pending') ->first(); if (!$draft) { return; } $enrollment = $this->db->table('enrollments') ->select('class_section_id') ->where('student_id', $studentId) ->where('school_year', $year) ->orderBy('updated_at', 'DESC') ->orderBy('id', 'DESC') ->limit(1) ->get() ->getRowArray(); if ((int)($enrollment['class_section_id'] ?? 0) > 0) { return; } $targetSectionId = (int)($draft['class_section_id'] ?? 0); if ($targetSectionId <= 0) { return; } $studentClass = new StudentClassModel(); $exists = $studentClass->where('student_id', $studentId) ->where('school_year', $year) ->first(); $payload = [ 'student_id' => $studentId, 'class_section_id' => $targetSectionId, 'school_year' => $year, 'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null, 'updated_at' => utc_now(), ]; if ($exists) { $studentClass->update((int)$exists['id'], $payload); } else { $payload['created_at'] = utc_now(); $studentClass->insert($payload); } $this->db->table('enrollments') ->where('student_id', $studentId) ->where('school_year', $year) ->whereIn('enrollment_status', ['payment pending', 'enrolled']) ->update([ 'class_section_id' => $targetSectionId, 'updated_at' => utc_now(), ]); $this->db->table('promotion_queue') ->where('student_id', $studentId) ->where('school_year_to', $year) ->update([ 'to_class_section_id' => $targetSectionId, 'status' => 'applied', 'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null, 'updated_at' => utc_now(), ]); $draftModel->update((int)$draft['id'], [ 'status' => 'applied', 'applied_at' => utc_now(), 'updated_at' => utc_now(), ]); } catch (\Throwable $e) { log_message('error', 'applyDistributionDraftToStudentClass failed: ' . $e->getMessage()); } } }