From 716cc8b8d3edd57379c6dfecd096a3c98fc67a9f Mon Sep 17 00:00:00 2001 From: root Date: Sat, 18 Jul 2026 14:45:38 -0400 Subject: [PATCH] fix school year for all tables --- app/Config/Routes.php | 1 + .../SchoolYearClosingController.php | 41 +- .../View/LandingPageController.php | 9 +- app/Controllers/View/PaymentController.php | 55 +++ ...-05-26-000000_AddEventCategoryToEvents.php | 8 + ...-000700_AddBatchNumberToReimbursements.php | 9 +- ...earlyBatchNumberToReimbursementBatches.php | 8 + ...025-09-27-000001_CreateStaffAttendance.php | 4 +- ...2025-10-16-000200_CreateFamiliesTables.php | 9 +- ...-17-121500_AddNoSchoolToCalendarEvents.php | 9 +- ...0-000500_CreateParentAttendanceReports.php | 9 +- ...3-011228_AddDonationCategoryToExpenses.php | 8 + .../2026-01-05-215636_CreatePrintRequests.php | 10 +- ...1-05-215851_FixPrintRequestsForeignKey.php | 24 +- ...6-195508_RevertPrintRequestsForeignKey.php | 28 +- ...-06-200210_DropPrintRequestsForeignKey.php | 14 +- ...ixPrintRequestsForeignKeyOnceAndForAll.php | 24 +- ...205621_FixPrintRequestsForeignKeyAgain.php | 14 +- ...27-092000_AddEventTypeToCalendarEvents.php | 7 + ...02-16-000001_AddEventPaidFlagToCharges.php | 12 +- ...3-000002_AddClassSectionToEventCharges.php | 6 +- ...00003_AddEventPaymentRefToEventCharges.php | 6 +- ...xternalParticipantFieldsToEventCharges.php | 8 + ...05_AddExternalParentInfoToEventCharges.php | 8 + ...1_AddExternalParentEmailToEventCharges.php | 7 +- ...9-000001_AddWaiverSignedToEventCharges.php | 6 +- ...-07-17-000100_AddDescriptionToInvoices.php | 30 ++ ...7-18-000200_AlignSchemaToScoolViewDump.php | 336 ++++++++++++++++ app/Libraries/InvoiceLedgerService.php | 30 +- app/Models/AdditionalChargeModel.php | 5 +- app/Models/AdminNotificationSubjectModel.php | 8 + app/Models/AttendanceDataModel.php | 5 +- app/Models/AttendanceDayModel.php | 7 + app/Models/AttendanceRecordModel.php | 7 + app/Models/AttendanceTrackingModel.php | 7 + app/Models/BadgePrintLogModel.php | 7 + app/Models/BelowSixtyDecisionModel.php | 7 + app/Models/CalendarModel.php | 7 + app/Models/CertificateRecordModel.php | 7 + app/Models/ClassModel.php | 12 +- app/Models/ClassPrepAdjustmentModel.php | 10 +- app/Models/ClassPreparationLogModel.php | 8 + app/Models/ClassProgressAttachmentModel.php | 8 + app/Models/ClassProgressReportModel.php | 5 +- app/Models/ClassSectionModel.php | 8 + app/Models/CompetitionClassWinnerModel.php | 8 + app/Models/CompetitionModel.php | 7 + app/Models/CompetitionScoreModel.php | 8 + app/Models/CompetitionWinnerModel.php | 8 + .../Concerns/SchoolYearAutoFillTrait.php | 178 +++++++++ app/Models/ContactUsModel.php | 7 +- app/Models/CurrentFlagModel.php | 7 + app/Models/DiscountUsageModel.php | 6 +- app/Models/DiscountVoucherModel.php | 6 +- app/Models/EarlyDismissalSignatureModel.php | 5 +- app/Models/EnrollmentModel.php | 7 +- app/Models/EventChargesModel.php | 7 + app/Models/EventModel.php | 7 + app/Models/ExamDraftModel.php | 4 + app/Models/ExamModel.php | 7 + app/Models/ExpenseModel.php | 6 +- app/Models/FinalExamModel.php | 6 +- app/Models/FinalScoreModel.php | 7 + app/Models/FlagModel.php | 7 + app/Models/GradingLockModel.php | 7 + app/Models/HomeworkModel.php | 6 +- app/Models/InventoryCategoryModel.php | 8 + app/Models/InventoryItemModel.php | 5 + app/Models/InventoryMovementModel.php | 7 + app/Models/InvoiceEventModel.php | 7 + app/Models/InvoiceModel.php | 4 + app/Models/InvoiceStudentListModel.php | 10 +- app/Models/IpAttemptModel.php | 9 +- app/Models/LateSlipLogModel.php | 7 + app/Models/LoginActivityModel.php | 10 +- app/Models/ManualPaymentModel.php | 7 + app/Models/MessageModel.php | 10 +- app/Models/MidtermExamModel.php | 6 +- app/Models/MissingScoreOverrideModel.php | 7 + app/Models/NotificationModel.php | 10 +- app/Models/ParentAttendanceReportModel.php | 5 +- app/Models/ParentMeetingScheduleModel.php | 7 + app/Models/ParentNotificationModel.php | 7 + app/Models/ParticipationModel.php | 6 +- app/Models/PaymentErrorModel.php | 6 +- app/Models/PaymentModel.php | 5 +- app/Models/PaymentNotificationLogModel.php | 7 + app/Models/PaymentTransactionModel.php | 5 +- app/Models/PlacementBatchModel.php | 7 + app/Models/PlacementLevelModel.php | 8 + app/Models/PlacementScoreModel.php | 8 + app/Models/PrintRequestModel.php | 6 +- app/Models/ProjectModel.php | 7 + app/Models/PromotionQueueModel.php | 11 +- app/Models/QuizModel.php | 6 +- app/Models/RefundModel.php | 7 + .../ReimbursementBatchAdminFileModel.php | 8 + app/Models/ReimbursementBatchItemModel.php | 7 + app/Models/ReimbursementBatchModel.php | 7 + app/Models/ReimbursementModel.php | 7 + app/Models/ReportCardAcknowledgementModel.php | 7 + app/Models/SchoolYearClosingBatchModel.php | 8 + app/Models/SchoolYearClosingItemModel.php | 8 + app/Models/SchoolYearTransitionLogModel.php | 8 + app/Models/ScoreCommentModel.php | 7 + app/Models/SemesterScoreModel.php | 11 +- app/Models/StaffAttendanceModel.php | 7 + app/Models/StaffModel.php | 12 +- app/Models/StudentClassModel.php | 5 +- app/Models/StudentDecisionModel.php | 7 + .../StudentSectionDistributionDraftModel.php | 7 + app/Models/TeacherClassModel.php | 3 + ...cherSubmissionNotificationHistoryModel.php | 7 + app/Models/UserNotificationModel.php | 10 +- app/Models/WhatsappGroupLinkModel.php | 7 + app/Models/WhatsappGroupMembershipModel.php | 7 + app/Models/WhatsappInviteLogModel.php | 5 + app/Services/SchoolYearClosingService.php | 177 ++++++++- app/Views/landing_page/parent_dashboard.php | 2 +- app/Views/parent/invoice_payment.php | 2 + app/Views/payment/manual_pay.php | 54 ++- app/Views/school_years/closing_preview.php | 12 + ..._scool_view_vs_school_prod_1_2026-07-17.md | 153 ++++++++ ...on_scool_view_vs_school_prod_2026-07-17.md | 366 ++++++++++++++++++ .../Libraries/InvoiceLedgerServiceTest.php | 91 +++++ 125 files changed, 2315 insertions(+), 81 deletions(-) create mode 100644 app/Database/Migrations/2026-07-17-000100_AddDescriptionToInvoices.php create mode 100644 app/Database/Migrations/2026-07-18-000200_AlignSchemaToScoolViewDump.php create mode 100644 app/Models/Concerns/SchoolYearAutoFillTrait.php create mode 100644 reports/db_schema_comparison_scool_view_vs_school_prod_1_2026-07-17.md create mode 100644 reports/db_schema_comparison_scool_view_vs_school_prod_2026-07-17.md create mode 100644 tests/app/Libraries/InvoiceLedgerServiceTest.php diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 57fb311..f6824e3 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -1146,6 +1146,7 @@ $routes->group('administrator/school-years', ['filter' => 'auth:admin'], static $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->get('(:segment)/closing/preview', 'Administrator\SchoolYearClosingController::previewByName/$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'); diff --git a/app/Controllers/Administrator/SchoolYearClosingController.php b/app/Controllers/Administrator/SchoolYearClosingController.php index 2d0ac31..20baf38 100644 --- a/app/Controllers/Administrator/SchoolYearClosingController.php +++ b/app/Controllers/Administrator/SchoolYearClosingController.php @@ -14,11 +14,13 @@ class SchoolYearClosingController extends BaseController $targetId = $this->normalizeInt($this->request->getGet('target_school_year_id')); $preview = service('schoolYearClosing')->preview($id, $targetId); $promotionTable = $this->promotionTablePayload($preview['promotion']['rows'] ?? []); + $latestBatch = service('schoolYearClosing')->latestBatch($id); return view('school_years/closing_preview', [ 'preview' => $preview, 'promotionTable' => $promotionTable, - 'latestBatch' => service('schoolYearClosing')->latestBatch($id), + 'latestBatch' => $latestBatch, + 'missingCarryForwardInvoices' => $this->missingCarryForwardInvoiceCount($latestBatch), 'schoolYears' => (new SchoolYearModel())->orderBy('name', 'DESC')->findAll(), ]); } catch (Throwable $e) { @@ -26,6 +28,23 @@ class SchoolYearClosingController extends BaseController } } + public function previewByName(string $name) + { + try { + $year = (new SchoolYearModel()) + ->where('name', rawurldecode($name)) + ->first(); + + if ($year === null) { + return redirect()->to('/administrator/school-years')->with('error', 'School year was not found.'); + } + + return $this->preview((int) $year['id']); + } catch (Throwable $e) { + return redirect()->to('/administrator/school-years')->with('error', $e->getMessage()); + } + } + public function start(int $id) { try { @@ -76,6 +95,26 @@ class SchoolYearClosingController extends BaseController return is_numeric($value) && (int) $value > 0 ? (int) $value : null; } + private function missingCarryForwardInvoiceCount(?array $batch): int + { + if ($batch === null || ! in_array((string) ($batch['status'] ?? ''), ['executed', 'completed'], true)) { + return 0; + } + + $db = \Config\Database::connect(); + if (! $db->tableExists('school_year_closing_items')) { + return 0; + } + + return $db->table('school_year_closing_items') + ->where('closing_batch_id', (int) $batch['id']) + ->groupStart() + ->where('target_invoice_id', null) + ->orWhere('target_invoice_id', 0) + ->groupEnd() + ->countAllResults(); + } + private function promotionTablePayload(array $rows): array { $allowedSorts = ['student', 'school_id', 'class', 'year_score', 'decision', 'source', 'queue', 'target', 'status']; diff --git a/app/Controllers/View/LandingPageController.php b/app/Controllers/View/LandingPageController.php index 121ba56..dd0966f 100644 --- a/app/Controllers/View/LandingPageController.php +++ b/app/Controllers/View/LandingPageController.php @@ -860,8 +860,13 @@ class LandingPageController extends BaseController ->get() ->getResultArray(); - // Fetch latest invoice balance - $paymentBalance = $this->invoiceModel->getLatestInvoiceTotalAmount($parentId); + $paymentRow = $this->db->table('invoices') + ->select('COALESCE(SUM(balance), 0) AS account_balance') + ->where('parent_id', $parentId) + ->where('school_year', $this->schoolYear) + ->get() + ->getRowArray(); + $paymentBalance = (float) ($paymentRow['account_balance'] ?? 0); // Pass data to the view, including the deadlines return view('/landing_page/parent_dashboard', [ diff --git a/app/Controllers/View/PaymentController.php b/app/Controllers/View/PaymentController.php index f5438bc..6631240 100644 --- a/app/Controllers/View/PaymentController.php +++ b/app/Controllers/View/PaymentController.php @@ -297,6 +297,8 @@ class PaymentController extends ResourceController $payments = []; $invoices = []; $pager = null; + $carryForwardPaymentRequired = false; + $carryForwardPaymentMessage = ''; // Read the search term (email or phone) $searchTerm = trim((string) $this->request->getGet('search_term')); @@ -351,6 +353,10 @@ class PaymentController extends ResourceController if ($parent && !empty($parent['id'])) { $parentData = $parent; $parentId = (int) $parent['id']; + $carryForwardPaymentRequired = $this->parentHasActiveCarryForwardBalance($parentId, $this->schoolYear); + if ($carryForwardPaymentRequired) { + $carryForwardPaymentMessage = 'This parent has a balance carried over from a previous school year. Manual payments must be paid in full; installments are not allowed.'; + } // Students $students = $this->studentModel @@ -461,6 +467,8 @@ class PaymentController extends ResourceController 'searchTermUsedInSearch' => $searchTerm, 'todayYmd' => utc_now(), 'installmentEndYmd' => $installmentEndYmd, // SELECT will use this in data-end-date + 'carryForwardPaymentRequired' => $carryForwardPaymentRequired, + 'carryForwardPaymentMessage' => $carryForwardPaymentMessage, ]); } @@ -952,6 +960,15 @@ class PaymentController extends ResourceController // Recompute invoice totals from tuition + events + additional charges $currentBalance = (float) $this->invoiceLedgerService->recalculateInvoice($invoiceId)['balance']; + $carryForwardPaymentRequired = $this->parentHasActiveCarryForwardBalance($parentId, $invYear); + if ($carryForwardPaymentRequired && $paymentType === 'installment') { + $this->db->transRollback(); + return redirect()->back()->withInput()->with( + 'error', + 'This parent has a balance carried over from a previous school year. Installments are not allowed; payment must be made in full.' + ); + } + if ($amount > $currentBalance + 0.00001) { $this->db->transRollback(); return redirect()->back()->withInput()->with( @@ -960,6 +977,14 @@ class PaymentController extends ResourceController ); } + if ($carryForwardPaymentRequired && (float)round($amount, 2) !== (float)round($currentBalance, 2)) { + $this->db->transRollback(); + return redirect()->back()->withInput()->with( + 'error', + 'This parent has a balance carried over from a previous school year. Payment must equal the full remaining balance (' . number_format($currentBalance, 2) . ').' + ); + } + if ($paymentMethod === 'card' && (float)round($amount, 2) !== (float)round($currentBalance, 2)) { $this->db->transRollback(); return redirect()->back()->withInput()->with( @@ -1158,6 +1183,36 @@ class PaymentController extends ResourceController } } + private function parentHasActiveCarryForwardBalance(int $parentId, ?string $schoolYear = null): bool + { + if ($parentId <= 0 || ! $this->db->tableExists('invoices')) { + return false; + } + + $builder = $this->db->table('invoices') + ->where('parent_id', $parentId) + ->where('balance >', 0); + + if ($schoolYear !== null && $schoolYear !== '') { + $builder->where('school_year', $schoolYear); + } + + $builder->groupStart() + ->like('invoice_number', 'CF-', 'after') + ->orWhere('semester', 'Opening Balance'); + + if ($this->db->fieldExists('description', 'invoices')) { + $builder + ->orLike('description', 'carried over') + ->orLike('description', 'carry-forward') + ->orLike('description', 'previous school year'); + } + + $builder->groupEnd(); + + return $builder->countAllResults() > 0; + } + /** * 🔄 Helper: Recalculate invoice totals and status based on all payments for current school year diff --git a/app/Database/Migrations/2024-05-26-000000_AddEventCategoryToEvents.php b/app/Database/Migrations/2024-05-26-000000_AddEventCategoryToEvents.php index b619652..dddcad7 100644 --- a/app/Database/Migrations/2024-05-26-000000_AddEventCategoryToEvents.php +++ b/app/Database/Migrations/2024-05-26-000000_AddEventCategoryToEvents.php @@ -8,6 +8,10 @@ class AddEventCategoryToEvents extends Migration { public function up() { + if (! $this->db->tableExists('events')) { + return; + } + $fields = $this->db->getFieldNames('events'); if (!in_array('event_category', $fields, true)) { @@ -25,6 +29,10 @@ class AddEventCategoryToEvents extends Migration public function down() { + if (! $this->db->tableExists('events')) { + return; + } + $fields = $this->db->getFieldNames('events'); if (in_array('event_category', $fields, true)) { diff --git a/app/Database/Migrations/2024-11-05-000700_AddBatchNumberToReimbursements.php b/app/Database/Migrations/2024-11-05-000700_AddBatchNumberToReimbursements.php index 20d94c3..17d52db 100644 --- a/app/Database/Migrations/2024-11-05-000700_AddBatchNumberToReimbursements.php +++ b/app/Database/Migrations/2024-11-05-000700_AddBatchNumberToReimbursements.php @@ -8,6 +8,10 @@ class AddBatchNumberToReimbursements extends Migration { public function up() { + if (! $this->db->tableExists('reimbursements') || $this->db->fieldExists('batch_number', 'reimbursements')) { + return; + } + $fields = [ 'batch_number' => [ 'type' => 'INT', @@ -24,7 +28,10 @@ class AddBatchNumberToReimbursements extends Migration public function down() { + if (! $this->db->tableExists('reimbursements') || ! $this->db->fieldExists('batch_number', 'reimbursements')) { + return; + } + $this->forge->dropColumn('reimbursements', 'batch_number'); } } - diff --git a/app/Database/Migrations/2024-11-07-000800_AddYearlyBatchNumberToReimbursementBatches.php b/app/Database/Migrations/2024-11-07-000800_AddYearlyBatchNumberToReimbursementBatches.php index a44845d..8b53678 100644 --- a/app/Database/Migrations/2024-11-07-000800_AddYearlyBatchNumberToReimbursementBatches.php +++ b/app/Database/Migrations/2024-11-07-000800_AddYearlyBatchNumberToReimbursementBatches.php @@ -8,6 +8,10 @@ class AddYearlyBatchNumberToReimbursementBatches extends Migration { public function up() { + if (! $this->db->tableExists('reimbursement_batches') || $this->db->fieldExists('yearly_batch_number', 'reimbursement_batches')) { + return; + } + $fields = [ 'yearly_batch_number' => [ 'type' => 'INT', @@ -26,6 +30,10 @@ class AddYearlyBatchNumberToReimbursementBatches extends Migration public function down() { + if (! $this->db->tableExists('reimbursement_batches') || ! $this->db->fieldExists('yearly_batch_number', 'reimbursement_batches')) { + return; + } + $this->forge->dropColumn('reimbursement_batches', 'yearly_batch_number'); } diff --git a/app/Database/Migrations/2025-09-27-000001_CreateStaffAttendance.php b/app/Database/Migrations/2025-09-27-000001_CreateStaffAttendance.php index bf709cf..38585fb 100644 --- a/app/Database/Migrations/2025-09-27-000001_CreateStaffAttendance.php +++ b/app/Database/Migrations/2025-09-27-000001_CreateStaffAttendance.php @@ -24,7 +24,9 @@ class CreateStaffAttendance extends Migration ]); $this->forge->addKey('id', true); $this->forge->addUniqueKey(['user_id','date','semester','school_year']); - $this->forge->addForeignKey('user_id','users','id','CASCADE','CASCADE'); + if ($this->db->tableExists('users')) { + $this->forge->addForeignKey('user_id','users','id','CASCADE','CASCADE'); + } $this->forge->createTable('staff_attendance', true); } diff --git a/app/Database/Migrations/2025-10-16-000200_CreateFamiliesTables.php b/app/Database/Migrations/2025-10-16-000200_CreateFamiliesTables.php index a485cb7..05fb788 100644 --- a/app/Database/Migrations/2025-10-16-000200_CreateFamiliesTables.php +++ b/app/Database/Migrations/2025-10-16-000200_CreateFamiliesTables.php @@ -43,7 +43,9 @@ class CreateFamiliesTables extends Migration $this->forge->addKey('id', true); $this->forge->addUniqueKey(['family_id', 'student_id']); $this->forge->addForeignKey('family_id', 'families', 'id', 'CASCADE', 'CASCADE'); - $this->forge->addForeignKey('student_id', 'students', 'id', 'CASCADE', 'CASCADE'); + if ($this->db->tableExists('students')) { + $this->forge->addForeignKey('student_id', 'students', 'id', 'CASCADE', 'CASCADE'); + } $this->forge->createTable('family_students', true); // family_guardians @@ -62,7 +64,9 @@ class CreateFamiliesTables extends Migration $this->forge->addKey('id', true); $this->forge->addUniqueKey(['family_id', 'user_id']); $this->forge->addForeignKey('family_id', 'families', 'id', 'CASCADE', 'CASCADE'); - $this->forge->addForeignKey('user_id', 'users', 'id', 'CASCADE', 'CASCADE'); + if ($this->db->tableExists('users')) { + $this->forge->addForeignKey('user_id', 'users', 'id', 'CASCADE', 'CASCADE'); + } $this->forge->createTable('family_guardians', true); // family_comm_prefs @@ -90,4 +94,3 @@ class CreateFamiliesTables extends Migration $this->forge->dropTable('families', true); } } - diff --git a/app/Database/Migrations/2025-10-17-121500_AddNoSchoolToCalendarEvents.php b/app/Database/Migrations/2025-10-17-121500_AddNoSchoolToCalendarEvents.php index c78ae39..ea96b14 100644 --- a/app/Database/Migrations/2025-10-17-121500_AddNoSchoolToCalendarEvents.php +++ b/app/Database/Migrations/2025-10-17-121500_AddNoSchoolToCalendarEvents.php @@ -9,6 +9,10 @@ class AddNoSchoolToCalendarEvents extends Migration public function up() { $db = \Config\Database::connect(); + if (! $db->tableExists('calendar_events')) { + return; + } + $cols = []; try { $cols = $db->getFieldNames('calendar_events'); @@ -31,6 +35,10 @@ class AddNoSchoolToCalendarEvents extends Migration public function down() { + if (! $this->db->tableExists('calendar_events')) { + return; + } + try { $this->forge->dropColumn('calendar_events', 'no_school'); } catch (\Throwable $e) { @@ -38,4 +46,3 @@ class AddNoSchoolToCalendarEvents extends Migration } } } - diff --git a/app/Database/Migrations/2025-10-20-000500_CreateParentAttendanceReports.php b/app/Database/Migrations/2025-10-20-000500_CreateParentAttendanceReports.php index 602c0d1..415c266 100644 --- a/app/Database/Migrations/2025-10-20-000500_CreateParentAttendanceReports.php +++ b/app/Database/Migrations/2025-10-20-000500_CreateParentAttendanceReports.php @@ -85,8 +85,12 @@ class CreateParentAttendanceReports extends Migration $this->forge->addKey('id', true); $this->forge->addKey(['report_date']); $this->forge->addKey(['student_id', 'report_date']); - $this->forge->addForeignKey('parent_id', 'users', 'id', 'CASCADE', 'CASCADE'); - $this->forge->addForeignKey('student_id', 'students', 'id', 'CASCADE', 'CASCADE'); + if ($this->db->tableExists('users')) { + $this->forge->addForeignKey('parent_id', 'users', 'id', 'CASCADE', 'CASCADE'); + } + if ($this->db->tableExists('students')) { + $this->forge->addForeignKey('student_id', 'students', 'id', 'CASCADE', 'CASCADE'); + } $this->forge->createTable('parent_attendance_reports', true); } @@ -97,4 +101,3 @@ class CreateParentAttendanceReports extends Migration } } } - diff --git a/app/Database/Migrations/2025-11-23-011228_AddDonationCategoryToExpenses.php b/app/Database/Migrations/2025-11-23-011228_AddDonationCategoryToExpenses.php index 31e633d..4f1aab0 100644 --- a/app/Database/Migrations/2025-11-23-011228_AddDonationCategoryToExpenses.php +++ b/app/Database/Migrations/2025-11-23-011228_AddDonationCategoryToExpenses.php @@ -11,6 +11,10 @@ class AddDonationCategoryToExpenses extends Migration */ public function up() { + if (! $this->db->tableExists('expenses')) { + return; + } + $this->forge->modifyColumn('expenses', [ 'category' => [ 'type' => "ENUM('Expense','Purchase','Reimbursement','Donation')", @@ -24,6 +28,10 @@ class AddDonationCategoryToExpenses extends Migration */ public function down() { + if (! $this->db->tableExists('expenses')) { + return; + } + // Convert any donation rows to Expense to keep rollback safe. $this->db->table('expenses') ->where('category', 'Donation') diff --git a/app/Database/Migrations/2026-01-05-215636_CreatePrintRequests.php b/app/Database/Migrations/2026-01-05-215636_CreatePrintRequests.php index 4af78e5..23a3c05 100644 --- a/app/Database/Migrations/2026-01-05-215636_CreatePrintRequests.php +++ b/app/Database/Migrations/2026-01-05-215636_CreatePrintRequests.php @@ -61,9 +61,13 @@ class CreatePrintRequests extends Migration ], ]); $this->forge->addKey('id', true); - $this->forge->addForeignKey('teacher_id', 'users', 'id', 'CASCADE', 'CASCADE'); - $this->forge->addForeignKey('admin_id', 'users', 'id', 'CASCADE', 'SET NULL'); - $this->forge->addForeignKey('class_id', 'classes', 'id', 'CASCADE', 'CASCADE'); + if ($this->db->tableExists('users')) { + $this->forge->addForeignKey('teacher_id', 'users', 'id', 'CASCADE', 'CASCADE'); + $this->forge->addForeignKey('admin_id', 'users', 'id', 'CASCADE', 'SET NULL'); + } + if ($this->db->tableExists('classes')) { + $this->forge->addForeignKey('class_id', 'classes', 'id', 'CASCADE', 'CASCADE'); + } $this->forge->createTable('print_requests'); } diff --git a/app/Database/Migrations/2026-01-05-215851_FixPrintRequestsForeignKey.php b/app/Database/Migrations/2026-01-05-215851_FixPrintRequestsForeignKey.php index 44dbc30..6db5c25 100644 --- a/app/Database/Migrations/2026-01-05-215851_FixPrintRequestsForeignKey.php +++ b/app/Database/Migrations/2026-01-05-215851_FixPrintRequestsForeignKey.php @@ -8,8 +8,16 @@ class FixPrintRequestsForeignKey extends Migration { public function up() { + if (! $this->db->tableExists('print_requests')) { + return; + } + // Drop the old foreign key if it exists - $this->forge->dropForeignKey('print_requests', 'print_requests_class_id_foreign'); + try { + $this->forge->dropForeignKey('print_requests', 'print_requests_class_id_foreign'); + } catch (\Throwable $e) { + // Already absent. + } if (! $this->db->tableExists('class_sections')) { return; @@ -24,8 +32,20 @@ class FixPrintRequestsForeignKey extends Migration public function down() { + if (! $this->db->tableExists('print_requests')) { + return; + } + // Drop the new foreign key - $this->forge->dropForeignKey('print_requests', 'print_requests_class_id_foreign'); + try { + $this->forge->dropForeignKey('print_requests', 'print_requests_class_id_foreign'); + } catch (\Throwable $e) { + // Already absent. + } + + if (! $this->db->tableExists('classes')) { + return; + } // Re-add the old foreign key $this->forge->addForeignKey('class_id', 'classes', 'id', 'CASCADE', 'CASCADE'); diff --git a/app/Database/Migrations/2026-01-06-195508_RevertPrintRequestsForeignKey.php b/app/Database/Migrations/2026-01-06-195508_RevertPrintRequestsForeignKey.php index fd73e11..d2b68c5 100644 --- a/app/Database/Migrations/2026-01-06-195508_RevertPrintRequestsForeignKey.php +++ b/app/Database/Migrations/2026-01-06-195508_RevertPrintRequestsForeignKey.php @@ -8,16 +8,28 @@ class RevertPrintRequestsForeignKey extends Migration { public function up() { + if (! $this->db->tableExists('print_requests')) { + return; + } + // Drop the old foreign key if it exists $db = \Config\Database::connect(); $keys = $db->getForeignKeyData('print_requests'); foreach ($keys as $key) { if ($key->constraint_name === 'print_requests_class_id_foreign') { - $this->forge->dropForeignKey('print_requests', 'print_requests_class_id_foreign'); + try { + $this->forge->dropForeignKey('print_requests', 'print_requests_class_id_foreign'); + } catch (\Throwable $e) { + // Already absent. + } break; } } + if (! $this->db->tableExists('classes')) { + return; + } + // Add the new foreign key $this->forge->addForeignKey('class_id', 'classes', 'id', 'CASCADE', 'CASCADE'); @@ -27,8 +39,20 @@ class RevertPrintRequestsForeignKey extends Migration public function down() { + if (! $this->db->tableExists('print_requests')) { + return; + } + // Drop the new foreign key - $this->forge->dropForeignKey('print_requests', 'print_requests_class_id_foreign'); + try { + $this->forge->dropForeignKey('print_requests', 'print_requests_class_id_foreign'); + } catch (\Throwable $e) { + // Already absent. + } + + if (! $this->db->tableExists('class_sections')) { + return; + } // Re-add the old foreign key $this->forge->addForeignKey('class_id', 'class_sections', 'id', 'CASCADE', 'CASCADE'); diff --git a/app/Database/Migrations/2026-01-06-200210_DropPrintRequestsForeignKey.php b/app/Database/Migrations/2026-01-06-200210_DropPrintRequestsForeignKey.php index acffa36..e0344a3 100644 --- a/app/Database/Migrations/2026-01-06-200210_DropPrintRequestsForeignKey.php +++ b/app/Database/Migrations/2026-01-06-200210_DropPrintRequestsForeignKey.php @@ -8,12 +8,24 @@ class DropPrintRequestsForeignKey extends Migration { public function up() { + if (! $this->db->tableExists('print_requests')) { + return; + } + // Drop the old foreign key if it exists - $this->forge->dropForeignKey('print_requests', 'print_requests_class_id_foreign'); + try { + $this->forge->dropForeignKey('print_requests', 'print_requests_class_id_foreign'); + } catch (\Throwable $e) { + // Already absent. + } } public function down() { + if (! $this->db->tableExists('print_requests') || ! $this->db->tableExists('class_sections')) { + return; + } + // Re-add the old foreign key $this->forge->addForeignKey('class_id', 'class_sections', 'id', 'CASCADE', 'CASCADE'); diff --git a/app/Database/Migrations/2026-01-06-201209_FixPrintRequestsForeignKeyOnceAndForAll.php b/app/Database/Migrations/2026-01-06-201209_FixPrintRequestsForeignKeyOnceAndForAll.php index e4aab1f..b5c2f08 100644 --- a/app/Database/Migrations/2026-01-06-201209_FixPrintRequestsForeignKeyOnceAndForAll.php +++ b/app/Database/Migrations/2026-01-06-201209_FixPrintRequestsForeignKeyOnceAndForAll.php @@ -8,21 +8,41 @@ class FixPrintRequestsForeignKeyOnceAndForAll extends Migration { public function up() { + if (! $this->db->tableExists('print_requests')) { + return; + } + $db = \Config\Database::connect(); $keys = $db->getForeignKeyData('print_requests'); foreach ($keys as $key) { if ($key->constraint_name === 'print_requests_class_id_foreign') { - $this->forge->dropForeignKey('print_requests', 'print_requests_class_id_foreign'); + try { + $this->forge->dropForeignKey('print_requests', 'print_requests_class_id_foreign'); + } catch (\Throwable $e) { + // Already absent. + } break; } } + if (! $this->db->tableExists('classSection')) { + return; + } + $this->forge->addForeignKey('class_id', 'classSection', 'id', 'CASCADE', 'CASCADE'); $this->forge->processIndexes('print_requests'); } public function down() { - $this->forge->dropForeignKey('print_requests', 'print_requests_class_id_foreign'); + if (! $this->db->tableExists('print_requests')) { + return; + } + + try { + $this->forge->dropForeignKey('print_requests', 'print_requests_class_id_foreign'); + } catch (\Throwable $e) { + // Already absent. + } } } diff --git a/app/Database/Migrations/2026-01-06-205621_FixPrintRequestsForeignKeyAgain.php b/app/Database/Migrations/2026-01-06-205621_FixPrintRequestsForeignKeyAgain.php index 6469c2b..1f6fe38 100644 --- a/app/Database/Migrations/2026-01-06-205621_FixPrintRequestsForeignKeyAgain.php +++ b/app/Database/Migrations/2026-01-06-205621_FixPrintRequestsForeignKeyAgain.php @@ -8,6 +8,10 @@ class FixPrintRequestsForeignKeyAgain extends Migration { public function up() { + if (! $this->db->tableExists('print_requests') || ! $this->db->tableExists('classSection')) { + return; + } + $db = \Config\Database::connect(); $keys = $db->getForeignKeyData('print_requests'); foreach ($keys as $key) { @@ -22,6 +26,14 @@ class FixPrintRequestsForeignKeyAgain extends Migration public function down() { - $this->forge->dropForeignKey('print_requests', 'print_requests_class_id_foreign'); + if (! $this->db->tableExists('print_requests')) { + return; + } + + try { + $this->forge->dropForeignKey('print_requests', 'print_requests_class_id_foreign'); + } catch (\Throwable $e) { + // Already absent. + } } } diff --git a/app/Database/Migrations/2026-01-27-092000_AddEventTypeToCalendarEvents.php b/app/Database/Migrations/2026-01-27-092000_AddEventTypeToCalendarEvents.php index d9766e8..1d0eb43 100644 --- a/app/Database/Migrations/2026-01-27-092000_AddEventTypeToCalendarEvents.php +++ b/app/Database/Migrations/2026-01-27-092000_AddEventTypeToCalendarEvents.php @@ -9,6 +9,9 @@ class AddEventTypeToCalendarEvents extends Migration public function up() { $db = \Config\Database::connect(); + if (! $db->tableExists('calendar_events')) { + return; + } if (!$db->fieldExists('event_type', 'calendar_events')) { $this->forge->addColumn('calendar_events', [ @@ -26,6 +29,10 @@ class AddEventTypeToCalendarEvents extends Migration public function down() { $db = \Config\Database::connect(); + if (! $db->tableExists('calendar_events')) { + return; + } + if ($db->fieldExists('event_type', 'calendar_events')) { $this->forge->dropColumn('calendar_events', 'event_type'); } diff --git a/app/Database/Migrations/2026-02-16-000001_AddEventPaidFlagToCharges.php b/app/Database/Migrations/2026-02-16-000001_AddEventPaidFlagToCharges.php index b957885..1a0eb85 100644 --- a/app/Database/Migrations/2026-02-16-000001_AddEventPaidFlagToCharges.php +++ b/app/Database/Migrations/2026-02-16-000001_AddEventPaidFlagToCharges.php @@ -8,6 +8,10 @@ class AddEventPaidFlagToCharges extends Migration { public function up() { + if (! $this->db->tableExists('event_charges')) { + return; + } + $fields = [ 'event_paid' => [ 'type' => 'TINYINT', @@ -17,11 +21,15 @@ class AddEventPaidFlagToCharges extends Migration ], ]; - $this->forge->addColumn('event_charges', $fields); + if (! $this->db->fieldExists('event_paid', 'event_charges')) { + $this->forge->addColumn('event_charges', $fields); + } } public function down() { - $this->forge->dropColumn('event_charges', 'event_paid'); + if ($this->db->tableExists('event_charges') && $this->db->fieldExists('event_paid', 'event_charges')) { + $this->forge->dropColumn('event_charges', 'event_paid'); + } } } diff --git a/app/Database/Migrations/2026-04-13-000002_AddClassSectionToEventCharges.php b/app/Database/Migrations/2026-04-13-000002_AddClassSectionToEventCharges.php index b93d2f5..0eaaf06 100644 --- a/app/Database/Migrations/2026-04-13-000002_AddClassSectionToEventCharges.php +++ b/app/Database/Migrations/2026-04-13-000002_AddClassSectionToEventCharges.php @@ -8,6 +8,10 @@ class AddClassSectionToEventCharges extends Migration { public function up() { + if (! $this->db->tableExists('event_charges')) { + return; + } + $fields = [ 'class_section_id' => [ 'type' => 'INT', @@ -25,7 +29,7 @@ class AddClassSectionToEventCharges extends Migration public function down() { - if ($this->db->fieldExists('class_section_id', 'event_charges')) { + if ($this->db->tableExists('event_charges') && $this->db->fieldExists('class_section_id', 'event_charges')) { $this->forge->dropColumn('event_charges', 'class_section_id'); } } diff --git a/app/Database/Migrations/2026-04-13-000003_AddEventPaymentRefToEventCharges.php b/app/Database/Migrations/2026-04-13-000003_AddEventPaymentRefToEventCharges.php index 8853af3..56b883a 100644 --- a/app/Database/Migrations/2026-04-13-000003_AddEventPaymentRefToEventCharges.php +++ b/app/Database/Migrations/2026-04-13-000003_AddEventPaymentRefToEventCharges.php @@ -8,6 +8,10 @@ class AddEventPaymentRefToEventCharges extends Migration { public function up() { + if (! $this->db->tableExists('event_charges')) { + return; + } + $fields = [ 'event_payment_id' => [ 'type' => 'INT', @@ -25,7 +29,7 @@ class AddEventPaymentRefToEventCharges extends Migration public function down() { - if ($this->db->fieldExists('event_payment_id', 'event_charges')) { + if ($this->db->tableExists('event_charges') && $this->db->fieldExists('event_payment_id', 'event_charges')) { $this->forge->dropColumn('event_charges', 'event_payment_id'); } } diff --git a/app/Database/Migrations/2026-04-13-000004_AddExternalParticipantFieldsToEventCharges.php b/app/Database/Migrations/2026-04-13-000004_AddExternalParticipantFieldsToEventCharges.php index 14e346e..91ebba3 100644 --- a/app/Database/Migrations/2026-04-13-000004_AddExternalParticipantFieldsToEventCharges.php +++ b/app/Database/Migrations/2026-04-13-000004_AddExternalParticipantFieldsToEventCharges.php @@ -8,6 +8,10 @@ class AddExternalParticipantFieldsToEventCharges extends Migration { public function up() { + if (! $this->db->tableExists('event_charges')) { + return; + } + $fields = [ 'external_firstname' => [ 'type' => 'VARCHAR', @@ -34,6 +38,10 @@ class AddExternalParticipantFieldsToEventCharges extends Migration public function down() { + if (! $this->db->tableExists('event_charges')) { + return; + } + foreach (['external_firstname', 'external_lastname', 'external_note'] as $field) { if ($this->db->fieldExists($field, 'event_charges')) { $this->forge->dropColumn('event_charges', $field); diff --git a/app/Database/Migrations/2026-04-13-000005_AddExternalParentInfoToEventCharges.php b/app/Database/Migrations/2026-04-13-000005_AddExternalParentInfoToEventCharges.php index 6341d01..7dfcd9c 100644 --- a/app/Database/Migrations/2026-04-13-000005_AddExternalParentInfoToEventCharges.php +++ b/app/Database/Migrations/2026-04-13-000005_AddExternalParentInfoToEventCharges.php @@ -8,6 +8,10 @@ class AddExternalParentInfoToEventCharges extends Migration { public function up() { + if (! $this->db->tableExists('event_charges')) { + return; + } + $fields = [ 'external_parent_firstname' => [ 'type' => 'VARCHAR', @@ -34,6 +38,10 @@ class AddExternalParentInfoToEventCharges extends Migration public function down() { + if (! $this->db->tableExists('event_charges')) { + return; + } + foreach (['external_parent_firstname', 'external_parent_lastname', 'external_parent_phone'] as $field) { if ($this->db->fieldExists($field, 'event_charges')) { $this->forge->dropColumn('event_charges', $field); diff --git a/app/Database/Migrations/2026-04-17-000001_AddExternalParentEmailToEventCharges.php b/app/Database/Migrations/2026-04-17-000001_AddExternalParentEmailToEventCharges.php index 0e09fca..2d8ff65 100644 --- a/app/Database/Migrations/2026-04-17-000001_AddExternalParentEmailToEventCharges.php +++ b/app/Database/Migrations/2026-04-17-000001_AddExternalParentEmailToEventCharges.php @@ -8,6 +8,10 @@ class AddExternalParentEmailToEventCharges extends Migration { public function up() { + if (! $this->db->tableExists('event_charges')) { + return; + } + $fields = [ 'external_parent_email' => [ 'type' => 'VARCHAR', @@ -24,9 +28,8 @@ class AddExternalParentEmailToEventCharges extends Migration public function down() { - if ($this->db->fieldExists('external_parent_email', 'event_charges')) { + if ($this->db->tableExists('event_charges') && $this->db->fieldExists('external_parent_email', 'event_charges')) { $this->forge->dropColumn('event_charges', 'external_parent_email'); } } } - diff --git a/app/Database/Migrations/2026-04-19-000001_AddWaiverSignedToEventCharges.php b/app/Database/Migrations/2026-04-19-000001_AddWaiverSignedToEventCharges.php index 26e5ac4..22900b5 100644 --- a/app/Database/Migrations/2026-04-19-000001_AddWaiverSignedToEventCharges.php +++ b/app/Database/Migrations/2026-04-19-000001_AddWaiverSignedToEventCharges.php @@ -8,6 +8,10 @@ class AddWaiverSignedToEventCharges extends Migration { public function up() { + if (! $this->db->tableExists('event_charges')) { + return; + } + $fields = [ 'waiver_signed' => [ 'type' => 'TINYINT', @@ -24,7 +28,7 @@ class AddWaiverSignedToEventCharges extends Migration public function down() { - if ($this->db->fieldExists('waiver_signed', 'event_charges')) { + if ($this->db->tableExists('event_charges') && $this->db->fieldExists('waiver_signed', 'event_charges')) { $this->forge->dropColumn('event_charges', 'waiver_signed'); } } diff --git a/app/Database/Migrations/2026-07-17-000100_AddDescriptionToInvoices.php b/app/Database/Migrations/2026-07-17-000100_AddDescriptionToInvoices.php new file mode 100644 index 0000000..7544744 --- /dev/null +++ b/app/Database/Migrations/2026-07-17-000100_AddDescriptionToInvoices.php @@ -0,0 +1,30 @@ +db->tableExists('invoices') || $this->db->fieldExists('description', 'invoices')) { + return; + } + + $this->forge->addColumn('invoices', [ + 'description' => [ + 'type' => 'TEXT', + 'null' => true, + 'after' => 'status', + ], + ]); + } + + public function down(): void + { + if ($this->db->tableExists('invoices') && $this->db->fieldExists('description', 'invoices')) { + $this->forge->dropColumn('invoices', 'description'); + } + } +} diff --git a/app/Database/Migrations/2026-07-18-000200_AlignSchemaToScoolViewDump.php b/app/Database/Migrations/2026-07-18-000200_AlignSchemaToScoolViewDump.php new file mode 100644 index 0000000..c8b2602 --- /dev/null +++ b/app/Database/Migrations/2026-07-18-000200_AlignSchemaToScoolViewDump.php @@ -0,0 +1,336 @@ + ['semester'], + 'contactus' => ['semester'], + 'discount_vouchers' => ['semester'], + 'emergency_contacts' => ['semester', 'school_year'], + 'ip_attempts' => ['semester'], + 'login_activity' => ['semester'], + 'notification_recipients' => ['semester'], + 'notifications' => ['semester'], + 'parents' => ['semester', 'school_year'], + 'payment_error' => ['semester'], + 'payment_transactions' => ['semester'], + 'payments' => ['semester'], + 'paypal_transactions' => ['semester'], + 'preferences' => ['school_year'], + 'refunds' => ['semester'], + 'reimbursement_batch_items' => ['semester'], + 'reimbursement_batches' => ['semester'], + 'reimbursements' => ['semester'], + 'students' => ['semester', 'school_year'], + 'support_requests' => ['semester'], + 'user_notifications' => ['semester'], + 'users' => ['semester', 'school_year'], + 'whatsapp_group_links' => ['semester'], + 'whatsapp_group_memberships' => ['semester'], + ]; + + /** + * Tables that should own a required school_year value after aligning the schema. + * + * @var list + */ + private array $schoolYearTables = [ + 'admin_notification_subjects', + 'attendance_comment_template', + 'class_progress_attachments', + 'competition_class_winners', + 'competition_scores', + 'competition_winners', + 'inventory_categories', + 'paypal_payments', + 'placement_scores', + 'promotion_queue', + 'qcmquestions', + 'school_year_closing_batches', + 'school_year_closing_items', + 'school_year_transition_logs', + 'staff', + 'whatsapp_group_links', + 'whatsapp_invites_log', + ]; + + public function up(): void + { + if ($this->db->DBDriver !== 'MySQLi') { + throw new RuntimeException('This migration requires MySQL/MariaDB through the MySQLi driver.'); + } + + $this->dropIndexIfExists('competition_winners', 'competition_id_class_section_id_rank'); + $this->dropIndexIfExists('refunds', 'idx_refunds_parent_year_semester_status'); + $this->dropIndexIfExists('teacher_class', 'idx_sy_teacher_class_school_year_223511c0'); + $this->dropIndexIfExists('whatsapp_group_links', 'uq_section_term'); + $this->dropIndexIfExists('whatsapp_group_memberships', 'uniq_whatsapp_membership'); + $this->dropIndexIfExists('whatsapp_group_memberships', 'class_section_id_school_year_semester'); + + foreach ($this->columnsToDrop as $table => $columns) { + foreach ($columns as $column) { + $this->dropColumnIfExists($table, $column); + } + } + + $this->db->resetDataCache(); + + foreach ($this->schoolYearTables as $table) { + $this->ensureRequiredSchoolYearColumn($table); + } + + $this->db->resetDataCache(); + + $this->ensureIndex( + 'refunds', + 'idx_refunds_parent_year_semester_status', + ['parent_id', 'school_year', 'status'] + ); + + $this->ensureIndex( + 'teacher_class', + 'unique_teacher_assignment', + ['teacher_id', 'class_section_id', 'school_year'], + true + ); + + $this->ensureIndex( + 'whatsapp_group_links', + 'uq_section_term', + ['class_section_id'], + true + ); + + $this->ensureIndex( + 'whatsapp_group_memberships', + 'uniq_whatsapp_membership', + ['class_section_id', 'school_year', 'subject_type', 'subject_id'], + true + ); + + $this->ensureIndex( + 'whatsapp_group_memberships', + 'class_section_id_school_year_semester', + ['class_section_id', 'school_year'] + ); + + if ($this->db->tableExists('whatsapp_group_links')) { + $this->db->query( + 'ALTER TABLE `whatsapp_group_links` ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci' + ); + } + } + + public function down(): void + { + throw new RuntimeException( + 'This migration is intentionally irreversible because matching scool_view drops columns and their data.' + ); + } + + private function ensureRequiredSchoolYearColumn(string $table): void + { + if (! $this->db->tableExists($table)) { + return; + } + + if (! $this->db->fieldExists('school_year', $table)) { + $this->forge->addColumn($table, [ + 'school_year' => [ + 'type' => 'VARCHAR', + 'constraint' => 9, + 'null' => true, + ], + ]); + } + + $year = $this->currentSchoolYear(); + $this->db->query( + sprintf( + 'UPDATE %s SET `school_year` = ? WHERE `school_year` IS NULL OR TRIM(`school_year`) = \'\'', + $this->quoteIdentifier($table) + ), + [$year] + ); + + $this->db->query(sprintf( + 'ALTER TABLE %s MODIFY `school_year` VARCHAR(9) NOT NULL', + $this->quoteIdentifier($table) + )); + } + + private function currentSchoolYear(): string + { + if ($this->db->tableExists('configuration')) { + $row = $this->db->table('configuration') + ->select('config_value') + ->whereIn('config_key', ['school_year', 'current_school_year']) + ->where('config_value IS NOT NULL', null, false) + ->where('TRIM(config_value) <>', '') + ->orderBy("FIELD(config_key, 'school_year', 'current_school_year')", '', false) + ->get(1) + ->getRowArray(); + + if (isset($row['config_value']) && preg_match('/^\d{4}-\d{4}$/', (string) $row['config_value'])) { + return (string) $row['config_value']; + } + } + + $year = (int) date('Y'); + + return $year . '-' . ($year + 1); + } + + private function dropColumnIfExists(string $table, string $column): void + { + if (! $this->db->tableExists($table) || ! $this->db->fieldExists($column, $table)) { + return; + } + + $this->dropForeignKeysContainingColumn($table, $column); + $this->dropIndexesContainingColumn($table, $column); + $this->dropChecksContainingColumn($table, $column); + + $this->forge->dropColumn($table, $column); + } + + private function ensureIndex(string $table, string $index, array $columns, bool $unique = false): void + { + if (! $this->db->tableExists($table) || ! $this->hasColumns($table, $columns)) { + return; + } + + if ($this->indexExists($table, $index)) { + return; + } + + $keyword = $unique ? 'UNIQUE INDEX' : 'INDEX'; + $columnList = implode(', ', array_map([$this, 'quoteIdentifier'], $columns)); + + $this->db->query(sprintf( + 'ALTER TABLE %s ADD %s %s (%s)', + $this->quoteIdentifier($table), + $keyword, + $this->quoteIdentifier($index), + $columnList + )); + } + + private function dropIndexIfExists(string $table, string $index): void + { + if (! $this->db->tableExists($table) || ! $this->indexExists($table, $index)) { + return; + } + + $this->db->query(sprintf( + 'ALTER TABLE %s DROP INDEX %s', + $this->quoteIdentifier($table), + $this->quoteIdentifier($index) + )); + } + + private function dropIndexesContainingColumn(string $table, string $column): void + { + $indexes = $this->db->query( + 'SELECT DISTINCT INDEX_NAME + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND COLUMN_NAME = ? + AND INDEX_NAME <> \'PRIMARY\'', + [$table, $column] + )->getResult(); + + foreach ($indexes as $index) { + $this->dropIndexIfExists($table, $index->INDEX_NAME); + } + } + + private function dropForeignKeysContainingColumn(string $table, string $column): void + { + $constraints = $this->db->query( + 'SELECT DISTINCT CONSTRAINT_NAME + FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND COLUMN_NAME = ? + AND REFERENCED_TABLE_NAME IS NOT NULL', + [$table, $column] + )->getResult(); + + foreach ($constraints as $constraint) { + $this->db->query(sprintf( + 'ALTER TABLE %s DROP FOREIGN KEY %s', + $this->quoteIdentifier($table), + $this->quoteIdentifier($constraint->CONSTRAINT_NAME) + )); + } + } + + private function dropChecksContainingColumn(string $table, string $column): void + { + $checks = $this->db->query( + 'SELECT tc.CONSTRAINT_NAME + FROM information_schema.TABLE_CONSTRAINTS tc + INNER JOIN information_schema.CHECK_CONSTRAINTS cc + ON cc.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA + AND cc.CONSTRAINT_NAME = tc.CONSTRAINT_NAME + WHERE tc.CONSTRAINT_SCHEMA = DATABASE() + AND tc.TABLE_NAME = ? + AND tc.CONSTRAINT_TYPE = \'CHECK\' + AND cc.CHECK_CLAUSE LIKE ?', + [$table, '%' . $column . '%'] + )->getResult(); + + foreach ($checks as $check) { + $this->db->query(sprintf( + 'ALTER TABLE %s DROP CHECK %s', + $this->quoteIdentifier($table), + $this->quoteIdentifier($check->CONSTRAINT_NAME) + )); + } + } + + private function indexExists(string $table, string $index): bool + { + $row = $this->db->query( + 'SELECT COUNT(*) AS aggregate + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND INDEX_NAME = ?', + [$table, $index] + )->getRow(); + + return (int) ($row->aggregate ?? 0) > 0; + } + + private function hasColumns(string $table, array $columns): bool + { + foreach ($columns as $column) { + if (! $this->db->fieldExists($column, $table)) { + return false; + } + } + + return true; + } + + private function quoteIdentifier(string $identifier): string + { + return '`' . str_replace('`', '``', $identifier) . '`'; + } +} diff --git a/app/Libraries/InvoiceLedgerService.php b/app/Libraries/InvoiceLedgerService.php index e401cb5..5c1302f 100644 --- a/app/Libraries/InvoiceLedgerService.php +++ b/app/Libraries/InvoiceLedgerService.php @@ -74,9 +74,15 @@ class InvoiceLedgerService $paidCents = $this->toCents($paidTotal); $refundPaidCents = $this->toCents($refundPaidTotal); - $discountBaseCents = max(0, $tuitionCents + $additionalCents); - $discountCents = min($discountRawCents, $discountBaseCents); - $totalAmountCents = $tuitionCents + $eventCents + $additionalCents; + if ($this->isCarryForwardInvoice($invoice)) { + $totalAmountCents = $this->toCents((float) ($invoice['total_amount'] ?? 0)); + $discountCents = 0; + } else { + $discountBaseCents = max(0, $tuitionCents + $additionalCents); + $discountCents = min($discountRawCents, $discountBaseCents); + $totalAmountCents = $tuitionCents + $eventCents + $additionalCents; + } + $balanceCents = max(0, $totalAmountCents - $discountCents - $paidCents - $refundPaidCents); if ($balanceCents === 0) { @@ -129,6 +135,24 @@ class InvoiceLedgerService return $this->invoiceModel->find($invoiceId); } + protected function isCarryForwardInvoice(array $invoice): bool + { + $invoiceNumber = (string) ($invoice['invoice_number'] ?? ''); + if (str_starts_with($invoiceNumber, 'CF-')) { + return true; + } + + if (strcasecmp((string) ($invoice['semester'] ?? ''), 'Opening Balance') === 0) { + return true; + } + + $description = strtolower((string) ($invoice['description'] ?? '')); + + return str_contains($description, 'carried over') + || str_contains($description, 'carry-forward') + || str_contains($description, 'previous school year'); + } + protected function calculateTuitionTotal(array $invoice): float { $parentId = (int) ($invoice['parent_id'] ?? 0); diff --git a/app/Models/AdditionalChargeModel.php b/app/Models/AdditionalChargeModel.php index dc73398..f0b28ad 100644 --- a/app/Models/AdditionalChargeModel.php +++ b/app/Models/AdditionalChargeModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class AdditionalChargeModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'additional_charges'; protected $primaryKey = 'id'; protected $returnType = 'array'; @@ -28,7 +31,7 @@ class AdditionalChargeModel extends Model protected $validationRules = [ 'parent_id' => 'permit_empty|integer', 'invoice_id' => 'permit_empty|integer', - 'school_year' => 'required|string|max_length[20]', + 'school_year' => 'required|string|max_length[9]', 'semester' => 'required|string|max_length[20]', 'charge_type' => 'required|in_list[add,deduct]', 'title' => 'required|string|min_length[2]|max_length[255]', diff --git a/app/Models/AdminNotificationSubjectModel.php b/app/Models/AdminNotificationSubjectModel.php index e89a9ca..70a37b2 100644 --- a/app/Models/AdminNotificationSubjectModel.php +++ b/app/Models/AdminNotificationSubjectModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class AdminNotificationSubjectModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'admin_notification_subjects'; protected $primaryKey = 'id'; protected $returnType = 'array'; @@ -15,7 +18,12 @@ class AdminNotificationSubjectModel extends Model 'subject', 'created_at', 'updated_at', + 'school_year', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = true; protected $createdField = 'created_at'; diff --git a/app/Models/AttendanceDataModel.php b/app/Models/AttendanceDataModel.php index 8c74f10..204e821 100644 --- a/app/Models/AttendanceDataModel.php +++ b/app/Models/AttendanceDataModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class AttendanceDataModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'attendance_data'; protected $primaryKey = 'id'; protected $useAutoIncrement = true; @@ -42,7 +45,7 @@ class AttendanceDataModel extends Model 'is_notified' => 'required|in_list[no,yes]', 'date' => 'required|valid_date[Y-m-d]', 'semester' => 'required', - 'school_year' => 'required', + 'school_year' => 'required|string|max_length[9]', ]; /** * Retrieve attendance by class and section for a specific date. diff --git a/app/Models/AttendanceDayModel.php b/app/Models/AttendanceDayModel.php index c41add5..eb5e8a0 100644 --- a/app/Models/AttendanceDayModel.php +++ b/app/Models/AttendanceDayModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class AttendanceDayModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'attendance_day'; protected $primaryKey = 'id'; protected $returnType = 'array'; @@ -28,6 +31,10 @@ class AttendanceDayModel extends Model 'created_at', 'updated_at', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + /** * Legacy helper kept for compatibility. diff --git a/app/Models/AttendanceRecordModel.php b/app/Models/AttendanceRecordModel.php index 17d9ad4..4b62654 100644 --- a/app/Models/AttendanceRecordModel.php +++ b/app/Models/AttendanceRecordModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class AttendanceRecordModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'attendance_record'; protected $primaryKey = 'id'; protected $allowedFields = [ @@ -22,6 +25,10 @@ class AttendanceRecordModel extends Model 'created_at', 'updated_at' ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = true; protected $createdField = 'created_at'; diff --git a/app/Models/AttendanceTrackingModel.php b/app/Models/AttendanceTrackingModel.php index d9420ac..3648975 100644 --- a/app/Models/AttendanceTrackingModel.php +++ b/app/Models/AttendanceTrackingModel.php @@ -3,10 +3,13 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; use CodeIgniter\I18n\Time; class AttendanceTrackingModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'attendance_tracking'; protected $primaryKey = 'id'; protected $useAutoIncrement = true; @@ -24,6 +27,10 @@ class AttendanceTrackingModel extends Model 'school_year', 'note', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = true; protected $createdField = 'created_at'; diff --git a/app/Models/BadgePrintLogModel.php b/app/Models/BadgePrintLogModel.php index 4e05fd1..ed4ef31 100644 --- a/app/Models/BadgePrintLogModel.php +++ b/app/Models/BadgePrintLogModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class BadgePrintLogModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'badge_print_logs'; protected $primaryKey = 'id'; protected $useTimestamps = false; @@ -19,6 +22,10 @@ class BadgePrintLogModel extends Model 'class_section_name', 'copies', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + /** * Insert a batch of print logs. Swallows errors if table is missing. diff --git a/app/Models/BelowSixtyDecisionModel.php b/app/Models/BelowSixtyDecisionModel.php index 0348dfd..af82d66 100644 --- a/app/Models/BelowSixtyDecisionModel.php +++ b/app/Models/BelowSixtyDecisionModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class BelowSixtyDecisionModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'below_sixty_decisions'; protected $primaryKey = 'id'; @@ -17,6 +20,10 @@ class BelowSixtyDecisionModel extends Model 'notes', 'decided_by', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = true; protected $createdField = 'created_at'; diff --git a/app/Models/CalendarModel.php b/app/Models/CalendarModel.php index 02807c1..39c74a7 100644 --- a/app/Models/CalendarModel.php +++ b/app/Models/CalendarModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class CalendarModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'calendar_events'; protected $primaryKey = 'id'; protected ?bool $hasEventTypeColumn = null; @@ -22,6 +25,10 @@ class CalendarModel extends Model 'notify_teacher', 'no_school' ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = true; protected $createdField = 'created_at'; diff --git a/app/Models/CertificateRecordModel.php b/app/Models/CertificateRecordModel.php index 3a5d6b4..ca8e1fc 100644 --- a/app/Models/CertificateRecordModel.php +++ b/app/Models/CertificateRecordModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class CertificateRecordModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'certificate_records'; protected $primaryKey = 'id'; protected $useTimestamps = true; @@ -22,6 +25,10 @@ class CertificateRecordModel extends Model 'issued_by', 'issued_at', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + /** * Generates the next certificate number for a given school year. diff --git a/app/Models/ClassModel.php b/app/Models/ClassModel.php index e206574..7f041fe 100644 --- a/app/Models/ClassModel.php +++ b/app/Models/ClassModel.php @@ -3,17 +3,25 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class ClassModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'classes'; // The table name protected $primaryKey = 'id'; // The correct primary key for the classes table + // Fields that are allowed to be manipulated. protected $allowedFields = [ 'class_name', 'schedule', - 'capacity' - ]; // Fields that are allowed to be manipulated + 'capacity', + 'school_year', + ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; protected $useTimestamps = false; diff --git a/app/Models/ClassPrepAdjustmentModel.php b/app/Models/ClassPrepAdjustmentModel.php index 106ed54..4b1dde1 100644 --- a/app/Models/ClassPrepAdjustmentModel.php +++ b/app/Models/ClassPrepAdjustmentModel.php @@ -2,11 +2,19 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class ClassPrepAdjustmentModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'class_prep_adjustments'; protected $allowedFields = [ - 'class_section_id', 'item_name', 'adjustment', 'adjustable', 'created_at' + 'class_section_id', 'item_name', 'adjustment', 'adjustable', 'created_at', + 'school_year', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[16]', + ]; + } diff --git a/app/Models/ClassPreparationLogModel.php b/app/Models/ClassPreparationLogModel.php index 348d4e1..e8af104 100644 --- a/app/Models/ClassPreparationLogModel.php +++ b/app/Models/ClassPreparationLogModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class ClassPreparationLogModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'class_preparation_log'; protected $primaryKey = 'id'; protected $allowedFields = [ @@ -13,6 +16,11 @@ class ClassPreparationLogModel extends Model 'class_section', 'prep_data', 'created_at', + 'school_year', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + public $useTimestamps = false; } diff --git a/app/Models/ClassProgressAttachmentModel.php b/app/Models/ClassProgressAttachmentModel.php index 6e66a6d..7d3cc7b 100644 --- a/app/Models/ClassProgressAttachmentModel.php +++ b/app/Models/ClassProgressAttachmentModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class ClassProgressAttachmentModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'class_progress_attachments'; protected $primaryKey = 'id'; protected $returnType = 'array'; @@ -16,6 +19,11 @@ class ClassProgressAttachmentModel extends Model 'mime_type', 'file_size', 'created_at', + 'school_year', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = false; } diff --git a/app/Models/ClassProgressReportModel.php b/app/Models/ClassProgressReportModel.php index 565a098..0162169 100644 --- a/app/Models/ClassProgressReportModel.php +++ b/app/Models/ClassProgressReportModel.php @@ -3,6 +3,7 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; /** * Weekly class progress reports (one row per subject per week). @@ -15,6 +16,8 @@ use CodeIgniter\Model; */ class ClassProgressReportModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'class_progress_reports'; protected $primaryKey = 'id'; protected $returnType = 'array'; @@ -51,7 +54,7 @@ class ClassProgressReportModel extends Model protected $validationRules = [ 'class_section_id' => 'required|integer', 'teacher_id' => 'required|integer', - 'school_year' => 'permit_empty|string|max_length[9]', + 'school_year' => 'required|string|max_length[9]', 'week_start' => 'required|valid_date[Y-m-d]', 'week_end' => 'required|valid_date[Y-m-d]', 'subject' => 'required|string|max_length[160]', diff --git a/app/Models/ClassSectionModel.php b/app/Models/ClassSectionModel.php index 9a15235..6acac37 100644 --- a/app/Models/ClassSectionModel.php +++ b/app/Models/ClassSectionModel.php @@ -2,9 +2,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class ClassSectionModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'classSection'; // Correct table name protected $primaryKey = 'id'; // Specify the primary key field @@ -14,7 +17,12 @@ class ClassSectionModel extends Model 'class_section_name', 'created_at', 'updated_at', + 'school_year', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = true; protected $createdField = 'created_at'; diff --git a/app/Models/CompetitionClassWinnerModel.php b/app/Models/CompetitionClassWinnerModel.php index 7489bb8..f9a25ee 100644 --- a/app/Models/CompetitionClassWinnerModel.php +++ b/app/Models/CompetitionClassWinnerModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class CompetitionClassWinnerModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'competition_class_winners'; protected $primaryKey = 'id'; @@ -20,7 +23,12 @@ class CompetitionClassWinnerModel extends Model 'prize_4', 'prize_5', 'prize_6', + 'school_year', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = true; protected $createdField = 'created_at'; diff --git a/app/Models/CompetitionModel.php b/app/Models/CompetitionModel.php index d7af602..2bd8b68 100644 --- a/app/Models/CompetitionModel.php +++ b/app/Models/CompetitionModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class CompetitionModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'competitions'; protected $primaryKey = 'id'; protected $useSoftDeletes = true; @@ -26,6 +29,10 @@ class CompetitionModel extends Model 'locked_by', 'created_by', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = true; protected $createdField = 'created_at'; diff --git a/app/Models/CompetitionScoreModel.php b/app/Models/CompetitionScoreModel.php index 2efbe07..2037b1d 100644 --- a/app/Models/CompetitionScoreModel.php +++ b/app/Models/CompetitionScoreModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class CompetitionScoreModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'competition_scores'; protected $primaryKey = 'id'; @@ -15,7 +18,12 @@ class CompetitionScoreModel extends Model 'class_section_id', 'score', 'notes', + 'school_year', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = true; protected $createdField = 'created_at'; diff --git a/app/Models/CompetitionWinnerModel.php b/app/Models/CompetitionWinnerModel.php index 20a0e42..26170f6 100644 --- a/app/Models/CompetitionWinnerModel.php +++ b/app/Models/CompetitionWinnerModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class CompetitionWinnerModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'competition_winners'; protected $primaryKey = 'id'; @@ -17,7 +20,12 @@ class CompetitionWinnerModel extends Model 'score', 'prize_amount', 'created_at', + 'school_year', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = false; } diff --git a/app/Models/Concerns/SchoolYearAutoFillTrait.php b/app/Models/Concerns/SchoolYearAutoFillTrait.php new file mode 100644 index 0000000..1199e8d --- /dev/null +++ b/app/Models/Concerns/SchoolYearAutoFillTrait.php @@ -0,0 +1,178 @@ +tempData['data'])) { + if ($row === null) { + $row = $this->tempData['data']; + } else { + $row = array_merge($this->tempData['data'], $this->transformDataToArray($row, 'insert')); + } + + $this->escape = $this->tempData['escape'] ?? []; + $this->tempData = []; + } + + return parent::insert($this->withDefaultSchoolYear($row), $returnID); + } + + public function insertBatch(?array $set = null, ?bool $escape = null, int $batchSize = 100, bool $testing = false) + { + if (is_array($set)) { + foreach ($set as $index => $row) { + $set[$index] = $this->withDefaultSchoolYear($row); + } + } + + return parent::insertBatch($set, $escape, $batchSize, $testing); + } + + protected function withDefaultSchoolYear($row) + { + if (! is_array($row)) { + return $row; + } + + if ($this->hasNonEmptySchoolYear($row)) { + return $row; + } + + $schoolYear = $this->deriveSchoolYearForRow($row) ?? $this->currentSchoolYearName(); + if ($schoolYear !== '') { + $row['school_year'] = $schoolYear; + } + + return $row; + } + + protected function deriveSchoolYearForRow(array $row): ?string + { + foreach (['school_year_from', 'school_year_to', 'previous_school_year'] as $field) { + $value = trim((string)($row[$field] ?? '')); + if ($this->isValidSchoolYear($value)) { + return $value; + } + } + + foreach (['source_school_year_id', 'target_school_year_id', 'school_year_id'] as $field) { + $id = (int)($row[$field] ?? 0); + if ($id <= 0) { + continue; + } + + $year = $this->schoolYearNameById($id); + if ($year !== null) { + return $year; + } + } + + $batchId = (int)($row['closing_batch_id'] ?? 0); + if ($batchId > 0) { + return $this->schoolYearNameByClosingBatchId($batchId); + } + + return null; + } + + private function hasNonEmptySchoolYear(array $row): bool + { + return array_key_exists('school_year', $row) && trim((string)$row['school_year']) !== ''; + } + + private function currentSchoolYearName(): string + { + try { + if ($this->db->tableExists('school_years')) { + $row = $this->db->table('school_years') + ->select('name') + ->where('status', 'active') + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + + $name = trim((string)($row['name'] ?? '')); + if ($this->isValidSchoolYear($name)) { + return $name; + } + } + + if ($this->db->tableExists('configuration')) { + foreach (['school_year', 'current_school_year'] as $key) { + $row = $this->db->table('configuration') + ->select('config_value') + ->where('config_key', $key) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + + $value = trim((string)($row['config_value'] ?? '')); + if ($this->isValidSchoolYear($value)) { + return $value; + } + } + } + } catch (\Throwable) { + return $this->calendarYearFallback(); + } + + return $this->calendarYearFallback(); + } + + private function schoolYearNameById(int $id): ?string + { + try { + if (! $this->db->tableExists('school_years')) { + return null; + } + + $row = $this->db->table('school_years') + ->select('name') + ->where('id', $id) + ->get(1) + ->getRowArray(); + + $name = trim((string)($row['name'] ?? '')); + + return $this->isValidSchoolYear($name) ? $name : null; + } catch (\Throwable) { + return null; + } + } + + private function schoolYearNameByClosingBatchId(int $id): ?string + { + try { + if (! $this->db->tableExists('school_year_closing_batches')) { + return null; + } + + $row = $this->db->table('school_year_closing_batches') + ->select('school_year') + ->where('id', $id) + ->get(1) + ->getRowArray(); + + $year = trim((string)($row['school_year'] ?? '')); + + return $this->isValidSchoolYear($year) ? $year : null; + } catch (\Throwable) { + return null; + } + } + + private function calendarYearFallback(): string + { + $year = (int) date('Y'); + + return $year . '-' . ($year + 1); + } + + private function isValidSchoolYear(string $value): bool + { + return preg_match('/^\d{4}-\d{4}$/', $value) === 1; + } +} diff --git a/app/Models/ContactUsModel.php b/app/Models/ContactUsModel.php index 7907a6a..764a9b4 100644 --- a/app/Models/ContactUsModel.php +++ b/app/Models/ContactUsModel.php @@ -4,9 +4,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class ContactUsModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'contactus'; protected $primaryKey = 'id'; protected $useAutoIncrement = true; @@ -20,7 +23,8 @@ class ContactUsModel extends Model 'subject', 'message', 'created_at', - 'updated_at' + 'updated_at', + 'school_year', ]; // Timestamps (set manually or use auto timestamps below) @@ -33,6 +37,7 @@ class ContactUsModel extends Model 'reciever_id' => 'required|integer', 'subject' => 'required|string|max_length[255]', 'message' => 'required|string', + 'school_year' => 'required|string|max_length[9]', ]; protected $validationMessages = []; diff --git a/app/Models/CurrentFlagModel.php b/app/Models/CurrentFlagModel.php index ab09a1f..3c8f05b 100644 --- a/app/Models/CurrentFlagModel.php +++ b/app/Models/CurrentFlagModel.php @@ -4,9 +4,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class CurrentFlagModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'current_flag'; protected $primaryKey = 'id'; protected $allowedFields = [ @@ -28,6 +31,10 @@ class CurrentFlagModel extends Model 'created_at', 'updated_at' ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + // Fields that are allowed to be inserted or updated diff --git a/app/Models/DiscountUsageModel.php b/app/Models/DiscountUsageModel.php index bf55f83..ca594f5 100644 --- a/app/Models/DiscountUsageModel.php +++ b/app/Models/DiscountUsageModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class DiscountUsageModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'discount_usages'; protected $primaryKey = 'id'; @@ -30,7 +33,8 @@ class DiscountUsageModel extends Model 'voucher_id' => 'required|is_natural_no_zero', 'invoice_id' => 'required|is_natural_no_zero', 'user_id' => 'permit_empty|is_natural_no_zero', - 'used_at' => 'valid_date' + 'used_at' => 'valid_date', + 'school_year' => 'required|string|max_length[9]', ]; protected $validationMessages = [ diff --git a/app/Models/DiscountVoucherModel.php b/app/Models/DiscountVoucherModel.php index 7e42d8d..cac12a7 100644 --- a/app/Models/DiscountVoucherModel.php +++ b/app/Models/DiscountVoucherModel.php @@ -2,9 +2,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class DiscountVoucherModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'discount_vouchers'; protected $primaryKey = 'id'; @@ -38,7 +41,8 @@ class DiscountVoucherModel extends Model 'valid_from' => 'permit_empty|valid_date[Y-m-d]', 'valid_until' => 'permit_empty|valid_date[Y-m-d]', 'is_active' => 'in_list[0,1]', - 'description' => 'permit_empty|string|max_length[1000]', // <-- NEW + 'description' => 'permit_empty|string|max_length[1000]', // <-- NEW, + 'school_year' => 'required|string|max_length[9]', ]; protected $validationMessages = [ diff --git a/app/Models/EarlyDismissalSignatureModel.php b/app/Models/EarlyDismissalSignatureModel.php index 1c8da76..e100f82 100644 --- a/app/Models/EarlyDismissalSignatureModel.php +++ b/app/Models/EarlyDismissalSignatureModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class EarlyDismissalSignatureModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'early_dismissal_signatures'; protected $primaryKey = 'id'; protected $useAutoIncrement = true; @@ -28,7 +31,7 @@ class EarlyDismissalSignatureModel extends Model protected $validationRules = [ 'report_date' => 'required|valid_date[Y-m-d]', 'filename' => 'required|string', - 'school_year' => 'permit_empty|string', + 'school_year' => 'required|string|max_length[9]', 'semester' => 'permit_empty|string', ]; } diff --git a/app/Models/EnrollmentModel.php b/app/Models/EnrollmentModel.php index 7e5e067..b7ba199 100644 --- a/app/Models/EnrollmentModel.php +++ b/app/Models/EnrollmentModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class EnrollmentModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'enrollments'; protected $primaryKey = 'id'; @@ -32,7 +35,7 @@ class EnrollmentModel extends Model 'student_id' => 'required|integer', 'class_section_id' => 'permit_empty|integer', 'parent_id' => 'required|integer', - 'school_year' => 'required|string|max_length[25]', + 'school_year' => 'required|string|max_length[9]', 'enrollment_date' => 'required|valid_date', 'withdrawal_date' => 'permit_empty|valid_date', 'is_withdrawn' => 'permit_empty|in_list[0,1]', @@ -56,7 +59,7 @@ class EnrollmentModel extends Model 'school_year' => [ 'required' => 'School year is required', 'string' => 'School year must be a string', - 'max_length' => 'School year must not exceed 25 characters', + 'max_length' => 'School year must not exceed 9 characters', ], 'enrollment_date' => [ 'required' => 'Enrollment date is required', diff --git a/app/Models/EventChargesModel.php b/app/Models/EventChargesModel.php index d8fb7b5..ac59627 100644 --- a/app/Models/EventChargesModel.php +++ b/app/Models/EventChargesModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class EventChargesModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'event_charges'; protected $primaryKey = 'id'; @@ -32,6 +35,10 @@ class EventChargesModel extends Model 'created_at', 'updated_at' ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = true; protected $createdField = 'created_at'; diff --git a/app/Models/EventModel.php b/app/Models/EventModel.php index 2d37c03..5b01b11 100644 --- a/app/Models/EventModel.php +++ b/app/Models/EventModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class EventModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'events'; protected $primaryKey = 'id'; @@ -22,6 +25,10 @@ class EventModel extends Model 'created_at', 'updated_at' ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = true; protected $createdField = 'created_at'; diff --git a/app/Models/ExamDraftModel.php b/app/Models/ExamDraftModel.php index 2832aac..a113f9e 100644 --- a/app/Models/ExamDraftModel.php +++ b/app/Models/ExamDraftModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class ExamDraftModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'exam_drafts'; protected $primaryKey = 'id'; protected $returnType = 'array'; @@ -65,6 +68,7 @@ class ExamDraftModel extends Model 'author_id' => 'if_exist|is_natural_no_zero', 'class_section_id' => 'if_exist|is_natural_no_zero', 'version' => 'if_exist|is_natural_no_zero', + 'school_year' => 'required|string|max_length[9]', ]; protected $validationMessages = [ diff --git a/app/Models/ExamModel.php b/app/Models/ExamModel.php index e792c09..f390b79 100644 --- a/app/Models/ExamModel.php +++ b/app/Models/ExamModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class ExamModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'exams'; protected $primaryKey = 'id'; protected $useAutoIncrement = true; @@ -18,6 +21,10 @@ class ExamModel extends Model 'school_year', 'created_at' ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + // Timestamps protected $useTimestamps = true; diff --git a/app/Models/ExpenseModel.php b/app/Models/ExpenseModel.php index f7b639e..8c5e0a3 100644 --- a/app/Models/ExpenseModel.php +++ b/app/Models/ExpenseModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class ExpenseModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'expenses'; protected $primaryKey = 'id'; @@ -32,7 +35,8 @@ class ExpenseModel extends Model protected $updatedField = 'updated_at'; protected $validationRules = [ - // Add your validation rules here as needed + // Add your validation rules here as needed, + 'school_year' => 'required|string|max_length[9]', ]; public function getReimbursedExpensesWithDetails(array $filters = []) diff --git a/app/Models/FinalExamModel.php b/app/Models/FinalExamModel.php index a8d3c18..457c303 100644 --- a/app/Models/FinalExamModel.php +++ b/app/Models/FinalExamModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class FinalExamModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'final_exam'; protected $primaryKey = 'id'; protected $useAutoIncrement = true; @@ -38,7 +41,8 @@ class FinalExamModel extends Model protected $deletedField = 'deleted_at'; // Validation - protected $validationRules = []; + protected $validationRules = [ 'school_year' => 'required|string|max_length[9]', +]; protected $validationMessages = []; protected $skipValidation = false; protected $cleanValidationRules = true; diff --git a/app/Models/FinalScoreModel.php b/app/Models/FinalScoreModel.php index d6c2388..c3ae1dd 100644 --- a/app/Models/FinalScoreModel.php +++ b/app/Models/FinalScoreModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class FinalScoreModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'final_score'; protected $primaryKey = 'id'; protected $useAutoIncrement = true; @@ -23,6 +26,10 @@ class FinalScoreModel extends Model 'created_at', 'updated_at' ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + // Function to get the final exam score for a specific student, semester, and school year public function getFinalExamScore($studentId, $semester, $schoolYear) diff --git a/app/Models/FlagModel.php b/app/Models/FlagModel.php index 4f5b6d7..76d1e86 100644 --- a/app/Models/FlagModel.php +++ b/app/Models/FlagModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class FlagModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'flag'; protected $primaryKey = 'id'; protected $allowedFields = [ @@ -27,4 +30,8 @@ class FlagModel extends Model 'created_at', 'updated_at' ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + } diff --git a/app/Models/GradingLockModel.php b/app/Models/GradingLockModel.php index 426798a..e628e54 100644 --- a/app/Models/GradingLockModel.php +++ b/app/Models/GradingLockModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class GradingLockModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'grading_locks'; protected $primaryKey = 'id'; protected $returnType = 'array'; @@ -20,6 +23,10 @@ class GradingLockModel extends Model 'created_at', 'updated_at', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + public function getLock(int $classSectionId, string $semester, string $schoolYear): ?array { diff --git a/app/Models/HomeworkModel.php b/app/Models/HomeworkModel.php index 0a24dce..5b4d2cb 100644 --- a/app/Models/HomeworkModel.php +++ b/app/Models/HomeworkModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class HomeworkModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'homework'; protected $primaryKey = 'id'; protected $useAutoIncrement = true; @@ -40,7 +43,8 @@ class HomeworkModel extends Model protected $deletedField = 'deleted_at'; // Validation - protected $validationRules = []; + protected $validationRules = [ 'school_year' => 'required|string|max_length[9]', +]; protected $validationMessages = []; protected $skipValidation = false; protected $cleanValidationRules = true; diff --git a/app/Models/InventoryCategoryModel.php b/app/Models/InventoryCategoryModel.php index 36aadf0..b5f3cc9 100644 --- a/app/Models/InventoryCategoryModel.php +++ b/app/Models/InventoryCategoryModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class InventoryCategoryModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'inventory_categories'; protected $primaryKey = 'id'; protected $returnType = 'array'; @@ -19,7 +22,12 @@ class InventoryCategoryModel extends Model 'grade_max', 'created_at', 'updated_at', + 'school_year', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + public function optionsForType(string $type): array { diff --git a/app/Models/InventoryItemModel.php b/app/Models/InventoryItemModel.php index 987251c..5596425 100644 --- a/app/Models/InventoryItemModel.php +++ b/app/Models/InventoryItemModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class InventoryItemModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'inventory_items'; protected $primaryKey = 'id'; @@ -38,6 +41,7 @@ protected $table = 'inventory_items'; 'needs_repair_qty', 'need_replace_qty', 'cannot_find_qty', + 'school_year', ]; @@ -46,5 +50,6 @@ protected $table = 'inventory_items'; 'name' => 'required|min_length[2]', 'quantity' => 'permit_empty|integer', 'unit_price' => 'permit_empty|decimal', + 'school_year' => 'required|string|max_length[16]', ]; } diff --git a/app/Models/InventoryMovementModel.php b/app/Models/InventoryMovementModel.php index 702584b..2c50df4 100644 --- a/app/Models/InventoryMovementModel.php +++ b/app/Models/InventoryMovementModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class InventoryMovementModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'inventory_movements'; protected $primaryKey = 'id'; protected $returnType = 'array'; @@ -16,4 +19,8 @@ class InventoryMovementModel extends Model 'semester','school_year', 'performed_by','teacher_id','student_id','class_section_id', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + } diff --git a/app/Models/InvoiceEventModel.php b/app/Models/InvoiceEventModel.php index 8a5de0d..643fa6a 100644 --- a/app/Models/InvoiceEventModel.php +++ b/app/Models/InvoiceEventModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class InvoiceEventModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'invoice_event'; protected $primaryKey = 'id'; @@ -20,6 +23,10 @@ class InvoiceEventModel extends Model 'created_at', 'updated_at' ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = true; protected $createdField = 'created_at'; diff --git a/app/Models/InvoiceModel.php b/app/Models/InvoiceModel.php index 84cf70d..ad00be4 100644 --- a/app/Models/InvoiceModel.php +++ b/app/Models/InvoiceModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class InvoiceModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'invoices'; protected $primaryKey = 'id'; protected $returnType = 'array'; @@ -40,6 +43,7 @@ class InvoiceModel extends Model 'issue_date' => 'required|valid_date[Y-m-d H:i:s]', 'due_date' => 'permit_empty|valid_date[Y-m-d H:i:s]', 'status' => 'required|string|max_length[50]', + 'school_year' => 'required|string|max_length[9]', ]; diff --git a/app/Models/InvoiceStudentListModel.php b/app/Models/InvoiceStudentListModel.php index 9256bc1..d9bb33e 100644 --- a/app/Models/InvoiceStudentListModel.php +++ b/app/Models/InvoiceStudentListModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class InvoiceStudentListModel extends Model { + use SchoolYearAutoFillTrait; + // Specify the table name protected $table = 'invoice_students_list'; @@ -21,8 +24,13 @@ class InvoiceStudentListModel extends Model 'school_id', 'enrolled', 'created_at', - 'updated_at' + 'updated_at', + 'school_year', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + // Enable auto-incrementing primary key protected $useAutoIncrement = true; diff --git a/app/Models/IpAttemptModel.php b/app/Models/IpAttemptModel.php index 54ce17d..8c3efea 100644 --- a/app/Models/IpAttemptModel.php +++ b/app/Models/IpAttemptModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class IpAttemptModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'ip_attempts'; protected $primaryKey = 'id'; @@ -13,7 +16,8 @@ class IpAttemptModel extends Model 'ip_address', 'attempts', 'last_attempt_at', - 'blocked_until' + 'blocked_until', + 'school_year', ]; // Automatically handle timestamps @@ -26,7 +30,8 @@ class IpAttemptModel extends Model 'ip_address' => 'required|valid_ip|max_length[45]', 'attempts' => 'required|integer', 'last_attempt_at' => 'required|valid_date', - 'blocked_until' => 'permit_empty|valid_date' + 'blocked_until' => 'permit_empty|valid_date', + 'school_year' => 'required|string|max_length[20]', ]; // Method to get IP attempt data by IP address diff --git a/app/Models/LateSlipLogModel.php b/app/Models/LateSlipLogModel.php index 0a61e9c..91011aa 100644 --- a/app/Models/LateSlipLogModel.php +++ b/app/Models/LateSlipLogModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class LateSlipLogModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'late_slip_logs'; protected $primaryKey = 'id'; protected $useTimestamps = false; // we control printed_at explicitly @@ -22,6 +25,10 @@ class LateSlipLogModel extends Model 'printed_by', 'printed_at', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + /** * Best-effort insert of a single late slip print log. diff --git a/app/Models/LoginActivityModel.php b/app/Models/LoginActivityModel.php index ab09e28..653edd9 100644 --- a/app/Models/LoginActivityModel.php +++ b/app/Models/LoginActivityModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class LoginActivityModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'login_activity'; protected $primaryKey = 'id'; protected $allowedFields = [ @@ -17,8 +20,13 @@ class LoginActivityModel extends Model 'user_agent', 'semester', 'created_at', - 'updated_at' + 'updated_at', + 'school_year', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = true; protected $createdField = 'created_at'; diff --git a/app/Models/ManualPaymentModel.php b/app/Models/ManualPaymentModel.php index bdce54f..854652f 100644 --- a/app/Models/ManualPaymentModel.php +++ b/app/Models/ManualPaymentModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class ManualPaymentModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'manual_payments'; protected $allowedFields = [ 'invoice_number', @@ -16,6 +19,10 @@ class ManualPaymentModel extends Model 'semester', 'school_year', 'created_at']; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = false; } diff --git a/app/Models/MessageModel.php b/app/Models/MessageModel.php index 5c549d6..a64b85f 100644 --- a/app/Models/MessageModel.php +++ b/app/Models/MessageModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class MessageModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'messages'; protected $primaryKey = 'id'; @@ -21,8 +24,13 @@ class MessageModel extends Model 'priority', 'attachment', 'status', - 'semester' + 'semester', + 'school_year', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = false; // Since you're manually handling date fields protected $returnType = 'array'; diff --git a/app/Models/MidtermExamModel.php b/app/Models/MidtermExamModel.php index 8b63275..0e1b33c 100644 --- a/app/Models/MidtermExamModel.php +++ b/app/Models/MidtermExamModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class MidtermExamModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'midterm_exam'; protected $primaryKey = 'id'; protected $useAutoIncrement = true; @@ -39,7 +42,8 @@ class MidtermExamModel extends Model protected $deletedField = 'deleted_at'; // Validation - protected $validationRules = []; + protected $validationRules = [ 'school_year' => 'required|string|max_length[9]', +]; protected $validationMessages = []; protected $skipValidation = false; protected $cleanValidationRules = true; diff --git a/app/Models/MissingScoreOverrideModel.php b/app/Models/MissingScoreOverrideModel.php index a475abf..18d6c3a 100644 --- a/app/Models/MissingScoreOverrideModel.php +++ b/app/Models/MissingScoreOverrideModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class MissingScoreOverrideModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'missing_score_overrides'; protected $primaryKey = 'id'; protected $returnType = 'array'; @@ -22,6 +25,10 @@ class MissingScoreOverrideModel extends Model 'created_at', 'updated_at', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + public function getOverridesMap(int $classSectionId, string $semester, string $schoolYear, string $itemType): array { diff --git a/app/Models/NotificationModel.php b/app/Models/NotificationModel.php index 180cbe8..13a88e2 100644 --- a/app/Models/NotificationModel.php +++ b/app/Models/NotificationModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class NotificationModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'notifications'; protected $primaryKey = 'id'; protected $allowedFields = [ @@ -20,8 +23,13 @@ class NotificationModel extends Model 'expires_at', 'sent_at', 'scheduled_at', - 'deleted_at' + 'deleted_at', + 'school_year', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[20]', + ]; + protected $useTimestamps = true; protected $createdField = 'created_at'; protected $updatedField = 'updated_at'; diff --git a/app/Models/ParentAttendanceReportModel.php b/app/Models/ParentAttendanceReportModel.php index 80098fe..23e86db 100644 --- a/app/Models/ParentAttendanceReportModel.php +++ b/app/Models/ParentAttendanceReportModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class ParentAttendanceReportModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'parent_attendance_reports'; protected $primaryKey = 'id'; protected $useAutoIncrement = true; @@ -36,7 +39,7 @@ class ParentAttendanceReportModel extends Model 'arrival_time' => 'permit_empty|regex_match[/^\d{2}:\d{2}(:\d{2})?$/]', 'dismiss_time' => 'permit_empty|regex_match[/^\d{2}:\d{2}(:\d{2})?$/]', 'semester' => 'permit_empty|string', - 'school_year' => 'permit_empty|string', + 'school_year' => 'required|string|max_length[9]', 'status' => 'permit_empty|in_list[new,seen,processed]', ]; diff --git a/app/Models/ParentMeetingScheduleModel.php b/app/Models/ParentMeetingScheduleModel.php index b5a78e3..aa55ace 100644 --- a/app/Models/ParentMeetingScheduleModel.php +++ b/app/Models/ParentMeetingScheduleModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class ParentMeetingScheduleModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'parent_meeting_schedules'; protected $primaryKey = 'id'; @@ -23,6 +26,10 @@ class ParentMeetingScheduleModel extends Model 'status', 'created_by', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = true; protected $createdField = 'created_at'; diff --git a/app/Models/ParentNotificationModel.php b/app/Models/ParentNotificationModel.php index 1e185a6..05024e9 100644 --- a/app/Models/ParentNotificationModel.php +++ b/app/Models/ParentNotificationModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class ParentNotificationModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'parent_notifications'; protected $primaryKey = 'id'; protected $useTimestamps = true; @@ -16,6 +19,10 @@ class ParentNotificationModel extends Model 'student_id','code','incident_date','channel','to_address','subject', 'status','response','semester','school_year' ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + public function hasSent(int $studentId, string $code, string $incidentYmd, string $channel='email', ?string $to=null): bool { diff --git a/app/Models/ParticipationModel.php b/app/Models/ParticipationModel.php index 4ad88de..b775a8f 100644 --- a/app/Models/ParticipationModel.php +++ b/app/Models/ParticipationModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class ParticipationModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'participation'; protected $primaryKey = 'id'; protected $useAutoIncrement = true; @@ -39,7 +42,8 @@ class ParticipationModel extends Model protected $deletedField = 'deleted_at'; // Validation - protected $validationRules = []; + protected $validationRules = [ 'school_year' => 'required|string|max_length[9]', +]; protected $validationMessages = []; protected $skipValidation = false; protected $cleanValidationRules = true; diff --git a/app/Models/PaymentErrorModel.php b/app/Models/PaymentErrorModel.php index 381a11c..e0b8965 100644 --- a/app/Models/PaymentErrorModel.php +++ b/app/Models/PaymentErrorModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class PaymentErrorModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'payment_error'; // The DB table protected $primaryKey = 'id'; // Primary key @@ -28,7 +31,8 @@ class PaymentErrorModel extends Model 'wrong_paid_amount' => 'required|decimal', 'payment_id' => 'required|integer', 'invoice_id' => 'required|integer', - 'parent_id' => 'required|integer' + 'parent_id' => 'required|integer', + 'school_year' => 'required|string|max_length[9]', ]; } diff --git a/app/Models/PaymentModel.php b/app/Models/PaymentModel.php index 1ea6d9b..aa2407c 100644 --- a/app/Models/PaymentModel.php +++ b/app/Models/PaymentModel.php @@ -4,10 +4,13 @@ namespace App\Models; use CodeIgniter\Database\ConnectionInterface; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; use CodeIgniter\Validation\ValidationInterface; class PaymentModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'payments'; protected $primaryKey = 'id'; protected $returnType = 'array'; @@ -69,7 +72,7 @@ class PaymentModel extends Model 'transaction_id' => 'permit_empty|max_length[100]', 'payment_method' => 'required|in_list[cash,check,card]', 'payment_date' => 'required|valid_date', - 'school_year' => 'permit_empty|max_length[20]', + 'school_year' => 'required|string|max_length[9]', 'status' => 'required|max_length[50]', 'check_file' => 'permit_empty|max_length[255]', 'check_number' => 'permit_empty|max_length[100]', diff --git a/app/Models/PaymentNotificationLogModel.php b/app/Models/PaymentNotificationLogModel.php index 7ad4fc3..f8f948e 100644 --- a/app/Models/PaymentNotificationLogModel.php +++ b/app/Models/PaymentNotificationLogModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class PaymentNotificationLogModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'payment_notification_logs'; protected $primaryKey = 'id'; protected $returnType = 'array'; @@ -29,6 +32,10 @@ class PaymentNotificationLogModel extends Model 'created_at', 'sent_at', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = false; diff --git a/app/Models/PaymentTransactionModel.php b/app/Models/PaymentTransactionModel.php index 295a2a5..b441bc0 100644 --- a/app/Models/PaymentTransactionModel.php +++ b/app/Models/PaymentTransactionModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class PaymentTransactionModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'payment_transactions'; // Table name protected $primaryKey = 'id'; // Primary key @@ -32,7 +35,7 @@ class PaymentTransactionModel extends Model 'payment_method' => 'required|string|max_length[50]', 'payment_status' => 'required|string|max_length[50]', 'semester' => 'permit_empty|string|max_length[30]', - 'school_year' => 'permit_empty|string|max_length[9]', + 'school_year' => 'required|string|max_length[9]', ]; // Custom error messages diff --git a/app/Models/PlacementBatchModel.php b/app/Models/PlacementBatchModel.php index 916c994..3109ce3 100644 --- a/app/Models/PlacementBatchModel.php +++ b/app/Models/PlacementBatchModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class PlacementBatchModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'placement_batches'; protected $primaryKey = 'id'; @@ -17,6 +20,10 @@ class PlacementBatchModel extends Model 'created_at', 'updated_at', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = true; protected $createdField = 'created_at'; diff --git a/app/Models/PlacementLevelModel.php b/app/Models/PlacementLevelModel.php index e6ea05a..a07197e 100644 --- a/app/Models/PlacementLevelModel.php +++ b/app/Models/PlacementLevelModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class PlacementLevelModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'placement_levels'; protected $primaryKey = 'id'; @@ -16,7 +19,12 @@ class PlacementLevelModel extends Model 'updated_by', 'created_at', 'updated_at', + 'school_year', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[20]', + ]; + protected $useTimestamps = true; protected $createdField = 'created_at'; diff --git a/app/Models/PlacementScoreModel.php b/app/Models/PlacementScoreModel.php index e0d9746..d187a81 100644 --- a/app/Models/PlacementScoreModel.php +++ b/app/Models/PlacementScoreModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class PlacementScoreModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'placement_scores'; protected $primaryKey = 'id'; @@ -17,7 +20,12 @@ class PlacementScoreModel extends Model 'updated_by', 'created_at', 'updated_at', + 'school_year', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = true; protected $createdField = 'created_at'; diff --git a/app/Models/PrintRequestModel.php b/app/Models/PrintRequestModel.php index f9e91ad..13aa024 100644 --- a/app/Models/PrintRequestModel.php +++ b/app/Models/PrintRequestModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class PrintRequestModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'print_requests'; protected $primaryKey = 'id'; protected $useAutoIncrement = true; @@ -31,7 +34,8 @@ class PrintRequestModel extends Model protected $updatedField = 'updated_at'; // Validation - protected $validationRules = []; + protected $validationRules = [ 'school_year' => 'required|string|max_length[9]', +]; protected $validationMessages = []; protected $skipValidation = false; protected $cleanValidationRules = true; diff --git a/app/Models/ProjectModel.php b/app/Models/ProjectModel.php index a74ae18..9ec8fce 100644 --- a/app/Models/ProjectModel.php +++ b/app/Models/ProjectModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class ProjectModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'project'; protected $primaryKey = 'id'; protected $useAutoIncrement = true; @@ -23,6 +26,10 @@ class ProjectModel extends Model 'created_at', 'updated_at' ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + /** * Calculate the average project score for a given student, semester, and school year. diff --git a/app/Models/PromotionQueueModel.php b/app/Models/PromotionQueueModel.php index 1f8b0b5..ce6d773 100644 --- a/app/Models/PromotionQueueModel.php +++ b/app/Models/PromotionQueueModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class PromotionQueueModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'promotion_queue'; protected $primaryKey = 'id'; @@ -20,7 +23,12 @@ class PromotionQueueModel extends Model 'created_at', 'updated_at', 'updated_by', + 'school_year', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = true; protected $createdField = 'created_at'; @@ -32,6 +40,8 @@ class PromotionQueueModel extends Model return false; } + $data['school_year'] = (string)($data['school_year'] ?? $data['school_year_from'] ?? $data['school_year_to']); + $existing = $this->where('student_id', (int)$data['student_id']) ->where('school_year_to', (string)$data['school_year_to']) ->first(); @@ -43,4 +53,3 @@ class PromotionQueueModel extends Model return (bool) $this->insert($data); } } - diff --git a/app/Models/QuizModel.php b/app/Models/QuizModel.php index 587b2ac..df6f121 100644 --- a/app/Models/QuizModel.php +++ b/app/Models/QuizModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class QuizModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'quiz'; protected $primaryKey = 'id'; protected $useAutoIncrement = true; @@ -40,7 +43,8 @@ class QuizModel extends Model protected $deletedField = 'deleted_at'; // Validation - protected $validationRules = []; + protected $validationRules = [ 'school_year' => 'required|string|max_length[9]', +]; protected $validationMessages = []; protected $skipValidation = false; protected $cleanValidationRules = true; diff --git a/app/Models/RefundModel.php b/app/Models/RefundModel.php index 8c0cff7..3eda549 100644 --- a/app/Models/RefundModel.php +++ b/app/Models/RefundModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class RefundModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'refunds'; protected $primaryKey = 'id'; @@ -30,6 +33,10 @@ class RefundModel extends Model 'check_nbr', 'check_file' ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = true; protected $createdField = 'created_at'; diff --git a/app/Models/ReimbursementBatchAdminFileModel.php b/app/Models/ReimbursementBatchAdminFileModel.php index 630340b..8512628 100644 --- a/app/Models/ReimbursementBatchAdminFileModel.php +++ b/app/Models/ReimbursementBatchAdminFileModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class ReimbursementBatchAdminFileModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'reimbursement_batch_admin_files'; protected $primaryKey = 'id'; protected $allowedFields = [ @@ -15,6 +18,11 @@ class ReimbursementBatchAdminFileModel extends Model 'original_filename', 'uploaded_at', 'uploaded_by', + 'school_year', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = false; } diff --git a/app/Models/ReimbursementBatchItemModel.php b/app/Models/ReimbursementBatchItemModel.php index 18407f6..d8eebbf 100644 --- a/app/Models/ReimbursementBatchItemModel.php +++ b/app/Models/ReimbursementBatchItemModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class ReimbursementBatchItemModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'reimbursement_batch_items'; protected $primaryKey = 'id'; protected $allowedFields = [ @@ -19,6 +22,10 @@ class ReimbursementBatchItemModel extends Model 'school_year', 'semester', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = false; } diff --git a/app/Models/ReimbursementBatchModel.php b/app/Models/ReimbursementBatchModel.php index d7ec50b..b2e656a 100644 --- a/app/Models/ReimbursementBatchModel.php +++ b/app/Models/ReimbursementBatchModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class ReimbursementBatchModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'reimbursement_batches'; protected $primaryKey = 'id'; protected $allowedFields = [ @@ -20,6 +23,10 @@ class ReimbursementBatchModel extends Model 'semester', 'yearly_batch_number', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = false; } diff --git a/app/Models/ReimbursementModel.php b/app/Models/ReimbursementModel.php index e51d16f..bf3b4e3 100644 --- a/app/Models/ReimbursementModel.php +++ b/app/Models/ReimbursementModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class ReimbursementModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'reimbursements'; protected $primaryKey = 'id'; protected $allowedFields = [ @@ -23,5 +26,9 @@ class ReimbursementModel extends Model 'reimbursement_method', 'batch_number' ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + } diff --git a/app/Models/ReportCardAcknowledgementModel.php b/app/Models/ReportCardAcknowledgementModel.php index 0778bc1..d0148d8 100644 --- a/app/Models/ReportCardAcknowledgementModel.php +++ b/app/Models/ReportCardAcknowledgementModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class ReportCardAcknowledgementModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'report_card_acknowledgements'; protected $primaryKey = 'id'; protected $allowedFields = [ @@ -20,6 +23,10 @@ class ReportCardAcknowledgementModel extends Model 'created_at', 'updated_at', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = true; protected $createdField = 'created_at'; protected $updatedField = 'updated_at'; diff --git a/app/Models/SchoolYearClosingBatchModel.php b/app/Models/SchoolYearClosingBatchModel.php index 02ab8fe..0d57ce0 100644 --- a/app/Models/SchoolYearClosingBatchModel.php +++ b/app/Models/SchoolYearClosingBatchModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class SchoolYearClosingBatchModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'school_year_closing_batches'; protected $primaryKey = 'id'; protected $returnType = 'array'; @@ -25,5 +28,10 @@ class SchoolYearClosingBatchModel extends Model 'completed_at', 'failed_at', 'failure_message', + 'school_year', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + } diff --git a/app/Models/SchoolYearClosingItemModel.php b/app/Models/SchoolYearClosingItemModel.php index 107fcdb..f345baf 100644 --- a/app/Models/SchoolYearClosingItemModel.php +++ b/app/Models/SchoolYearClosingItemModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class SchoolYearClosingItemModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'school_year_closing_items'; protected $primaryKey = 'id'; protected $returnType = 'array'; @@ -22,5 +25,10 @@ class SchoolYearClosingItemModel extends Model 'target_adjustment_id', 'status', 'error_message', + 'school_year', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + } diff --git a/app/Models/SchoolYearTransitionLogModel.php b/app/Models/SchoolYearTransitionLogModel.php index 7a7e033..4e33c08 100644 --- a/app/Models/SchoolYearTransitionLogModel.php +++ b/app/Models/SchoolYearTransitionLogModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class SchoolYearTransitionLogModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'school_year_transition_logs'; protected $primaryKey = 'id'; protected $returnType = 'array'; @@ -19,5 +22,10 @@ class SchoolYearTransitionLogModel extends Model 'performed_by', 'metadata_json', 'created_at', + 'school_year', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + } diff --git a/app/Models/ScoreCommentModel.php b/app/Models/ScoreCommentModel.php index 019798c..38e7854 100644 --- a/app/Models/ScoreCommentModel.php +++ b/app/Models/ScoreCommentModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class ScoreCommentModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'score_comments'; protected $primaryKey = 'id'; @@ -21,6 +24,10 @@ class ScoreCommentModel extends Model 'commented_by', 'created_at' ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + public $timestamps = false; // because we're using MySQL's default timestamp } diff --git a/app/Models/SemesterScoreModel.php b/app/Models/SemesterScoreModel.php index 5c2dc65..ba9469a 100644 --- a/app/Models/SemesterScoreModel.php +++ b/app/Models/SemesterScoreModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class SemesterScoreModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'semester_scores'; protected $primaryKey = 'id'; protected $allowedFields = [ @@ -26,6 +29,10 @@ class SemesterScoreModel extends Model 'semester', 'school_year' ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + /** * Insert or update a semester score record @@ -34,6 +41,8 @@ class SemesterScoreModel extends Model public function upsert(array $data): bool { + $data = $this->withDefaultSchoolYear($data); + // First check if record exists $existing = $this->where([ 'student_id' => $data['student_id'], @@ -50,4 +59,4 @@ public function upsert(array $data): bool return $this->insert($data) !== false; } } -} \ No newline at end of file +} diff --git a/app/Models/StaffAttendanceModel.php b/app/Models/StaffAttendanceModel.php index d2a1d6b..ceeb947 100644 --- a/app/Models/StaffAttendanceModel.php +++ b/app/Models/StaffAttendanceModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; use Config\Services; class StaffAttendanceModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'staff_attendance'; protected $primaryKey = 'id'; protected $returnType = 'array'; @@ -25,6 +28,10 @@ class StaffAttendanceModel extends Model 'created_by', 'updated_by', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = true; protected $createdField = 'created_at'; diff --git a/app/Models/StaffModel.php b/app/Models/StaffModel.php index 67a0f1e..d6692e6 100644 --- a/app/Models/StaffModel.php +++ b/app/Models/StaffModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class StaffModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'staff'; protected $primaryKey = 'id'; protected $useAutoIncrement = true; @@ -22,8 +25,13 @@ class StaffModel extends Model 'role_name', 'active_role', 'created_at', - 'updated_at' + 'updated_at', + 'school_year', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = false; // Managed manually in controller @@ -51,7 +59,7 @@ class StaffModel extends Model return false; } - unset($data['school_year'], $data['status']); + unset($data['status']); $existing = $this->where('user_id', $data['user_id'])->first(); diff --git a/app/Models/StudentClassModel.php b/app/Models/StudentClassModel.php index a1139de..4c01289 100644 --- a/app/Models/StudentClassModel.php +++ b/app/Models/StudentClassModel.php @@ -4,9 +4,12 @@ namespace App\Models; use CodeIgniter\Database\BaseBuilder; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class StudentClassModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'student_class'; protected $primaryKey = 'id'; protected $returnType = 'array'; @@ -32,7 +35,7 @@ class StudentClassModel extends Model protected $validationRules = [ 'student_id' => 'required|integer', 'class_section_id'=> 'permit_empty|integer', - 'school_year' => 'required|max_length[20]', + 'school_year' => 'required|string|max_length[9]', 'is_event_only' => 'permit_empty|in_list[0,1]', 'updated_by' => 'permit_empty|integer', ]; diff --git a/app/Models/StudentDecisionModel.php b/app/Models/StudentDecisionModel.php index d99ce27..461b3fa 100644 --- a/app/Models/StudentDecisionModel.php +++ b/app/Models/StudentDecisionModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class StudentDecisionModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'student_decisions'; protected $primaryKey = 'id'; @@ -19,6 +22,10 @@ class StudentDecisionModel extends Model 'notes', 'generated_by', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = true; protected $createdField = 'created_at'; diff --git a/app/Models/StudentSectionDistributionDraftModel.php b/app/Models/StudentSectionDistributionDraftModel.php index 7fe2351..c24e724 100644 --- a/app/Models/StudentSectionDistributionDraftModel.php +++ b/app/Models/StudentSectionDistributionDraftModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class StudentSectionDistributionDraftModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'student_section_distribution_drafts'; protected $primaryKey = 'id'; protected $returnType = 'array'; @@ -27,6 +30,10 @@ class StudentSectionDistributionDraftModel extends Model 'created_at', 'updated_at', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[20]', + ]; + protected $useTimestamps = true; protected $createdField = 'created_at'; diff --git a/app/Models/TeacherClassModel.php b/app/Models/TeacherClassModel.php index ddab36d..1abfce7 100644 --- a/app/Models/TeacherClassModel.php +++ b/app/Models/TeacherClassModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class TeacherClassModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'teacher_class'; protected $primaryKey = 'id'; protected $allowedFields = [ diff --git a/app/Models/TeacherSubmissionNotificationHistoryModel.php b/app/Models/TeacherSubmissionNotificationHistoryModel.php index 79c92cb..25b52a6 100644 --- a/app/Models/TeacherSubmissionNotificationHistoryModel.php +++ b/app/Models/TeacherSubmissionNotificationHistoryModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class TeacherSubmissionNotificationHistoryModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'teacher_submission_notification_history'; protected $primaryKey = 'id'; protected $allowedFields = [ @@ -19,5 +22,9 @@ class TeacherSubmissionNotificationHistoryModel extends Model 'semester', 'sent_at', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[9]', + ]; + protected $useTimestamps = false; } diff --git a/app/Models/UserNotificationModel.php b/app/Models/UserNotificationModel.php index 6141183..21fdd54 100644 --- a/app/Models/UserNotificationModel.php +++ b/app/Models/UserNotificationModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class UserNotificationModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'user_notifications'; protected $primaryKey = 'id'; @@ -14,8 +17,13 @@ class UserNotificationModel extends Model 'user_id', 'is_read', 'delivered', - 'delivered_at' + 'delivered_at', + 'school_year', ]; + protected $validationRules = [ + 'school_year' => 'required|string|max_length[20]', + ]; + protected $useTimestamps = false; } \ No newline at end of file diff --git a/app/Models/WhatsappGroupLinkModel.php b/app/Models/WhatsappGroupLinkModel.php index 47c5552..3f5e7c3 100644 --- a/app/Models/WhatsappGroupLinkModel.php +++ b/app/Models/WhatsappGroupLinkModel.php @@ -1,9 +1,12 @@ 'required|string|max_length[9]', + ]; + protected $useTimestamps = true; // requires created_at / updated_at columns private ?bool $hasSchoolYearColumn = null; diff --git a/app/Models/WhatsappGroupMembershipModel.php b/app/Models/WhatsappGroupMembershipModel.php index cf351d0..13e4522 100644 --- a/app/Models/WhatsappGroupMembershipModel.php +++ b/app/Models/WhatsappGroupMembershipModel.php @@ -1,9 +1,12 @@ 'required|string|max_length[9]', + ]; + protected $useTimestamps = true; // created_at, updated_at /** diff --git a/app/Models/WhatsappInviteLogModel.php b/app/Models/WhatsappInviteLogModel.php index c695a8f..13aefea 100644 --- a/app/Models/WhatsappInviteLogModel.php +++ b/app/Models/WhatsappInviteLogModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Concerns\SchoolYearAutoFillTrait; class WhatsappInviteLogModel extends Model { + use SchoolYearAutoFillTrait; + protected $table = 'whatsapp_invites_log'; protected $primaryKey = 'id'; protected $useAutoIncrement = true; @@ -21,6 +24,7 @@ class WhatsappInviteLogModel extends Model 'status', 'error_message', 'sent_at', + 'school_year', ]; protected $useTimestamps = false; // we handle sent_at manually @@ -34,6 +38,7 @@ class WhatsappInviteLogModel extends Model 'status' => 'required|max_length[20]', 'class_section_id' => 'permit_empty|integer', 'link_id' => 'permit_empty|integer', + 'school_year' => 'required|string|max_length[9]', ]; protected $validationMessages = []; diff --git a/app/Services/SchoolYearClosingService.php b/app/Services/SchoolYearClosingService.php index c1f2832..7d654e7 100644 --- a/app/Services/SchoolYearClosingService.php +++ b/app/Services/SchoolYearClosingService.php @@ -6,6 +6,7 @@ use App\Models\SchoolYearClosingBatchModel; use App\Models\SchoolYearClosingItemModel; use App\Models\SchoolYearModel; use App\Models\ConfigurationModel; +use App\Models\InvoiceModel; use App\Support\SchoolYear\SchoolYearStatus; use CodeIgniter\Database\BaseConnection; use InvalidArgumentException; @@ -123,8 +124,8 @@ final class SchoolYearClosingService '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'], + 'total_positive_balance' => $this->sumCarryForwardBalances($preview['carry_forward'], true), + 'total_credit_balance' => $this->sumCarryForwardBalances($preview['carry_forward'], false), 'started_by' => $userId, 'started_at' => $now, ], true); @@ -168,22 +169,44 @@ final class SchoolYearClosingService $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.'); + if (($batch['status'] ?? '') === 'started') { + $this->refreshBatchFromPreview($batch, $preview); + $batch = $this->latestOpenBatch($sourceYearId) ?? $batch; + } elseif (in_array((string) ($batch['status'] ?? ''), ['executed', 'completed'], true) && $this->countExistingTargetInvoices($batch) === 0) { + $this->refreshBatchFromPreview($batch, $preview); + $batch = $this->latestOpenBatch($sourceYearId) ?? $batch; + } elseif (! in_array((string) ($batch['status'] ?? ''), ['executed', 'completed'], true)) { + throw new InvalidArgumentException('Closing preview has changed. Refresh and restart closing before executing carry-forward.'); + } } + $source = $this->requireYear($sourceYearId); + $target = $this->requireYear((int) $batch['target_school_year_id']); + $sourceName = (string) ($source['name'] ?? ''); + $targetName = (string) ($target['name'] ?? ''); + $this->db->transStart(); $items = $this->itemModel->where('closing_batch_id', (int) $batch['id'])->findAll(); foreach ($items as $item) { - if (($item['status'] ?? '') === 'completed') { + if (($item['status'] ?? '') === 'completed' && (int) ($item['target_invoice_id'] ?? 0) > 0) { continue; } - $this->itemModel->update((int) $item['id'], ['status' => 'completed']); + $targetInvoiceId = $this->createCarryForwardInvoice($item, $sourceName, $targetName, $userId); + $this->itemModel->update((int) $item['id'], [ + 'target_invoice_id' => $targetInvoiceId, + 'status' => 'completed', + 'error_message' => null, + ]); } - $this->batchModel->update((int) $batch['id'], ['status' => 'executed']); - $this->managementService->log($sourceYearId, SchoolYearStatus::CLOSING, SchoolYearStatus::CLOSING, 'carry_forward_execute', $userId, [ + if (($batch['status'] ?? '') !== 'completed') { + $this->batchModel->update((int) $batch['id'], ['status' => 'executed']); + } + $currentStatus = (string) ($source['status'] ?? SchoolYearStatus::CLOSING); + $this->managementService->log($sourceYearId, $currentStatus, $currentStatus, '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.', + 'target_school_year_id' => (int) $batch['target_school_year_id'], + 'note' => 'Created target-year opening balance invoices for previewed carry-forward items.', ]); $this->db->transComplete(); @@ -358,6 +381,7 @@ final class SchoolYearClosingService $count = $this->db->table('invoices') ->where('school_year', $schoolYear) ->where('balance >', 0) + ->where("LOWER(status) IN ('unpaid', 'partially paid')", null, false) ->countAllResults(); return $count > 0 @@ -374,7 +398,9 @@ final class SchoolYearClosingService $builder = $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); + ->where('i.school_year', $schoolYear) + ->where('i.balance !=', 0) + ->where("LOWER(i.status) IN ('unpaid', 'partially paid')", null, false); if ($this->db->tableExists('users')) { $builder @@ -407,6 +433,137 @@ final class SchoolYearClosingService }, $rows); } + private function refreshBatchFromPreview(array $batch, array $preview): void + { + $batchId = (int) ($batch['id'] ?? 0); + if ($batchId <= 0) { + throw new InvalidArgumentException('Closing batch was not found.'); + } + + $this->itemModel->where('closing_batch_id', $batchId)->delete(); + 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->batchModel->update($batchId, [ + 'preview_hash' => $preview['hash'], + 'total_families' => count($preview['carry_forward']), + 'total_positive_balance' => $this->sumCarryForwardBalances($preview['carry_forward'], true), + 'total_credit_balance' => $this->sumCarryForwardBalances($preview['carry_forward'], false), + ]); + } + + private function countExistingTargetInvoices(array $batch): int + { + $batchId = (int) ($batch['id'] ?? 0); + if ($batchId <= 0 || ! $this->db->tableExists('invoices')) { + return 0; + } + + $items = $this->itemModel + ->select('target_invoice_id') + ->where('closing_batch_id', $batchId) + ->where('target_invoice_id IS NOT NULL', null, false) + ->where('target_invoice_id >', 0) + ->findAll(); + + $invoiceIds = array_values(array_unique(array_map(static fn (array $row): int => (int) ($row['target_invoice_id'] ?? 0), $items))); + if ($invoiceIds === []) { + return 0; + } + + return $this->db->table('invoices') + ->whereIn('id', $invoiceIds) + ->countAllResults(); + } + + private function sumCarryForwardBalances(array $rows, bool $positive): float + { + $total = 0.0; + foreach ($rows as $row) { + $amount = (float) ($row['carry_forward_amount'] ?? 0); + if ($positive && $amount > 0) { + $total += $amount; + } elseif (! $positive && $amount < 0) { + $total += abs($amount); + } + } + + return round($total, 2); + } + + private function createCarryForwardInvoice(array $item, string $sourceYear, string $targetYear, ?int $userId): int + { + $existingId = (int) ($item['target_invoice_id'] ?? 0); + if ($existingId > 0) { + $existing = $this->db->table('invoices')->where('id', $existingId)->get()->getRowArray(); + if ($existing !== null) { + return $existingId; + } + } + + $amount = round((float) ($item['carry_forward_amount'] ?? 0), 2); + if (abs($amount) < 0.005) { + return 0; + } + + $parentId = (int) ($item['family_id'] ?? 0); + $invoiceNumber = $this->carryForwardInvoiceNumber((int) ($item['id'] ?? 0), $parentId, $sourceYear, $targetYear); + $existing = $this->db->table('invoices') + ->select('id') + ->where('invoice_number', $invoiceNumber) + ->get() + ->getRowArray(); + if ($existing !== null) { + return (int) $existing['id']; + } + + $now = function_exists('utc_now') ? utc_now() : gmdate('Y-m-d H:i:s'); + $payload = [ + 'parent_id' => $parentId, + 'invoice_number' => $invoiceNumber, + 'total_amount' => $amount, + 'paid_amount' => 0, + 'balance' => $amount, + 'school_year' => $targetYear, + 'semester' => 'Opening Balance', + 'issue_date' => $now, + 'due_date' => null, + 'status' => $amount > 0 ? 'Unpaid' : 'Credit', + 'created_at' => $now, + 'updated_at' => $now, + 'updated_by' => $userId, + ]; + + if ($this->db->fieldExists('description', 'invoices')) { + $label = $amount > 0 ? 'Balance carried over' : 'Credit carried over'; + $payload['description'] = "{$label} from previous school year {$sourceYear}."; + } + + $invoiceModel = new InvoiceModel(); + $invoiceId = $invoiceModel->insert($payload, true); + if (! $invoiceId) { + throw new RuntimeException('Unable to create carry-forward invoice: ' . json_encode($invoiceModel->errors())); + } + + return (int) $invoiceId; + } + + private function carryForwardInvoiceNumber(int $itemId, int $parentId, string $sourceYear, string $targetYear): string + { + $source = preg_replace('/[^0-9A-Za-z]/', '', $sourceYear); + $target = preg_replace('/[^0-9A-Za-z]/', '', $targetYear); + + return sprintf('CF-%s-%s-P%d-I%d', $source, $target, $parentId, $itemId); + } + private function promotionPreview(string $schoolYear, ?string $targetSchoolYear): array { $summary = [ @@ -764,7 +921,7 @@ final class SchoolYearClosingService { return $this->batchModel ->where('source_school_year_id', $sourceYearId) - ->whereIn('status', ['started', 'executed']) + ->whereIn('status', ['started', 'executed', 'completed']) ->orderBy('id', 'DESC') ->first(); } diff --git a/app/Views/landing_page/parent_dashboard.php b/app/Views/landing_page/parent_dashboard.php index aa29e9b..645c026 100644 --- a/app/Views/landing_page/parent_dashboard.php +++ b/app/Views/landing_page/parent_dashboard.php @@ -160,7 +160,7 @@

Payment

$
-

Latest outstanding balance from the most recent invoice.

+

Current account balance for this school year, including any previous-year carry-over.

diff --git a/app/Views/parent/invoice_payment.php b/app/Views/parent/invoice_payment.php index 4f66211..10e2fc6 100644 --- a/app/Views/parent/invoice_payment.php +++ b/app/Views/parent/invoice_payment.php @@ -97,6 +97,7 @@ $deadline = parseDbDateTime($dueDate, 'UTC', $displayTz); // format either DATE Invoice Number Issue Date Due Date + Description Balance Last Payment Amount Last Payment Date @@ -117,6 +118,7 @@ $deadline = parseDbDateTime($dueDate, 'UTC', $displayTz); // format either DATE format('m-d-Y h:i A')) : '—' ?> format('m-d-Y')) : '—' ?> + $ $ format('m-d-Y h:i A')) : '—' ?> diff --git a/app/Views/payment/manual_pay.php b/app/Views/payment/manual_pay.php index b36e3fa..52c6447 100644 --- a/app/Views/payment/manual_pay.php +++ b/app/Views/payment/manual_pay.php @@ -182,6 +182,12 @@ + +
+ +
+ +
@@ -406,7 +412,8 @@ id="invoice_id" class="form-select" required - data-end-date=""> + data-end-date="" + data-carry-forward-full-required="">
@@ -573,6 +585,8 @@ const $instSeq = () => _el('installment_seq'); const $maxInstLab = () => _el('max-installments'); const $form = () => _el('addPaymentForm'); + const carryForwardFullRequired = ; + const carryForwardFullMessage = ; // Confirm overlay elements const $overlay = () => _el('payConfirmOverlay'); @@ -699,6 +713,25 @@ $amountHint().textContent = 'Card payments must cover the remaining balance in full.'; } + function forceCarryForwardFullRules() { + const type = $type(); + if (type) { + type.value = 'full'; + type.removeAttribute('disabled'); + const installmentOption = type.querySelector('option[value="installment"]'); + if (installmentOption) installmentOption.disabled = true; + } + + $instSec().style.display = 'none'; + $instSeqRow().style.display = 'none'; + + const bal = getBalance(); + $amount().value = bal.toFixed(2); + $amount().setAttribute('readonly', 'readonly'); + if ($instAmt()) $instAmt().value = ''; + $amountHint().textContent = carryForwardFullMessage; + } + function setupInstallments() { const endDate = $invoice().getAttribute('data-end-date') || ''; const months = monthsUntil(endDate); @@ -767,6 +800,8 @@ if (m === 'card') { forceCardRules(); + } else if (carryForwardFullRequired) { + forceCarryForwardFullRules(); } else { // enable full/installment select $type().removeAttribute('disabled'); @@ -787,6 +822,10 @@ forceCardRules(); return; } + if (carryForwardFullRequired) { + forceCarryForwardFullRules(); + return; + } if (t === 'installment') { $instSec().style.display = ''; setupInstallments(); @@ -807,6 +846,8 @@ if (($method().value || '').toLowerCase() === 'card') { forceCardRules(); + } else if (carryForwardFullRequired) { + forceCarryForwardFullRules(); } else { updateAmountHint(); if ($type().value === 'installment') { @@ -879,6 +920,10 @@ alert('Please enter a valid amount > 0.'); return; } + if (carryForwardFullRequired && type === 'installment') { + alert(carryForwardFullMessage); + return; + } const invoiceText = invOpt.text || ('Invoice #' + invOpt.value); // Derive balance: prefer total - paid - discount - refunds, else fallback to data-balance @@ -909,6 +954,11 @@ const newBal = (isFinite(balance) && isFinite(amount)) ? (balance - amount) : NaN; const overpay = (isFinite(newBal) && newBal < -0.005); + if (carryForwardFullRequired && isFinite(balance) && Math.abs(amount - balance) > 0.005) { + alert(carryForwardFullMessage); + $amount().value = Math.max(0, balance).toFixed(2); + return; + } $pcAmount().textContent = fmtUSD(amount); $pcInvoice().innerHTML = 'Invoice: ' + esc(invoiceText); diff --git a/app/Views/school_years/closing_preview.php b/app/Views/school_years/closing_preview.php index 02df21a..7555061 100644 --- a/app/Views/school_years/closing_preview.php +++ b/app/Views/school_years/closing_preview.php @@ -24,6 +24,7 @@ $carryForward = $preview['carry_forward'] ?? []; $status = (string) ($source['status'] ?? ''); $batchStatus = (string) ($latestBatch['status'] ?? ''); + $missingCarryForwardInvoices = (int) ($missingCarryForwardInvoices ?? 0); $money = static fn ($value): string => '$' . number_format((float) $value, 2); $score = static fn ($value): string => is_numeric($value) ? number_format((float) $value, 2) : '-'; $promotionUrl = static function (array $overrides = []) use ($source, $target, $promotionSort, $promotionOrder, $promotionPage, $promotionPerPage): string { @@ -429,6 +430,17 @@ + +
+ + +
+ +
diff --git a/reports/db_schema_comparison_scool_view_vs_school_prod_1_2026-07-17.md b/reports/db_schema_comparison_scool_view_vs_school_prod_1_2026-07-17.md new file mode 100644 index 0000000..de84255 --- /dev/null +++ b/reports/db_schema_comparison_scool_view_vs_school_prod_1_2026-07-17.md @@ -0,0 +1,153 @@ +# Database Schema Comparison Report + +- Compared: `/Volumes/ExternalApps/database_dump/scool_view.sql.zip` (`scool_view.sql`) +- Against: `/Volumes/ExternalApps/database_dump/school_prod_1.sql.zip` (`school_prod.sql`) +- Scope: table definitions, column definitions, table options, indexes/keys, auto-increment `MODIFY` statements, and foreign-key constraints from the dumps. + +## Summary + +- Tables in `scool_view`: 131 +- Tables in `school_prod_1`: 131 +- Tables present in both: 131 +- Tables only in `scool_view`: 0 +- Tables only in `school_prod_1`: 0 +- Matching table schemas: 131 +- Different table schemas: 0 + +**Result: no schema differences found.** + +## Table Status + +| Table | Status | Difference areas | +|---|---:|---| +| `additional_charges` | Match | - | +| `admin_notification_subjects` | Match | - | +| `attendance_comment_template` | Match | - | +| `attendance_data` | Match | - | +| `attendance_day` | Match | - | +| `attendance_record` | Match | - | +| `attendance_tracking` | Match | - | +| `authorized_users` | Match | - | +| `badge_print_logs` | Match | - | +| `below_sixty_decisions` | Match | - | +| `calendar_events` | Match | - | +| `certificate_records` | Match | - | +| `chapters` | Match | - | +| `class_prep_adjustments` | Match | - | +| `class_preparation_log` | Match | - | +| `class_progress_attachments` | Match | - | +| `class_progress_reports` | Match | - | +| `classes` | Match | - | +| `classSection` | Match | - | +| `competition_class_winners` | Match | - | +| `competition_scores` | Match | - | +| `competition_winners` | Match | - | +| `competitions` | Match | - | +| `configuration` | Match | - | +| `contactus` | Match | - | +| `current_flag` | Match | - | +| `discount_usages` | Match | - | +| `discount_vouchers` | Match | - | +| `early_dismissal_signatures` | Match | - | +| `email_templates` | Match | - | +| `emergency_contacts` | Match | - | +| `enrollments` | Match | - | +| `event_charges` | Match | - | +| `events` | Match | - | +| `exam_drafts` | Match | - | +| `examquestiondistribution` | Match | - | +| `examquestions` | Match | - | +| `exams` | Match | - | +| `expenses` | Match | - | +| `families` | Match | - | +| `family_comm_prefs` | Match | - | +| `family_guardians` | Match | - | +| `family_students` | Match | - | +| `final_exam` | Match | - | +| `final_score` | Match | - | +| `flag` | Match | - | +| `grading_locks` | Match | - | +| `homework` | Match | - | +| `inventory_categories` | Match | - | +| `inventory_items` | Match | - | +| `inventory_movements` | Match | - | +| `invoice_event` | Match | - | +| `invoice_students_list` | Match | - | +| `invoices` | Match | - | +| `ip_attempts` | Match | - | +| `late_slip_logs` | Match | - | +| `login_activity` | Match | - | +| `longanswerquestions` | Match | - | +| `manual_payments` | Match | - | +| `messages` | Match | - | +| `midterm_exam` | Match | - | +| `migrations` | Match | - | +| `missing_score_overrides` | Match | - | +| `nav_items` | Match | - | +| `notification_recipients` | Match | - | +| `notifications` | Match | - | +| `parent_attendance_reports` | Match | - | +| `parent_meeting_schedules` | Match | - | +| `parent_notifications` | Match | - | +| `parents` | Match | - | +| `participation` | Match | - | +| `password_reset_requests` | Match | - | +| `password_resets` | Match | - | +| `payment_error` | Match | - | +| `payment_notification_logs` | Match | - | +| `payment_transactions` | Match | - | +| `payments` | Match | - | +| `paypal_payments` | Match | - | +| `paypal_transactions` | Match | - | +| `permissions` | Match | - | +| `placement_batches` | Match | - | +| `placement_levels` | Match | - | +| `placement_scores` | Match | - | +| `preferences` | Match | - | +| `print_requests` | Match | - | +| `project` | Match | - | +| `promotion_queue` | Match | - | +| `qcmquestions` | Match | - | +| `questions` | Match | - | +| `questionstruefalse` | Match | - | +| `questiontypes` | Match | - | +| `quiz` | Match | - | +| `refunds` | Match | - | +| `reimbursement_batch_admin_files` | Match | - | +| `reimbursement_batch_items` | Match | - | +| `reimbursement_batches` | Match | - | +| `reimbursements` | Match | - | +| `report_card_acknowledgements` | Match | - | +| `role_nav_items` | Match | - | +| `role_permissions` | Match | - | +| `roles` | Match | - | +| `scan_log` | Match | - | +| `school_year_closing_batches` | Match | - | +| `school_year_closing_items` | Match | - | +| `school_year_transition_logs` | Match | - | +| `school_years` | Match | - | +| `score_comments` | Match | - | +| `sections` | Match | - | +| `semester_scores` | Match | - | +| `settings` | Match | - | +| `shortanswerquestions` | Match | - | +| `staff` | Match | - | +| `staff_attendance` | Match | - | +| `student_allergies` | Match | - | +| `student_class` | Match | - | +| `student_decisions` | Match | - | +| `student_medical_conditions` | Match | - | +| `student_section_distribution_drafts` | Match | - | +| `students` | Match | - | +| `subject_curriculum_items` | Match | - | +| `support_requests` | Match | - | +| `teacher_attendance_data` | Match | - | +| `teacher_class` | Match | - | +| `teacher_submission_notification_history` | Match | - | +| `user_notifications` | Match | - | +| `user_preferences` | Match | - | +| `user_roles` | Match | - | +| `users` | Match | - | +| `whatsapp_group_links` | Match | - | +| `whatsapp_group_memberships` | Match | - | +| `whatsapp_invites_log` | Match | - | diff --git a/reports/db_schema_comparison_scool_view_vs_school_prod_2026-07-17.md b/reports/db_schema_comparison_scool_view_vs_school_prod_2026-07-17.md new file mode 100644 index 0000000..40d5fc1 --- /dev/null +++ b/reports/db_schema_comparison_scool_view_vs_school_prod_2026-07-17.md @@ -0,0 +1,366 @@ +# Database Schema Comparison Report + +- Compared: `/Volumes/ExternalApps/database_dump/scool_view.sql.zip` (`scool_view.sql`) +- Against: `/Volumes/ExternalApps/database_dump/school_prod.sql.zip` (`school_prod.sql`) +- Scope: table definitions, column definitions, table options, indexes/keys, auto-increment `MODIFY` statements, and foreign-key constraints from the dumps. + +## Summary + +- Tables in `scool_view`: 131 +- Tables in `school_prod`: 131 +- Tables present in both: 131 +- Tables only in `scool_view`: 0 +- Tables only in `school_prod`: 0 +- Matching table schemas: 104 +- Different table schemas: 27 + +**Result: schema differences found.** + +## Table Status + +| Table | Status | Difference areas | +|---|---:|---| +| `additional_charges` | Match | - | +| `admin_notification_subjects` | Match | - | +| `attendance_comment_template` | Match | - | +| `attendance_data` | Match | - | +| `attendance_day` | Match | - | +| `attendance_record` | Match | - | +| `attendance_tracking` | Match | - | +| `authorized_users` | Match | - | +| `badge_print_logs` | Match | - | +| `below_sixty_decisions` | Match | - | +| `calendar_events` | Match | - | +| `certificate_records` | Match | - | +| `chapters` | Match | - | +| `class_prep_adjustments` | Match | - | +| `class_preparation_log` | Match | - | +| `class_progress_attachments` | Match | - | +| `class_progress_reports` | Match | - | +| `classes` | Match | - | +| `classSection` | Different | columns | +| `competition_class_winners` | Match | - | +| `competition_scores` | Match | - | +| `competition_winners` | Different | keys | +| `competitions` | Match | - | +| `configuration` | Match | - | +| `contactus` | Different | columns | +| `current_flag` | Match | - | +| `discount_usages` | Match | - | +| `discount_vouchers` | Different | columns | +| `early_dismissal_signatures` | Match | - | +| `email_templates` | Match | - | +| `emergency_contacts` | Different | columns | +| `enrollments` | Match | - | +| `event_charges` | Match | - | +| `events` | Match | - | +| `exam_drafts` | Match | - | +| `examquestiondistribution` | Match | - | +| `examquestions` | Match | - | +| `exams` | Match | - | +| `expenses` | Match | - | +| `families` | Match | - | +| `family_comm_prefs` | Match | - | +| `family_guardians` | Match | - | +| `family_students` | Match | - | +| `final_exam` | Match | - | +| `final_score` | Match | - | +| `flag` | Match | - | +| `grading_locks` | Match | - | +| `homework` | Match | - | +| `inventory_categories` | Match | - | +| `inventory_items` | Match | - | +| `inventory_movements` | Match | - | +| `invoice_event` | Match | - | +| `invoice_students_list` | Match | - | +| `invoices` | Match | - | +| `ip_attempts` | Different | columns | +| `late_slip_logs` | Match | - | +| `login_activity` | Different | columns | +| `longanswerquestions` | Match | - | +| `manual_payments` | Match | - | +| `messages` | Match | - | +| `midterm_exam` | Match | - | +| `migrations` | Match | - | +| `missing_score_overrides` | Match | - | +| `nav_items` | Match | - | +| `notification_recipients` | Different | columns | +| `notifications` | Different | columns | +| `parent_attendance_reports` | Match | - | +| `parent_meeting_schedules` | Match | - | +| `parent_notifications` | Match | - | +| `parents` | Different | columns | +| `participation` | Match | - | +| `password_reset_requests` | Match | - | +| `password_resets` | Match | - | +| `payment_error` | Different | columns | +| `payment_notification_logs` | Match | - | +| `payment_transactions` | Different | columns | +| `payments` | Different | columns | +| `paypal_payments` | Match | - | +| `paypal_transactions` | Different | columns | +| `permissions` | Match | - | +| `placement_batches` | Match | - | +| `placement_levels` | Match | - | +| `placement_scores` | Match | - | +| `preferences` | Different | columns | +| `print_requests` | Match | - | +| `project` | Match | - | +| `promotion_queue` | Match | - | +| `qcmquestions` | Match | - | +| `questions` | Match | - | +| `questionstruefalse` | Match | - | +| `questiontypes` | Match | - | +| `quiz` | Match | - | +| `refunds` | Different | columns, keys | +| `reimbursement_batch_admin_files` | Match | - | +| `reimbursement_batch_items` | Different | columns | +| `reimbursement_batches` | Different | columns | +| `reimbursements` | Different | columns | +| `report_card_acknowledgements` | Match | - | +| `role_nav_items` | Match | - | +| `role_permissions` | Match | - | +| `roles` | Match | - | +| `scan_log` | Match | - | +| `school_year_closing_batches` | Match | - | +| `school_year_closing_items` | Match | - | +| `school_year_transition_logs` | Match | - | +| `school_years` | Match | - | +| `score_comments` | Match | - | +| `sections` | Match | - | +| `semester_scores` | Match | - | +| `settings` | Match | - | +| `shortanswerquestions` | Match | - | +| `staff` | Different | columns | +| `staff_attendance` | Match | - | +| `student_allergies` | Match | - | +| `student_class` | Match | - | +| `student_decisions` | Match | - | +| `student_medical_conditions` | Match | - | +| `student_section_distribution_drafts` | Match | - | +| `students` | Different | columns | +| `subject_curriculum_items` | Match | - | +| `support_requests` | Different | columns | +| `teacher_attendance_data` | Match | - | +| `teacher_class` | Different | keys | +| `teacher_submission_notification_history` | Match | - | +| `user_notifications` | Different | columns | +| `user_preferences` | Match | - | +| `user_roles` | Match | - | +| `users` | Different | columns | +| `whatsapp_group_links` | Different | columns, table options, keys | +| `whatsapp_group_memberships` | Different | columns, keys | +| `whatsapp_invites_log` | Match | - | + +## Detailed Differences + +### `classSection` + +Columns only in `school_prod`: + +- `semester`: `varchar(255) NOT NULL` + +### `competition_winners` + +**Indexes / keys** +Only in `school_prod`: +```sql +ADD KEY `competition_id_class_section_id_rank` (`competition_id`,`class_section_id`,`rank`) +``` + +### `contactus` + +Columns only in `school_prod`: + +- `semester`: `varchar(255) COLLATE utf8mb4_general_ci NOT NULL` + +### `discount_vouchers` + +Columns only in `school_prod`: + +- `semester`: `varchar(10) COLLATE utf8mb4_general_ci DEFAULT NULL` + +### `emergency_contacts` + +Columns only in `school_prod`: + +- `semester`: `varchar(255) COLLATE utf8mb4_general_ci NOT NULL` +- `school_year`: `varchar(9) COLLATE utf8mb4_general_ci DEFAULT NULL` + +### `ip_attempts` + +Columns only in `school_prod`: + +- `semester`: `varchar(10) COLLATE utf8mb4_general_ci DEFAULT NULL` + +### `login_activity` + +Columns only in `school_prod`: + +- `semester`: `varchar(255) NOT NULL` + +### `notification_recipients` + +Columns only in `school_prod`: + +- `semester`: `varchar(10) COLLATE utf8mb4_general_ci DEFAULT NULL` + +### `notifications` + +Columns only in `school_prod`: + +- `semester`: `varchar(10) COLLATE utf8mb4_general_ci DEFAULT NULL` + +### `parents` + +Columns only in `school_prod`: + +- `semester`: `varchar(10) COLLATE utf8mb4_general_ci DEFAULT NULL` +- `school_year`: `varchar(20) COLLATE utf8mb4_general_ci NOT NULL` + +### `payment_error` + +Columns only in `school_prod`: + +- `semester`: `varchar(10) COLLATE utf8mb4_general_ci DEFAULT NULL` + +### `payment_transactions` + +Columns only in `school_prod`: + +- `semester`: `varchar(10) COLLATE utf8mb4_general_ci DEFAULT NULL` + +### `payments` + +Columns only in `school_prod`: + +- `semester`: `varchar(10) COLLATE utf8mb4_general_ci DEFAULT NULL` + +### `paypal_transactions` + +Columns only in `school_prod`: + +- `semester`: `varchar(10) DEFAULT NULL` + +### `preferences` + +Columns only in `school_prod`: + +- `school_year`: `varchar(20) COLLATE utf8mb4_general_ci DEFAULT NULL` + +### `refunds` + +Columns only in `school_prod`: + +- `semester`: `varchar(10) COLLATE utf8mb4_general_ci DEFAULT NULL` + +**Indexes / keys** +Only in `scool_view`: +```sql +ADD KEY `idx_refunds_parent_year_semester_status` (`parent_id`,`school_year`,`status`) +``` +Only in `school_prod`: +```sql +ADD KEY `idx_refunds_parent_year_semester_status` (`parent_id`,`school_year`,`semester`,`status`) +``` + +### `reimbursement_batch_items` + +Columns only in `school_prod`: + +- `semester`: `varchar(10) COLLATE utf8mb4_general_ci DEFAULT NULL` + +### `reimbursement_batches` + +Columns only in `school_prod`: + +- `semester`: `varchar(10) COLLATE utf8mb4_general_ci DEFAULT NULL` + +### `reimbursements` + +Columns only in `school_prod`: + +- `semester`: `varchar(10) COLLATE utf8mb4_general_ci DEFAULT NULL` + +### `staff` + +Columns only in `school_prod`: + +- `school_year`: `varchar(9) COLLATE utf8mb4_general_ci NOT NULL` + +### `students` + +Columns only in `school_prod`: + +- `semester`: `varchar(10) COLLATE utf8mb4_general_ci DEFAULT NULL` +- `school_year`: `varchar(9) COLLATE utf8mb4_general_ci DEFAULT NULL` + +### `support_requests` + +Columns only in `school_prod`: + +- `semester`: `varchar(255) NOT NULL` + +### `teacher_class` + +**Indexes / keys** +Only in `scool_view`: +```sql +ADD UNIQUE KEY `unique_teacher_assignment` (`teacher_id`,`class_section_id`,`school_year`) +``` +Only in `school_prod`: +```sql +ADD KEY `idx_sy_teacher_class_school_year_223511c0` (`school_year`) +``` + +### `user_notifications` + +Columns only in `school_prod`: + +- `semester`: `varchar(10) COLLATE utf8mb4_general_ci DEFAULT NULL` + +### `users` + +Columns only in `school_prod`: + +- `semester`: `varchar(255) NOT NULL` +- `school_year`: `varchar(9) DEFAULT NULL` + +### `whatsapp_group_links` + +Columns only in `school_prod`: + +- `school_year`: `varchar(9) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL` +- `semester`: `varchar(20) COLLATE utf8mb4_general_ci NOT NULL` + +Table options changed: +- `scool_view`: `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci` +- `school_prod`: `(none)` + +**Indexes / keys** +Only in `scool_view`: +```sql +ADD UNIQUE KEY `uq_section_term` (`class_section_id`) +``` +Only in `school_prod`: +```sql +ADD UNIQUE KEY `uq_section_term` (`class_section_id`,`school_year`,`semester`) +``` + +### `whatsapp_group_memberships` + +Columns only in `school_prod`: + +- `semester`: `varchar(20) NOT NULL` + +**Indexes / keys** +Only in `scool_view`: +```sql +ADD UNIQUE KEY `uniq_whatsapp_membership` (`class_section_id`,`school_year`,`subject_type`,`subject_id`) +ADD KEY `class_section_id_school_year_semester` (`class_section_id`,`school_year`) +``` +Only in `school_prod`: +```sql +ADD UNIQUE KEY `uniq_whatsapp_membership` (`class_section_id`,`school_year`,`semester`,`subject_type`,`subject_id`) +ADD KEY `class_section_id_school_year_semester` (`class_section_id`,`school_year`,`semester`) +``` diff --git a/tests/app/Libraries/InvoiceLedgerServiceTest.php b/tests/app/Libraries/InvoiceLedgerServiceTest.php new file mode 100644 index 0000000..e6246e4 --- /dev/null +++ b/tests/app/Libraries/InvoiceLedgerServiceTest.php @@ -0,0 +1,91 @@ +invoice = $invoice; + $this->paidTotal = $paidTotal; + } + + protected function loadInvoice(int $invoiceId): ?array + { + return $invoiceId === (int) ($this->invoice['id'] ?? 0) ? $this->invoice : null; + } + + protected function calculateTuitionTotal(array $invoice): float + { + return 0.0; + } + + protected function calculateEventTotal(array $invoice): float + { + return 0.0; + } + + protected function calculateAdditionalCharges(int $invoiceId): float + { + return 0.0; + } + + protected function calculateDiscounts(int $invoiceId): float + { + return 0.0; + } + + protected function calculateValidPayments(int $invoiceId): float + { + return $this->paidTotal; + } + + protected function calculatePaidRefunds(int $invoiceId): float + { + return 0.0; + } +} + +class InvoiceLedgerServiceTest extends CIUnitTestCase +{ + public function testCarryForwardInvoiceUsesStoredOpeningBalance(): void + { + $service = new InvoiceLedgerServiceHarness([ + 'id' => 10, + 'invoice_number' => 'CF-20252026-20262027-P8-I4', + 'total_amount' => '90.00', + 'semester' => 'Opening Balance', + 'description' => 'Balance carried over from previous school year 2025-2026.', + ]); + + $calculation = $service->calculateInvoice(10); + + $this->assertSame('90.00', $calculation['total_amount']); + $this->assertSame('90.00', $calculation['balance']); + $this->assertSame(FinancialStatus::INVOICE_UNPAID, $calculation['status']); + } + + public function testCarryForwardInvoiceIsPaidAfterFullPayment(): void + { + $service = new InvoiceLedgerServiceHarness([ + 'id' => 10, + 'invoice_number' => 'CF-20252026-20262027-P8-I4', + 'total_amount' => '90.00', + 'semester' => 'Opening Balance', + 'description' => 'Balance carried over from previous school year 2025-2026.', + ], 90.0); + + $calculation = $service->calculateInvoice(10); + + $this->assertSame('90.00', $calculation['total_amount']); + $this->assertSame('0.00', $calculation['balance']); + $this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']); + } +}