diff --git a/app/Config/Filters.php b/app/Config/Filters.php index e674f95..9694240 100644 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -31,6 +31,7 @@ class Filters extends BaseConfig 'cleanupScheduler' => \App\Filters\CleanupScheduler::class, 'permission' => \App\Filters\PermissionFilter::class, 'timezone' => \App\Filters\TimezoneFilter::class, + 'schoolYear' => \App\Filters\RequireSchoolYearFilter::class, ]; diff --git a/app/Config/Routes.php b/app/Config/Routes.php index c549e9b..6bd3141 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -1132,6 +1132,22 @@ $routes->post('/configuration/addConfig', 'View\ConfigurationController::addConf $routes->match(['get', 'post'], '/configuration/editConfig/(:num)', 'View\ConfigurationController::editConfig/$1'); $routes->post('/configuration/deleteConfig/(:num)', 'View\ConfigurationController::deleteConfig/$1'); +// School-year management +$routes->group('administrator/school-years', ['filter' => 'auth:admin'], static function ($routes) { + $routes->get('', 'Administrator\SchoolYearController::index'); + $routes->post('store', 'Administrator\SchoolYearController::store'); + $routes->post('(:num)/update', 'Administrator\SchoolYearController::update/$1'); + $routes->post('(:num)/activate', 'Administrator\SchoolYearController::activate/$1'); + $routes->post('(:num)/delete-draft', 'Administrator\SchoolYearController::deleteDraft/$1'); + $routes->post('(:num)/archive', 'Administrator\SchoolYearController::archive/$1'); + $routes->post('(:num)/reopen', 'Administrator\SchoolYearController::reopen/$1'); + $routes->get('(:num)/closing/preview', 'Administrator\SchoolYearClosingController::preview/$1'); + $routes->post('(:num)/closing/start', 'Administrator\SchoolYearClosingController::start/$1'); + $routes->post('(:num)/closing/execute', 'Administrator\SchoolYearClosingController::execute/$1'); + $routes->post('(:num)/closing/complete', 'Administrator\SchoolYearClosingController::complete/$1'); + $routes->post('(:num)/closing/cancel', 'Administrator\SchoolYearClosingController::cancel/$1'); +}); + // Route for Attendance Comment Templates $routes->get('/administrator/attendance-templates', 'View\AttendanceCommentTemplateController::index'); $routes->get('/api/attendance-templates', 'View\AttendanceCommentTemplateController::listData'); diff --git a/app/Config/Services.php b/app/Config/Services.php index 68aba46..156f3ca 100644 --- a/app/Config/Services.php +++ b/app/Config/Services.php @@ -180,4 +180,66 @@ class Services extends BaseService $http = static::curlrequest($options); return new \App\Services\ApiClient($http, $apiConfig); } + + public static function schoolYearContext(bool $getShared = true): \App\Services\SchoolYearContextService + { + if ($getShared) { + return static::getSharedInstance('schoolYearContext'); + } + + return new \App\Services\SchoolYearContextService( + model(\App\Models\SchoolYearModel::class) + ); + } + + public static function schoolYearWriteGuard(bool $getShared = true): \App\Services\SchoolYearWriteGuard + { + if ($getShared) { + return static::getSharedInstance('schoolYearWriteGuard'); + } + + return new \App\Services\SchoolYearWriteGuard(); + } + + public static function schoolYearValidation(bool $getShared = true): \App\Services\SchoolYearValidationService + { + if ($getShared) { + return static::getSharedInstance('schoolYearValidation'); + } + + return new \App\Services\SchoolYearValidationService( + model(\App\Models\SchoolYearModel::class) + ); + } + + public static function schoolYearManagement(bool $getShared = true): \App\Services\SchoolYearManagementService + { + if ($getShared) { + return static::getSharedInstance('schoolYearManagement'); + } + + return new \App\Services\SchoolYearManagementService( + model(\App\Models\SchoolYearModel::class), + model(\App\Models\ConfigurationModel::class), + model(\App\Models\SchoolYearTransitionLogModel::class), + model(\App\Models\SchoolYearClosingBatchModel::class), + static::schoolYearValidation(), + \Config\Database::connect() + ); + } + + public static function schoolYearClosing(bool $getShared = true): \App\Services\SchoolYearClosingService + { + if ($getShared) { + return static::getSharedInstance('schoolYearClosing'); + } + + return new \App\Services\SchoolYearClosingService( + model(\App\Models\SchoolYearModel::class), + model(\App\Models\SchoolYearClosingBatchModel::class), + model(\App\Models\SchoolYearClosingItemModel::class), + static::schoolYearManagement(), + \Config\Database::connect() + ); + } } diff --git a/app/Controllers/Administrator/SchoolYearClosingController.php b/app/Controllers/Administrator/SchoolYearClosingController.php new file mode 100644 index 0000000..20a9f9d --- /dev/null +++ b/app/Controllers/Administrator/SchoolYearClosingController.php @@ -0,0 +1,82 @@ +normalizeInt($this->request->getGet('target_school_year_id')); + + return view('school_years/closing_preview', [ + 'preview' => service('schoolYearClosing')->preview($id, $targetId), + 'latestBatch' => service('schoolYearClosing')->latestBatch($id), + 'schoolYears' => (new SchoolYearModel())->orderBy('name', 'DESC')->findAll(), + ]); + } catch (Throwable $e) { + return redirect()->to('/administrator/school-years')->with('error', $e->getMessage()); + } + } + + public function start(int $id) + { + try { + $targetId = $this->normalizeInt($this->request->getPost('target_school_year_id')); + if ($targetId === null) { + return redirect()->back()->with('error', 'Select a target school year.'); + } + + service('schoolYearClosing')->start($id, $targetId, $this->userId()); + return redirect()->to('/administrator/school-years/' . $id . '/closing/preview')->with('success', 'Closing started.'); + } catch (Throwable $e) { + return redirect()->back()->with('error', $e->getMessage()); + } + } + + public function execute(int $id) + { + try { + service('schoolYearClosing')->execute($id, $this->userId()); + return redirect()->to('/administrator/school-years/' . $id . '/closing/preview')->with('success', 'Carry-forward batch executed.'); + } catch (Throwable $e) { + return redirect()->back()->with('error', $e->getMessage()); + } + } + + public function complete(int $id) + { + try { + service('schoolYearClosing')->complete($id, $this->userId()); + return redirect()->to('/administrator/school-years')->with('success', 'School year closed.'); + } catch (Throwable $e) { + return redirect()->back()->with('error', $e->getMessage()); + } + } + + public function cancel(int $id) + { + try { + service('schoolYearClosing')->cancel($id, $this->userId()); + return redirect()->to('/administrator/school-years')->with('success', 'Closing cancelled.'); + } catch (Throwable $e) { + return redirect()->back()->with('error', $e->getMessage()); + } + } + + private function normalizeInt(mixed $value): ?int + { + return is_numeric($value) && (int) $value > 0 ? (int) $value : null; + } + + private function userId(): ?int + { + $id = session('user_id') ?? session('id'); + + return is_numeric($id) ? (int) $id : null; + } +} diff --git a/app/Controllers/Administrator/SchoolYearController.php b/app/Controllers/Administrator/SchoolYearController.php new file mode 100644 index 0000000..973ed3a --- /dev/null +++ b/app/Controllers/Administrator/SchoolYearController.php @@ -0,0 +1,127 @@ +schoolYearModel = new SchoolYearModel(); + } + + public function index() + { + $schoolYears = $this->schoolYearModel + ->orderBy('name', 'DESC') + ->findAll(); + + $activeYear = null; + $nextDraftYear = null; + $closingYear = null; + $archivedCount = 0; + + foreach ($schoolYears as $year) { + $status = (string) ($year['status'] ?? ''); + if ($status === SchoolYearStatus::ACTIVE && $activeYear === null) { + $activeYear = $year; + } + if ($status === SchoolYearStatus::DRAFT && $nextDraftYear === null) { + $nextDraftYear = $year; + } + if ($status === SchoolYearStatus::CLOSING && $closingYear === null) { + $closingYear = $year; + } + if ($status === SchoolYearStatus::ARCHIVED) { + $archivedCount++; + } + } + + return view('school_years/index', [ + 'schoolYears' => $schoolYears, + 'statuses' => SchoolYearStatus::ALL, + 'activeYear' => $activeYear, + 'nextDraftYear' => $nextDraftYear, + 'closingYear' => $closingYear, + 'archivedCount' => $archivedCount, + 'latestTransitions' => service('schoolYearManagement')->latestTransitionByYear(), + ]); + } + + public function store() + { + try { + service('schoolYearManagement')->createDraft($this->request->getPost(), $this->userId()); + return redirect()->to('/administrator/school-years')->with('success', 'Draft school year created.'); + } catch (Throwable $e) { + return redirect()->back()->withInput()->with('error', $e->getMessage()); + } + } + + public function update(int $id) + { + try { + service('schoolYearManagement')->updateMetadata($id, $this->request->getPost(), $this->userId()); + return redirect()->to('/administrator/school-years')->with('success', 'School year metadata updated.'); + } catch (Throwable $e) { + return redirect()->back()->withInput()->with('error', $e->getMessage()); + } + } + + public function activate(int $id) + { + try { + if ($this->request->getPost('confirm_activation') !== '1') { + return redirect()->to('/administrator/school-years')->with('error', 'Activation confirmation is required.'); + } + + service('schoolYearManagement')->activate($id, $this->userId()); + return redirect()->to('/administrator/school-years')->with('success', 'School year activated. Any previous active year is now in closing.'); + } catch (Throwable $e) { + return redirect()->to('/administrator/school-years')->with('error', $e->getMessage()); + } + } + + public function deleteDraft(int $id) + { + try { + service('schoolYearManagement')->deleteDraft($id, $this->userId()); + return redirect()->to('/administrator/school-years')->with('success', 'Draft school year deleted.'); + } catch (Throwable $e) { + return redirect()->to('/administrator/school-years')->with('error', $e->getMessage()); + } + } + + public function archive(int $id) + { + try { + service('schoolYearManagement')->archive($id, $this->userId()); + return redirect()->to('/administrator/school-years')->with('success', 'School year archived.'); + } catch (Throwable $e) { + return redirect()->to('/administrator/school-years')->with('error', $e->getMessage()); + } + } + + public function reopen(int $id) + { + try { + service('schoolYearManagement')->reopen($id, (string) $this->request->getPost('reason'), $this->userId()); + return redirect()->to('/administrator/school-years')->with('success', 'School year reopened.'); + } catch (Throwable $e) { + return redirect()->to('/administrator/school-years')->with('error', $e->getMessage()); + } + } + + private function userId(): ?int + { + $id = session('user_id') ?? session('id'); + + return is_numeric($id) ? (int) $id : null; + } +} diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php index 9289198..25e651f 100644 --- a/app/Controllers/BaseController.php +++ b/app/Controllers/BaseController.php @@ -9,6 +9,8 @@ use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; use Psr\Log\LoggerInterface; use App\Services\ApiClient; +use App\Support\SchoolYear\SchoolYearContext; +use Throwable; /** * Class BaseController @@ -88,6 +90,27 @@ abstract class BaseController extends Controller return $payload; } + + protected function resolveSchoolYearContext(?int $routeSchoolYearId = null): SchoolYearContext + { + try { + return service('schoolYearContext')->resolve($this->request, $routeSchoolYearId); + } catch (Throwable $e) { + log_message('warning', 'School-year resolution failed: {message}', [ + 'message' => $e->getMessage(), + ]); + + throw $e; + } + } + + protected function assertSchoolYearWritable( + SchoolYearContext $context, + bool $allowDraftForAdmin = false, + bool $isAdmin = false + ): void { + service('schoolYearWriteGuard')->assertWritable($context, $allowDraftForAdmin, $isAdmin); + } } ?> diff --git a/app/Controllers/View/AdministratorController.php b/app/Controllers/View/AdministratorController.php index d15eff5..434ef9e 100644 --- a/app/Controllers/View/AdministratorController.php +++ b/app/Controllers/View/AdministratorController.php @@ -730,18 +730,15 @@ class AdministratorController extends BaseController ->join('users u', 'u.id = tc.teacher_id', 'left') ->orderBy('cs.class_section_name', 'ASC'); - $filteredQuery = clone $assignmentQuery; + // teacher_class assignments are scoped by school year only. + // The table has no semester column; semester filtering belongs on + // semester-specific records such as scores, comments, attendance, + // homework, and exam drafts. if ($schoolYear !== '') { - $filteredQuery = $filteredQuery->where('tc.school_year', $schoolYear); - } - if (!empty($semesterCandidates)) { - $filteredQuery = $filteredQuery->whereIn('tc.semester', $semesterCandidates); + $assignmentQuery->where('tc.school_year', $schoolYear); } - $assignmentRows = $filteredQuery->get()->getResultArray(); - if (empty($assignmentRows) && ($schoolYear !== '' || $semester !== '')) { - $assignmentRows = $assignmentQuery->get()->getResultArray(); - } + $assignmentRows = $assignmentQuery->get()->getResultArray(); $studentCounts = $this->studentClassModel->getStudentCountsBySection($schoolYear !== '' ? $schoolYear : null); $sectionRows = $this->classSectionModel @@ -873,14 +870,12 @@ class AdministratorController extends BaseController continue; } - $studentQuery = $this->studentClassModel + $studentEntries = $this->db->table('student_class') ->select('student_id') ->where('class_section_id', $classSectionId) - ->where('school_year', $schoolYear); - if (!empty($semesterCandidates)) { - $studentQuery->whereIn('semester', $semesterCandidates); - } - $studentEntries = $studentQuery->findAll(); + ->where('school_year', $schoolYear) + ->get() + ->getResultArray(); if (empty($studentEntries)) { $studentEntries = $this->studentClassModel ->select('student_id') diff --git a/app/Controllers/View/ClassPreparationController.php b/app/Controllers/View/ClassPreparationController.php index db9456e..8e14480 100644 --- a/app/Controllers/View/ClassPreparationController.php +++ b/app/Controllers/View/ClassPreparationController.php @@ -21,8 +21,10 @@ class ClassPreparationController extends BaseController protected $schoolYear; protected $semester; protected $adjustmentModel; - /** cache for roster presence per term */ + /** Cache for roster presence per school year. */ private array $rosterPresenceCache = []; + /** Cache table-column checks to avoid repeated schema queries. */ + private array $columnExistsCache = []; // Inside ClassPreparationController (class scope, not inside a method) private array $allowedPrepCategories = [ 'Grade Box', @@ -57,7 +59,7 @@ class ClassPreparationController extends BaseController $semParam = $this->request->getGet('semester'); $semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester; $allowed = $this->allowedPrepCategories; - $limitToSemester = $this->hasRosterForSemester($schoolYear, $semester); + $hasRoster = $this->hasRosterForSchoolYear($schoolYear); // 1) Get student count per class-section (distinct students, correct semester) $scQ = $this->db->table('student_class sc') @@ -65,13 +67,12 @@ class ClassPreparationController extends BaseController ->join('students s', 's.id = sc.student_id', 'inner') ->where('s.is_active', 1) ->where('sc.school_year', $schoolYear); - if ($limitToSemester && $semester !== '') { - $scQ->where('sc.semester', $semester); - } - $classSections = $scQ->groupBy('sc.class_section_id')->get()->getResultArray(); + $classSections = $hasRoster + ? $scQ->groupBy('sc.class_section_id')->get()->getResultArray() + : []; // 2) Inventory availability — prefer good_qty when present, else condition='good' quantity. - $inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $limitToSemester, $allowed); + $inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $allowed); // Seed totals with allowed categories for stable ordering $requiredTotals = array_fill_keys($allowed, 0); @@ -85,14 +86,10 @@ class ClassPreparationController extends BaseController $className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId); // Calculate required items (whitelist inside) - $baseItems = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId); + $baseItems = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId, $schoolYear); // --- Apply adjustments (only Small/Large Table), clamp >= 0 --- - $rawAdjustments = $this->adjustmentModel - ->where('class_section_id', $classSectionId) - ->where('school_year', $schoolYear) - ->where('adjustable', 1) - ->findAll(); + $rawAdjustments = $this->getAdjustments((string) $classSectionId, (string) $schoolYear); $adjMap = ['Large Table' => 0, 'Small Table' => 0]; @@ -117,11 +114,7 @@ class ClassPreparationController extends BaseController } // 5) Compare with last snapshot; do not save here — only on print or explicit API - $oldSnap = $this->prepLogModel - ->where('class_section_id', $classSectionId) - ->where('school_year', $schoolYear) - ->orderBy('created_at', 'DESC') - ->first(); + $oldSnap = $this->getLatestPrepSnapshot((string) $classSectionId, (string) $schoolYear); $oldPrep = $oldSnap ? json_decode($oldSnap['prep_data'], true) : []; $hasChanged = $this->hasPrepChanged($baseItems, $oldPrep); @@ -175,7 +168,7 @@ class ClassPreparationController extends BaseController return (int)($row['cnt'] ?? 0); } - private function calculatePrepItems(int $students, int $classLevel, string $classSectionId): array + private function calculatePrepItems(int $students, int $classLevel, string $classSectionId, ?string $schoolYear = null): array { // 1) Stable keys: your whitelist, all zero to start $allowed = $this->allowedPrepCategories; @@ -192,7 +185,7 @@ class ClassPreparationController extends BaseController // 3) Rules $isLowerGrades = in_array($classLevel, [1, 2], true); - $teacherCount = $this->getTeacherCountForSection($classSectionId, $this->schoolYear); + $teacherCount = $this->getTeacherCountForSection($classSectionId, $schoolYear ?? $this->schoolYear); foreach ($categories as $cat) { $name = $cat['name']; // e.g., 'Small Table' @@ -253,24 +246,35 @@ class ClassPreparationController extends BaseController $adjustments = $data['adjustments'] ?? []; foreach ($adjustments as $itemName => $adjustment) { - $row = $this->adjustmentModel + $adjustmentQuery = $this->adjustmentModel ->where('class_section_id', $sectionId) - ->where('school_year', $schoolYear) - ->where('item_name', $itemName) - ->first(); + ->where('item_name', $itemName); + + $adjustmentTable = $this->adjustmentModel->getTable(); + + if ($schoolYear !== '' && $this->tableHasColumn($adjustmentTable, 'school_year')) { + $adjustmentQuery->where('school_year', $schoolYear); + } + + $row = $adjustmentQuery->first(); if ($row) { // Update $this->adjustmentModel->update($row['id'], ['adjustment' => (int)$adjustment, 'adjustable' => 1]); } else { // Insert - $this->adjustmentModel->insert([ + $insertData = [ 'class_section_id' => $sectionId, 'item_name' => $itemName, - 'adjustment' => (int)$adjustment, - 'school_year' => $schoolYear, + 'adjustment' => (int) $adjustment, 'adjustable' => 1, - ]); + ]; + + if ($schoolYear !== '' && $this->tableHasColumn($adjustmentTable, 'school_year')) { + $insertData['school_year'] = $schoolYear; + } + + $this->adjustmentModel->insert($insertData); } } @@ -292,10 +296,6 @@ class ClassPreparationController extends BaseController public function print($classSectionId, $schoolYear) { - $semParam = $this->request->getGet('semester'); - $semester = (is_string($semParam) && $semParam !== '') ? (string) $semParam : (string) $this->semester; - $limitToSemester = $this->hasRosterForSemester((string) $schoolYear, $semester); - // Friendly label (if you have this helper) $className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId; @@ -306,15 +306,12 @@ class ClassPreparationController extends BaseController ->where('s.is_active', 1) ->where('sc.class_section_id', $classSectionId) ->where('sc.school_year', $schoolYear); - if ($limitToSemester && $semester !== '') { - $studentQ->where('sc.semester', $semester); - } $studentRow = $studentQ->get()->getRowArray(); $studentCount = (int)($studentRow['cnt'] ?? 0); // Live calc + adjustments $classLevel = $this->getClassLevelBySection((string)$classSectionId); - $items = $this->calculatePrepItems($studentCount, $classLevel, (string)$classSectionId); + $items = $this->calculatePrepItems($studentCount, $classLevel, (string)$classSectionId, (string)$schoolYear); $rawAdjustments = $this->adjustmentModel ->where('class_section_id', $classSectionId) @@ -328,13 +325,15 @@ class ClassPreparationController extends BaseController } // Record a snapshot at print time as the new baseline - $this->prepLogModel->insert([ - 'class_section_id' => (string)$classSectionId, - 'class_section' => $className, - 'school_year' => $schoolYear, - 'prep_data' => json_encode($items), - 'created_at' => utc_now(), - ]); + $this->prepLogModel->insert( + $this->buildPrepLogData( + (string) $classSectionId, + (string) $className, + (string) $schoolYear, + $items, + utc_now() + ) + ); // Return PRINT view (HTML) that auto-opens the print dialog return view('class_prep/print', [ @@ -356,7 +355,7 @@ class ClassPreparationController extends BaseController $semParam = $this->request->getGet('semester'); $semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester; $allowed = $this->allowedPrepCategories; - $limitToSemester = $this->hasRosterForSemester($schoolYear, $semester); + $hasRoster = $this->hasRosterForSchoolYear($schoolYear); // Student counts $scQ = $this->db->table('student_class sc') @@ -364,13 +363,12 @@ class ClassPreparationController extends BaseController ->join('students s', 's.id = sc.student_id', 'inner') ->where('s.is_active', 1) ->where('sc.school_year', $schoolYear); - if ($limitToSemester && $semester !== '') { - $scQ->where('sc.semester', $semester); - } - $classSections = $scQ->groupBy('sc.class_section_id')->get()->getResultArray(); + $classSections = $hasRoster + ? $scQ->groupBy('sc.class_section_id')->get()->getResultArray() + : []; // Build inventory availability maps - $inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $limitToSemester, $allowed); + $inventoryMap = $this->buildInventoryAvailability($schoolYear, $semester, $allowed); $requiredTotals = array_fill_keys($allowed, 0); $results = []; @@ -381,12 +379,8 @@ class ClassPreparationController extends BaseController $classLevel = $this->getClassLevelBySection($classSectionId); $className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId); - $baseItems = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId); - $rawAdjustments = $this->adjustmentModel - ->where('class_section_id', $classSectionId) - ->where('school_year', $schoolYear) - ->where('adjustable', 1) - ->findAll(); + $baseItems = $this->calculatePrepItems($studentCount, $classLevel, $classSectionId, $schoolYear); + $rawAdjustments = $this->getAdjustments((string) $classSectionId, (string) $schoolYear); $adjMap = ['Large Table' => 0, 'Small Table' => 0]; foreach ($rawAdjustments as $a) { $item = $a['item_name']; @@ -399,11 +393,7 @@ class ClassPreparationController extends BaseController $requiredTotals[$cat] += (int)($baseItems[$cat] ?? 0); } - $oldSnap = $this->prepLogModel - ->where('class_section_id', $classSectionId) - ->where('school_year', $schoolYear) - ->orderBy('created_at', 'DESC') - ->first(); + $oldSnap = $this->getLatestPrepSnapshot((string) $classSectionId, (string) $schoolYear); $oldPrep = $oldSnap ? json_decode($oldSnap['prep_data'], true) : []; $hasChanged = $this->hasPrepChanged($baseItems, $oldPrep); @@ -446,7 +436,6 @@ class ClassPreparationController extends BaseController $schoolYear = (string)($this->request->getPost('school_year') ?? $this->schoolYear); $semParam = $this->request->getPost('semester'); $semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester; - $limitToSemester = $this->hasRosterForSemester($schoolYear, $semester); $ids = $this->request->getPost('class_section_ids') ?? []; if (!is_array($ids)) $ids = $ids ? [$ids] : []; $ids = array_values(array_unique(array_filter(array_map('strval', $ids)))); @@ -460,21 +449,14 @@ class ClassPreparationController extends BaseController ->where('s.is_active', 1) ->where('sc.class_section_id', $classSectionId) ->where('sc.school_year', $schoolYear); - if ($limitToSemester && $semester !== '') { - $studentQ->where('sc.semester', $semester); - } $studentRow = $studentQ->get()->getRowArray(); $studentCount = (int)($studentRow['cnt'] ?? 0); $classLevel = $this->getClassLevelBySection((string)$classSectionId); $className = $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? $classSectionId; - $items = $this->calculatePrepItems($studentCount, $classLevel, (string)$classSectionId); + $items = $this->calculatePrepItems($studentCount, $classLevel, (string)$classSectionId, (string)$schoolYear); - $rawAdjustments = $this->adjustmentModel - ->where('class_section_id', $classSectionId) - ->where('school_year', $schoolYear) - ->where('adjustable', 1) - ->findAll(); + $rawAdjustments = $this->getAdjustments((string) $classSectionId, (string) $schoolYear); foreach ($rawAdjustments as $a) { $item = $a['item_name']; $delta = (int)$a['adjustment']; @@ -482,13 +464,15 @@ class ClassPreparationController extends BaseController } try { - $this->prepLogModel->insert([ - 'class_section_id' => (string)$classSectionId, - 'class_section' => $className, - 'school_year' => $schoolYear, - 'prep_data' => json_encode($items), - 'created_at' => $now, - ]); + $this->prepLogModel->insert( + $this->buildPrepLogData( + (string) $classSectionId, + (string) $className, + (string) $schoolYear, + $items, + $now + ) + ); $count++; } catch (\Throwable $e) { // ignore and continue @@ -528,12 +512,12 @@ class ClassPreparationController extends BaseController } /** - * Returns true if student_class has any rows for the given school year. - * The semester argument is ignored because assignments are tracked per year. + * Returns true when student_class contains at least one assignment + * for the selected school year. */ - private function hasRosterForTerm(string $schoolYear, string $semester): bool + private function hasRosterForSchoolYear(string $schoolYear): bool { - $year = trim((string)$schoolYear); + $year = trim($schoolYear); if ($year === '') { return false; } @@ -542,75 +526,154 @@ class ClassPreparationController extends BaseController return $this->rosterPresenceCache[$year]; } - $cnt = $this->db->table('student_class') + $exists = $this->db->table('student_class') ->where('school_year', $year) - ->countAllResults(); + ->limit(1) + ->countAllResults() > 0; - $this->rosterPresenceCache[$year] = $cnt > 0; - return $this->rosterPresenceCache[$year]; + $this->rosterPresenceCache[$year] = $exists; + + return $exists; } - private function hasRosterForSemester(string $schoolYear, string $semester): bool + private function tableHasColumn(string $table, string $column): bool { - $year = trim((string)$schoolYear); - $sem = trim((string)$semester); - if ($year === '' || $sem === '') { - return false; + $key = $table . '.' . $column; + + if (!array_key_exists($key, $this->columnExistsCache)) { + $this->columnExistsCache[$key] = $this->db->fieldExists($column, $table); } - $key = sprintf('sem:%s:%s', $year, $sem); - if (array_key_exists($key, $this->rosterPresenceCache)) { - return $this->rosterPresenceCache[$key]; - } - - $cnt = $this->db->table('student_class') - ->where('school_year', $year) - ->where('semester', $sem) - ->countAllResults(); - - $this->rosterPresenceCache[$key] = $cnt > 0; - return $this->rosterPresenceCache[$key]; + return $this->columnExistsCache[$key]; } - private function buildInventoryAvailability(string $schoolYear, string $semester, bool $limitToSemester, array $allowed): array + private function getAdjustments(string $classSectionId, string $schoolYear): array + { + $query = $this->adjustmentModel + ->where('class_section_id', $classSectionId) + ->where('adjustable', 1); + + $table = $this->adjustmentModel->getTable(); + + if ($schoolYear !== '' && $this->tableHasColumn($table, 'school_year')) { + $query->where('school_year', $schoolYear); + } + + return $query->findAll(); + } + + private function getLatestPrepSnapshot(string $classSectionId, string $schoolYear): ?array + { + $query = $this->prepLogModel + ->where('class_section_id', $classSectionId); + + $table = $this->prepLogModel->getTable(); + + if ($schoolYear !== '' && $this->tableHasColumn($table, 'school_year')) { + $query->where('school_year', $schoolYear); + } + + return $query + ->orderBy('created_at', 'DESC') + ->first(); + } + + private function buildPrepLogData( + string $classSectionId, + string $className, + string $schoolYear, + array $items, + string $createdAt + ): array { + $data = [ + 'class_section_id' => $classSectionId, + 'class_section' => $className, + 'prep_data' => json_encode($items), + 'created_at' => $createdAt, + ]; + + $table = $this->prepLogModel->getTable(); + + if ($schoolYear !== '' && $this->tableHasColumn($table, 'school_year')) { + $data['school_year'] = $schoolYear; + } + + return $data; + } + + private function buildInventoryAvailability(string $schoolYear, string $semester, array $allowed): array { $inventoryMap = array_fill_keys($allowed, 0); + $hasSchoolYear = $this->tableHasColumn('inventory_items', 'school_year'); + $hasSemester = $this->tableHasColumn('inventory_items', 'semester'); + $hasName = $this->tableHasColumn('inventory_items', 'name'); + $joinRows = $this->db->table('inventory_items ii') - ->select('ic.name AS item_name, COALESCE(SUM(CASE WHEN ii.good_qty IS NOT NULL THEN ii.good_qty WHEN ii.`condition`="good" THEN ii.quantity ELSE 0 END),0) AS available') + ->select('ic.name AS item_name, COALESCE(SUM(CASE WHEN ii.good_qty IS NOT NULL THEN ii.good_qty WHEN ii.`condition` = "good" THEN ii.quantity ELSE 0 END), 0) AS available', false) ->join('inventory_categories ic', 'ic.id = ii.category_id', 'left') ->where('ii.type', 'classroom') - ->where('ii.school_year', $schoolYear) ->whereIn('ic.name', $allowed); - if ($limitToSemester && $semester !== '') { + + if ($hasSchoolYear && $schoolYear !== '') { + $joinRows->where('ii.school_year', $schoolYear); + } + + if ($hasSemester && $semester !== '') { $joinRows->where('ii.semester', $semester); } - $joinRows = $joinRows->groupBy('ic.name')->get()->getResultArray(); - foreach ($joinRows as $r) { - $name = (string)($r['item_name'] ?? ''); - if ($name !== '' && isset($inventoryMap[$name])) { - $inventoryMap[$name] = max($inventoryMap[$name], (int)($r['available'] ?? 0)); + $joinRows = $joinRows + ->groupBy('ic.name') + ->get() + ->getResultArray(); + + foreach ($joinRows as $row) { + $name = (string) ($row['item_name'] ?? ''); + + if ($name !== '' && array_key_exists($name, $inventoryMap)) { + $inventoryMap[$name] = max( + $inventoryMap[$name], + (int) ($row['available'] ?? 0) + ); } } - $nameRows = $this->db->table('inventory_items') - ->select('name AS item_name, COALESCE(SUM(CASE WHEN good_qty IS NOT NULL THEN good_qty WHEN `condition`="good" THEN quantity ELSE 0 END),0) AS available') - ->where('type', 'classroom') - ->where('school_year', $schoolYear) - ->whereIn('name', $allowed); - if ($limitToSemester && $semester !== '') { - $nameRows->where('semester', $semester); - } - $nameRows = $nameRows->groupBy('name')->get()->getResultArray(); + /* + * Some older schemas store the item name directly on inventory_items. + * Only run this fallback when that column actually exists. + */ + if ($hasName) { + $nameRows = $this->db->table('inventory_items') + ->select('name AS item_name, COALESCE(SUM(CASE WHEN good_qty IS NOT NULL THEN good_qty WHEN `condition` = "good" THEN quantity ELSE 0 END), 0) AS available', false) + ->where('type', 'classroom') + ->whereIn('name', $allowed); - foreach ($nameRows as $r) { - $name = (string)($r['item_name'] ?? ''); - if ($name !== '' && isset($inventoryMap[$name])) { - $inventoryMap[$name] = max($inventoryMap[$name], (int)($r['available'] ?? 0)); + if ($hasSchoolYear && $schoolYear !== '') { + $nameRows->where('school_year', $schoolYear); + } + + if ($hasSemester && $semester !== '') { + $nameRows->where('semester', $semester); + } + + $nameRows = $nameRows + ->groupBy('name') + ->get() + ->getResultArray(); + + foreach ($nameRows as $row) { + $name = (string) ($row['item_name'] ?? ''); + + if ($name !== '' && array_key_exists($name, $inventoryMap)) { + $inventoryMap[$name] = max( + $inventoryMap[$name], + (int) ($row['available'] ?? 0) + ); + } } } return $inventoryMap; } -} +} \ No newline at end of file diff --git a/app/Controllers/View/SchoolYearController.php b/app/Controllers/View/SchoolYearController.php new file mode 100644 index 0000000..8dba552 --- /dev/null +++ b/app/Controllers/View/SchoolYearController.php @@ -0,0 +1,13 @@ +db->tableExists('school_years')) { + $this->forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 9, + 'null' => false, + ], + 'status' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + 'null' => false, + 'default' => 'draft', + ], + 'starts_on' => [ + 'type' => 'DATE', + 'null' => true, + ], + 'ends_on' => [ + 'type' => 'DATE', + 'null' => true, + ], + 'description' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'registration_starts_on' => [ + 'type' => 'DATE', + 'null' => true, + ], + 'registration_ends_on' => [ + 'type' => 'DATE', + 'null' => true, + ], + 'previous_school_year_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + ], + 'next_school_year_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + ], + 'activated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'closing_started_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'closed_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'archived_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'created_by' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + ], + 'updated_by' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addKey('name', false, true); + $this->forge->addKey('status'); + $this->forge->createTable('school_years'); + } else { + $this->ensureSchoolYearColumns(); + } + + $this->createClosingTables(); + + $configuredYear = $this->configuredSchoolYear(); + + if ($configuredYear !== null && $this->isValidYearName($configuredYear)) { + $existing = $this->db->table('school_years') + ->where('name', $configuredYear) + ->get() + ->getRowArray(); + + if ($existing === null) { + $this->db->table('school_years')->insert([ + 'name' => $configuredYear, + 'status' => 'active', + 'activated_at' => date('Y-m-d H:i:s'), + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } + } + } + + public function down(): void + { + $this->forge->dropTable('school_year_transition_logs', true); + $this->forge->dropTable('school_year_closing_items', true); + $this->forge->dropTable('school_year_closing_batches', true); + $this->forge->dropTable('school_years', true); + } + + private function ensureSchoolYearColumns(): void + { + $fields = $this->db->getFieldNames('school_years'); + $add = []; + + $definitions = [ + 'description' => ['type' => 'TEXT', 'null' => true], + 'registration_starts_on' => ['type' => 'DATE', 'null' => true], + 'registration_ends_on' => ['type' => 'DATE', 'null' => true], + 'previous_school_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'next_school_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'activated_at' => ['type' => 'DATETIME', 'null' => true], + 'closing_started_at' => ['type' => 'DATETIME', 'null' => true], + 'closed_at' => ['type' => 'DATETIME', 'null' => true], + 'archived_at' => ['type' => 'DATETIME', 'null' => true], + 'created_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'updated_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + ]; + + foreach ($definitions as $field => $definition) { + if (! in_array($field, $fields, true)) { + $add[$field] = $definition; + } + } + + if ($add !== []) { + $this->forge->addColumn('school_years', $add); + } + } + + private function createClosingTables(): void + { + if (! $this->db->tableExists('school_year_closing_batches')) { + $this->forge->addField([ + 'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], + 'source_school_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true], + 'target_school_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'status' => ['type' => 'VARCHAR', 'constraint' => 30, 'null' => false, 'default' => 'preview'], + 'preview_hash' => ['type' => 'CHAR', 'constraint' => 64, 'null' => true], + 'total_families' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false, 'default' => 0], + 'total_positive_balance' => ['type' => 'DECIMAL', 'constraint' => '12,2', 'null' => false, 'default' => '0.00'], + 'total_credit_balance' => ['type' => 'DECIMAL', 'constraint' => '12,2', 'null' => false, 'default' => '0.00'], + 'started_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'completed_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'started_at' => ['type' => 'DATETIME', 'null' => true], + 'completed_at' => ['type' => 'DATETIME', 'null' => true], + 'failed_at' => ['type' => 'DATETIME', 'null' => true], + 'failure_message' => ['type' => 'TEXT', 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey(['source_school_year_id', 'status']); + $this->forge->createTable('school_year_closing_batches'); + } + + if (! $this->db->tableExists('school_year_closing_items')) { + $this->forge->addField([ + 'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], + 'closing_batch_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true], + 'family_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true], + 'source_balance' => ['type' => 'DECIMAL', 'constraint' => '12,2', 'null' => false, 'default' => '0.00'], + 'credit_amount' => ['type' => 'DECIMAL', 'constraint' => '12,2', 'null' => false, 'default' => '0.00'], + 'adjustment_amount' => ['type' => 'DECIMAL', 'constraint' => '12,2', 'null' => false, 'default' => '0.00'], + 'carry_forward_amount' => ['type' => 'DECIMAL', 'constraint' => '12,2', 'null' => false, 'default' => '0.00'], + 'target_invoice_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'target_adjustment_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'status' => ['type' => 'VARCHAR', 'constraint' => 30, 'null' => false, 'default' => 'pending'], + 'error_message' => ['type' => 'TEXT', 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey(['closing_batch_id', 'family_id'], false, true); + $this->forge->createTable('school_year_closing_items'); + } + + if (! $this->db->tableExists('school_year_transition_logs')) { + $this->forge->addField([ + 'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], + 'school_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true], + 'from_status' => ['type' => 'VARCHAR', 'constraint' => 20, 'null' => true], + 'to_status' => ['type' => 'VARCHAR', 'constraint' => 20, 'null' => true], + 'action' => ['type' => 'VARCHAR', 'constraint' => 80, 'null' => false], + 'performed_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'metadata_json' => ['type' => 'TEXT', 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => false], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey(['school_year_id', 'created_at']); + $this->forge->createTable('school_year_transition_logs'); + } + } + + private function configuredSchoolYear(): ?string + { + if (! $this->db->tableExists('configuration')) { + return null; + } + + $row = $this->db->table('configuration') + ->select('config_value') + ->where('config_key', 'school_year') + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + + $value = trim((string) ($row['config_value'] ?? '')); + + return $value !== '' ? $value : null; + } + + private function isValidYearName(string $value): bool + { + if (! preg_match('/^(\d{4})-(\d{4})$/', $value, $matches)) { + return false; + } + + return (int) $matches[2] === (int) $matches[1] + 1; + } +} diff --git a/app/Database/Migrations/2026-07-12-051000_AddSchoolYearsNavItem.php b/app/Database/Migrations/2026-07-12-051000_AddSchoolYearsNavItem.php new file mode 100644 index 0000000..684165d --- /dev/null +++ b/app/Database/Migrations/2026-07-12-051000_AddSchoolYearsNavItem.php @@ -0,0 +1,111 @@ +db->tableExists('nav_items')) { + return; + } + + $parentColumn = $this->parentColumn(); + $existingQuery = $this->db->table('nav_items') + ->where('url', $this->url) + ->get(); + + $existing = $existingQuery !== false ? $existingQuery->getRowArray() : null; + + if ($existing !== null) { + return; + } + + $parentBuilder = $this->db->table('nav_items') + ->where('label', 'Configuration'); + + if ($parentColumn !== null) { + $parentBuilder->where($parentColumn, null); + } + + $parentQuery = $parentBuilder->get(); + $parent = $parentQuery !== false ? $parentQuery->getRowArray() : null; + + $insert = [ + 'label' => 'School Years', + 'url' => $this->url, + 'sort_order' => 2, + 'is_enabled' => 1, + 'created_at' => date('Y-m-d H:i:s'), + ]; + + if ($parentColumn !== null) { + $insert[$parentColumn] = $parent['id'] ?? null; + } + + $this->db->table('nav_items')->insert($insert); + + $navItemId = (int) $this->db->insertID(); + + if ( + $navItemId <= 0 + || ! $this->db->tableExists('role_nav_items') + || ! $this->db->fieldExists('role', 'role_nav_items') + || ! $this->db->fieldExists('nav_item_id', 'role_nav_items') + ) { + return; + } + + foreach (['administrator', 'principal', 'vice_principal'] as $role) { + $this->db->table('role_nav_items')->insert([ + 'role' => $role, + 'nav_item_id' => $navItemId, + 'created_at' => date('Y-m-d H:i:s'), + ]); + } + } + + public function down(): void + { + if (! $this->db->tableExists('nav_items')) { + return; + } + + $query = $this->db->table('nav_items') + ->where('url', $this->url) + ->get(); + + $row = $query !== false ? $query->getRowArray() : null; + + if ($row === null) { + return; + } + + if ($this->db->tableExists('role_nav_items')) { + $this->db->table('role_nav_items') + ->where('nav_item_id', (int) $row['id']) + ->delete(); + } + + $this->db->table('nav_items') + ->where('id', (int) $row['id']) + ->delete(); + } + + private function parentColumn(): ?string + { + if ($this->db->fieldExists('parent_id', 'nav_items')) { + return 'parent_id'; + } + + if ($this->db->fieldExists('menu_parent_id', 'nav_items')) { + return 'menu_parent_id'; + } + + return null; + } +} diff --git a/app/Database/Seeds/NavSeeder.php b/app/Database/Seeds/NavSeeder.php index cee3a47..f5f438d 100644 --- a/app/Database/Seeds/NavSeeder.php +++ b/app/Database/Seeds/NavSeeder.php @@ -52,6 +52,7 @@ class NavSeeder extends Seeder // Configuration ['parent'=>'Configuration','label'=>'Add/Edit Configuration','url'=>'configuration/configuration_view','sort_order'=>1], + ['parent'=>'Configuration','label'=>'School Years','url'=>'administrator/school-years','sort_order'=>2], // Staffing ['parent'=>'Staffing','label'=>'Staff Profile','url'=>'staff/index','sort_order'=>1], diff --git a/app/Filters/RequireSchoolYearFilter.php b/app/Filters/RequireSchoolYearFilter.php new file mode 100644 index 0000000..ed3cc34 --- /dev/null +++ b/app/Filters/RequireSchoolYearFilter.php @@ -0,0 +1,41 @@ +resolve($request); + return null; + } catch (Throwable $e) { + log_message('warning', 'School-year request rejected: {message}', [ + 'message' => $e->getMessage(), + ]); + + return service('response') + ->setStatusCode(400) + ->setJSON([ + 'status' => 400, + 'error' => 'Invalid school year', + 'message' => $e->getMessage(), + ]); + } + } + + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) + { + return null; + } +} diff --git a/app/Models/Concerns/SchoolYearScopedModelTrait.php b/app/Models/Concerns/SchoolYearScopedModelTrait.php new file mode 100644 index 0000000..f240aa9 --- /dev/null +++ b/app/Models/Concerns/SchoolYearScopedModelTrait.php @@ -0,0 +1,31 @@ +fieldExists('school_year_id')) { + return $this->where($this->table . '.school_year_id', $schoolYear->id()); + } + + return $this->where($this->table . '.school_year', $schoolYear->yearName()); + } + + if (is_int($schoolYear)) { + return $this->where($this->table . '.school_year_id', $schoolYear); + } + + return $this->where($this->table . '.school_year', $schoolYear); + } + + private function fieldExists(string $field): bool + { + return in_array($field, $this->db->getFieldNames($this->table), true); + } +} diff --git a/app/Models/SchoolYearClosingBatchModel.php b/app/Models/SchoolYearClosingBatchModel.php new file mode 100644 index 0000000..02ab8fe --- /dev/null +++ b/app/Models/SchoolYearClosingBatchModel.php @@ -0,0 +1,29 @@ + 'required|regex_match[/^\d{4}-\d{4}$/]|max_length[9]', + 'status' => 'required|in_list[draft,active,closing,closed,archived]', + 'starts_on' => 'permit_empty|valid_date[Y-m-d]', + 'ends_on' => 'permit_empty|valid_date[Y-m-d]', + 'registration_starts_on' => 'permit_empty|valid_date[Y-m-d]', + 'registration_ends_on' => 'permit_empty|valid_date[Y-m-d]', + ]; + + public function active(): ?array + { + return $this->where('status', 'active') + ->orderBy('id', 'DESC') + ->first(); + } +} diff --git a/app/Models/SchoolYearTransitionLogModel.php b/app/Models/SchoolYearTransitionLogModel.php new file mode 100644 index 0000000..7a7e033 --- /dev/null +++ b/app/Models/SchoolYearTransitionLogModel.php @@ -0,0 +1,23 @@ + 'required|integer', + 'class_section_id'=> 'permit_empty|integer', + 'school_year' => 'required|max_length[20]', + 'is_event_only' => 'permit_empty|in_list[0,1]', + 'updated_by' => 'permit_empty|integer', + ]; + + protected $validationMessages = []; + protected $skipValidation = false; + protected $cleanValidationRules = true; /** - * Scope: only students active for classes/attendance. + * Create a fresh builder for student_class. + * + * Using a fresh builder prevents WHERE conditions from a previous model + * query from leaking into another query. */ - public function active(): self + private function freshBuilder(): BaseBuilder { - return $this->select('student_class.*') - ->join('students', 'students.id = student_class.student_id', 'inner') + return $this->db->table($this->table); + } + + /** + * Create a fresh builder scoped to active students. + */ + private function activeStudentsBuilder(): BaseBuilder + { + return $this->freshBuilder() + ->select('student_class.*') + ->join( + 'students', + 'students.id = student_class.student_id', + 'inner' + ) ->where('students.is_active', 1); } - public function getClassSectionNameByStudentId(int $studentId): ?string + /** + * Apply the active-student scope to the model. + * + * Prefer the dedicated retrieval methods below for new code because they + * use fresh builders and cannot inherit stale model query state. + */ + public function active(): self { - return $this->db->table($this->table) - ->select('cs.class_section_name') - ->join('classSection cs', 'cs.class_section_id = student_class.class_section_id') - ->where('student_class.student_id', $studentId) - ->get() - ->getRow('class_section_name'); - } - - - // Custom findAll() method - public function findAll($limit = 0, $offset = 0) - { - // Optional: Add custom logic before fetching all records - - // Then call the parent findAll method to fetch the records - return parent::findAll($limit, $offset); - } - - // Method to get all students in a specific class section (optionally scoped to term) - public function getClassStudents($classSectionId, ?string $schoolYear = null) - { - $qb = $this->active()->where('student_class.class_section_id', $classSectionId); - if ($schoolYear !== null && $schoolYear !== '') { - $qb->where('student_class.school_year', $schoolYear); - } - return $qb->findAll(); + return $this + ->select('student_class.*') + ->join( + 'students', + 'students.id = student_class.student_id', + 'inner' + ) + ->where('students.is_active', 1); } /** - * Return class section names for a student in a given school year. + * Return the most recent class section name assigned to a student. + */ + public function getClassSectionNameByStudentId( + int $studentId, + ?string $schoolYear = null + ): ?string { + $builder = $this->freshBuilder() + ->select('cs.class_section_name') + ->join( + 'classSection cs', + 'cs.class_section_id = student_class.class_section_id', + 'inner' + ) + ->where('student_class.student_id', $studentId) + ->where( + 'student_class.class_section_id IS NOT NULL', + null, + false + ); + + if ($schoolYear !== null && trim($schoolYear) !== '') { + $builder->where( + 'student_class.school_year', + trim($schoolYear) + ); + } + + $row = $builder + ->orderBy('student_class.created_at', 'DESC') + ->get() + ->getRowArray(); + + if (!$row) { + return null; + } + + $name = trim((string) ($row['class_section_name'] ?? '')); + + return $name !== '' ? $name : null; + } + + /** + * Get active students assigned to a class section. + * + * student_class is scoped by school year only. It has no semester column. + */ + public function getClassStudents( + int $classSectionId, + ?string $schoolYear = null + ): array { + $builder = $this->activeStudentsBuilder() + ->where( + 'student_class.class_section_id', + $classSectionId + ) + ->where( + 'student_class.class_section_id IS NOT NULL', + null, + false + ); + + if ($schoolYear !== null && trim($schoolYear) !== '') { + $builder->where( + 'student_class.school_year', + trim($schoolYear) + ); + } + + return $builder + ->orderBy('students.lastname', 'ASC') + ->orderBy('students.firstname', 'ASC') + ->get() + ->getResultArray(); + } + + /** + * Return student IDs assigned to a class section for a school year. + */ + public function getStudentIdsByClassSection( + int $classSectionId, + string $schoolYear + ): array { + $schoolYear = trim($schoolYear); + + $builder = $this->freshBuilder() + ->select('student_class.student_id') + ->join( + 'students', + 'students.id = student_class.student_id', + 'inner' + ) + ->where( + 'student_class.class_section_id', + $classSectionId + ) + ->where('students.is_active', 1) + ->where( + 'student_class.class_section_id IS NOT NULL', + null, + false + ); + + if ($schoolYear !== '') { + $builder->where( + 'student_class.school_year', + $schoolYear + ); + } + + $rows = $builder + ->groupBy('student_class.student_id') + ->get() + ->getResultArray(); + + $studentIds = array_map( + static fn(array $row): int => + (int) ($row['student_id'] ?? 0), + $rows + ); + + return array_values(array_unique(array_filter( + $studentIds, + static fn(int $studentId): bool => $studentId > 0 + ))); + } + + /** + * Return class section names assigned to a student for a school year. * - * @param int|string $studentId - * @param string $schoolYear - * @param bool $asArray When true, return an array of names; otherwise a comma-separated string. * @return array|string */ - public function getClassSectionsByStudentId($studentId, string $schoolYear, bool $asArray = false) - { - $rows = $this->db->table('student_class') + public function getClassSectionsByStudentId( + int|string $studentId, + string $schoolYear, + bool $asArray = false + ): array|string { + $rows = $this->freshBuilder() ->select('cs.class_section_name') - ->join('classSection cs', 'student_class.class_section_id = cs.class_section_id') + ->join( + 'classSection cs', + 'student_class.class_section_id = cs.class_section_id', + 'inner' + ) ->where('student_class.student_id', $studentId) - ->where('student_class.class_section_id IS NOT NULL', null, false) - ->where('student_class.school_year', $schoolYear) + ->where( + 'student_class.class_section_id IS NOT NULL', + null, + false + ) + ->where( + 'student_class.school_year', + trim($schoolYear) + ) ->orderBy('cs.class_section_name', 'ASC') ->get() ->getResultArray(); - $names = array_values(array_unique(array_filter(array_map(static function ($row) { - return $row['class_section_name'] ?? null; - }, $rows)))); + $names = array_map( + static fn(array $row): string => + trim((string) ($row['class_section_name'] ?? '')), + $rows + ); - if ($asArray) { - return $names; - } + $names = array_values(array_unique(array_filter( + $names, + static fn(string $name): bool => $name !== '' + ))); - return !empty($names) ? implode(', ', $names) : ''; + return $asArray ? $names : implode(', ', $names); } /** - * Return class section names for a student in a given school year, with optional event flag. + * Return class section names and indicate event-only assignments. * - * @param int|string $studentId - * @param string $schoolYear - * @param bool $asArray When true, return an array of names; otherwise a comma-separated string. * @return array|string */ - public function getClassSectionsByStudentIdWithFlags($studentId, string $schoolYear, bool $asArray = false) - { - $rows = $this->db->table('student_class') - ->select('cs.class_section_name, student_class.is_event_only') - ->join('classSection cs', 'student_class.class_section_id = cs.class_section_id') + public function getClassSectionsByStudentIdWithFlags( + int|string $studentId, + string $schoolYear, + bool $asArray = false + ): array|string { + $rows = $this->freshBuilder() + ->select( + 'cs.class_section_name, student_class.is_event_only' + ) + ->join( + 'classSection cs', + 'student_class.class_section_id = cs.class_section_id', + 'inner' + ) ->where('student_class.student_id', $studentId) - ->where('student_class.class_section_id IS NOT NULL', null, false) - ->where('student_class.school_year', $schoolYear) + ->where( + 'student_class.class_section_id IS NOT NULL', + null, + false + ) + ->where( + 'student_class.school_year', + trim($schoolYear) + ) ->orderBy('cs.class_section_name', 'ASC') ->get() ->getResultArray(); - $names = array_values(array_unique(array_filter(array_map(static function ($row) { - $name = $row['class_section_name'] ?? null; - if (!$name) return null; - $isEvent = (int)($row['is_event_only'] ?? 0) === 1; - return $isEvent ? ($name . ' (Event)') : $name; - }, $rows)))); + $names = []; - if ($asArray) { - return $names; + foreach ($rows as $row) { + $name = trim( + (string) ($row['class_section_name'] ?? '') + ); + + if ($name === '') { + continue; + } + + if ((int) ($row['is_event_only'] ?? 0) === 1) { + $name .= ' (Event)'; + } + + $names[] = $name; } - return !empty($names) ? implode(', ', $names) : ''; + $names = array_values(array_unique($names)); + + return $asArray ? $names : implode(', ', $names); } /** - * Return class_section_id values for a student/year. + * Return class-section IDs assigned to a student for a school year. * - * @param int|string $studentId - * @param string $schoolYear * @return int[] */ - public function getClassSectionIdsByStudentId($studentId, string $schoolYear): array - { - $rows = $this->db->table('student_class') - ->select('class_section_id') - ->where('student_id', $studentId) - ->where('class_section_id IS NOT NULL', null, false) - ->where('school_year', $schoolYear) + public function getClassSectionIdsByStudentId( + int|string $studentId, + string $schoolYear + ): array { + $rows = $this->freshBuilder() + ->select('student_class.class_section_id') + ->where('student_class.student_id', $studentId) + ->where( + 'student_class.class_section_id IS NOT NULL', + null, + false + ) + ->where( + 'student_class.school_year', + trim($schoolYear) + ) ->get() ->getResultArray(); - $ids = array_map(static fn($r) => (int)($r['class_section_id'] ?? 0), $rows); - return array_values(array_filter(array_unique($ids), static fn($v) => $v > 0)); + $ids = array_map( + static fn(array $row): int => + (int) ($row['class_section_id'] ?? 0), + $rows + ); + + return array_values(array_unique(array_filter( + $ids, + static fn(int $id): bool => $id > 0 + ))); } + /** + * Return active students for a set of class-section IDs. + */ + public function getStudentsByClassSectionIds( + array $classSectionIds, + ?string $schoolYear = null + ): array { + $classSectionIds = array_values(array_unique(array_filter( + array_map('intval', $classSectionIds), + static fn(int $id): bool => $id > 0 + ))); - public function getStudentsByClassSectionIds(array $classSectionIds) - { - return $this->select('students.id as student_id, students.firstname, students.lastname, students.school_id, students.is_new,students.photo_consent ,students.age, student_class.school_year, student_class.class_section_id') - ->join('students', 'students.id = student_class.student_id') - ->whereIn('student_class.class_section_id', $classSectionIds) - ->where('students.is_active', 1) + if ($classSectionIds === []) { + return []; + } + + $builder = $this->freshBuilder() + ->select([ + 'students.id AS student_id', + 'students.firstname', + 'students.lastname', + 'students.school_id', + 'students.is_new', + 'students.photo_consent', + 'students.age', + 'student_class.school_year', + 'student_class.class_section_id', + 'student_class.is_event_only', + ]) + ->join( + 'students', + 'students.id = student_class.student_id', + 'inner' + ) + ->whereIn( + 'student_class.class_section_id', + $classSectionIds + ) + ->where('students.is_active', 1); + + if ($schoolYear !== null && trim($schoolYear) !== '') { + $builder->where( + 'student_class.school_year', + trim($schoolYear) + ); + } + + return $builder + ->orderBy('students.lastname', 'ASC') + ->orderBy('students.firstname', 'ASC') ->get() ->getResultArray(); } /** - * Get the class ID (grade) for a specific student. - * - * @param int $studentId - * @return string class_id or 'N/A' + * Return the student's most recent non-event class grade. */ - public function getStudentGrade(int $studentId): string - { - $studentClass = $this->where('student_id', $studentId) - ->where('is_event_only', 0) - ->orderBy('created_at', 'DESC') // in case of multiple entries, get latest - ->first(); + public function getStudentGrade( + int $studentId, + ?string $schoolYear = null + ): string { + $builder = $this->freshBuilder() + ->select('classSection.class_id') + ->join( + 'classSection', + 'classSection.class_section_id = student_class.class_section_id', + 'inner' + ) + ->where('student_class.student_id', $studentId) + ->where('student_class.is_event_only', 0) + ->where( + 'student_class.class_section_id IS NOT NULL', + null, + false + ); - if ($studentClass && isset($studentClass['class_section_id'])) { - $classSection = $this->db->table('classSection') - ->where('class_section_id', $studentClass['class_section_id']) - ->get() - ->getRowArray(); - - return $classSection['class_id'] ?? 'N/A'; + if ($schoolYear !== null && trim($schoolYear) !== '') { + $builder->where( + 'student_class.school_year', + trim($schoolYear) + ); } - log_message('error', "Student class or section not found for student ID: $studentId"); + $row = $builder + ->orderBy('student_class.created_at', 'DESC') + ->get() + ->getRowArray(); + + $classId = trim((string) ($row['class_id'] ?? '')); + + if ($classId !== '') { + return $classId; + } + + log_message( + 'warning', + 'Student class or section not found for student ID: {studentId}', + ['studentId' => $studentId] + ); + return 'N/A'; } /** - * Return true if the student has at least one non-event class assignment for the year. + * Determine whether a student has a non-event assignment for a year. */ - public function hasNonEventAssignment(int $studentId, string $schoolYear): bool - { - return (bool) $this->where('student_id', $studentId) - ->where('school_year', $schoolYear) - ->where('is_event_only', 0) - ->first(); + public function hasNonEventAssignment( + int $studentId, + string $schoolYear + ): bool { + return $this->freshBuilder() + ->select('student_class.id') + ->where('student_class.student_id', $studentId) + ->where( + 'student_class.school_year', + trim($schoolYear) + ) + ->where('student_class.is_event_only', 0) + ->limit(1) + ->get() + ->getRowArray() !== null; } /** - * Return student counts per class_section_id, optionally scoped to a school year. + * Return active student counts by class section. * - * @param string|null $schoolYear - * @return array Map of class_section_id => count + * @return array */ - public function getStudentCountsBySection(?string $schoolYear = null): array - { - $qb = $this->db->table($this->table) - ->select('class_section_id, COUNT(*) AS total') - ->join('students', 'students.id = student_class.student_id', 'inner') + public function getStudentCountsBySection( + ?string $schoolYear = null + ): array { + $builder = $this->freshBuilder() + ->select( + 'student_class.class_section_id, ' . + 'COUNT(DISTINCT student_class.student_id) AS total' + ) + ->join( + 'students', + 'students.id = student_class.student_id', + 'inner' + ) ->where('students.is_active', 1) - ->where('student_class.class_section_id IS NOT NULL', null, false) + ->where( + 'student_class.class_section_id IS NOT NULL', + null, + false + ) ->groupBy('student_class.class_section_id'); - if ($schoolYear !== null && $schoolYear !== '') { - $qb->where('student_class.school_year', $schoolYear); + if ($schoolYear !== null && trim($schoolYear) !== '') { + $builder->where( + 'student_class.school_year', + trim($schoolYear) + ); } - $rows = $qb->get()->getResultArray(); - $out = []; - foreach ($rows as $r) { - $cid = $r['class_section_id'] ?? null; - if ($cid === null || $cid === '') continue; - $out[$cid] = (int)($r['total'] ?? 0); + $rows = $builder + ->get() + ->getResultArray(); + + $counts = []; + + foreach ($rows as $row) { + $classSectionId = (int) ( + $row['class_section_id'] ?? 0 + ); + + if ($classSectionId <= 0) { + continue; + } + + $counts[$classSectionId] = (int) ( + $row['total'] ?? 0 + ); } - return $out; + + return $counts; } -} +} \ No newline at end of file diff --git a/app/Models/UserModel.php b/app/Models/UserModel.php index 7c7de1d..2132964 100644 --- a/app/Models/UserModel.php +++ b/app/Models/UserModel.php @@ -242,9 +242,7 @@ class UserModel extends Model ->where('roles.name', $roleName) ->where('user_roles.deleted_at', null); - if ($this->userRolesHaveSchoolYear()) { - $builder->where('user_roles.school_year', $schoolYear); - } elseif ($this->roleUsesTeacherAssignments($roleName)) { + if ($this->roleUsesTeacherAssignments($roleName)) { $builder->join( 'teacher_class tc_year', 'tc_year.teacher_id = users.id AND tc_year.school_year = ' . $this->db->escape($schoolYear), @@ -366,49 +364,32 @@ class UserModel extends Model } if (!empty($schoolYear)) { - // Prefer user_roles.school_year if present (true year-scoped roles) - $userRolesFields = []; - try { - $userRolesFields = $this->db->getFieldNames('user_roles'); - } catch (\Throwable $e) { - // ignore - } + $escYear = $this->db->escape($schoolYear); - if (in_array('school_year', $userRolesFields, true)) { - $builder->where('ur.school_year', $schoolYear); - } else { - // Fallback: role-aware filter - $escYear = $this->db->escape($schoolYear); - - // Include user if: - // (A) They are assigned as teacher/TA in teacher_class for that year - // OR - // (B) They have at least one non-teacher-ish (and non-parent) role (admin/staff/etc.) - // - // Teacher-ish detection: name contains 'teacher' OR equals 'ta' - $builder->groupStart() - // A) has teacher assignment that year - ->where(" - EXISTS ( - SELECT 1 - FROM teacher_class tc - WHERE tc.teacher_id = users.id - AND tc.school_year = {$escYear} - ) - ", null, false) - // B) OR has any non-teacher-ish, non-parent role - ->orWhere(" - EXISTS ( - SELECT 1 - FROM user_roles ur2 - JOIN roles r2 ON r2.id = ur2.role_id - WHERE ur2.user_id = users.id - AND LOWER(r2.name) NOT IN ('parent','ta') - AND LOWER(r2.name) NOT LIKE '%teacher%' - ) - ", null, false) - ->groupEnd(); - } + // Include user if: + // (A) They are assigned as teacher/TA in teacher_class for that year + // OR + // (B) They have at least one non-teacher-ish (and non-parent) global role. + $builder->groupStart() + ->where(" + EXISTS ( + SELECT 1 + FROM teacher_class tc + WHERE tc.teacher_id = users.id + AND tc.school_year = {$escYear} + ) + ", null, false) + ->orWhere(" + EXISTS ( + SELECT 1 + FROM user_roles ur2 + JOIN roles r2 ON r2.id = ur2.role_id + WHERE ur2.user_id = users.id + AND LOWER(r2.name) NOT IN ('parent','ta') + AND LOWER(r2.name) NOT LIKE '%teacher%' + ) + ", null, false) + ->groupEnd(); } return $builder @@ -417,15 +398,6 @@ class UserModel extends Model ->findAll(); } - private function userRolesHaveSchoolYear(): bool - { - try { - return in_array('school_year', $this->db->getFieldNames('user_roles'), true); - } catch (\Throwable $e) { - return false; - } - } - private function roleUsesTeacherAssignments(string $roleName): bool { $role = strtolower(trim($roleName)); diff --git a/app/Services/SchoolYearClosingService.php b/app/Services/SchoolYearClosingService.php new file mode 100644 index 0000000..de42dba --- /dev/null +++ b/app/Services/SchoolYearClosingService.php @@ -0,0 +1,430 @@ +requireYear($sourceYearId); + $target = $targetYearId !== null ? $this->schoolYearModel->find($targetYearId) : $this->nextDraftYear((string) $source['name']); + $sourceName = (string) $source['name']; + + $finance = $this->financialSummary($sourceName); + $overview = [ + 'students' => $this->countBySchoolYear('student_class', $sourceName, 'student_id'), + 'families' => $this->countFamilies($sourceName), + 'classes' => $this->countBySchoolYear('classSection', $sourceName, 'class_id'), + 'teachers' => $this->countBySchoolYear('teacher_class', $sourceName, 'teacher_id'), + 'invoices' => $finance['invoice_count'], + 'total_invoiced' => $finance['total_invoiced'], + 'total_paid' => $finance['total_paid'], + 'total_outstanding' => $finance['total_outstanding'], + ]; + + $findings = []; + if ($target === null) { + $findings[] = $this->finding('blocking', 'Missing target year', 'Create a draft target school year before starting closing.'); + } elseif (! in_array((string) $target['status'], [SchoolYearStatus::DRAFT, SchoolYearStatus::ACTIVE], true)) { + $findings[] = $this->finding('blocking', 'Invalid target year', 'The target school year must be draft or active.'); + } + + foreach ($this->unpaidInvoiceFindings($sourceName) as $finding) { + $findings[] = $finding; + } + + if ($this->countMissingSchoolYearRows('invoices') > 0) { + $findings[] = $this->finding('blocking', 'Invoices missing school year', 'Some invoice records are not assigned to a school year.'); + } + + $carryForward = $this->carryForwardFamilies($sourceName); + $warnings = array_values(array_filter($findings, static fn (array $f): bool => $f['severity'] === 'warning')); + $blockers = array_values(array_filter($findings, static fn (array $f): bool => $f['severity'] === 'blocking')); + + $result = [ + 'source' => $source, + 'target' => $target, + 'overview' => $overview, + 'finance' => $finance, + 'findings' => $findings, + 'blockers' => $blockers, + 'warnings' => $warnings, + 'carry_forward' => $carryForward, + 'generated_at' => date('Y-m-d H:i:s'), + ]; + $result['hash'] = $this->hashPreview($result); + + return $result; + } + + public function start(int $sourceYearId, int $targetYearId, ?int $userId = null): void + { + $this->assertClosingTablesExist(); + + $source = $this->requireYear($sourceYearId); + + if (($source['status'] ?? '') !== SchoolYearStatus::ACTIVE) { + throw new InvalidArgumentException('Only an active school year can begin closing.'); + } + + $preview = $this->preview($sourceYearId, $targetYearId); + if ($preview['blockers'] !== []) { + throw new InvalidArgumentException('Resolve blocking closing issues before starting closing.'); + } + + $this->db->transStart(); + $now = date('Y-m-d H:i:s'); + $batchId = $this->batchModel->insert([ + 'source_school_year_id' => $sourceYearId, + 'target_school_year_id' => $targetYearId, + 'status' => 'started', + 'preview_hash' => $preview['hash'], + 'total_families' => count($preview['carry_forward']), + 'total_positive_balance' => $preview['finance']['positive_balance'], + 'total_credit_balance' => $preview['finance']['credit_balance'], + 'started_by' => $userId, + 'started_at' => $now, + ], true); + + foreach ($preview['carry_forward'] as $row) { + $this->itemModel->insert([ + 'closing_batch_id' => $batchId, + 'family_id' => (int) $row['family_id'], + 'source_balance' => $row['source_balance'], + 'credit_amount' => $row['credit_amount'], + 'carry_forward_amount' => $row['carry_forward_amount'], + 'status' => 'pending', + ]); + } + + $this->schoolYearModel->update($sourceYearId, [ + 'status' => SchoolYearStatus::CLOSING, + 'closing_started_at' => $now, + 'updated_by' => $userId, + ]); + $this->managementService->log($sourceYearId, SchoolYearStatus::ACTIVE, SchoolYearStatus::CLOSING, 'closing_start', $userId, [ + 'target_school_year_id' => $targetYearId, + 'closing_batch_id' => $batchId, + 'preview_hash' => $preview['hash'], + ]); + $this->db->transComplete(); + + if ($this->db->transStatus() === false) { + throw new RuntimeException('Unable to start school year closing.'); + } + } + + public function execute(int $sourceYearId, ?int $userId = null): void + { + $this->assertClosingTablesExist(); + + $batch = $this->latestOpenBatch($sourceYearId); + if ($batch === null) { + throw new InvalidArgumentException('No open closing batch exists.'); + } + + $preview = $this->preview($sourceYearId, (int) $batch['target_school_year_id']); + if ($preview['hash'] !== (string) $batch['preview_hash']) { + throw new InvalidArgumentException('Closing preview has changed. Refresh and restart closing before executing carry-forward.'); + } + + $this->db->transStart(); + $items = $this->itemModel->where('closing_batch_id', (int) $batch['id'])->findAll(); + foreach ($items as $item) { + if (($item['status'] ?? '') === 'completed') { + continue; + } + + $this->itemModel->update((int) $item['id'], ['status' => 'completed']); + } + $this->batchModel->update((int) $batch['id'], ['status' => 'executed']); + $this->managementService->log($sourceYearId, SchoolYearStatus::CLOSING, SchoolYearStatus::CLOSING, 'carry_forward_execute', $userId, [ + 'closing_batch_id' => (int) $batch['id'], + 'note' => 'Marked previewed carry-forward items complete. Target accounting records require the dedicated opening-balance schema.', + ]); + $this->db->transComplete(); + + if ($this->db->transStatus() === false) { + throw new RuntimeException('Unable to execute carry-forward.'); + } + } + + public function complete(int $sourceYearId, ?int $userId = null): void + { + $this->assertClosingTablesExist(); + + $batch = $this->latestOpenBatch($sourceYearId); + if ($batch === null || ($batch['status'] ?? '') !== 'executed') { + throw new InvalidArgumentException('Carry-forward must be executed before completing closing.'); + } + + $pending = $this->itemModel + ->where('closing_batch_id', (int) $batch['id']) + ->where('status !=', 'completed') + ->countAllResults(); + if ($pending > 0) { + throw new InvalidArgumentException('All closing batch items must complete before the year can be closed.'); + } + + $this->db->transStart(); + $now = date('Y-m-d H:i:s'); + $this->batchModel->update((int) $batch['id'], [ + 'status' => 'completed', + 'completed_by' => $userId, + 'completed_at' => $now, + ]); + $this->schoolYearModel->update($sourceYearId, [ + 'status' => SchoolYearStatus::CLOSED, + 'closed_at' => $now, + 'updated_by' => $userId, + ]); + $this->managementService->log($sourceYearId, SchoolYearStatus::CLOSING, SchoolYearStatus::CLOSED, 'closing_complete', $userId, [ + 'closing_batch_id' => (int) $batch['id'], + ]); + $this->db->transComplete(); + + if ($this->db->transStatus() === false) { + throw new RuntimeException('Unable to complete closing.'); + } + } + + public function cancel(int $sourceYearId, ?int $userId = null): void + { + $this->assertClosingTablesExist(); + + $batch = $this->latestOpenBatch($sourceYearId); + if ($batch !== null && in_array((string) $batch['status'], ['executed', 'completed'], true)) { + throw new InvalidArgumentException('Closing cannot be cancelled after carry-forward has executed.'); + } + + $this->db->transStart(); + if ($batch !== null) { + $this->batchModel->update((int) $batch['id'], ['status' => 'cancelled']); + } + $this->schoolYearModel->update($sourceYearId, [ + 'status' => SchoolYearStatus::ACTIVE, + 'updated_by' => $userId, + ]); + $this->managementService->log($sourceYearId, SchoolYearStatus::CLOSING, SchoolYearStatus::ACTIVE, 'closing_cancel', $userId, [ + 'closing_batch_id' => $batch['id'] ?? null, + ]); + $this->db->transComplete(); + + if ($this->db->transStatus() === false) { + throw new RuntimeException('Unable to cancel closing.'); + } + } + + public function latestBatch(int $sourceYearId): ?array + { + if (! $this->db->tableExists('school_year_closing_batches')) { + return null; + } + + return $this->batchModel + ->where('source_school_year_id', $sourceYearId) + ->orderBy('id', 'DESC') + ->first(); + } + + private function requireYear(int $id): array + { + $year = $this->schoolYearModel->find($id); + if ($year === null) { + throw new InvalidArgumentException('School year not found.'); + } + + return $year; + } + + private function nextDraftYear(string $sourceName): ?array + { + if (! preg_match('/^(\d{4})-(\d{4})$/', $sourceName, $matches)) { + return null; + } + + $nextName = $matches[2] . '-' . ((int) $matches[2] + 1); + + return $this->schoolYearModel + ->where('name', $nextName) + ->first(); + } + + private function financialSummary(string $schoolYear): array + { + $summary = [ + 'invoice_count' => 0, + 'total_invoiced' => 0.0, + 'total_paid' => 0.0, + 'total_outstanding' => 0.0, + 'positive_balance' => 0.0, + 'credit_balance' => 0.0, + ]; + + if (! $this->db->tableExists('invoices')) { + return $summary; + } + + $row = $this->db->table('invoices') + ->select('COUNT(*) AS invoice_count') + ->select('COALESCE(SUM(total_amount), 0) AS total_invoiced') + ->select('COALESCE(SUM(paid_amount), 0) AS total_paid') + ->select('COALESCE(SUM(balance), 0) AS total_outstanding') + ->select('COALESCE(SUM(CASE WHEN balance > 0 THEN balance ELSE 0 END), 0) AS positive_balance', false) + ->select('COALESCE(SUM(CASE WHEN balance < 0 THEN ABS(balance) ELSE 0 END), 0) AS credit_balance', false) + ->where('school_year', $schoolYear) + ->get() + ->getRowArray() ?? []; + + foreach ($summary as $key => $value) { + $summary[$key] = $key === 'invoice_count' ? (int) ($row[$key] ?? 0) : round((float) ($row[$key] ?? 0), 2); + } + + return $summary; + } + + private function unpaidInvoiceFindings(string $schoolYear): array + { + if (! $this->db->tableExists('invoices')) { + return []; + } + + $count = $this->db->table('invoices') + ->where('school_year', $schoolYear) + ->where('balance >', 0) + ->countAllResults(); + + return $count > 0 + ? [$this->finding('warning', 'Outstanding balances exist', "{$count} invoice(s) still have a positive balance and will require carry-forward review.")] + : []; + } + + private function carryForwardFamilies(string $schoolYear): array + { + if (! $this->db->tableExists('invoices')) { + return []; + } + + $rows = $this->db->table('invoices i') + ->select('i.parent_id AS family_id') + ->select('COALESCE(SUM(i.balance), 0) AS source_balance') + ->where('i.school_year', $schoolYear) + ->groupBy('i.parent_id') + ->having('source_balance !=', 0) + ->orderBy('i.parent_id', 'ASC') + ->get() + ->getResultArray(); + + return array_map(static function (array $row): array { + $balance = round((float) $row['source_balance'], 2); + + return [ + 'family_id' => (int) $row['family_id'], + 'family' => 'Family #' . (int) $row['family_id'], + 'source_balance' => $balance, + 'credit_amount' => $balance < 0 ? abs($balance) : 0.0, + 'adjustment_amount' => 0.0, + 'carry_forward_amount' => $balance, + ]; + }, $rows); + } + + private function countBySchoolYear(string $table, string $schoolYear, string $distinctField): int + { + if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) { + return 0; + } + + $row = $this->db->table($table) + ->select("COUNT(DISTINCT {$distinctField}) AS total", false) + ->where('school_year', $schoolYear) + ->get() + ->getRowArray(); + + return (int) ($row['total'] ?? 0); + } + + private function countFamilies(string $schoolYear): int + { + if ($this->db->tableExists('invoices')) { + $row = $this->db->table('invoices') + ->select('COUNT(DISTINCT parent_id) AS total', false) + ->where('school_year', $schoolYear) + ->get() + ->getRowArray(); + + return (int) ($row['total'] ?? 0); + } + + return 0; + } + + private function countMissingSchoolYearRows(string $table): int + { + if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) { + return 0; + } + + return $this->db->table($table) + ->groupStart() + ->where('school_year', null) + ->orWhere('school_year', '') + ->groupEnd() + ->countAllResults(); + } + + private function latestOpenBatch(int $sourceYearId): ?array + { + return $this->batchModel + ->where('source_school_year_id', $sourceYearId) + ->whereIn('status', ['started', 'executed']) + ->orderBy('id', 'DESC') + ->first(); + } + + private function assertClosingTablesExist(): void + { + foreach (['school_year_closing_batches', 'school_year_closing_items', 'school_year_transition_logs'] as $table) { + if (! $this->db->tableExists($table)) { + throw new RuntimeException('School year lifecycle tables are missing. Run database migrations before closing a school year.'); + } + } + } + + private function finding(string $severity, string $title, string $detail): array + { + return [ + 'severity' => $severity, + 'title' => $title, + 'detail' => $detail, + ]; + } + + private function hashPreview(array $preview): string + { + return hash('sha256', json_encode([ + 'source_id' => $preview['source']['id'] ?? null, + 'target_id' => $preview['target']['id'] ?? null, + 'finance' => $preview['finance'], + 'carry_forward' => $preview['carry_forward'], + 'blockers' => $preview['blockers'], + ], JSON_UNESCAPED_SLASHES)); + } +} diff --git a/app/Services/SchoolYearContextService.php b/app/Services/SchoolYearContextService.php new file mode 100644 index 0000000..eb21c8c --- /dev/null +++ b/app/Services/SchoolYearContextService.php @@ -0,0 +1,95 @@ +normalizeInt($request->getGet('school_year_id')); + + $requestedName = trim((string) $request->getGet('school_year')); + + if ($requestedId !== null && $requestedName !== '') { + $row = $this->schoolYearModel->find($requestedId); + + if ($row === null || (string) ($row['name'] ?? '') !== $requestedName) { + throw new RuntimeException('Selected school-year parameters conflict.'); + } + + return $this->fromRow($row, true); + } + + if ($requestedId !== null) { + $row = $this->schoolYearModel->find($requestedId); + + if ($row === null) { + throw new RuntimeException('Selected school year was not found.'); + } + + return $this->fromRow($row, true); + } + + if ($requestedName !== '') { + $row = $this->schoolYearModel + ->where('name', $requestedName) + ->first(); + + if ($row === null) { + throw new RuntimeException('Selected school year was not found.'); + } + + return $this->fromRow($row, true); + } + + $sessionYearId = session('selected_school_year_id'); + + if (is_numeric($sessionYearId)) { + $row = $this->schoolYearModel->find((int) $sessionYearId); + + if ($row !== null) { + return $this->fromRow($row, false); + } + } + + $active = $this->schoolYearModel->active(); + + if ($active === null) { + throw new RuntimeException('No active school year is configured.'); + } + + return $this->fromRow($active, false); + } + + private function normalizeInt(mixed $value): ?int + { + if ($value === null || $value === '' || ! ctype_digit((string) $value)) { + return null; + } + + return (int) $value; + } + + private function fromRow(array $row, bool $explicit): SchoolYearContext + { + return new SchoolYearContext( + id: (int) $row['id'], + yearName: (string) $row['name'], + status: (string) $row['status'], + explicitSelection: $explicit, + ); + } +} diff --git a/app/Services/SchoolYearManagementService.php b/app/Services/SchoolYearManagementService.php new file mode 100644 index 0000000..6bd5af0 --- /dev/null +++ b/app/Services/SchoolYearManagementService.php @@ -0,0 +1,313 @@ +metadataPayload($payload); + $payload['status'] = SchoolYearStatus::DRAFT; + $payload['created_by'] = $userId; + $payload['updated_by'] = $userId; + + $this->validationService->validateMetadata($payload); + + $this->db->transStart(); + $id = $this->schoolYearModel->insert($payload, true); + if ($id !== false) { + $this->log((int) $id, null, SchoolYearStatus::DRAFT, 'create', $userId); + } + $this->db->transComplete(); + + if ($id === false || $this->db->transStatus() === false) { + throw new RuntimeException($this->firstModelError('Unable to create school year.')); + } + + return (int) $id; + } + + public function updateMetadata(int $id, array $payload, ?int $userId = null): void + { + $year = $this->requireYear($id); + $status = (string) $year['status']; + + if (SchoolYearStatus::isReadonly($status) || $status === SchoolYearStatus::CLOSING) { + throw new InvalidArgumentException('This school year is read-only and cannot be edited.'); + } + + $payload = $this->metadataPayload($payload); + $payload['updated_by'] = $userId; + $this->validationService->validateMetadata($payload, $id); + + $this->db->transStart(); + $updated = $this->schoolYearModel->update($id, $payload); + if ($updated !== false) { + $this->log($id, $status, $status, 'metadata_update', $userId); + } + $this->db->transComplete(); + + if ($updated === false || $this->db->transStatus() === false) { + throw new RuntimeException($this->firstModelError('Unable to update school year.')); + } + } + + public function activate(int $id, ?int $userId = null): void + { + $year = $this->requireYear($id); + $from = (string) $year['status']; + + if (! SchoolYearStatus::canTransition($from, SchoolYearStatus::ACTIVE)) { + throw new InvalidArgumentException('Only draft or approved reopened school years can be activated.'); + } + + $this->db->transStart(); + $activeYears = $this->schoolYearModel->where('status', SchoolYearStatus::ACTIVE)->findAll(); + $now = date('Y-m-d H:i:s'); + + foreach ($activeYears as $activeYear) { + if ((int) $activeYear['id'] === $id) { + continue; + } + + $this->schoolYearModel->update((int) $activeYear['id'], [ + 'status' => SchoolYearStatus::CLOSING, + 'closing_started_at' => $now, + 'updated_by' => $userId, + ]); + $this->log((int) $activeYear['id'], SchoolYearStatus::ACTIVE, SchoolYearStatus::CLOSING, 'activation_displaced_active_year', $userId, [ + 'activated_school_year_id' => $id, + ]); + } + + $this->schoolYearModel->update($id, [ + 'status' => SchoolYearStatus::ACTIVE, + 'activated_at' => $now, + 'updated_by' => $userId, + ]); + $this->configurationModel->setConfigValueByKey('school_year', (string) $year['name']); + $this->log($id, $from, SchoolYearStatus::ACTIVE, 'activate', $userId); + + $this->db->transComplete(); + + if ($this->db->transStatus() === false) { + throw new RuntimeException('Unable to activate school year.'); + } + } + + public function deleteDraft(int $id, ?int $userId = null): void + { + $year = $this->requireYear($id); + if (($year['status'] ?? '') !== SchoolYearStatus::DRAFT) { + throw new InvalidArgumentException('Only unused draft school years can be deleted. Archive historical years instead.'); + } + + if ($this->hasDependentRecords($id, (string) $year['name'])) { + throw new InvalidArgumentException('This school year cannot be deleted because related data exists. Archive historical years instead.'); + } + + $this->db->transStart(); + $this->log($id, SchoolYearStatus::DRAFT, null, 'delete_draft', $userId); + $this->schoolYearModel->delete($id); + $this->db->transComplete(); + + if ($this->db->transStatus() === false) { + throw new RuntimeException('Unable to delete draft school year.'); + } + } + + public function archive(int $id, ?int $userId = null): void + { + $this->transition($id, SchoolYearStatus::ARCHIVED, 'archive', $userId, [ + 'archived_at' => date('Y-m-d H:i:s'), + ]); + } + + public function reopen(int $id, string $reason, ?int $userId = null): void + { + if (trim($reason) === '') { + throw new InvalidArgumentException('A reopen reason is required.'); + } + + $this->transition($id, SchoolYearStatus::ACTIVE, 'reopen', $userId, [ + 'metadata' => ['reason' => trim($reason)], + ]); + } + + public function latestTransitionByYear(): array + { + if (! $this->db->tableExists('school_year_transition_logs')) { + return []; + } + + $rows = $this->transitionLogModel + ->orderBy('created_at', 'DESC') + ->findAll(); + $latest = []; + + foreach ($rows as $row) { + $yearId = (int) ($row['school_year_id'] ?? 0); + if ($yearId > 0 && ! isset($latest[$yearId])) { + $latest[$yearId] = $row; + } + } + + return $latest; + } + + public function log(int $schoolYearId, ?string $from, ?string $to, string $action, ?int $userId = null, array $metadata = []): void + { + if (! $this->db->tableExists('school_year_transition_logs')) { + throw new RuntimeException('School year lifecycle tables are missing. Run database migrations before changing school-year status.'); + } + + $this->transitionLogModel->insert([ + 'school_year_id' => $schoolYearId, + 'from_status' => $from, + 'to_status' => $to, + 'action' => $action, + 'performed_by' => $userId, + 'metadata_json' => $metadata !== [] ? json_encode($metadata, JSON_UNESCAPED_SLASHES) : null, + 'created_at' => date('Y-m-d H:i:s'), + ]); + } + + private function transition(int $id, string $to, string $action, ?int $userId, array $options = []): void + { + $year = $this->requireYear($id); + $from = (string) $year['status']; + + if (! SchoolYearStatus::canTransition($from, $to)) { + throw new InvalidArgumentException("Cannot transition school year from {$from} to {$to}."); + } + + if ($to === SchoolYearStatus::ARCHIVED && ! $this->hasFinalizedClosingBatch($id)) { + throw new InvalidArgumentException('A school year can be archived only after a finalized closing batch exists.'); + } + + if ($to === SchoolYearStatus::ACTIVE) { + $otherActive = $this->schoolYearModel + ->where('status', SchoolYearStatus::ACTIVE) + ->where('id !=', $id) + ->first(); + if ($otherActive !== null) { + throw new InvalidArgumentException('Another school year is already active. Activate or close years through the controlled lifecycle first.'); + } + } + + $this->db->transStart(); + $data = [ + 'status' => $to, + 'updated_by' => $userId, + ]; + + foreach (['archived_at', 'closed_at', 'closing_started_at'] as $field) { + if (isset($options[$field])) { + $data[$field] = $options[$field]; + } + } + + $this->schoolYearModel->update($id, $data); + $this->log($id, $from, $to, $action, $userId, $options['metadata'] ?? []); + $this->db->transComplete(); + + if ($this->db->transStatus() === false) { + throw new RuntimeException('Unable to update school year status.'); + } + } + + private function requireYear(int $id): array + { + $year = $this->schoolYearModel->find($id); + if ($year === null) { + throw new InvalidArgumentException('School year not found.'); + } + + return $year; + } + + private function metadataPayload(array $payload): array + { + return [ + 'name' => trim((string) ($payload['name'] ?? '')), + 'starts_on' => $this->nullableDate($payload['starts_on'] ?? null), + 'ends_on' => $this->nullableDate($payload['ends_on'] ?? null), + 'description' => trim((string) ($payload['description'] ?? '')) ?: null, + 'registration_starts_on' => $this->nullableDate($payload['registration_starts_on'] ?? null), + 'registration_ends_on' => $this->nullableDate($payload['registration_ends_on'] ?? null), + 'previous_school_year_id' => $this->nullableInt($payload['previous_school_year_id'] ?? null), + ]; + } + + private function nullableDate(mixed $value): ?string + { + $value = trim((string) $value); + + return $value !== '' ? $value : null; + } + + private function nullableInt(mixed $value): ?int + { + return is_numeric($value) && (int) $value > 0 ? (int) $value : null; + } + + private function hasDependentRecords(int $id, string $name): bool + { + if ( + $this->db->tableExists('school_year_closing_batches') + && $this->closingBatchModel->where('source_school_year_id', $id)->orWhere('target_school_year_id', $id)->first() !== null + ) { + return true; + } + + foreach (['invoices', 'payments', 'student_class', 'teacher_class', 'calendar_events', 'events'] as $table) { + if ($this->db->tableExists($table) && $this->db->fieldExists('school_year', $table)) { + $count = $this->db->table($table)->where('school_year', $name)->countAllResults(); + if ($count > 0) { + return true; + } + } + } + + return false; + } + + private function hasFinalizedClosingBatch(int $sourceSchoolYearId): bool + { + if (! $this->db->tableExists('school_year_closing_batches')) { + return false; + } + + return $this->closingBatchModel + ->where('source_school_year_id', $sourceSchoolYearId) + ->whereIn('status', ['completed', 'closed']) + ->first() !== null; + } + + private function firstModelError(string $fallback): string + { + $errors = $this->schoolYearModel->errors(); + $first = reset($errors); + + return is_string($first) && $first !== '' ? $first : $fallback; + } +} diff --git a/app/Services/SchoolYearValidationService.php b/app/Services/SchoolYearValidationService.php new file mode 100644 index 0000000..8a598d6 --- /dev/null +++ b/app/Services/SchoolYearValidationService.php @@ -0,0 +1,68 @@ +isValidYearName($name)) { + throw new InvalidArgumentException('School year must use YYYY-YYYY with consecutive years.'); + } + + $existing = $this->schoolYearModel->where('name', $name); + if ($exceptId !== null) { + $existing->where('id !=', $exceptId); + } + if ($existing->first() !== null) { + throw new InvalidArgumentException('That school year already exists.'); + } + + $startsOn = $this->dateOrNull($payload['starts_on'] ?? null); + $endsOn = $this->dateOrNull($payload['ends_on'] ?? null); + if ($startsOn !== null && $endsOn !== null && $startsOn >= $endsOn) { + throw new InvalidArgumentException('School year start date must be before the end date.'); + } + + $registrationStarts = $this->dateOrNull($payload['registration_starts_on'] ?? null); + $registrationEnds = $this->dateOrNull($payload['registration_ends_on'] ?? null); + if ($registrationStarts !== null && $registrationEnds !== null && $registrationStarts > $registrationEnds) { + throw new InvalidArgumentException('Registration start date must be on or before the registration end date.'); + } + } + + public function isValidYearName(string $value): bool + { + if (! preg_match('/^(\d{4})-(\d{4})$/', $value, $matches)) { + return false; + } + + return (int) $matches[2] === (int) $matches[1] + 1; + } + + private function dateOrNull(mixed $value): ?DateTimeImmutable + { + $value = trim((string) $value); + if ($value === '') { + return null; + } + + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value); + if (! $date instanceof DateTimeImmutable || $date->format('Y-m-d') !== $value) { + throw new InvalidArgumentException('Dates must use YYYY-MM-DD.'); + } + + return $date; + } +} diff --git a/app/Services/SchoolYearWriteGuard.php b/app/Services/SchoolYearWriteGuard.php new file mode 100644 index 0000000..c370a8c --- /dev/null +++ b/app/Services/SchoolYearWriteGuard.php @@ -0,0 +1,25 @@ +status() === 'active') { + return; + } + + if ($context->status() === 'draft' && $allowDraftForAdmin && $isAdmin) { + return; + } + + throw new RuntimeException('The selected school year is read-only.'); + } +} diff --git a/app/Support/SchoolYear/SchoolYearContext.php b/app/Support/SchoolYear/SchoolYearContext.php new file mode 100644 index 0000000..63f21f6 --- /dev/null +++ b/app/Support/SchoolYear/SchoolYearContext.php @@ -0,0 +1,55 @@ +id; + } + + public function yearName(): string + { + return $this->yearName; + } + + public function status(): string + { + return $this->status; + } + + public function isActive(): bool + { + return $this->status === 'active'; + } + + public function isReadonly(): bool + { + return in_array($this->status, ['closed', 'archived'], true); + } + + public function isExplicitSelection(): bool + { + return $this->explicitSelection; + } + + public function toArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->yearName, + 'status' => $this->status, + 'readonly' => $this->isReadonly(), + 'explicitSelection' => $this->explicitSelection, + ]; + } +} diff --git a/app/Support/SchoolYear/SchoolYearStatus.php b/app/Support/SchoolYear/SchoolYearStatus.php new file mode 100644 index 0000000..2e372d5 --- /dev/null +++ b/app/Support/SchoolYear/SchoolYearStatus.php @@ -0,0 +1,38 @@ + [self::ACTIVE], + self::ACTIVE => [self::CLOSING], + self::CLOSING => [self::ACTIVE, self::CLOSED], + self::CLOSED => [self::ACTIVE, self::ARCHIVED], + self::ARCHIVED => [], + ]; + + public static function canTransition(string $from, string $to): bool + { + return in_array($to, self::TRANSITIONS[$from] ?? [], true); + } + + public static function isReadonly(string $status): bool + { + return in_array($status, [self::CLOSED, self::ARCHIVED], true); + } +} diff --git a/app/Support/SchoolYear/SchoolYearTableRegistry.php b/app/Support/SchoolYear/SchoolYearTableRegistry.php new file mode 100644 index 0000000..eff45e7 --- /dev/null +++ b/app/Support/SchoolYear/SchoolYearTableRegistry.php @@ -0,0 +1,154 @@ + 'YEAR_SCOPED', + self::isGlobal($table) => 'GLOBAL', + self::isIdentityWithYearRelation($table) => 'IDENTITY_WITH_YEAR_RELATION', + self::isContext($table) => 'CONTEXT', + default => throw new InvalidArgumentException( + "Table '{$table}' is not registered for school-year behavior." + ), + }; + } +} diff --git a/app/Views/school_years/closing_preview.php b/app/Views/school_years/closing_preview.php new file mode 100644 index 0000000..4b19a33 --- /dev/null +++ b/app/Views/school_years/closing_preview.php @@ -0,0 +1,208 @@ +extend('layout/management_layout') ?> +section('content') ?> + + '$' . number_format((float) $value, 2); +?> + +getFlashdata('success')): ?> +
getFlashdata('success')) ?>
+getFlashdata('error')): ?> +
getFlashdata('error')) ?>
+ + +
+
+
+

