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
+347 -39
View File
@@ -33,6 +33,7 @@ use App\Models\TeacherSubmissionNotificationHistoryModel;
use App\Models\ExamDraftModel;
use App\Models\HomeworkModel;
use App\Services\SemesterRangeService;
use App\Support\Enrollment\DeliberationDecision;
use CodeIgniter\Events\Events;
@@ -572,14 +573,14 @@ class AdministratorController extends BaseController
// USERS (phone col: cellphone)
$uCols = ['firstname', 'lastname', 'email', 'cellphone', 'school_id', 'city', 'state'];
$uQB = $db->table('users')
->select('id, firstname, lastname, email, cellphone, school_id, city, state, semester');
->select('id, firstname, lastname, email, cellphone, school_id, city, state');
$applyMultiTokenLike($uQB, $uCols, $tokens, ['cellphone']);
$users = $uQB->limit(150)->get()->getResultArray();
// STUDENTS (no phone column to search)
$sCols = ['firstname', 'lastname', 'school_id', 'rfid_tag', 'dob', 'gender'];
$sQB = $db->table('students')
->select('id, parent_id, school_id, firstname, lastname, dob, gender, semester, rfid_tag');
->select('id, parent_id, school_id, firstname, lastname, dob, gender, rfid_tag');
$applyMultiTokenLike($sQB, $sCols, $tokens, []);
$students = $sQB->limit(150)->get()->getResultArray();
@@ -2061,6 +2062,7 @@ class AdministratorController extends BaseController
{
$db = db_connect();
$isPg = ($db->getPlatform() === 'Postgre'); // 'MySQLi', 'Postgre', 'SQLSRV', ...
$selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
// In MySQL, avoid truncation of long lists
if (!$isPg) {
@@ -2133,20 +2135,23 @@ class AdministratorController extends BaseController
->getResultArray();
}
// === Inject class_section_name from student_class via your method and replace grade ===
// === Inject current-year class_section_name from student_class and replace grade ===
foreach ($students as $i => $row) {
$sid = (int) ($row['id'] ?? 0);
if ($sid > 0) {
// Fetch class_section_name for this student
// Assumes your method signature: getClassSectionNameByStudentId(int $studentId): ?string
$classSectionName = (string) ($this->studentClassModel->getClassSectionNameByStudentId($sid) ?? '');
$classSectionName = (string) ($this->studentClassModel->getClassSectionNameByStudentId($sid, $selectedYear) ?? '');
// Expose it explicitly and replace original grade if present
$students[$i]['class_section_name'] = $classSectionName;
} else {
// Keep keys consistent even if id missing
$students[$i]['class_section_name'] = '';
}
$studentYear = trim((string) ($row['school_year'] ?? ''));
$students[$i]['age'] = $this->calculateAgeAsOfSchoolYearStartYear(
$row['dob'] ?? null,
$studentYear !== '' ? $studentYear : $selectedYear
);
}
// === end injection ===
@@ -2154,9 +2159,45 @@ class AdministratorController extends BaseController
'students' => $students,
'gradeOptions' => ['K', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'Youth'],
'genderOptions' => ['Male', 'Female', 'Other'],
'selectedYear' => $selectedYear,
]);
}
private function calculateAgeAsOfSchoolYearStartYear(?string $dob, string $schoolYear): ?int
{
$dob = trim((string) $dob);
$schoolYear = trim($schoolYear);
if ($dob === '' || ! preg_match('/^(\d{4})/', $schoolYear, $matches)) {
return null;
}
try {
$timezone = new \DateTimeZone((string) (config('School')->attendance['timezone'] ?? user_timezone()));
$birthDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $timezone);
$errors = \DateTimeImmutable::getLastErrors();
$hasParseErrors = is_array($errors)
&& (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0);
if ($birthDate === false || $hasParseErrors) {
return null;
}
$schoolYearStartYearCutoff = new \DateTimeImmutable($matches[1] . '-09-01', $timezone);
if ($birthDate > $schoolYearStartYearCutoff) {
return null;
}
return $birthDate->diff($schoolYearStartYearCutoff)->y;
} catch (\Throwable $e) {
log_message('warning', 'Unable to calculate school-year age from DOB: {message}', [
'message' => $e->getMessage(),
]);
return null;
}
}
public function parentProfiles()
@@ -2330,28 +2371,19 @@ class AdministratorController extends BaseController
$schoolYearContext = $this->resolveSchoolYearContext();
$selectedYear = $schoolYearContext->yearName();
$this->syncReviewDecisionEnrollments($selectedYear);
$students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear);
$selectedStartYear = $this->getSchoolYearStartYear((string)$selectedYear);
$removedPriorIds = [];
if ($selectedStartYear !== null) {
$removedRows = $this->db->table('enrollments')
->select('student_id, school_year')
->where('is_withdrawn', 1)
->get()->getResultArray();
foreach ($removedRows as $row) {
$rowYear = $this->getSchoolYearStartYear((string)($row['school_year'] ?? ''));
if ($rowYear !== null && $rowYear < $selectedStartYear) {
$removedPriorIds[(int)($row['student_id'] ?? 0)] = true;
}
}
}
$removedPriorStatuses = $this->removedPriorYearStudentStatuses($selectedYear);
$returningStudentIds = $this->priorYearStudentIds($selectedYear);
foreach ($students as &$s) {
// ===== Ensure IDs needed by the modal =====
$s['student_id'] = (int)($s['id'] ?? 0);
$s['removed_previous_year'] = isset($removedPriorIds[$s['student_id']]) ? 'Yes' : 'No';
$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'])) {
@@ -2389,7 +2421,9 @@ class AdministratorController extends BaseController
// ===== Admission override =====
// Enrollment status for selected year
$statusForYear = $this->enrollmentModel->getEnrollmentStatus((int)$s['student_id'], $selectedYear);
if (!empty($statusForYear)) {
if (!empty($priorRemovedStatus)) {
$s['enrollment_status'] = $priorRemovedStatus;
} elseif (!empty($statusForYear)) {
$s['enrollment_status'] = $statusForYear;
} elseif (($s['admission_status'] ?? null) === 'denied') {
$s['enrollment_status'] = 'denied';
@@ -2401,6 +2435,8 @@ class AdministratorController extends BaseController
// ===== Class section name for the selected year =====
$name = $this->studentClassModel->getClassSectionsByStudentId((int)$s['student_id'], $selectedYear);
$s['class_section'] = $name ?: 'Class not Assigned';
$calculatedAge = $this->calculateAgeAsOfSchoolYearStartYear($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'])
@@ -2445,27 +2481,18 @@ class AdministratorController extends BaseController
try {
$selectedYear = $this->currentSchoolYearName((string)($this->schoolYear ?? ''));
$this->syncReviewDecisionEnrollments($selectedYear);
$students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear);
$selectedStartYear = $this->getSchoolYearStartYear((string)$selectedYear);
$removedPriorIds = [];
if ($selectedStartYear !== null) {
$removedRows = $this->db->table('enrollments')
->select('student_id, school_year')
->where('is_withdrawn', 1)
->get()->getResultArray();
foreach ($removedRows as $row) {
$rowYear = $this->getSchoolYearStartYear((string)($row['school_year'] ?? ''));
if ($rowYear !== null && $rowYear < $selectedStartYear) {
$removedPriorIds[(int)($row['student_id'] ?? 0)] = true;
}
}
}
$removedPriorStatuses = $this->removedPriorYearStudentStatuses($selectedYear);
$returningStudentIds = $this->priorYearStudentIds($selectedYear);
foreach ($students as &$s) {
$s['student_id'] = (int)($s['id'] ?? 0);
$s['removed_previous_year'] = isset($removedPriorIds[$s['student_id']]) ? 'Yes' : 'No';
$priorRemovedStatus = $removedPriorStatuses[$s['student_id']] ?? null;
$s['removed_previous_year'] = $priorRemovedStatus !== null ? 'Yes' : 'No';
$s['prior_removed_status'] = $priorRemovedStatus;
if (empty($s['parent_id']) && !empty($s['secondparent_user_id'])) {
$s['parent_id'] = (int)$s['secondparent_user_id'];
@@ -2490,7 +2517,9 @@ class AdministratorController extends BaseController
$s['new_student'] = $s['is_new'] === 1 ? 'Yes' : 'No';
$statusForYear = $this->enrollmentModel->getEnrollmentStatus((int)$s['student_id'], $selectedYear);
if (!empty($statusForYear)) {
if (!empty($priorRemovedStatus)) {
$s['enrollment_status'] = $priorRemovedStatus;
} elseif (!empty($statusForYear)) {
$s['enrollment_status'] = $statusForYear;
} elseif (($s['admission_status'] ?? null) === 'denied') {
$s['enrollment_status'] = 'denied';
@@ -2501,6 +2530,8 @@ class AdministratorController extends BaseController
$className = $this->studentClassModel->getClassSectionsByStudentId((int)$s['student_id'], $selectedYear);
$s['class_section'] = $className ?: 'Class not Assigned';
$calculatedAge = $this->calculateAgeAsOfSchoolYearStartYear($s['dob'] ?? null, $selectedYear);
$s['age'] = $calculatedAge ?? ($s['age'] ?? null);
$s['registration_date_order'] = !empty($s['registration_date'])
? date('Y-m-d', strtotime($s['registration_date']))
@@ -2536,6 +2567,172 @@ class AdministratorController extends BaseController
}
}
private 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) {
$this->db->table('enrollments')
->where('id', (int) $existing['id'])
->update($payload);
} else {
$payload['created_at'] = $now;
$this->db->table('enrollments')->insert($this->filterEnrollmentPayloadByColumns($payload));
}
}
}
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'];
@@ -2572,6 +2769,115 @@ class AdministratorController extends BaseController
->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->where('is_withdrawn', 1);
$hasRemovalCondition = true;
}
if ($hasEnrollmentStatus) {
if ($hasRemovalCondition) {
$builder->orWhereIn('enrollment_status', ['withdrawn', 'widthran', 'denied']);
} else {
$builder->whereIn('enrollment_status', ['withdrawn', 'widthran', 'denied']);
}
$hasRemovalCondition = true;
}
if ($hasAdmissionStatus) {
if ($hasRemovalCondition) {
$builder->orWhere('admission_status', 'denied');
} else {
$builder->where('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 (in_array($enrollmentStatus, ['withdrawn', 'widthran'], true) || (int)($row['is_withdrawn'] ?? 0) === 1) {
return 'withdrawn';
}
return null;
}
private function priorYearStudentIds(string $selectedYear): array
{
$selectedStartYear = $this->getSchoolYearStartYear($selectedYear);
@@ -2674,6 +2980,7 @@ class AdministratorController extends BaseController
$validStatuses = [
'admission under review',
'review & decision',
'payment pending',
'enrolled',
'withdraw under review',
@@ -2921,6 +3228,7 @@ class AdministratorController extends BaseController
// === 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',
+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,
@@ -0,0 +1,633 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use CodeIgniter\Database\BaseConnection;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Psr\Log\LoggerInterface;
use Throwable;
class EnrollmentAdminController extends BaseController
{
protected BaseConnection $db;
public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger)
{
parent::initController($request, $response, $logger);
$this->db = \Config\Database::connect();
}
public function dashboard()
{
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? $this->currentSchoolYearName((string) ($this->schoolYear ?? ''))));
$status = trim((string) ($this->request->getGet('status') ?? 'open'));
$flagType = trim((string) ($this->request->getGet('flag_type') ?? ''));
$assignedTo = trim((string) ($this->request->getGet('assigned_to') ?? ''));
$flags = $this->enrollmentFlags($schoolYear, $status, $flagType, $assignedTo);
return view('administrator/enrollment_admin_dashboard', [
'flags' => $flags,
'schoolYear' => $schoolYear,
'status' => $status,
'flagType' => $flagType,
'assignedTo' => $assignedTo,
'flagTypes' => $this->flagTypes(),
'schoolYears' => $this->schoolYears(),
'classSections' => $this->classSections($schoolYear),
'admins' => $this->adminUsers(),
'enrollmentFollowups' => $this->enrollmentFollowups($schoolYear),
'auditRows' => $this->auditRows($schoolYear),
'launchState' => $this->launchState($schoolYear),
'previewParentId' => $this->firstParentWithStudents(),
'emailExamples' => service('enrollmentRegistrationEmail')->previewExamplesForSchoolYear($schoolYear),
]);
}
public function approveLaunch()
{
$schoolYear = trim((string) ($this->request->getPost('school_year') ?? ''));
if ($schoolYear === '') {
return redirect()->back()->with('error', 'School year is required.');
}
if (! $this->launchConfigurationComplete($schoolYear, $missing)) {
return redirect()->back()->with('error', 'Registration launch is not ready: ' . implode(', ', $missing));
}
$this->db->table('school_years')
->where('name', $schoolYear)
->update([
'registration_launch_approved_at' => date('Y-m-d H:i:s'),
'registration_launch_approved_by' => $this->userId(),
'registration_email_template_version' => \App\Services\EnrollmentRegistrationEmailService::TEMPLATE_VERSION,
'updated_at' => date('Y-m-d H:i:s'),
]);
return redirect()->back()->with('success', 'Registration launch approved for ' . $schoolYear . '.');
}
public function sendRegistrationEmails()
{
$schoolYear = trim((string) ($this->request->getPost('school_year') ?? ''));
if ($schoolYear === '') {
return redirect()->back()->with('error', 'School year is required.');
}
if (! $this->launchConfigurationComplete($schoolYear, $missing)) {
return redirect()->back()->with('error', 'Registration launch is not ready: ' . implode(', ', $missing));
}
$force = (bool) $this->request->getPost('force_resend');
$result = service('enrollmentRegistrationEmail')->sendForSchoolYearName($schoolYear, $force);
$message = sprintf(
'Registration emails processed for %s: %d sent, %d failed, %d skipped.',
$schoolYear,
(int) ($result['sent'] ?? 0),
(int) ($result['failed'] ?? 0),
(int) ($result['skipped'] ?? 0)
);
$details = array_filter(array_map('strval', $result['messages'] ?? []));
if ($details !== []) {
$message .= ' ' . implode(' ', $details);
}
return redirect()->back()->with(((int) ($result['failed'] ?? 0) > 0) ? 'error' : 'success', $message);
}
public function previewEmail()
{
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? ''));
$parentId = (int) ($this->request->getGet('parent_id') ?? 0);
if ($schoolYear === '' || $parentId <= 0) {
return redirect()->back()->with('error', 'School year and parent are required for preview.');
}
$message = service('enrollmentRegistrationEmail')->previewForParent($schoolYear, $parentId);
if ($message === null) {
return redirect()->back()->with('error', 'No preview email could be generated for that parent.');
}
return view('administrator/enrollment_email_preview', [
'schoolYear' => $schoolYear,
'parentId' => $parentId,
'subject' => $message['subject'],
'body' => $message['body'],
]);
}
public function resolveFlag(int $id)
{
try {
$flag = $this->requireFlag($id);
$notes = trim((string) ($this->request->getPost('resolution_notes') ?? ''));
if ($notes === '') {
return redirect()->back()->with('error', 'Resolution notes are required.');
}
$this->resolveFlagRow($flag, $notes, 'flag_resolved');
return redirect()->back()->with('success', 'Enrollment flag resolved.');
} catch (Throwable $e) {
return redirect()->back()->with('error', $e->getMessage());
}
}
public function assignClass(int $id)
{
try {
$flag = $this->requireFlag($id);
$sectionId = (int) ($this->request->getPost('class_section_id') ?? 0);
$notes = trim((string) ($this->request->getPost('resolution_notes') ?? ''));
if ($sectionId <= 0) {
return redirect()->back()->with('error', 'Select a target class section.');
}
if ($notes === '') {
return redirect()->back()->with('error', 'Resolution notes are required.');
}
$this->applyClassSection((int) $flag['student_id'], (string) $flag['school_year'], $sectionId, 'manual_class_assignment');
$this->resolveFlagRow($flag, $notes, 'manual_class_assignment', ['class_section_id' => $sectionId]);
return redirect()->back()->with('success', 'Class assignment applied.');
} catch (Throwable $e) {
return redirect()->back()->with('error', $e->getMessage());
}
}
public function confirmMakeupPromotion(int $id)
{
try {
$flag = $this->requireFlag($id);
if ((string) ($flag['flag_type'] ?? '') !== 'PENDING_MAKE_UP_EXAM_PROMOTION') {
return redirect()->back()->with('error', 'This flag is not a make-up exam promotion flag.');
}
$sectionId = (int) ($this->request->getPost('class_section_id') ?? 0);
$examResult = trim((string) ($this->request->getPost('exam_result') ?? ''));
$notes = trim((string) ($this->request->getPost('resolution_notes') ?? ''));
if (! in_array($examResult, ['passed', 'failed'], true)) {
return redirect()->back()->with('error', 'Select whether the make-up exam was passed or failed.');
}
if ($notes === '') {
return redirect()->back()->with('error', 'Resolution notes are required.');
}
if ($examResult === 'passed') {
if ($sectionId <= 0) {
return redirect()->back()->with('error', 'Select the promoted class section.');
}
$this->applyClassSection((int) $flag['student_id'], (string) $flag['school_year'], $sectionId, 'make_up_exam_promotion_completed', 'promotion_completed');
$this->resolveFlagRow($flag, $notes, 'make_up_exam_promotion_completed', [
'exam_result' => $examResult,
'class_section_id' => $sectionId,
]);
} else {
$this->updateEnrollmentPlacementStatus((int) $flag['student_id'], (string) $flag['school_year'], 'no_promotion_required');
$this->resolveFlagRow($flag, $notes, 'make_up_exam_no_promotion_required', [
'exam_result' => $examResult,
]);
}
return redirect()->back()->with('success', 'Make-up exam follow-up resolved.');
} catch (Throwable $e) {
return redirect()->back()->with('error', $e->getMessage());
}
}
public function approveException(int $id)
{
try {
$flag = $this->requireFlag($id);
$reason = trim((string) ($this->request->getPost('reason') ?? ''));
if ($reason === '') {
return redirect()->back()->with('error', 'Approval reason is required.');
}
$this->db->table('enrollments')
->where('student_id', (int) $flag['student_id'])
->where('school_year', (string) $flag['school_year'])
->update([
'exception_required' => 0,
'exception_reason' => $reason,
'updated_at' => date('Y-m-d H:i:s'),
]);
$this->resolveFlagRow($flag, $reason, 'enrollment_exception_approved');
return redirect()->back()->with('success', 'Enrollment exception approved.');
} catch (Throwable $e) {
return redirect()->back()->with('error', $e->getMessage());
}
}
private function enrollmentFlags(string $schoolYear, string $status, string $flagType, string $assignedTo): array
{
if (! $this->db->tableExists('enrollment_flags')) {
return [];
}
$builder = $this->db->table('enrollment_flags ef')
->select('ef.*')
->select('s.firstname, s.lastname, s.school_id')
->select('u.firstname AS assignee_firstname, u.lastname AS assignee_lastname')
->join('students s', 's.id = ef.student_id', 'left')
->join('users u', 'u.id = ef.assigned_to', 'left')
->orderBy('ef.created_at', 'DESC')
->orderBy('ef.id', 'DESC');
if ($schoolYear !== '') {
$builder->where('ef.school_year', $schoolYear);
}
if ($status !== '') {
$builder->where('ef.status', $status);
}
if ($flagType !== '') {
$builder->where('ef.flag_type', $flagType);
}
if (is_numeric($assignedTo) && (int) $assignedTo > 0) {
$builder->where('ef.assigned_to', (int) $assignedTo);
}
$rows = $builder->get()->getResultArray();
foreach ($rows as &$row) {
$row['student_name'] = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Student #' . (int) ($row['student_id'] ?? 0);
$row['assignee_name'] = trim((string) ($row['assignee_firstname'] ?? '') . ' ' . (string) ($row['assignee_lastname'] ?? ''));
$row['details'] = json_decode((string) ($row['details_json'] ?? ''), true) ?: [];
}
unset($row);
return $rows;
}
private function enrollmentFollowups(string $schoolYear): array
{
if (! $this->db->tableExists('enrollments')) {
return [];
}
$fields = $this->db->getFieldNames('enrollments');
$select = [
'e.id',
'e.student_id',
'e.school_year',
'e.enrollment_status',
'e.class_section_id',
'e.updated_at',
's.firstname',
's.lastname',
's.school_id',
'cs.class_section_name',
];
foreach ([
'deliberation_decision',
'placement_status',
'exception_required',
'exception_reason',
'source_school_year',
'assigned_class_section_id',
'age_on_reference_date',
] as $field) {
if (in_array($field, $fields, true)) {
$select[] = 'e.' . $field;
}
}
$builder = $this->db->table('enrollments e')
->select(implode(', ', $select))
->join('students s', 's.id = e.student_id', 'left')
->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left')
->orderBy('e.updated_at', 'DESC')
->orderBy('e.id', 'DESC')
->limit(200);
if ($schoolYear !== '') {
$builder->where('e.school_year', $schoolYear);
}
$builder->groupStart()
->whereIn('e.enrollment_status', [
'review & decision',
'admission under review',
'waitlist',
'denied',
'Review & Decision',
'Admission Under Review',
'Waitlist',
'Denied',
]);
if (in_array('placement_status', $fields, true)) {
$builder->orWhereIn('e.placement_status', [
'temporary_same_grade',
'temporary_manual_class_required',
'manual_class_required',
'exit_required',
'automatic_distribution_pending',
]);
}
if (in_array('exception_required', $fields, true)) {
$builder->orWhere('e.exception_required', 1);
}
if (in_array('deliberation_decision', $fields, true)) {
$builder->orWhereIn('e.deliberation_decision', [
'make_up_exam',
'repeat_class',
'deferred',
'expelled',
'withdrawn',
]);
}
$rows = $builder->groupEnd()->get()->getResultArray();
foreach ($rows as &$row) {
$row['student_name'] = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Student #' . (int) ($row['student_id'] ?? 0);
}
unset($row);
return $rows;
}
private function requireFlag(int $id): array
{
if ($id <= 0 || ! $this->db->tableExists('enrollment_flags')) {
throw new \RuntimeException('Enrollment flag was not found.');
}
$flag = $this->db->table('enrollment_flags')->where('id', $id)->limit(1)->get()->getRowArray();
if ($flag === null) {
throw new \RuntimeException('Enrollment flag was not found.');
}
return $flag;
}
private function resolveFlagRow(array $flag, string $notes, string $auditAction, array $metadata = []): void
{
$this->db->transStart();
$original = $flag;
$this->db->table('enrollment_flags')
->where('id', (int) $flag['id'])
->update([
'status' => 'resolved',
'resolved_at' => date('Y-m-d H:i:s'),
'resolution_notes' => $notes,
]);
$this->audit((int) $flag['student_id'], (string) $flag['school_year'], (string) ($flag['source_school_year'] ?? ''), $auditAction, $original, array_merge($metadata, [
'flag_id' => (int) $flag['id'],
'flag_type' => (string) $flag['flag_type'],
'resolution_notes' => $notes,
]), $notes);
$this->db->transComplete();
if ($this->db->transStatus() === false) {
throw new \RuntimeException('Unable to resolve enrollment flag.');
}
}
private function applyClassSection(int $studentId, string $schoolYear, int $sectionId, string $auditAction, string $placementStatus = 'manual_class_assigned'): void
{
$section = $this->db->table('classSection')
->select('class_section_id, class_id, class_section_name')
->where('class_section_id', $sectionId)
->orderBy('id', 'DESC')
->limit(1)
->get()
->getRowArray();
if ($section === null) {
throw new \RuntimeException('Selected class section was not found.');
}
$originalEnrollment = $this->latestEnrollment($studentId, $schoolYear);
$payload = [
'class_section_id' => $sectionId,
'assigned_class_section_id' => $sectionId,
'assigned_grade_id' => (int) ($section['class_id'] ?? 0) ?: null,
'placement_status' => $placementStatus,
'updated_at' => date('Y-m-d H:i:s'),
];
$this->db->table('enrollments')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->update($payload);
$studentClass = $this->db->table('student_class')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->orderBy('id', 'DESC')
->limit(1)
->get()
->getRowArray();
$studentClassPayload = [
'student_id' => $studentId,
'class_section_id' => $sectionId,
'school_year' => $schoolYear,
'updated_by' => $this->userId(),
'updated_at' => date('Y-m-d H:i:s'),
];
if ($studentClass !== null) {
$this->db->table('student_class')->where('id', (int) $studentClass['id'])->update($studentClassPayload);
} else {
$studentClassPayload['created_at'] = date('Y-m-d H:i:s');
$this->db->table('student_class')->insert($studentClassPayload);
}
$this->audit($studentId, $schoolYear, (string) ($originalEnrollment['source_school_year'] ?? ''), $auditAction, $originalEnrollment, $payload, 'Class section assigned by administrator.');
}
private function updateEnrollmentPlacementStatus(int $studentId, string $schoolYear, string $placementStatus): void
{
$this->db->table('enrollments')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->update([
'placement_status' => $placementStatus,
'updated_at' => date('Y-m-d H:i:s'),
]);
}
private function latestEnrollment(int $studentId, string $schoolYear): ?array
{
return $this->db->table('enrollments')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->orderBy('updated_at', 'DESC')
->orderBy('id', 'DESC')
->limit(1)
->get()
->getRowArray() ?: null;
}
private function audit(int $studentId, string $schoolYear, string $sourceSchoolYear, string $action, ?array $original, array $new, string $reason): void
{
if (! $this->db->tableExists('enrollment_transition_audits')) {
return;
}
$this->db->table('enrollment_transition_audits')->insert([
'student_id' => $studentId,
'school_year' => $schoolYear,
'source_school_year' => $sourceSchoolYear !== '' ? $sourceSchoolYear : null,
'action' => $action,
'performed_by' => $this->userId(),
'original_values_json' => $original !== null ? json_encode($original, JSON_UNESCAPED_SLASHES) : null,
'new_values_json' => json_encode($new, JSON_UNESCAPED_SLASHES),
'reason' => $reason,
'created_at' => date('Y-m-d H:i:s'),
]);
}
private function auditRows(string $schoolYear): array
{
if (! $this->db->tableExists('enrollment_transition_audits')) {
return [];
}
$builder = $this->db->table('enrollment_transition_audits eta')
->select('eta.*')
->select('s.firstname, s.lastname')
->select('u.firstname AS user_firstname, u.lastname AS user_lastname')
->join('students s', 's.id = eta.student_id', 'left')
->join('users u', 'u.id = eta.performed_by', 'left')
->orderBy('eta.created_at', 'DESC')
->limit(50);
if ($schoolYear !== '') {
$builder->where('eta.school_year', $schoolYear);
}
$rows = $builder->get()->getResultArray();
foreach ($rows as &$row) {
$row['student_name'] = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Student #' . (int) ($row['student_id'] ?? 0);
$row['performed_by_name'] = trim((string) ($row['user_firstname'] ?? '') . ' ' . (string) ($row['user_lastname'] ?? '')) ?: ((int) ($row['performed_by'] ?? 0) > 0 ? 'User #' . (int) $row['performed_by'] : '');
}
unset($row);
return $rows;
}
private function launchState(string $schoolYear): array
{
if ($schoolYear === '' || ! $this->db->tableExists('school_years')) {
return ['approved' => false, 'approved_at' => null, 'missing' => ['school year']];
}
$row = $this->db->table('school_years')->where('name', $schoolYear)->limit(1)->get()->getRowArray();
$this->launchConfigurationComplete($schoolYear, $missing);
return [
'approved' => ! empty($row['registration_launch_approved_at'] ?? null),
'approved_at' => $row['registration_launch_approved_at'] ?? null,
'missing' => $missing,
];
}
private function launchConfigurationComplete(string $schoolYear, ?array &$missing = null): bool
{
$missing = [];
$row = $this->db->table('school_years')->where('name', $schoolYear)->limit(1)->get()->getRowArray();
if ($row === null) {
$missing[] = 'school year record';
return false;
}
foreach (['registration_starts_on' => 'registration opening date', 'registration_ends_on' => 'registration deadline'] as $field => $label) {
if (empty($row[$field])) {
$missing[] = $label;
}
}
if (! $this->db->tableExists('email_templates')) {
$missing[] = 'email templates table';
} else {
$fields = $this->db->getFieldNames('email_templates');
$keyField = in_array('code', $fields, true) ? 'code' : 'template_key';
$template = $this->db->table('email_templates')
->where($keyField, 'registration_opening')
->where('is_active', 1)
->countAllResults();
if ($template <= 0) {
$missing[] = 'approved registration email template';
}
}
return $missing === [];
}
private function firstParentWithStudents(): ?int
{
if (! $this->db->tableExists('students')) {
return null;
}
$row = $this->db->table('students')
->select('parent_id')
->where('parent_id IS NOT NULL', null, false)
->orderBy('parent_id', 'ASC')
->limit(1)
->get()
->getRowArray();
return is_numeric($row['parent_id'] ?? null) ? (int) $row['parent_id'] : null;
}
private function flagTypes(): array
{
if (! $this->db->tableExists('enrollment_flags')) {
return [];
}
return array_column($this->db->table('enrollment_flags')->select('flag_type')->distinct()->orderBy('flag_type')->get()->getResultArray(), 'flag_type');
}
private function schoolYears(): array
{
if (! $this->db->tableExists('school_years')) {
return [];
}
return $this->db->table('school_years')->select('name')->orderBy('name', 'DESC')->get()->getResultArray();
}
private function classSections(string $schoolYear): array
{
if (! $this->db->tableExists('classSection')) {
return [];
}
$builder = $this->db->table('classSection')->select('class_section_id, class_section_name')->orderBy('class_section_name', 'ASC');
if ($schoolYear !== '' && $this->db->fieldExists('school_year', 'classSection')) {
$builder->where('school_year', $schoolYear);
}
return $builder->get()->getResultArray();
}
private function adminUsers(): array
{
if (! $this->db->tableExists('users')) {
return [];
}
return $this->db->table('users')
->select('id, firstname, lastname, user_type')
->whereIn('user_type', ['administrator', 'admin', 'principal', 'administrative staff'])
->orderBy('lastname', 'ASC')
->get()
->getResultArray();
}
private function userId(): ?int
{
$id = session('user_id') ?? session('id');
return is_numeric($id) ? (int) $id : null;
}
}
+10
View File
@@ -92,6 +92,13 @@ class EventController extends ResourceController
return $this->eventChargesHasCreatedBy;
}
private function assertSchoolYearNameWritable(string $schoolYear): void
{
service('schoolYearWriteGuard')->assertWritable(
service('schoolYearContext')->forYearName($schoolYear)
);
}
private function currentSchoolYearName(): string
{
try {
@@ -939,6 +946,7 @@ class EventController extends ResourceController
if (!$charge) {
return redirect()->to($returnTo)->with('error', 'Charge not found.');
}
$this->assertSchoolYearNameWritable((string)($charge['school_year'] ?? ''));
$paymentId = (int)($charge['event_payment_id'] ?? 0);
if ($paymentId > 0) {
@@ -989,6 +997,7 @@ class EventController extends ResourceController
if (!$charge) {
return redirect()->to($returnTo)->with('error', 'Charge not found.');
}
$this->assertSchoolYearNameWritable((string)($charge['school_year'] ?? ''));
$signed = $this->request->getPost('waiver_signed') === '1';
@@ -1070,6 +1079,7 @@ class EventController extends ResourceController
if (!$charge) {
return null;
}
$this->assertSchoolYearNameWritable((string)($charge['school_year'] ?? ''));
$event = $this->eventModel->find($charge['event_id']);
$eventAmount = max(0.0, (float)($event['amount'] ?? 0));
@@ -261,6 +261,7 @@ class ExpenseController extends BaseController
if (!$expense) {
throw new \RuntimeException('Expense not found');
}
$this->assertSchoolYearNameWritable((string)($expense['school_year'] ?? ''));
$db->query(
"SELECT id FROM reimbursements WHERE expense_id = ? AND LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided') FOR UPDATE",
[$id]
@@ -338,6 +339,7 @@ class ExpenseController extends BaseController
if (!$expense) {
throw PageNotFoundException::forPageNotFound("Expense #$id not found");
}
$this->assertSchoolYearNameWritable((string)($expense['school_year'] ?? ''));
// Base rules
$rules = [
+53 -1
View File
@@ -254,7 +254,41 @@ class FamilyAdminController extends BaseController
LIMIT 1",
[$studentId]
)->getRowArray();
if (!empty($row['id'])) $familyId = (int) $row['id'];
if (!empty($row['id'])) {
$familyId = (int) $row['id'];
} else {
// Legacy/newly-created students can have students.parent_id set
// before the normalized family_students row is created.
$row = $db->query(
"SELECT f.id
FROM students s
JOIN family_guardians fg ON fg.user_id = s.parent_id
JOIN families f ON f.id = fg.family_id
WHERE s.id = ?
ORDER BY fg.is_primary DESC, f.household_name
LIMIT 1",
[$studentId]
)->getRowArray();
if (!empty($row['id'])) {
$familyId = (int) $row['id'];
} else {
$row = $db->query(
"SELECT f.id
FROM students s
JOIN families f ON f.family_code = CONCAT('FAM-', s.parent_id)
WHERE s.id = ?
AND s.parent_id IS NOT NULL
ORDER BY f.household_name
LIMIT 1",
[$studentId]
)->getRowArray();
if (!empty($row['id'])) {
$familyId = (int) $row['id'];
}
}
}
} elseif ($guardianId) {
// 1) Try via guardians link
$row = $db->query(
@@ -330,6 +364,24 @@ class FamilyAdminController extends BaseController
ORDER BY s.lastname, s.firstname",
[$familyId]
)->getResultArray();
if ($studentId > 0) {
$studentIds = array_map(static fn(array $row): int => (int) ($row['id'] ?? 0), $studentsRows);
if (!in_array($studentId, $studentIds, true)) {
$selectedStudent = $db->query(
"SELECT id, firstname, lastname
FROM students
WHERE id = ?
LIMIT 1",
[$studentId]
)->getRowArray();
if ($selectedStudent) {
$studentsRows[] = $selectedStudent;
}
}
}
if (!empty($studentsRows)) {
foreach ($studentsRows as &$sr) {
$sid = (int) ($sr['id'] ?? 0);
+16
View File
@@ -24,6 +24,13 @@ class FlagController extends Controller
helper(['url', 'form']);
}
private function assertSchoolYearNameWritable(string $schoolYear): void
{
service('schoolYearWriteGuard')->assertWritable(
service('schoolYearContext')->forYearName($schoolYear)
);
}
public function index()
{
$currentFlagModel = new CurrentFlagModel();
@@ -436,6 +443,13 @@ class FlagController extends Controller
return $this->index();
}
$flagData = $currentFlagModel->find($id);
if (!$flagData) {
session()->setFlashdata('error', 'Incident not found.');
return $this->index();
}
$this->assertSchoolYearNameWritable((string)($flagData['school_year'] ?? ''));
$update = ['flag_state' => $newState];
if ($newState === 'Closed') {
$update['updated_by_closed'] = $userId;
@@ -490,6 +504,7 @@ class FlagController extends Controller
session()->setFlashdata('error', 'Incident not found.');
return redirect()->back();
}
$this->assertSchoolYearNameWritable((string)($flagData['school_year'] ?? ''));
// Proceed only if flag is not closed
if ($flagData['flag_state'] !== 'Closed') {
@@ -537,6 +552,7 @@ class FlagController extends Controller
session()->setFlashdata('error', 'Incident not found.');
return redirect()->back();
}
$this->assertSchoolYearNameWritable((string)($flagData['school_year'] ?? ''));
// Check if the flag is not already canceled
if ($flagData['flag_state'] !== 'Canceled') {
+12 -12
View File
@@ -2,10 +2,10 @@
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\StudentModel;
use App\Models\StudentClassModel;
use App\Models\ConfigurationModel;
use CodeIgniter\Controller;
use CodeIgniter\Events\Events;
use App\Models\HomeworkModel;
use App\Models\QuizModel;
@@ -35,7 +35,7 @@ use App\Services\NavbarService;
//use App\Models\ScoreModel;
class GradingController extends Controller
class GradingController extends BaseController
{
protected $semesterScoreService;
protected $db;
@@ -1119,15 +1119,6 @@ public function belowSixty()
]);
}
private function currentSchoolYearName(?string $fallback = null): string
{
try {
return service('schoolYearContext')->resolve($this->request)->yearName();
} catch (\Throwable) {
return trim((string) ($fallback ?? ''));
}
}
public function editBelowSixtyEmail()
{
$studentId = (int)$this->request->getGet('student_id');
@@ -2329,7 +2320,8 @@ public function belowSixty()
public function belowSixtyDecisions()
{
$configuredYear = (string) $this->schoolYear;
$schoolYearContext = $this->resolveSchoolYearContext();
$configuredYear = $schoolYearContext->yearName();
$schoolYear = trim((string)($this->request->getGet('school_year') ?? ''));
@@ -2541,11 +2533,15 @@ public function belowSixty()
'schoolYear' => $schoolYear,
'schoolYears' => $schoolYears,
'canViewGrading' => $canViewGrading,
'isEditable' => ! $schoolYearContext->isReadonly(),
]);
}
public function saveBelowSixtyDecision()
{
$schoolYearContext = $this->resolveSchoolYearContext();
$this->assertSchoolYearWritable($schoolYearContext);
$studentId = (int)($this->request->getPost('student_id') ?? 0);
$semester = strtolower(trim((string)($this->request->getPost('semester') ?? 'year')));
$schoolYear = trim((string)($this->request->getPost('school_year') ?? ''));
@@ -2556,6 +2552,10 @@ public function saveBelowSixtyDecision()
return redirect()->back()->with('error', 'Missing student or school year.');
}
if ($schoolYear !== $schoolYearContext->yearName()) {
return redirect()->back()->with('error', 'Selected school year does not match the submitted decision.');
}
// This decision page should feed certificate decisions as whole-year decisions.
// Force year mode here so certificate logic receives final year decision.
$semester = 'year';
+147 -10
View File
@@ -81,7 +81,8 @@ class InvoiceController extends ResourceController
$this->gradeFee = $this->configModel->getConfig('grade_fee');
$this->schoolYear = $this->currentSchoolYearName();
$this->semester = $this->configModel->getConfig('semester');
$this->dueDate = $this->configModel->getConfig('due_date');
$this->dueDate = $this->configModel->getConfig('first_day_of_school')
?: $this->configModel->getConfig('due_date');
$this->firstStudentFee = (float) ($this->configModel->getConfig('first_student_fee') ?? 350);
$this->secondStudentFee = (float) ($this->configModel->getConfig('second_student_fee') ?? 200);
$this->youthFee = (float) ($this->configModel->getConfig('youth_fee') ?? 200);
@@ -230,6 +231,13 @@ class InvoiceController extends ResourceController
}
}
private function assertSchoolYearNameWritable(string $schoolYear): void
{
service('schoolYearWriteGuard')->assertWritable(
service('schoolYearContext')->forYearName($schoolYear)
);
}
/**
* API: Invoice management composite data (used by invoice_management view)
* Returns the same structure previously rendered server-side in index().
@@ -891,7 +899,7 @@ class InvoiceController extends ResourceController
}
}
$eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear);
$eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear, $invoice['semester'] ?? null);
// Attach SCHOOL IDs
$allKids = array_merge($registeredKids, $withdrawnKids);
@@ -1073,18 +1081,40 @@ class InvoiceController extends ResourceController
$pdf->SetFont('Arial', 'B', 12);
$pdf->Cell(40, 6, 'Due Date:', 0, 0, 'L');
$pdf->SetFont('Arial', '', 12);
$dueLocal = null;
$formatCalendarDate = static function ($raw): ?string {
if ($raw instanceof \DateTimeInterface) {
return $raw->format('m-d-Y');
}
$value = trim((string)($raw ?? ''));
if ($value === '' || preg_match('/^0{4}-0{2}-0{2}/', $value)) {
return null;
}
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})/', $value, $matches)) {
return $matches[2] . '-' . $matches[3] . '-' . $matches[1];
}
if (preg_match('/^(\d{1,2})[-\/](\d{1,2})[-\/](\d{4})/', $value, $matches)) {
return sprintf('%02d-%02d-%04d', (int)$matches[1], (int)$matches[2], (int)$matches[3]);
}
$timestamp = strtotime($value);
return $timestamp === false ? null : date('m-d-Y', $timestamp);
};
$dueDisplay = $formatCalendarDate($invoice['due_date'] ?? null);
try {
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
if (!empty($invoice['due_date'])) {
$dueLocal = (new \DateTimeImmutable($invoice['due_date'], new \DateTimeZone('UTC')))
->setTimezone(new \DateTimeZone($tzName));
} elseif (!empty($invoice['created_at'])) {
if ($dueDisplay === null && !empty($invoice['created_at'])) {
$dueLocal = new \DateTimeImmutable($invoice['created_at'], new \DateTimeZone($tzName));
$dueDisplay = $dueLocal->format('m-d-Y');
}
} catch (\Throwable $e) {}
if (!$dueLocal) { $dueLocal = new \DateTimeImmutable('now', new \DateTimeZone($tzName ?? 'UTC')); }
$pdf->Cell(0, 6, $dueLocal->format('m-d-Y'), 0, 1, 'L');
if ($dueDisplay === null) {
$dueDisplay = (new \DateTimeImmutable('now', new \DateTimeZone($tzName ?? 'UTC')))->format('m-d-Y');
}
$pdf->Cell(0, 6, $dueDisplay, 0, 1, 'L');
$pdf->Ln(5);
$pdf->SetFont('Arial', '', 9);
@@ -1145,16 +1175,111 @@ class InvoiceController extends ResourceController
];
};
// --- Frozen invoice charge lines. Do not rebuild issued charges from current enrollment/events.
$studentById = [];
foreach (($data['students'] ?? []) as $student) {
$sid = (int)($student['student_id'] ?? 0);
if ($sid <= 0) {
continue;
}
$studentById[$sid] = trim((string)($student['student_firstname'] ?? '') . ' ' . (string)($student['student_lastname'] ?? ''));
}
$studentTuitionRows = [];
foreach (array_merge($registeredKids ?? [], $withdrawnKids ?? []) as $student) {
$sid = (int)($student['student_id'] ?? 0);
$charge = $studentCharges[$sid] ?? null;
$amount = (float)($charge['unit_fee'] ?? 0.0);
if ($sid <= 0 || abs($amount) < 0.00001) {
continue;
}
$name = trim((string)($student['student_firstname'] ?? '') . ' ' . (string)($student['student_lastname'] ?? ''));
$grade = trim((string)($student['grade'] ?? ''));
$desc = 'Tuition - ' . ($name !== '' ? $name : 'Student #' . $sid);
if ($grade !== '' && strtoupper($grade) !== 'N/A') {
$desc .= ' (' . $grade . ')';
}
$studentTuitionRows[] = [
'description' => $desc,
'amount' => $amount,
];
}
$eventRows = [];
foreach (($events ?? []) as $event) {
$amount = (float)($event['charged'] ?? 0.0);
if (abs($amount) < 0.00001) {
continue;
}
$sid = (int)($event['student_id'] ?? 0);
$studentName = $sid > 0 ? ($studentById[$sid] ?? '') : '';
if ($studentName === '') {
$studentName = trim((string)($event['external_firstname'] ?? '') . ' ' . (string)($event['external_lastname'] ?? ''));
}
$desc = trim((string)($event['event_name'] ?? 'Event charge'));
if ($studentName !== '') {
$desc .= ' - ' . $studentName;
}
$eventRows[] = [
'description' => $desc,
'amount' => $amount,
];
}
// --- Frozen invoice charge lines remain authoritative for totals.
// Aggregate tuition/event lines are expanded for display when invoice details are available.
foreach (($data['invoiceLines'] ?? []) as $line) {
$dt = $toLocal($line['created_at'] ?? ($invoice['created_at'] ?? null), true);
$amount = ((int)($line['line_amount_cents'] ?? 0)) / 100;
$type = (string)($line['line_type'] ?? 'other');
$category = str_contains($type, 'event') ? 'event'
: (str_contains($type, 'additional') ? 'additional' : 'registration');
if (!str_contains($type, 'additional') && !str_contains($type, 'event') && !empty($studentTuitionRows)) {
$expandedTotal = 0.0;
foreach ($studentTuitionRows as $row) {
$expandedTotal += (float)$row['amount'];
$push($dt, $row['description'], (float)$row['amount'], 'registration');
}
$delta = round($amount - $expandedTotal, 2);
if (abs($delta) >= 0.01) {
$push($dt, 'Tuition charges adjustment', $delta, 'registration');
}
continue;
}
if (str_contains($type, 'event') && !empty($eventRows)) {
$expandedTotal = 0.0;
foreach ($eventRows as $row) {
$expandedTotal += (float)$row['amount'];
$push($dt, $row['description'], (float)$row['amount'], 'event');
}
$delta = round($amount - $expandedTotal, 2);
if (abs($delta) >= 0.01) {
$push($dt, 'Event charges adjustment', $delta, 'event');
}
continue;
}
$push($dt, (string)($line['description'] ?? 'Invoice line'), $amount, $category);
}
if (empty($data['invoiceLines'] ?? [])) {
$fallbackDt = $toLocal($invoice['created_at'] ?? ($invoice['issue_date'] ?? null), true);
foreach ($studentTuitionRows as $row) {
$push($fallbackDt, $row['description'], (float)$row['amount'], 'registration');
}
foreach ($eventRows as $row) {
$push($fallbackDt, $row['description'], (float)$row['amount'], 'event');
}
}
// --- Payments (negative) — stored in local time
foreach ($payments as $payment) {
$dt = $toLocal($payment['payment_date'] ?? null, false /* local */);
@@ -1513,6 +1638,12 @@ private function getGradeLevel($grade): array
// API: Update invoice status
public function updateStatusAPI($invoiceId)
{
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice) {
return $this->failNotFound('Invoice not found.');
}
$this->assertSchoolYearNameWritable((string)($invoice['school_year'] ?? ''));
$status = $this->request->getPost('status');
if ($this->invoiceModel->updateInvoiceStatus($invoiceId, $status)) {
return $this->respond(['status' => 'success']);
@@ -1524,6 +1655,12 @@ private function getGradeLevel($grade): array
// View: Update invoice status
public function updateStatus($invoiceId)
{
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice) {
return redirect()->back()->with('error', 'Invoice not found.');
}
$this->assertSchoolYearNameWritable((string)($invoice['school_year'] ?? ''));
$status = $this->request->getPost('status');
if ($this->invoiceModel->updateInvoiceStatus($invoiceId, $status)) {
return redirect()->to('/invoices');
File diff suppressed because it is too large Load Diff
@@ -151,6 +151,13 @@ class PaymentController extends ResourceController
}
}
private function assertSchoolYearNameWritable(string $schoolYear): void
{
service('schoolYearWriteGuard')->assertWritable(
service('schoolYearContext')->forYearName($schoolYear)
);
}
// View: Create a new payment plan
public function create()
{
@@ -160,6 +167,12 @@ class PaymentController extends ResourceController
// API: Update balance after payment
public function updateBalanceAPI($paymentId)
{
$payment = $this->paymentModel->find($paymentId);
if (!$payment) {
return $this->failNotFound('Payment not found.');
}
$this->assertSchoolYearNameWritable((string)($payment['school_year'] ?? ''));
$amountPaid = $this->request->getPost('paid_amount');
if ($this->paymentModel->updateBalance($paymentId, $amountPaid)) {
return $this->respond(['status' => 'success']);
@@ -171,6 +184,12 @@ class PaymentController extends ResourceController
// View: Update balance after payment
public function updateBalance($paymentId)
{
$payment = $this->paymentModel->find($paymentId);
if (!$payment) {
return redirect()->back()->with('error', 'Payment not found.');
}
$this->assertSchoolYearNameWritable((string)($payment['school_year'] ?? ''));
$amountPaid = $this->request->getPost('paid_amount');
if ($this->paymentModel->updateBalance($paymentId, $amountPaid)) {
return redirect()->to('/payments');
@@ -879,6 +898,7 @@ class PaymentController extends ResourceController
if (!$invoice) {
return redirect()->back()->with('error', 'Invoice not found.');
}
$this->assertSchoolYearNameWritable((string)($invoice['school_year'] ?? ''));
// Snapshot pre-payment balance
$initialPreBalance = $this->getCurrentInvoiceBalance($invoiceId);
@@ -1142,6 +1162,7 @@ class PaymentController extends ResourceController
if (!$invoice) {
return redirect()->back()->with('error', 'Linked invoice not found.');
}
$this->assertSchoolYearNameWritable((string)($invoice['school_year'] ?? ($payment['school_year'] ?? '')));
//$schoolYear = (new ConfigurationModel())->getConfig('school_year');
$checkFile = $payment['check_file']; // default keep old file
@@ -1280,6 +1301,7 @@ class PaymentController extends ResourceController
}
$schoolYear = (string)($invoice['school_year'] ?? $this->schoolYear);
$this->assertSchoolYearNameWritable($schoolYear);
$this->recalculateInvoice((int)$invoice['id'], $schoolYear);
return redirect()->back()->with('success', 'Invoice recalculated.');
@@ -488,6 +488,7 @@ class ReimbursementController extends BaseController
'error' => 'Expense not found.',
]);
}
$this->assertSchoolYearNameWritable((string)($expense['school_year'] ?? ''));
if (!empty($expense['reimbursement_id'])) {
return $this->response->setStatusCode(409)->setJSON([
@@ -655,6 +656,12 @@ public function updateBatchAssignment()
$this->db->transBegin();
try {
$expense = $this->expenseModel->find($expenseId);
if (!$expense) {
throw new \RuntimeException('Expense not found.');
}
$this->assertSchoolYearNameWritable((string)($expense['school_year'] ?? ''));
$activeItem = $this->batchItemModel
->where('expense_id', $expenseId)
->where('unassigned_at IS NULL', null, false)
@@ -698,6 +705,7 @@ public function updateBatchAssignment()
'error' => 'Batch not found or already closed.',
]);
}
$this->assertSchoolYearNameWritable((string)($batch['school_year'] ?? ''));
if (!$reimbursementId) {
if ($activeItem && !empty($activeItem['reimbursement_id'])) {
@@ -847,6 +855,7 @@ public function updateBatchAssignment()
'error' => 'Batch not found or already closed.',
]);
}
$this->assertSchoolYearNameWritable((string)($batch['school_year'] ?? ''));
$adminRows = $this->batchItemModel
->select('DISTINCT COALESCE(admin_id, 0) AS admin_id')
@@ -1038,6 +1047,7 @@ public function updateBatchAssignment()
'error' => 'Batch not found.',
]);
}
$this->assertSchoolYearNameWritable((string)($batch['school_year'] ?? ''));
if (strtolower((string) ($batch['status'] ?? '')) !== 'open') {
return $this->response->setStatusCode(409)->setJSON([
'success' => false,
@@ -1618,6 +1628,7 @@ public function updateBatchAssignment()
'error' => 'Requested batch was not found.',
]);
}
$this->assertSchoolYearNameWritable((string)($batch['school_year'] ?? ''));
$receiptRows = !empty($receiptIds) ? $this->fetchBatchReceiptRows($batchId, $receiptIds) : [];
@@ -2108,6 +2119,7 @@ public function updateBatchAssignment()
if (!$reimb) {
throw PageNotFoundException::forPageNotFound("Reimbursement #$id not found");
}
$this->assertSchoolYearNameWritable((string)($reimb['school_year'] ?? ''));
if ($this->isPaidReimbursement($reimb)) {
return redirect()->to('/reimbursements')->with('error', 'Paid reimbursements are immutable. Reverse and replace the transaction instead.');
}
@@ -2207,6 +2219,7 @@ public function updateBatchAssignment()
if (!$reimbursement) {
throw new \RuntimeException('Reimbursement not found.');
}
$this->assertSchoolYearNameWritable((string)($reimbursement['school_year'] ?? ''));
if (FinancialStatus::normalizeReimbursementStatus($reimbursement['status'] ?? null) !== FinancialStatus::REIMBURSEMENT_PAID) {
throw new \RuntimeException('Only paid reimbursements can be reversed.');
}
@@ -2277,6 +2290,7 @@ public function updateBatchAssignment()
if (!$expense) {
throw new \RuntimeException('Expense not found.');
}
$this->assertSchoolYearNameWritable((string)($expense['school_year'] ?? ''));
if (FinancialStatus::normalize((string) ($expense['status'] ?? '')) !== 'approved') {
throw new \RuntimeException('Expense must be approved before reimbursement.');
}
File diff suppressed because it is too large Load Diff