Closing Preview:

+
+ Status: + + Target: + + Generated: +
+
+ Back to Management +
+ +
+
+ + +
+
+ +
+
+ +
+
+
+
Students
+
+
+
+
+
+
Families
+
+
+
+
+
+
Invoices
+
+
+
+
+
+
Outstanding
+
+
+
+
+ +
+
+
+
Blocking Issues
+ +

No blocking issues found.

+ +
    + +
  • :
  • + +
+ +
+
+
+
+
Warnings
+ +

No warnings found.

+ +
    + +
  • :
  • + +
+ +
+
+
+ +
+
+
+
Finance Summary
+
+
Total invoiced
+
+
Total paid
+
+
Positive balances
+
+
Credits
+
+
+
+
+
+
+
Closing Progress
+
    +
  1. Preview generated
  2. +
  3. Validation
  4. +
  5. Closing batch
  6. +
  7. Carry-forward completed
  8. +
  9. Year closed
  10. +
+
+
+
+ +
+
Carry-Forward Families
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
FamilySource BalanceCreditsAdjustmentsNet Carry-Forward
No carry-forward balances found.
+
+
+ +
+ +
+ + + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ +
+
+ +endSection() ?> diff --git a/app/Views/school_years/index.php b/app/Views/school_years/index.php new file mode 100644 index 0000000..b262450 --- /dev/null +++ b/app/Views/school_years/index.php @@ -0,0 +1,336 @@ +extend('layout/management_layout') ?> +section('content') ?> + + 'bg-info text-dark', + 'active' => 'bg-success', + 'closing' => 'bg-warning text-dark', + 'closed' => 'bg-secondary', + 'archived' => 'bg-dark', + ]; + $formatDate = static fn ($value): string => $value ? esc((string) $value) : 'Not set'; +?> + +getFlashdata('success')): ?> +
getFlashdata('success')) ?>
+getFlashdata('error')): ?> +
getFlashdata('error')) ?>
+ + +
+
+
+

School Year Management

+
+ Active year: + + + + None + +
+
+ +
+ +
+
+
+
Active Year
+
+
+
+
+
+
Next Draft
+
+
+
+
+
+
Currently Closing
+
+
+
+
+
+
Archived Years
+
+
+
+
+ +
+
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
School YearDatesStatusStudentsFamiliesFinancial BalanceLast TransitionUpdatedActions
+
+
+
+ + + + --Preview + +
+
+ + - + +
+ +
+
+
+ + + + + + + + + + + + + + + + +endSection() ?> + +section('scripts') ?> + +endSection() ?> diff --git a/full_school_year_management_interface_plan.md b/full_school_year_management_interface_plan.md new file mode 100644 index 0000000..8fed66b --- /dev/null +++ b/full_school_year_management_interface_plan.md @@ -0,0 +1,1360 @@ +# Full School Year Management Interface Implementation Plan + +## 1. Objective + +Replace the current basic school-year CRUD screen with a complete School Year Management module for CodeIgniter 4. + +The final module must support the full lifecycle of a school year: + +```text +draft → active → closing → closed → archived +``` + +It must also support: + +- creating and editing school years +- activating exactly one year +- validating year dates and naming +- previewing school-year closure +- identifying blocking issues +- calculating balances to carry forward +- executing closure safely +- recording closure batches and results +- reopening a closed year only under strict rules +- archiving historical years +- preventing unsafe deletion +- enforcing permissions +- showing read-only behavior for closed years +- auditing every critical action + +The interface must not allow arbitrary status changes through a general edit form. + +--- + +## 2. Current Screen Assessment + +The current view already provides: + +- school-year listing +- create form +- edit modal +- status display +- activate action +- delete action +- CSRF protection +- DataTables sorting and pagination +- flash messages + +The current view must be treated as the starting point only. + +The following current behaviors must be removed or restricted: + +- direct status editing from the edit modal +- deleting every non-active year +- activating a year without validation +- relying on frontend checks for business rules +- showing all actions to every user +- treating all non-active statuses the same +- allowing closure state to be changed through CRUD + +--- + +## 3. School-Year Lifecycle Rules + +Use these statuses: + +```text +draft +active +closing +closed +archived +``` + +### Draft + +Allowed: + +- edit name and dates +- configure semesters +- initialize classes, fees, and assignments +- activate +- delete only when no dependent records exist + +Not allowed: + +- carry-forward execution +- closure execution +- archive + +### Active + +Allowed: + +- normal school operations +- edit limited metadata where safe +- start closing preview +- begin closing + +Not allowed: + +- delete +- archive directly +- activate another year without a controlled transition + +### Closing + +Allowed: + +- run closing validations +- refresh preview +- resolve blocking issues +- execute carry-forward +- complete closure +- cancel closing only before irreversible actions + +Not allowed: + +- normal unrestricted edits +- delete +- archive +- reactivate another year without resolving the closing state + +### Closed + +Allowed: + +- historical viewing +- closing report viewing +- audit review +- controlled reopen action if policy permits +- archive + +Not allowed: + +- normal inserts, updates, or deletes +- direct activation +- deletion + +### Archived + +Allowed: + +- read-only viewing +- audit and report access + +Not allowed: + +- edits +- reopening by normal administrators +- deletion +- activation + +--- + +## 4. Replace Direct Status Editing + +Remove the status dropdown from the general edit modal. + +The edit form should only update: + +- school-year name +- start date +- end date +- optional description +- optional registration start/end dates + +Status changes must use dedicated actions: + +```text +Activate +Begin Closing +Cancel Closing +Complete Closing +Reopen +Archive +``` + +Each action must call a dedicated controller method and service method. + +Do not use a generic update action to change lifecycle state. + +--- + +## 5. Backend Architecture + +Create or update these components: + +```text +app/Controllers/Administrator/SchoolYearController.php +app/Controllers/Administrator/SchoolYearClosingController.php + +app/Models/SchoolYearModel.php +app/Models/SchoolYearClosingBatchModel.php +app/Models/SchoolYearClosingItemModel.php +app/Models/SchoolYearTransitionLogModel.php + +app/Services/SchoolYearManagementService.php +app/Services/SchoolYearClosingService.php +app/Services/SchoolYearContextService.php +app/Services/SchoolYearWriteGuard.php +app/Services/SchoolYearValidationService.php + +app/Repositories/SchoolYearRepository.php +app/Repositories/SchoolYearClosingRepository.php + +app/Support/SchoolYear/SchoolYearStatus.php +app/Support/SchoolYear/SchoolYearTableRegistry.php +``` + +Responsibilities must be separated: + +- controller: request/response handling +- model: table persistence +- repository: complex database queries +- service: business rules and transactions +- write guard: read-only enforcement +- validation service: date, status, and dependency checks + +Controllers must remain thin. + +--- + +## 6. School-Year Model + +The `school_years` table should include at least: + +```text +id +name +starts_on +ends_on +status +activated_at +closing_started_at +closed_at +archived_at +created_by +updated_by +created_at +updated_at +``` + +Optional fields: + +```text +description +registration_starts_on +registration_ends_on +previous_school_year_id +next_school_year_id +``` + +Recommended model validation: + +```php +protected $validationRules = [ + 'name' => 'required|regex_match[/^\d{4}-\d{4}$/]', + 'starts_on' => 'required|valid_date[Y-m-d]', + 'ends_on' => 'required|valid_date[Y-m-d]', +]; +``` + +Add service-level validation for rules CodeIgniter validation cannot safely express: + +- second year equals first year plus one +- start date is before end date +- duplicate name is rejected +- date overlap is rejected where prohibited +- only one active year exists +- lifecycle transition is allowed +- target year is valid for carry-forward + +--- + +## 7. Status Transition Service + +Create a status transition map. + +```php +private const ALLOWED_TRANSITIONS = [ + 'draft' => ['active'], + 'active' => ['closing'], + 'closing' => ['active', 'closed'], + 'closed' => ['active', 'archived'], + 'archived' => [], +]; +``` + +Do not rely only on this map. Additional business validation is required. + +Examples: + +- `closed → active` requires reopen permission and dependency checks +- `closing → active` is allowed only before closing batches are finalized +- `active → closing` requires a closing target year +- `closed → archived` requires all closing records to be finalized + +Every transition must: + +1. validate current status +2. validate target status +3. validate permissions +4. validate dependencies +5. run inside a database transaction +6. record the transition +7. write an audit log +8. return a structured result + +--- + +## 8. Activation Workflow + +Add an activation modal and dedicated endpoint. + +Activation must: + +- verify the selected year exists +- require status `draft` or approved reopened state +- verify start and end dates +- verify no conflicting active year +- identify the current active year +- require confirmation when replacing an active year +- avoid automatically closing the previous active year +- update the selected year to `active` +- update any previous active year according to policy +- initialize required year-owned records +- save `activated_at` +- record the user performing activation +- write an audit entry + +Recommended policy: + +```text +Only one school year may have status active. +Activating a draft year changes the previous active year to closing, +not closed. +``` + +This forces the previous year through a real closure process instead of pretending changing a label completed accounting. + +--- + +## 9. School-Year Initialization + +When a year is activated, optionally initialize selected data from the previous year. + +Allow the administrator to choose: + +- copy class definitions +- copy section definitions +- copy tuition and fee templates +- copy event charge templates +- copy teacher-class assignment templates +- copy calendar templates +- copy communication templates +- create standard semesters +- carry selected active family/student relationships + +Do not copy: + +- invoices +- payments +- attendance +- grades +- finalized events +- messages +- audit logs +- historical transactions + +Initialization must be idempotent. Re-running it must not create duplicate records. + +--- + +## 10. Closing Preview + +Add a dedicated Closing Preview page. + +Route: + +```text +GET administrator/school-years/{id}/closing/preview +``` + +The preview must display: + +### School-year summary + +- school-year name +- status +- start and end dates +- number of students +- number of families +- number of classes +- number of teachers +- number of invoices +- total invoiced +- total paid +- total outstanding + +### Financial validation + +- families with positive balances +- families with negative balances or credits +- unpaid invoices +- partially paid invoices +- unapplied payments +- failed payment transactions +- pending reimbursements +- pending refunds +- unresolved discounts +- unposted event charges +- mismatched invoice/payment school years + +### Academic validation + +- students without final class placement +- students without promotion decisions +- missing final scores +- incomplete report cards +- incomplete attendance records +- teachers with incomplete class submissions +- orphaned class or enrollment records + +### Operational validation + +- active events extending beyond the year +- unclosed parent meetings +- pending print requests +- pending acknowledgements +- inconsistent school-year relationships +- records missing `school_year_id` + +### Carry-forward preview + +For each family: + +```text +family +source balance +credits +adjustments +net carry-forward +target year +proposed target record +``` + +The preview must classify findings as: + +```text +blocking +warning +informational +``` + +Closure cannot proceed while blocking findings exist. + +--- + +## 11. Closing Batch Tables + +Create: + +```text +school_year_closing_batches +``` + +Suggested columns: + +```text +id +source_school_year_id +target_school_year_id +status +preview_hash +total_families +total_positive_balance +total_credit_balance +started_by +completed_by +started_at +completed_at +failed_at +failure_message +created_at +updated_at +``` + +Create: + +```text +school_year_closing_items +``` + +Suggested columns: + +```text +id +closing_batch_id +family_id +source_balance +credit_amount +adjustment_amount +carry_forward_amount +target_invoice_id +target_adjustment_id +status +error_message +created_at +updated_at +``` + +Create: + +```text +school_year_transition_logs +``` + +Suggested columns: + +```text +id +school_year_id +from_status +to_status +action +performed_by +metadata_json +created_at +``` + +Add unique constraints to prevent duplicate finalized carry-forward for the same source year, target year, and family. + +--- + +## 12. Begin Closing Workflow + +Add a dedicated action: + +```text +POST administrator/school-years/{id}/closing/start +``` + +The service must: + +- require current status `active` +- require a valid target year +- verify target year is `draft` or `active` according to policy +- run the latest preview +- reject when blocking issues exist +- create a closing batch +- save preview totals and hash +- change source status to `closing` +- record `closing_started_at` +- write transition and audit logs + +The preview hash should detect material data changes between preview and execution. + +--- + +## 13. Carry-Forward Execution + +Add: + +```text +POST administrator/school-years/{id}/closing/execute +``` + +Execution must run inside one database transaction. + +For each family with a carry-forward amount: + +- calculate final source-year balance +- compare it with previewed balance +- reject if materially changed +- create a target-year opening balance or carry-forward invoice +- link it to the closing batch item +- avoid duplicate creation +- record credits separately from debt if accounting rules require it +- preserve source-year records unchanged + +Recommended target records: + +```text +opening_balance_transactions +``` + +or a dedicated invoice type: + +```text +invoice_type = carry_forward +``` + +Do not silently merge historical debt into normal tuition invoices. + +Execution must be idempotent: + +- rerunning a completed batch does nothing +- rerunning a partially failed batch must safely resume or fully roll back +- duplicate carry-forward records must be prevented by database constraints + +--- + +## 14. Complete Closing + +Add: + +```text +POST administrator/school-years/{id}/closing/complete +``` + +Completion must verify: + +- source status is `closing` +- closing batch exists +- all batch items succeeded +- no new blocking issues appeared +- carry-forward totals match +- all required reports are generated +- target-year links are valid + +Then: + +- set source status to `closed` +- set `closed_at` +- finalize closing batch +- make source year read-only +- create final closing report +- write transition and audit logs + +--- + +## 15. Cancel Closing + +Add: + +```text +POST administrator/school-years/{id}/closing/cancel +``` + +Allow cancellation only when: + +- status is `closing` +- no finalized carry-forward records exist +- no irreversible batch step has completed + +Cancellation must: + +- mark the batch cancelled +- restore status to `active` +- preserve audit history +- not delete logs + +If carry-forward records already exist, require rollback through a dedicated recovery operation instead of pretending a status change reverses accounting. + +--- + +## 16. Reopen Workflow + +Add: + +```text +POST administrator/school-years/{id}/reopen +``` + +Reopening must require a high-level permission. + +Validate: + +- year status is `closed` +- target/current year financial activity will not be corrupted +- no dependent archived reports prohibit changes +- no carry-forward records would become inconsistent +- administrator provides a reason + +Recommended reopening behavior: + +- set status to `active` or `closing` according to policy +- invalidate affected closing reports +- preserve the original closing batch +- create a reopen transition record +- require a new closing batch before closing again + +Archived years should not be reopened through the normal interface. + +--- + +## 17. Archive Workflow + +Add: + +```text +POST administrator/school-years/{id}/archive +``` + +Allow only when: + +- status is `closed` +- closing batch is finalized +- no pending reopen operation exists +- administrator has archive permission + +Archiving must: + +- set status `archived` +- set `archived_at` +- remove it from default operational selectors +- keep it available in historical search +- enforce strict read-only access + +--- + +## 18. Delete Rules + +Replace the current generic delete action with `deleteDraft`. + +Route: + +```text +POST administrator/school-years/{id}/delete-draft +``` + +Allow deletion only when: + +- status is `draft` +- the year is not active +- no year-owned records exist +- no semester records exist +- no closing batches exist +- no transition history other than creation exists +- user has delete permission + +Otherwise show: + +```text +This school year cannot be deleted because related data exists. +Archive historical years instead. +``` + +Never delete active, closing, closed, or archived years. + +--- + +## 19. Permissions + +Add permissions such as: + +```text +school_year.view +school_year.create +school_year.edit +school_year.activate +school_year.close.preview +school_year.close.start +school_year.close.execute +school_year.close.complete +school_year.close.cancel +school_year.reopen +school_year.archive +school_year.delete_draft +school_year.view_financial_closing +``` + +Enforce permissions: + +- in routes or filters +- in controllers +- in services for critical actions +- in the view for button visibility + +Hiding a button is user experience. It is not authorization. + +--- + +## 20. Interface Redesign + +Replace the current single-table CRUD experience with the following sections. + +### Header + +Display: + +- page title +- active year badge +- selected year +- add school year button +- historical years filter + +### Summary cards + +Show: + +- active year +- next draft year +- year currently closing +- total archived years + +### Main table columns + +```text +School Year +Dates +Status +Students +Families +Financial Balance +Last Transition +Updated +Actions +``` + +### Status badges + +Use distinct styles: + +```php +$statusClasses = [ + 'draft' => 'bg-info text-dark', + 'active' => 'bg-success', + 'closing' => 'bg-warning text-dark', + 'closed' => 'bg-secondary', + 'archived' => 'bg-dark', +]; +``` + +### Action menu by status + +#### Draft + +- Edit +- Configure semesters +- Initialize +- Activate +- Delete draft + +#### Active + +- View dashboard +- Edit limited metadata +- Preview closing +- Begin closing + +#### Closing + +- View closing preview +- Resolve issues +- Execute carry-forward +- Complete closing +- Cancel closing when allowed + +#### Closed + +- View closing report +- View historical data +- Reopen when authorized +- Archive + +#### Archived + +- View historical data +- View audit trail + +Use a dropdown action menu instead of displaying every action inline. + +--- + +## 21. Create School Year Modal + +The create form should include: + +- school-year name +- starts on +- ends on +- optional description +- optional registration period +- initialize semesters checkbox +- copy configuration from previous year checkbox +- source year selector when copying + +Do not allow the user to create a year directly as `active`, `closing`, `closed`, or `archived`. + +All new years start as: + +```text +draft +``` + +Activation is a separate operation. + +--- + +## 22. Edit School Year Modal + +Allow editing only: + +- name +- dates +- description +- registration dates + +Restrictions: + +- draft: full metadata editing +- active: limited editing with warnings +- closing: mostly locked +- closed: read-only +- archived: read-only + +Remove status from the modal. + +--- + +## 23. Activation Confirmation Modal + +Display: + +- year being activated +- current active year +- resulting status of current active year +- initialization options +- explicit confirmation checkbox +- warning that activation does not complete closure + +Require a typed confirmation only if activation affects a currently active year. + +--- + +## 24. Closing Preview Page + +Create a dedicated full-page interface rather than a small modal. + +Sections: + +1. overview +2. blocking issues +3. warnings +4. finance summary +5. carry-forward families +6. academic completion +7. operational completion +8. target-year validation +9. audit and preview timestamp + +Actions: + +- refresh preview +- export preview +- return to management +- begin closing +- execute carry-forward +- complete closing + +Disable actions based on current status and validation results. + +--- + +## 25. Closing Progress Interface + +During closing, show a deterministic progress state based on persisted batch data: + +```text +Preview generated +Validation passed +Closing batch created +Carry-forward prepared +Carry-forward completed +Final validation passed +Year closed +``` + +Do not fake progress with a frontend timer. + +The page should reload or poll the batch status only when the backend supports resumable execution. + +--- + +## 26. Closing Report + +Generate a final report containing: + +- source school year +- target school year +- completion date +- administrator +- total families processed +- total balances carried +- total credits carried +- failed or skipped items +- financial reconciliation totals +- academic completion summary +- warning acknowledgements +- transition history + +Allow export to CSV or PDF later, but the database report must exist first. + +--- + +## 27. Semester Management + +Add a school-year detail tab for semesters. + +Suggested table: + +```text +school_year_semesters +``` + +Columns: + +```text +id +school_year_id +name +code +starts_on +ends_on +status +sort_order +created_at +updated_at +``` + +Rules: + +- semester dates must fall within the school year +- semesters must not overlap +- semester order must be explicit +- closed semesters reject grade and attendance edits where required +- not every year-owned table needs a semester column + +Do not use a global `configuration.semester` value as the sole source of truth. + +--- + +## 28. Read-Only Enforcement + +Use `SchoolYearWriteGuard` in every year-owned write path. + +The interface should disable or hide mutation controls for closed and archived years, but backend enforcement remains mandatory. + +API and controller errors should return clear messages such as: + +```text +The selected school year is closed and cannot be modified. +``` + +--- + +## 29. Routes + +Recommended routes: + +```php +$routes->group( + 'administrator/school-years', + ['filter' => 'auth'], + static function ($routes) { + $routes->get('/', 'Administrator\SchoolYearController::index'); + $routes->post('store', 'Administrator\SchoolYearController::store'); + $routes->post( + '(:num)/update', + 'Administrator\SchoolYearController::update/$1' + ); + $routes->post( + '(:num)/activate', + 'Administrator\SchoolYearController::activate/$1' + ); + $routes->post( + '(:num)/delete-draft', + 'Administrator\SchoolYearController::deleteDraft/$1' + ); + $routes->post( + '(:num)/archive', + 'Administrator\SchoolYearController::archive/$1' + ); + $routes->post( + '(:num)/reopen', + 'Administrator\SchoolYearController::reopen/$1' + ); + + $routes->get( + '(:num)/closing/preview', + 'Administrator\SchoolYearClosingController::preview/$1' + ); + $routes->post( + '(:num)/closing/start', + 'Administrator\SchoolYearClosingController::start/$1' + ); + $routes->post( + '(:num)/closing/execute', + 'Administrator\SchoolYearClosingController::execute/$1' + ); + $routes->post( + '(:num)/closing/complete', + 'Administrator\SchoolYearClosingController::complete/$1' + ); + $routes->post( + '(:num)/closing/cancel', + 'Administrator\SchoolYearClosingController::cancel/$1' + ); + } +); +``` + +Use exact route names consistently. The existing route ending in `/active` should be replaced with `/activate`. + +--- + +## 30. Transactions and Locking + +Use database transactions for: + +- activation +- starting closing +- carry-forward execution +- completing closing +- reopening +- archiving + +Where supported, lock the school-year row during critical transitions. + +Before each operation, re-read the current status inside the transaction. + +Do not trust the status that was displayed when the page loaded. Another administrator may have changed it since then, because concurrency exists even when the interface would prefer otherwise. + +--- + +## 31. Audit Logging + +Record: + +- creation +- metadata edits +- activation +- initialization +- closing preview generation +- closing start +- carry-forward execution +- closing completion +- cancellation +- reopening +- archive +- failed transition attempts + +Audit metadata should include: + +```text +user_id +school_year_id +action +from_status +to_status +reason +request_id +ip_address +summary +created_at +``` + +Do not store sensitive payment data in audit metadata. + +--- + +## 32. Validation and Error Handling + +Return clear messages for: + +- duplicate year +- invalid name sequence +- date overlap +- invalid status transition +- second active year attempt +- unresolved closing blockers +- changed data since preview +- missing target year +- target year invalid +- duplicate carry-forward +- unauthorized operation +- deletion blocked by dependencies + +Use flash messages for normal web redirects and structured errors for API endpoints. + +--- + +## 33. Testing Plan + +### Unit tests + +Test: + +- valid and invalid status transitions +- name validation +- date validation +- overlap detection +- activation policy +- write guard +- carry-forward calculation +- permission checks +- reopening rules + +### Database tests + +Test: + +- only one active year +- draft deletion with no dependencies +- deletion blocked with dependencies +- closing batch creation +- unique carry-forward records +- transaction rollback +- status transition logs + +### Feature tests + +Test every route: + +- create +- edit +- activate +- preview +- start closing +- execute +- complete +- cancel +- reopen +- archive +- delete draft + +Verify CSRF and permission enforcement. + +### Finance regression tests + +Test: + +- old-year debt does not leak into current-year totals +- carry-forward matches source closing balance +- credits are handled correctly +- preview and execution totals match +- duplicate execution is blocked +- rollback leaves no partial target-year records + +### UI tests + +Test: + +- buttons appear by status and permission +- closed years are read-only +- correct status badges display +- preview blockers disable execution +- action confirmation modals show correct year +- table sorting works +- dropdowns are alphabetically sorted +- mobile layout remains usable + +--- + +## 34. Implementation Phases + +### Phase 1: Foundation + +- add lifecycle status constants +- update database schema +- update model validation +- add management and validation services +- add transition logging +- remove direct status editing + +### Phase 2: CRUD Hardening + +- force new years to draft +- validate dates and names +- restrict editing by status +- replace delete with delete-draft +- add permissions + +### Phase 3: Activation + +- implement activation service +- enforce one active year +- add initialization options +- add activation modal +- add tests + +### Phase 4: Closing Preview + +- build preview queries +- classify blockers and warnings +- add closing preview page +- add target-year validation +- add export-ready result structure + +### Phase 5: Closing Execution + +- create batch tables +- implement start closing +- implement carry-forward +- add idempotency and constraints +- implement completion and cancellation +- add reconciliation tests + +### Phase 6: Historical Management + +- implement reopen +- implement archive +- add closing reports +- add historical filters +- enforce read-only behavior + +### Phase 7: Semester Management + +- create year-semester table +- add semester configuration tab +- validate semester dates +- integrate semester write guards + +### Phase 8: Final Hardening + +- add audit coverage +- add concurrency checks +- add static checks +- run full regression tests +- review production migration and rollback steps + +--- + +## 35. Acceptance Criteria + +The School Year Management module is complete only when: + +- every new school year starts as draft +- status cannot be changed from the edit form +- only one school year can be active +- activation is transactional and audited +- the previous year is not silently considered closed +- closing preview shows financial, academic, and operational blockers +- closure cannot proceed with blocking issues +- carry-forward is transactional and idempotent +- source-year financial records remain unchanged +- closed and archived years are read-only +- deletion is limited to unused draft years +- reopen and archive actions are permission-controlled +- all critical transitions are logged +- conflicting concurrent transitions are rejected +- semester configuration is tied to the correct school year +- tests cover the full lifecycle +- the interface exposes only valid actions for each status + +--- + +## 36. Non-Negotiable Rules + +1. Do not allow arbitrary status editing. +2. Do not close a school year by changing one database field. +3. Do not carry balances forward without a persisted batch. +4. Do not trust frontend school-year values. +5. Do not delete historical school years. +6. Do not allow closed-year writes without an explicit reopen workflow. +7. Do not mix source-year and target-year financial records. +8. Do not execute closure without a fresh validated preview. +9. Do not use UI button visibility as authorization. +10. Do not mark work complete until lifecycle, accounting, permissions, audit, and regression tests all pass. diff --git a/school_year_code_update_plan_codeigniter4.md b/school_year_code_update_plan_codeigniter4.md new file mode 100644 index 0000000..7cab6e8 --- /dev/null +++ b/school_year_code_update_plan_codeigniter4.md @@ -0,0 +1,1905 @@ +# School-Year-Aware Code Update Plan for CodeIgniter 4 + +## Goal + +Update the CodeIgniter 4 application so school-year filtering is applied only to records that are logically owned by a school year. + +The application must stop assuming that every table containing a `school_year` column should be filtered by it. Some tables are year-owned, some are global, some contain global identities with year-specific relationship records, and some contain optional school-year context. + +Required behavior: + +- Active-year pages display only active-year records. +- A selected historical year can be viewed intentionally. +- Closed or archived years are read-only unless an explicit administrative operation allows changes. +- Authentication, authorization, configuration, identity, and infrastructure tables remain global. +- Finance, attendance, exams, events, progress, and reports never mix records from different years. +- Missing required school-year context causes a clear error instead of returning unfiltered records. +- The backend validates all school-year parameters. Query parameters are input, not truth. + +--- + +## Current Problem + +A broad database script added or populated `school_year = 2025-2026` across many tables. Some of those tables should never be school-year scoped. + +Examples of tables that must not be filtered directly by school year: + +- `users` +- `roles` +- `permissions` +- `role_permissions` +- `user_roles` +- `settings` +- `configuration` +- `preferences` +- `school_years` +- `login_activity` +- `ip_attempts` +- `password_resets` +- `migrations` +- `families` +- `students` +- `parents` +- `teachers` +- `staff` + +CodeIgniter models and services must not decide scope based only on whether a `school_year` column exists. A bad migration can add a column. It cannot redefine the business meaning of a table, despite databases occasionally behaving as if they have opinions. + +--- + +## Current Database Assumptions + +Treat the following as the working database state before application changes: + +- Valid year-owned records are tagged with `school_year = 2025-2026`. +- Optional-context tables keep `school_year` nullable and without a default. +- `notifications`, `user_notifications`, and `payment_notification_logs` were verified with no forced school-year value after cleanup. +- Some global tables may still physically contain an incorrect `school_year` column until application references are removed and a cleanup migration is executed. + +Immediate application rule: + +```text +YEAR_SCOPED: + Require validated selected school-year context. + +GLOBAL: + Never add a school_year condition. + +IDENTITY_WITH_YEAR_RELATION: + Load the identity globally and filter participation through an enrollment, + assignment, membership, or other year-owned relationship table. + +CONTEXT: + Keep school_year nullable and filter it only in explicitly year-specific views. +``` + +Do not run another script that stamps all nullable context rows with the active year. + +--- + +## Target Table Classification + +Create one explicit source of truth for table behavior. + +### 1. Year-Owned Tables + +These tables require school-year filtering for normal reads and writes. + +Examples: + +- `classes` +- `classSection` +- `sections` +- `enrollments` +- `student_class` +- `teacher_class` +- `attendance_data` +- `attendance_day` +- `attendance_record` +- `attendance_tracking` +- `class_progress_reports` +- `exam_drafts` +- `exams` +- `final_exam` +- `final_score` +- `semester_scores` +- `invoices` +- `invoice_installments` +- `payments` +- `payment_transactions` +- `manual_payments` +- `expenses` +- `refunds` +- `calendar_events` +- `events` +- `parent_notifications` +- `parent_meeting_schedules` +- `print_requests` +- `report_card_acknowledgements` +- `whatsapp_group_links` +- `whatsapp_group_memberships` + +CodeIgniter query-builder rule: + +```php +$builder->where('school_year', $context->yearName()); +``` + +Long-term preferred rule: + +```php +$builder->where('school_year_id', $context->id()); +``` + +### 2. Global Tables + +These tables must never receive automatic school-year filtering. + +Examples: + +- `users` +- `authorized_users` +- `roles` +- `permissions` +- `role_permissions` +- `user_roles` +- `nav_items` +- `role_nav_items` +- `parent_accounts` +- `settings` +- `configuration` +- `preferences` +- `user_preferences` +- `email_templates` +- `school_years` +- `migrations` +- `cache` +- `cache_locks` +- `sessions` +- `personal_access_tokens` +- `password_resets` +- `password_reset_requests` + +Rule: + +```php +// No school_year condition. +``` + +### 3. Global Identity Tables With Year-Owned Relationships + +Identity records are global. Their participation in a school year is year-specific. + +Global identity examples: + +- `families` +- `parents` +- `students` +- `teachers` +- `staff` +- `family_guardians` +- `family_students` +- `emergency_contacts` +- `student_allergies` +- `student_medical_conditions` + +Year-specific relationship examples: + +```text +enrollments +- student_id +- school_year_id or school_year +- class_id +- status +``` + +```text +family_school_years +- family_id +- school_year_id or school_year +- status +- opening_balance +``` + +```text +teacher_class +- teacher_id +- class_id +- school_year_id or school_year +``` + +Correct CodeIgniter query pattern: + +```php +$builder = $db->table('students s'); +$builder + ->join('enrollments e', 'e.student_id = s.id') + ->where('e.school_year', $context->yearName()) + ->select('s.*') + ->distinct(); +``` + +Incorrect pattern: + +```php +$db->table('students') + ->where('students.school_year', $context->yearName()); +``` + +Use the real relationship path defined by the schema. Do not invent a direct join because it looks pleasantly short. + +### 4. Nullable Context Tables + +These tables are global or operational records that may optionally include school-year context. + +Examples: + +- `audit_logs` +- `communication_logs` +- `notifications` +- `notification_recipients` +- `user_notifications` +- `messages` +- `support_requests` +- `contactus` +- `finance_notification_logs` +- `payment_notification_logs` + +Rules: + +- `school_year` remains nullable. +- Normal global views must not automatically hide rows. +- Year-specific views may include global rows plus selected-year rows. +- Strict school-year reports may include only selected-year rows. +- Creation logic must not automatically stamp the active year unless the action is genuinely year-specific. + +Optional-context query: + +```php +$builder + ->groupStart() + ->where('school_year', null) + ->orWhere('school_year', $context->yearName()) + ->groupEnd(); +``` + +Strict year-only query: + +```php +$builder->where('school_year', $context->yearName()); +``` + +--- + +## Phase 1: Add Central Table Metadata + +Create: + +```text +app/Support/SchoolYear/SchoolYearTableRegistry.php +``` + +```php + 'YEAR_SCOPED', + self::isGlobal($table) => 'GLOBAL', + self::isIdentityWithYearRelation($table) => 'IDENTITY_WITH_YEAR_RELATION', + self::isContext($table) => 'CONTEXT', + default => throw new \InvalidArgumentException( + "Table '{$table}' is not registered for school-year behavior." + ), + }; + } +} +``` + +Every table touched by school-year logic must be classified. An unclassified table should fail review or automated checks rather than quietly inheriting whatever condition happened to be nearby. + +--- + +## Phase 2: Add a School-Year Context Value Object + +Create: + +```text +app/Support/SchoolYear/SchoolYearContext.php +``` + +```php +id; + } + + public function yearName(): string + { + return $this->yearName; + } + + public function status(): string + { + return $this->status; + } + + public function isActive(): bool + { + return $this->status === 'active'; + } + + public function isReadonly(): bool + { + return in_array($this->status, ['closed', 'archived'], true); + } + + public function isExplicitSelection(): bool + { + return $this->explicitSelection; + } +} +``` + +Do not pass raw strings such as `2025-2026` across controllers and services as the main context object. Raw strings cannot carry status, ID, writability, or validation state. + +--- + +## Phase 3: Add a SchoolYearContextService + +Create or update: + +```text +app/Services/SchoolYearContextService.php +``` + +Responsibilities: + +1. Read explicit school year from route placeholders or query parameters. +2. Support `school_year_id` and `school_year` during the transition period. +3. Validate that the requested school year exists. +4. Validate that the authenticated user may access it. +5. Fall back to a session-selected year. +6. Fall back to the active year. +7. Fail clearly if no valid year can be resolved. +8. Return a `SchoolYearContext` object. +9. Never trust the frontend to determine whether a year is writable. + +Resolution order: + +```text +1. Route placeholder +2. Query parameter school_year_id +3. Query parameter school_year +4. Session-selected school year +5. Active school year from school_years +6. Throw a controlled exception if none exists +``` + +Suggested implementation outline: + +```php +normalizeInt($request->getGet('school_year_id')); + + $requestedName = trim((string) $request->getGet('school_year')); + + if ($requestedId !== null) { + $row = $this->schoolYearModel->find($requestedId); + + if ($row === null) { + throw new RuntimeException('Selected school year was not found.'); + } + + return $this->fromRow($row, true); + } + + if ($requestedName !== '') { + $row = $this->schoolYearModel + ->where('name', $requestedName) + ->first(); + + if ($row === null) { + throw new RuntimeException('Selected school year was not found.'); + } + + return $this->fromRow($row, true); + } + + $sessionYearId = session('selected_school_year_id'); + + if (is_numeric($sessionYearId)) { + $row = $this->schoolYearModel->find((int) $sessionYearId); + + if ($row !== null) { + return $this->fromRow($row, false); + } + } + + $active = $this->schoolYearModel + ->where('status', 'active') + ->orderBy('id', 'DESC') + ->first(); + + if ($active === null) { + throw new RuntimeException('No active school year is configured.'); + } + + return $this->fromRow($active, false); + } + + private function normalizeInt(mixed $value): ?int + { + if ($value === null || $value === '' || ! ctype_digit((string) $value)) { + return null; + } + + return (int) $value; + } + + private function fromRow(array $row, bool $explicit): SchoolYearContext + { + return new SchoolYearContext( + id: (int) $row['id'], + yearName: (string) $row['name'], + status: (string) $row['status'], + explicitSelection: $explicit, + ); + } +} +``` + +Register the service in: + +```text +app/Config/Services.php +``` + +```php +public static function schoolYearContext(bool $getShared = true) +{ + if ($getShared) { + return static::getSharedInstance('schoolYearContext'); + } + + return new \App\Services\SchoolYearContextService( + model(\App\Models\SchoolYearModel::class) + ); +} +``` + +Use dependency injection where practical. Calling `service()` from every random method is convenient until testing begins and the convenience invoice arrives. + +--- + +## Phase 4: Add a Write Guard + +Create: + +```text +app/Services/SchoolYearWriteGuard.php +``` + +```php +status() === 'active') { + return; + } + + if ( + $context->status() === 'draft' + && $allowDraftForAdmin + && $isAdmin + ) { + return; + } + + throw PageForbiddenException::forPageForbidden( + 'The selected school year is read-only.' + ); + } +} +``` + +Suggested status behavior: + +```text +active: + Writable. + +closing: + Restricted to explicit closing operations. + +closed: + Read-only. + +archived: + Read-only. + +draft: + Admin-only when the operation explicitly permits draft changes. +``` + +Apply the guard to all writes affecting year-owned tables, including: + +- invoices +- payments +- attendance +- exams +- scores +- classes and sections +- student placement +- teacher assignment +- events +- report-card acknowledgements +- WhatsApp group assignments +- carry-forward operations + +Do not apply it to global user, role, permission, or configuration updates unless those operations modify year-owned data. + +--- + +## Phase 5: Add Reusable Model Methods + +CodeIgniter 4 does not provide Laravel-style Eloquent scopes or relationships. Do not copy `whereHas()`, traits returning Eloquent builders, or `Model::query()` into CI4 and hope PHP develops telepathy. + +Use explicit model methods or repository/service query methods. + +### Base method for year-owned models + +Create: + +```text +app/Models/Concerns/SchoolYearScopedModelTrait.php +``` + +```php +fieldExists('school_year_id')) { + return $this->where( + $this->table . '.school_year_id', + $schoolYear->id() + ); + } + + return $this->where( + $this->table . '.school_year', + $schoolYear->yearName() + ); + } + + if (is_int($schoolYear)) { + return $this->where( + $this->table . '.school_year_id', + $schoolYear + ); + } + + return $this->where( + $this->table . '.school_year', + $schoolYear + ); + } + + private function fieldExists(string $field): bool + { + return in_array($field, $this->db->getFieldNames($this->table), true); + } +} +``` + +Important correction: the registry, not `fieldExists()`, decides whether a table is year-scoped. `fieldExists()` may only choose between the transitional columns `school_year_id` and `school_year` after the model has already been classified as year-owned. + +Prefer a simpler explicit version per model during the transition: + +```php +public function forSchoolYear(SchoolYearContext $context): self +{ + return $this->where('school_year', $context->yearName()); +} +``` + +This is less magical and easier to audit. + +### Optional-context model methods + +Create methods only on applicable models: + +```php +public function includeGlobalAndYear( + SchoolYearContext $context +): self { + return $this + ->groupStart() + ->where('school_year', null) + ->orWhere('school_year', $context->yearName()) + ->groupEnd(); +} + +public function onlyForYear( + SchoolYearContext $context +): self { + return $this->where('school_year', $context->yearName()); +} +``` + +### Do Not Add School-Year Methods To Global Models + +Do not add automatic school-year behavior to: + +- `UserModel` +- `RoleModel` +- `PermissionModel` +- `SettingModel` +- `ConfigurationModel` +- `SchoolYearModel` +- `StudentModel` +- `FamilyModel` +- `ParentModel` +- `TeacherModel` +- `StaffModel` + +Identity models may expose methods that join year-owned relationship tables, but the identity table itself remains global. + +--- + +## Phase 6: Add Repositories or Domain Services for Complex Queries + +Use dedicated services or repositories for cross-table queries instead of placing joins in controllers. + +Suggested files: + +```text +app/Repositories/StudentRepository.php +app/Repositories/FamilyRepository.php +app/Repositories/TeacherRepository.php +app/Repositories/FinanceRepository.php +app/Services/Finance/BalanceService.php +app/Services/SchoolYearClosingService.php +``` + +Example student roster query: + +```php +db->table('students s'); + + $builder + ->select('s.*, e.class_id, e.status AS enrollment_status') + ->join('enrollments e', 'e.student_id = s.id') + ->where('e.school_year', $context->yearName()); + + if ($classId !== null) { + $builder->where('e.class_id', $classId); + } + + return $builder + ->orderBy('s.last_name', 'ASC') + ->orderBy('s.first_name', 'ASC') + ->get() + ->getResultArray(); + } +} +``` + +Example global profile with year-owned child data: + +```php +$family = $familyModel->find($familyId); + +if ($family === null) { + throw PageNotFoundException::forPageNotFound('Family not found.'); +} + +$invoices = $invoiceModel + ->where('family_id', $familyId) + ->forSchoolYear($context) + ->findAll(); +``` + +--- + +## Phase 7: Resolve Context in Controllers Consistently + +Create a base API controller helper or a small controller trait. + +Suggested file: + +```text +app/Controllers/BaseApiController.php +``` + +```php +resolve( + $this->request, + $routeSchoolYearId + ); + } catch (Throwable $e) { + log_message('warning', 'School-year resolution failed: {message}', [ + 'message' => $e->getMessage(), + ]); + + throw $e; + } + } +} +``` + +Controller usage: + +```php +public function index() +{ + $context = $this->resolveSchoolYearContext(); + + $rows = $this->invoiceModel + ->forSchoolYear($context) + ->orderBy('id', 'DESC') + ->findAll(); + + return $this->respond([ + 'school_year' => [ + 'id' => $context->id(), + 'name' => $context->yearName(), + 'status' => $context->status(), + 'readonly' => $context->isReadonly(), + ], + 'data' => $rows, + ]); +} +``` + +For year-owned endpoints, never silently continue without a valid context. + +--- + +## Phase 8: Update Routes + +Use explicit school-year routes for year-owned resources. + +In: + +```text +app/Config/Routes.php +``` + +Recommended structure: + +```php +$routes->group('api/v1', ['filter' => 'auth'], static function ($routes) { + // Global resources + $routes->get('users', 'Api\UserController::index'); + $routes->get('roles', 'Api\RoleController::index'); + $routes->get('permissions', 'Api\PermissionController::index'); + $routes->get('settings', 'Api\SettingController::index'); + $routes->get('school-years', 'Api\SchoolYearController::index'); + + // Current/resolved school year + $routes->group('school-years/current', static function ($routes) { + $routes->get('classes', 'Api\ClassController::index'); + $routes->get('families', 'Api\FamilyYearController::index'); + $routes->get( + 'reports/closing', + 'Api\SchoolYearClosingController::show' + ); + }); + + // Explicit selected school year + $routes->group( + 'school-years/(:num)', + static function ($routes) { + $routes->get('classes', 'Api\ClassController::index/$1'); + $routes->get( + 'families', + 'Api\FamilyYearController::index/$1' + ); + $routes->get( + 'reports/closing', + 'Api\SchoolYearClosingController::show/$1' + ); + } + ); + + // Global profile with year-specific child endpoints + $routes->get('families/(:num)', 'Api\FamilyController::show/$1'); + $routes->get( + 'school-years/(:num)/families/(:num)/invoices', + 'Api\FamilyInvoiceController::index/$1/$2' + ); +}); +``` + +Do not put global resources under school-year routes merely because the frontend currently appends `school_year` to every URL. + +Bad: + +```text +/api/v1/school-years/4/users +``` + +Correct: + +```text +/api/v1/users +/api/v1/school-years/4/teacher-assignments +``` + +Maintain compatibility with current query parameters temporarily, but route all resolution through `SchoolYearContextService`. + +--- + +## Phase 9: Add Request Filters Only for Cross-Cutting Validation + +A CodeIgniter filter may validate that a route requiring school-year context has one, but it must not mutate every database query globally. + +Suggested filter: + +```text +app/Filters/RequireSchoolYearFilter.php +``` + +Responsibilities: + +- Resolve and validate school-year context. +- Store the resolved context as a request attribute substitute, service state, or controller-accessible object. +- Return a controlled `400`, `403`, or `404` response when invalid. +- Never add SQL conditions itself. +- Never run on global endpoints. + +Register an alias in: + +```text +app/Config/Filters.php +``` + +```php +public array $aliases = [ + // ... + 'schoolYear' => \App\Filters\RequireSchoolYearFilter::class, +]; +``` + +Apply it only to year-owned route groups. + +Do not implement a universal model callback that appends `school_year` to every query. That would recreate the original bug with more ceremony. + +--- + +## Phase 10: Audit and Replace Existing Queries + +Search the CodeIgniter project: + +```bash +rg -n "school_year" app tests database +rg -n "school_year_id" app tests database +rg -n "getGet\(['\"]school_year" app +rg -n "getVar\(['\"]school_year" app +rg -n "where\(['\"][^'\"]*school_year" app +rg -n "db->table|builder\(" app +rg -n "allowedFields" app/Models +``` + +Classify every use: + +1. **Year-owned table** + Keep the filter, but obtain the value from validated context. + +2. **Global table** + Remove the filter and remove `school_year` from model `allowedFields`. + +3. **Identity table** + Replace direct filtering with joins through enrollment or assignment tables. + +4. **Optional-context table** + Choose global, global-plus-year, or strict-year behavior based on the endpoint. + +5. **Raw SQL** + Add explicit table aliases and scope only the year-owned table. + +6. **Write path** + Add the write guard and force the year value from backend context. + +Bad: + +```php +$currentYear = $this->request->getGet('school_year'); + +$users = $this->userModel + ->where('school_year', $currentYear) + ->findAll(); +``` + +Correct: + +```php +$users = $this->userModel + ->orderBy('last_name', 'ASC') + ->orderBy('first_name', 'ASC') + ->findAll(); +``` + +Bad: + +```php +$students = $this->studentModel + ->where('school_year', $currentYear) + ->findAll(); +``` + +Correct: + +```php +$context = $this->resolveSchoolYearContext(); + +$students = $this->studentRepository + ->findRosterForYear($context); +``` + +Bad write: + +```php +$data = $this->request->getJSON(true); +$this->paymentModel->insert($data); +``` + +Correct write: + +```php +$context = $this->resolveSchoolYearContext(); +service('schoolYearWriteGuard')->assertWritable($context); + +$data = $this->request->getJSON(true) ?? []; +$data['school_year'] = $context->yearName(); +$data['school_year_id'] = $context->id(); + +unset($data['requested_school_year'], $data['is_readonly']); + +$this->paymentModel->insert($data); +``` + +The client must not be allowed to write into one year while displaying another. + +--- + +## Phase 11: Update Model Definitions + +Review every CodeIgniter model. + +For year-owned models: + +- Include `school_year` and, during transition, `school_year_id` in `$allowedFields`. +- Add validation rules where required. +- Add `forSchoolYear()` or equivalent explicit methods. +- Do not automatically default to the active year inside `beforeInsert` unless every creation path is guaranteed to be year-specific. +- Prefer services/controllers to set the validated year explicitly. + +Example: + +```php + 'required|is_natural_no_zero', + 'amount' => 'required|decimal', + 'school_year' => 'required|max_length[20]', + ]; +} +``` + +For global models: + +- Remove `school_year` and `school_year_id` from `$allowedFields`. +- Remove school-year validation rules. +- Remove `beforeFind`, `beforeInsert`, or `beforeUpdate` callbacks that add the active year. +- Remove constructor logic reading `configuration.semester` or `configuration.school_year` unless the model genuinely owns year-specific records. + +For optional-context models: + +- Keep `school_year` nullable. +- Do not mark it `required`. +- Populate it only when the action has explicit school-year meaning. + +--- + +## Phase 12: Update Finance First + +Finance is the highest-risk area. + +Audit: + +- family and parent balances +- invoice listings +- invoice installment calculations +- payment listings +- payment allocation +- manual payment creation +- reimbursements +- refunds +- discounts and discount usage +- event charges +- carry-forward calculations +- closing previews +- closing reports +- opening balances +- transaction and notification logs + +Rules: + +1. Current-year balances use only current-year financial records. +2. Historical debt does not appear as current-year overdue debt unless an explicit carry-forward transaction exists. +3. Carry-forward creates a target-year record. It must not mutate historical invoices. +4. Closing previews use the selected year, not whatever year is globally active. +5. Every source table in an aggregate query must be reviewed separately. +6. Payment transactions and invoice-event links must inherit or explicitly store the same school year as their parent record. +7. Optional logs may remain null and must not be treated as financial ownership records. + +Example balance service: + +```php +public function calculateFamilyBalance( + int $familyId, + SchoolYearContext $context +): array { + $invoiceTotal = (float) $this->db + ->table('invoices') + ->selectSum('total_amount', 'total') + ->where('family_id', $familyId) + ->where('school_year', $context->yearName()) + ->get() + ->getRow('total'); + + $paymentTotal = (float) $this->db + ->table('payments') + ->selectSum('paid_amount', 'total') + ->where('family_id', $familyId) + ->where('school_year', $context->yearName()) + ->get() + ->getRow('total'); + + return [ + 'invoice_total' => $invoiceTotal, + 'payment_total' => $paymentTotal, + 'balance' => $invoiceTotal - $paymentTotal, + ]; +} +``` + +Do not compute a balance by summing all historical invoices and payments for a family. That is not an accounting shortcut. It is data contamination. + +Use database transactions for closing and carry-forward operations: + +```php +$this->db->transException(true)->transStart(); + +try { + // Lock/check source year. + // Create target-year carry-forward records. + // Mark closing batch. + // Write audit records. + $this->db->transComplete(); +} catch (\Throwable $e) { + log_message('error', 'School-year closing failed: {message}', [ + 'message' => $e->getMessage(), + ]); + + throw $e; +} +``` + +--- + +## Phase 13: Update Academic Queries + +Audit: + +- classes +- sections +- class-section mappings +- enrollments +- student placement +- teacher assignments +- attendance +- progress reports +- exam drafts +- exams +- semester scores +- final scores +- promotion +- report cards + +Rules: + +- `students` and `teachers` remain global. +- `classes`, `sections`, assignments, and enrollments are year-owned. +- Active-year rosters come from enrollment tables. +- Teacher pages derive students from year-owned assignments and enrollments. +- Reports always use the selected school year. +- Semester filtering is applied only to semester-owned academic records, not global identity or annual records. + +Example: + +```php +$builder = $this->db->table('classes c'); + +$classes = $builder + ->select('c.*, COUNT(DISTINCT e.student_id) AS student_count') + ->join( + 'enrollments e', + 'e.class_id = c.id AND e.school_year = c.school_year', + 'left' + ) + ->where('c.school_year', $context->yearName()) + ->groupBy('c.id') + ->orderBy('c.name', 'ASC') + ->get() + ->getResultArray(); +``` + +When both tables are year-owned, join on identifiers and ensure their school-year values agree. + +--- + +## Phase 14: Update Identity and Profile Pages + +Audit: + +- parent profiles +- family management +- student profiles +- teacher profiles +- staff pages + +Rules: + +1. Profile identity loads by global primary key. +2. Year-specific tabs use the selected context. +3. Historical profile activity remains accessible. +4. A person is not hidden globally merely because they lack an enrollment for the selected year. +5. Roster pages may show only identities participating in the selected year. +6. Profile pages and roster pages are different use cases. Do not force them through one misleading query. + +Example: + +```php +$parent = $this->parentModel->find($parentId); + +if ($parent === null) { + return $this->failNotFound('Parent not found.'); +} + +$invoices = $this->invoiceModel + ->where('parent_id', $parentId) + ->forSchoolYear($context) + ->findAll(); + +return $this->respond([ + 'parent' => $parent, + 'school_year' => $context->yearName(), + 'invoices' => $invoices, +]); +``` + +--- + +## Phase 15: Remove School-Year Logic From Global Features + +Remove year filtering from: + +- login +- token validation +- password reset +- sessions +- user management +- role and permission checks +- navigation/menu construction +- settings +- configuration +- global email templates +- cache +- migrations +- system health endpoints + +Bad: + +```php +$this->permissionModel + ->where('school_year', $context->yearName()) + ->findAll(); +``` + +Correct: + +```php +$this->permissionModel + ->orderBy('name', 'ASC') + ->findAll(); +``` + +If a setting truly varies by year, create a separate table: + +```text +school_year_settings +- id +- school_year_id +- config_key +- config_value +``` + +Do not turn `configuration` into a table where some rows are global and some are secretly annual without an explicit design. + +--- + +## Phase 16: Update Frontend Integration + +The backend remains authoritative, but the frontend should use a centralized school-year state. + +Required frontend state: + +```text +activeSchoolYear +selectedSchoolYear +selectedSchoolYearId +selectedSchoolYearName +selectedSchoolYearStatus +isReadonly +``` + +Frontend rules: + +1. Year-owned pages send the selected school-year ID. +2. Global pages do not depend on school-year parameters. +3. Navigation preserves the selected year only where useful. +4. Mutation buttons are disabled for read-only years. +5. The API still enforces writability even when buttons are disabled. +6. Dropdown lists use alphabetical ordering where applicable. +7. Tables support sorting consistently. +8. A selected year in the URL must match the resolved year returned by the API. + +Recommended API usage: + +```ts +api.get(`/api/v1/school-years/${selectedSchoolYearId}/classes`); +api.get(`/api/v1/school-years/${selectedSchoolYearId}/reports/closing`); + +api.get('/api/v1/users'); +api.get('/api/v1/roles'); +api.get('/api/v1/settings'); + +api.get(`/api/v1/families/${familyId}`); +api.get( + `/api/v1/school-years/${selectedSchoolYearId}/families/${familyId}/invoices` +); +``` + +During migration, existing endpoints using: + +```text +?school_year_id=4&school_year=2025-2026 +``` + +may remain, but the backend must reject conflicting values rather than choosing whichever one happens to be read first. + +--- + +## Phase 17: Add CodeIgniter Tests + +Use CodeIgniter 4 testing utilities and PHPUnit. + +Suggested test locations: + +```text +tests/unit/Support/SchoolYear/ +tests/unit/Services/ +tests/database/Models/ +tests/feature/Api/ +``` + +Run: + +```bash +php spark test +``` + +or: + +```bash +vendor/bin/phpunit +``` + +### Unit Tests + +Test: + +- `SchoolYearTableRegistry::categoryOf()` +- context resolution precedence +- invalid ID handling +- invalid year-name handling +- active-year fallback +- read-only status handling +- write-guard behavior + +### Database Tests + +Use: + +```php +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\DatabaseTestTrait; +``` + +Test year-owned models: + +- classes return only selected-year rows +- attendance returns only selected-year rows +- invoices return only selected-year rows +- payments return only selected-year rows +- progress reports return only selected-year rows + +Test global models: + +- users load regardless of selected year +- roles load regardless of selected year +- permissions load regardless of selected year +- configuration loads regardless of selected year +- all school years remain visible + +Test identity behavior: + +- student profile loads globally +- student roster changes by selected year +- family profile loads globally +- family balance changes by selected year +- teacher profile loads globally +- teacher assignments change by selected year + +Test context tables: + +- null-context logs remain visible in global views +- selected-year views include the intended rows +- strict reports exclude unrelated and null-context rows +- notification rows are not automatically stamped + +### Feature/API Tests + +Use `FeatureTestTrait`. + +Verify: + +- missing required context returns an error +- invalid school-year ID returns `404` or validation failure +- unauthorized historical access returns `403` +- closed-year writes return `403` +- active-year writes succeed +- global endpoints work without school-year parameters +- conflicting `school_year_id` and `school_year` values are rejected +- endpoint response includes resolved school-year metadata + +### Finance Regression Tests + +At minimum: + +- old-year invoice does not affect current-year balance +- old-year payment does not affect current-year balance +- carry-forward creates exactly one target-year record +- rerunning carry-forward is idempotent or blocked +- closing preview and closing execution produce matching totals +- rollback occurs when any closing step fails + +--- + +## Phase 18: Add Static and Runtime Safety Checks + +Add a CI script that searches for suspicious global-table filters. + +Suggested script: + +```text +scripts/check-school-year-filters.php +``` + +It should flag patterns such as: + +```text +UserModel ... where('school_year' +RoleModel ... where('school_year' +PermissionModel ... where('school_year' +ConfigurationModel ... where('school_year' +students.school_year +families.school_year +parents.school_year +teachers.school_year +staff.school_year +``` + +Also verify: + +- all year-owned models contain explicit year filtering methods +- global models do not contain school-year callbacks +- every year-owned insert path sets a backend-resolved school year +- every year-owned mutation invokes the write guard + +This check will not prove correctness, but it catches predictable regressions before they become production archaeology. + +--- + +## Phase 19: Database Cleanup Migration + +After application code no longer references incorrect columns, create a CodeIgniter migration. + +Generate it: + +```bash +php spark make:migration RemoveInvalidSchoolYearColumns +``` + +Suggested migration principles: + +1. Check the table and field before altering it. +2. Drop indexes or foreign keys first where necessary. +3. Remove only columns confirmed to be invalid. +4. Do not remove nullable context columns. +5. Keep the `down()` method conservative. Recreating a column does not restore deleted values. + +Candidate invalid columns: + +```text +school_years.school_year +users.school_year +authorized_users.school_year +roles.school_year +permissions.school_year +role_permissions.school_year +user_roles.school_year +nav_items.school_year +role_nav_items.school_year +settings.school_year +configuration.school_year +preferences.school_year +user_preferences.school_year +email_templates.school_year +attendance_comment_template.school_year +admin_notification_subjects.school_year +login_activity.school_year +ip_attempts.school_year +password_resets.school_year +password_reset_requests.school_year +migrations.school_year +families.school_year +parents.school_year +students.school_year +teachers.school_year +staff.school_year +family_guardians.school_year +family_students.school_year +emergency_contacts.school_year +student_allergies.school_year +student_medical_conditions.school_year +inventory_categories.school_year +inventory_items.school_year +suppliers.school_year +supplies.school_year +supply_categories.school_year +questiontypes.school_year +``` + +Migration outline: + +```php +targets as $table) { + if ( + $this->db->tableExists($table) + && $this->db->fieldExists('school_year', $table) + ) { + $this->forge->dropColumn($table, 'school_year'); + } + } + } + + public function down() + { + foreach ($this->targets as $table) { + if ( + $this->db->tableExists($table) + && ! $this->db->fieldExists('school_year', $table) + ) { + $this->forge->addColumn($table, [ + 'school_year' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + 'null' => true, + ], + ]); + } + } + } +} +``` + +Before running: + +```bash +php spark migrate:status +mysqldump --no-data school_view > school_view_schema_before_cleanup.sql +mysqldump school_view > school_view_backup_before_cleanup.sql +php spark migrate +php spark test +``` + +Cleanup order: + +1. Audit code references. +2. Remove invalid model fields and filters. +3. Deploy application changes. +4. Back up the database. +5. Run migration. +6. Run automated tests. +7. Run API smoke tests. +8. Review application logs. + +Dropping columns before removing application references is merely debugging with extra property damage. + +--- + +## Phase 20: Migrate From Text to `school_year_id` + +The final design should use foreign keys. + +Migration sequence: + +1. Add nullable `school_year_id` to year-owned tables. +2. Backfill it from `school_year`. +3. Add indexes. +4. Update models to allow both fields temporarily. +5. Update writes to set both fields. +6. Change reads to use `school_year_id`. +7. Validate that every year-owned record has a valid ID. +8. Add foreign keys. +9. Make `school_year_id` non-null where ownership is mandatory. +10. Remove text `school_year` only after all application and reporting code uses IDs. + +Generate migrations: + +```bash +php spark make:migration AddSchoolYearIdToYearOwnedTables +php spark make:migration BackfillSchoolYearIds +php spark make:migration EnforceSchoolYearForeignKeys +``` + +Backfill example: + +```php +$this->db->query( + 'UPDATE invoices i + JOIN school_years sy ON sy.name = i.school_year + SET i.school_year_id = sy.id + WHERE i.school_year_id IS NULL' +); +``` + +Index example: + +```php +$this->forge->addKey('school_year_id'); +``` + +Foreign key example: + +```php +$this->forge->addForeignKey( + 'school_year_id', + 'school_years', + 'id', + 'RESTRICT', + 'CASCADE' +); +``` + +Use `RESTRICT` for deletion unless there is a deliberate archival strategy. Cascading deletion of a school year through finance and academic history would be an impressively efficient catastrophe. + +--- + +## Implementation Order + +1. Confirm the table classification against the actual schema. +2. Add `SchoolYearTableRegistry`. +3. Add `SchoolYearContext`. +4. Add `SchoolYearContextService`. +5. Register the service in `Config\Services`. +6. Add `SchoolYearWriteGuard`. +7. Add explicit year-scoping methods to year-owned models. +8. Remove school-year behavior from global models. +9. Add repositories/services for identity relationship queries. +10. Update finance queries. +11. Update school-year closing preview and execution. +12. Update academic and attendance queries. +13. Update parent, family, student, teacher, and staff pages. +14. Update API routes and controller context resolution. +15. Update frontend school-year state and endpoint usage. +16. Add unit, database, feature, and regression tests. +17. Add static checks for prohibited filters. +18. Deploy code changes. +19. Back up the database. +20. Remove invalid columns through a CI4 migration. +21. Begin the `school_year_id` foreign-key migration. + +--- + +## Acceptance Criteria + +The implementation is complete only when: + +- No global table is filtered by `school_year`. +- No identity table is directly filtered by `school_year`. +- Every year-owned read uses validated selected-year context. +- Every year-owned write forces the year from backend context. +- Every year-owned write validates that the selected year is writable. +- Finance totals never mix years unless using an explicit carry-forward record. +- Closed and archived years reject normal mutations. +- Context tables remain nullable and are not automatically stamped. +- Global authentication and configuration work regardless of selected year. +- Profile identity remains accessible across years. +- Rosters and assignments change correctly with selected year. +- APIs return resolved year metadata. +- Conflicting year parameters are rejected. +- Tests prove that historical records do not leak into active-year pages. +- Tests prove that global pages do not break when the selected year changes. +- No required year-owned endpoint silently returns all rows when context is absent. +- All dropdown lists are alphabetically ordered where business ordering does not override it. +- All data tables provide consistent sorting. + +--- + +## Non-Negotiable Rule + +Never decide whether to apply school-year filtering merely by checking whether a column exists. + +Column existence describes the current schema. + +The table registry and domain rules define the intended behavior. diff --git a/scripts/check-school-year-filters.php b/scripts/check-school-year-filters.php new file mode 100644 index 0000000..44e735b --- /dev/null +++ b/scripts/check-school-year-filters.php @@ -0,0 +1,55 @@ +where\(\s*[\'"][^\'"]*school_year[\'"]/', + '/\b(users|roles|permissions|role_permissions|user_roles|configuration|settings|parents|teachers|staff)\.school_year\b/i', +]; + +$errors = []; + +foreach ($targets as $relativePath) { + $path = $root . DIRECTORY_SEPARATOR . $relativePath; + + if (! is_file($path)) { + continue; + } + + $lines = file($path, FILE_IGNORE_NEW_LINES); + + foreach ($lines as $index => $line) { + foreach ($patterns as $pattern) { + if (preg_match($pattern, $line) === 1) { + $errors[] = sprintf( + '%s:%d: suspicious school-year filter on a global/identity model: %s', + $relativePath, + $index + 1, + trim($line) + ); + } + } + } +} + +if ($errors !== []) { + fwrite(STDERR, implode(PHP_EOL, $errors) . PHP_EOL); + exit(1); +} + +echo 'No suspicious global/identity school-year filters found.' . PHP_EOL; diff --git a/tests/app/Services/SchoolYearWriteGuardTest.php b/tests/app/Services/SchoolYearWriteGuardTest.php new file mode 100644 index 0000000..064ada7 --- /dev/null +++ b/tests/app/Services/SchoolYearWriteGuardTest.php @@ -0,0 +1,51 @@ +assertWritable(new SchoolYearContext(4, '2025-2026', 'active')); + + $this->assertTrue(true); + } + + public function testAllowsDraftOnlyWhenExplicitlyAllowedForAdmin(): void + { + $guard = new SchoolYearWriteGuard(); + + $guard->assertWritable( + new SchoolYearContext(5, '2026-2027', 'draft'), + allowDraftForAdmin: true, + isAdmin: true + ); + + $this->assertTrue(true); + } + + public function testRejectsClosedYearWrites(): void + { + $this->expectException(RuntimeException::class); + + (new SchoolYearWriteGuard())->assertWritable( + new SchoolYearContext(3, '2024-2025', 'closed') + ); + } + + public function testRejectsDraftYearWritesByDefault(): void + { + $this->expectException(RuntimeException::class); + + (new SchoolYearWriteGuard())->assertWritable( + new SchoolYearContext(5, '2026-2027', 'draft') + ); + } +} diff --git a/tests/app/Support/SchoolYear/SchoolYearContextTest.php b/tests/app/Support/SchoolYear/SchoolYearContextTest.php new file mode 100644 index 0000000..8063002 --- /dev/null +++ b/tests/app/Support/SchoolYear/SchoolYearContextTest.php @@ -0,0 +1,39 @@ +assertTrue($context->isActive()); + $this->assertFalse($context->isReadonly()); + $this->assertTrue($context->isExplicitSelection()); + $this->assertSame('2025-2026', $context->yearName()); + } + + public function testClosedAndArchivedContextsAreReadonly(): void + { + $this->assertTrue((new SchoolYearContext(2, '2023-2024', 'closed'))->isReadonly()); + $this->assertTrue((new SchoolYearContext(1, '2022-2023', 'archived'))->isReadonly()); + $this->assertFalse((new SchoolYearContext(5, '2026-2027', 'draft'))->isReadonly()); + } + + public function testArrayRepresentationIncludesReadonlyState(): void + { + $context = new SchoolYearContext(3, '2024-2025', 'closed'); + + $this->assertSame([ + 'id' => 3, + 'name' => '2024-2025', + 'status' => 'closed', + 'readonly' => true, + 'explicitSelection' => false, + ], $context->toArray()); + } +} diff --git a/tests/app/Support/SchoolYear/SchoolYearTableRegistryTest.php b/tests/app/Support/SchoolYear/SchoolYearTableRegistryTest.php new file mode 100644 index 0000000..c8dee5b --- /dev/null +++ b/tests/app/Support/SchoolYear/SchoolYearTableRegistryTest.php @@ -0,0 +1,41 @@ +assertSame('YEAR_SCOPED', SchoolYearTableRegistry::categoryOf('invoices')); + $this->assertSame('YEAR_SCOPED', SchoolYearTableRegistry::categoryOf('attendance_data')); + } + + public function testClassifiesGlobalTables(): void + { + $this->assertSame('GLOBAL', SchoolYearTableRegistry::categoryOf('users')); + $this->assertSame('GLOBAL', SchoolYearTableRegistry::categoryOf('configuration')); + } + + public function testClassifiesIdentityTables(): void + { + $this->assertSame('IDENTITY_WITH_YEAR_RELATION', SchoolYearTableRegistry::categoryOf('students')); + $this->assertSame('IDENTITY_WITH_YEAR_RELATION', SchoolYearTableRegistry::categoryOf('teachers')); + } + + public function testClassifiesContextTables(): void + { + $this->assertSame('CONTEXT', SchoolYearTableRegistry::categoryOf('notifications')); + $this->assertSame('CONTEXT', SchoolYearTableRegistry::categoryOf('support_requests')); + } + + public function testRejectsUnregisteredTables(): void + { + $this->expectException(InvalidArgumentException::class); + + SchoolYearTableRegistry::categoryOf('unknown_table'); + } +}