From 36e8ffe56d02fe6b8421205fbca7cf518f71de22 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 9 Sep 2026 21:40:01 -0400 Subject: [PATCH] fix the enrollement-carryover balance --- app/Commands/RepairSchoolYearCarryForward.php | 103 ++++++++ app/Controllers/View/InvoiceController.php | 130 +++++++-- app/Libraries/InvoiceLedgerService.php | 48 +++- app/Services/EnrollmentWithdrawalService.php | 50 ++++ app/Services/SchoolYearClosingService.php | 201 +++++++++++++- .../enroll_withdraw/enrollment_withdrawal.php | 18 +- app/Views/school_years/closing_preview.php | 52 +++- scripts/compare_sql.py | 207 +++++++++++++++ scripts/deploy_home.sh | 247 +++++++++++++++++ scripts/test_deploy.sh | 248 ++++++++++++++++++ .../Libraries/InvoiceLedgerServiceTest.php | 20 ++ .../Services/SchoolYearClosingServiceTest.php | 80 ++++++ 12 files changed, 1365 insertions(+), 39 deletions(-) create mode 100644 app/Commands/RepairSchoolYearCarryForward.php create mode 100644 scripts/compare_sql.py create mode 100644 scripts/deploy_home.sh create mode 100644 scripts/test_deploy.sh create mode 100644 tests/app/Services/SchoolYearClosingServiceTest.php diff --git a/app/Commands/RepairSchoolYearCarryForward.php b/app/Commands/RepairSchoolYearCarryForward.php new file mode 100644 index 0000000..84f13c1 --- /dev/null +++ b/app/Commands/RepairSchoolYearCarryForward.php @@ -0,0 +1,103 @@ + 'Required source school-year record ID.', + '--actor-id' => 'Administrator user ID recorded in the repair audit log.', + '--commit' => 'Create missing closing items and target-year opening-balance invoices.', + '--json' => 'Print machine-readable output.', + ]; + + public function run(array $params) + { + $sourceYearId = (int) (CLI::getOption('source-year-id') ?? 0); + if ($sourceYearId <= 0) { + CLI::error('--source-year-id must be a positive integer.'); + return; + } + + try { + $closing = service('schoolYearClosing'); + $batch = $closing->latestBatch($sourceYearId); + if ($batch === null) { + throw new \RuntimeException('No closing batch exists for the source school year.'); + } + + $preview = $closing->preview($sourceYearId, (int) ($batch['target_school_year_id'] ?? 0)); + $db = \Config\Database::connect(); + $existingRows = $db->table('school_year_closing_items') + ->select('family_id') + ->where('closing_batch_id', (int) $batch['id']) + ->get() + ->getResultArray(); + $existing = array_fill_keys(array_map( + static fn (array $row): int => (int) ($row['family_id'] ?? 0), + $existingRows + ), true); + $missing = array_values(array_filter( + $preview['carry_forward'] ?? [], + static fn (array $row): bool => ! isset($existing[(int) ($row['family_id'] ?? 0)]) + )); + + $result = [ + 'mode' => CLI::getOption('commit') !== null ? 'commit' : 'dry-run', + 'closing_batch_id' => (int) $batch['id'], + 'missing_count' => count($missing), + 'missing_amount' => round(array_sum(array_map( + static fn (array $row): float => (float) ($row['carry_forward_amount'] ?? 0), + $missing + )), 2), + 'missing_items' => array_map(static fn (array $row): array => [ + 'family_id' => (int) ($row['family_id'] ?? 0), + 'parent' => (string) ($row['parent'] ?? ''), + 'carry_forward_amount' => round((float) ($row['carry_forward_amount'] ?? 0), 2), + ], $missing), + ]; + + if (CLI::getOption('commit') !== null) { + $actorId = (int) (CLI::getOption('actor-id') ?? 0); + if ($actorId <= 0) { + throw new \InvalidArgumentException('--actor-id must be a positive administrator user ID when using --commit.'); + } + $result['repair'] = $closing->repairMissingCarryForward($sourceYearId, $actorId); + } + + if (CLI::getOption('json') !== null) { + CLI::write(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + return; + } + + CLI::write(sprintf( + '%s: %d missing family balance(s), $%0.2f total.', + strtoupper((string) $result['mode']), + (int) $result['missing_count'], + (float) $result['missing_amount'] + )); + foreach ($result['missing_items'] as $item) { + CLI::write(sprintf( + 'Family #%d (%s): $%0.2f', + (int) $item['family_id'], + (string) $item['parent'], + (float) $item['carry_forward_amount'] + )); + } + if (isset($result['repair'])) { + CLI::write('Repair completed and audited.', 'green'); + } elseif ($missing !== []) { + CLI::write('Run again with --actor-id= --commit to apply the repair.', 'yellow'); + } + } catch (\Throwable $e) { + CLI::error($e->getMessage()); + } + } +} diff --git a/app/Controllers/View/InvoiceController.php b/app/Controllers/View/InvoiceController.php index 744f3f9..b1b6269 100644 --- a/app/Controllers/View/InvoiceController.php +++ b/app/Controllers/View/InvoiceController.php @@ -1064,13 +1064,66 @@ class InvoiceController extends ResourceController $description = $this->invoiceLedgerService->carryForwardDisplayDescription($invoice); } + $ledger = null; + if ($invoiceId !== null && $invoiceId > 0) { + try { + $ledger = $isCarryForward + ? $this->invoiceLedgerService->storedInvoiceLedger($invoiceId) + : $this->invoiceLedgerService->calculateInvoice($invoiceId); + } catch (\Throwable $e) { + log_message('warning', 'Unable to calculate invoice management projection for invoice {id}: {message}', [ + 'id' => $invoiceId, + 'message' => $e->getMessage(), + ]); + } + } + + $invoiceAmount = $ledger !== null + ? (float) ($ledger['total_amount'] ?? 0) + : ($invoice !== null ? (float) ($invoice['total_amount'] ?? 0) : 0.0); + + if (! $isCarryForward && $invoice !== null) { + $snapshotTuition = array_reduce( + array_merge($enrolledKids, $withdrawnKids), + static fn (float $sum, array $kid): float => $sum + (float)($kid['tuition_fee'] ?? 0.0), + 0.0 + ); + + if (abs($snapshotTuition) > 0.00001) { + $eventTotal = array_reduce( + $this->eventChargesForInvoice($invoice), + static fn (float $sum, array $charge): float => $sum + (float)($charge['charged'] ?? 0.0), + 0.0 + ); + $additionalRows = $this->additionalChargeModel + ->select('charge_type, amount') + ->where('invoice_id', $invoiceId) + ->where('status', FinancialStatus::ADDITIONAL_CHARGE_APPLIED) + ->findAll(); + $additionalTotal = array_reduce( + $additionalRows, + static function (float $sum, array $charge): float { + $signedAmount = InvoiceLedgerService::signedAdditionalChargeAmount($charge); + // The management column is gross charges. Deductions are + // applied to Balance Due but are not themselves charges. + return $signedAmount > 0 ? $sum + $signedAmount : $sum; + }, + 0.0 + ); + + $invoiceAmount = round($snapshotTuition + $eventTotal + $additionalTotal, 2); + } + } + return [ 'parent_name' => trim((string) ($parent['firstname'] ?? '') . ' ' . (string) ($parent['lastname'] ?? '')), 'parent_id' => $parentId, 'enrolledKids' => $enrolledKids, 'withdrawnKids' => $withdrawnKids, - 'invoice_amount' => $invoice !== null ? (float) ($invoice['total_amount'] ?? 0) : 0.0, - 'invoice_balance' => $invoice !== null ? (float) ($invoice['balance'] ?? 0) : 0.0, + 'invoice_amount' => $invoiceAmount, + 'invoice_balance' => $ledger !== null + ? (float) ($ledger['balance'] ?? 0) + : ($invoice !== null ? (float) ($invoice['balance'] ?? 0) : 0.0), 'refund_amount' => (float) ($refundSummary['amount'] ?? 0.0), 'refund_details' => $refundSummary['details'] ?? [], 'last_updated' => $invoice['updated_at'] ?? null, @@ -1078,7 +1131,9 @@ class InvoiceController extends ResourceController 'invoice_id' => $invoiceId, 'invoice_number' => $invoice !== null ? (string) ($invoice['invoice_number'] ?? '') : '', 'invoice_description' => $description, - 'invoice_status' => $invoice !== null ? (string) ($invoice['status'] ?? '') : '', + 'invoice_status' => $ledger !== null + ? (string) ($ledger['status'] ?? '') + : ($invoice !== null ? (string) ($invoice['status'] ?? '') : ''), 'is_carry_forward' => $isCarryForward, ]; } @@ -1352,7 +1407,10 @@ class InvoiceController extends ResourceController return ['error' => "Parent associated with the invoice was not found."]; } - $ledger = $this->invoiceLedgerService->storedInvoiceLedger((int) $invoiceId); + // Build the PDF from the canonical calculation so its summary matches the + // itemized tuition, event, additional-charge, and payment rows. Stored + // projections can be stale until the next write-side recalculation. + $ledger = $this->invoiceLedgerService->calculateInvoice((int) $invoiceId); $invoiceLines = []; $registeredKids = []; @@ -1409,12 +1467,12 @@ class InvoiceController extends ResourceController /* ============================================================ * ADDITIONAL CHARGES (itemized) for this invoice * - uses the additional_charges table for line items - * - uses invoice.additional_charge as the authoritative total (Strategy B) + * - includes only applied rows, matching InvoiceLedgerService * ============================================================ */ $acRows = $this->additionalChargeModel ->select('id, charge_type, title, description, amount, due_date, status, created_at') ->where('invoice_id', $invoiceId) - ->where('status !=', 'void') + ->where('status', FinancialStatus::ADDITIONAL_CHARGE_APPLIED) ->orderBy('created_at', 'ASC') ->orderBy('id', 'ASC') ->findAll(); @@ -1423,26 +1481,21 @@ class InvoiceController extends ResourceController $additionalChargesTotal = 0.0; foreach ($acRows as $ac) { - $signed = (float)($ac['amount'] ?? 0); + $signed = InvoiceLedgerService::signedAdditionalChargeAmount($ac); $ctype = strtolower((string)($ac['charge_type'] ?? '')); - if (in_array($ctype, ['deduct'], true) && $signed > 0) { - $signed = -$signed; - } elseif (in_array($ctype, ['add'], true) && $signed < 0) { - $signed = abs($signed); - } - $lineDate = !empty($ac['created_at']) ? date('Y-m-d', strtotime($ac['created_at'])) : (!empty($invoice['created_at']) ? local_date($invoice['created_at'], 'Y-m-d') : local_date(utc_now(), 'Y-m-d')); - $typeLabel = in_array($ctype, ['deduct'], true) ? 'Deduct' : 'Add'; - $title = ''; //trim((string)($ac['title'] ?? 'Additional Charge')); - $desc = $typeLabel . ': '; - - if (!empty($ac['description'])) { - $desc = $ac['description']; + $typeLabel = $signed < 0 ? 'Deduct' : 'Add'; + $title = trim((string)($ac['title'] ?? '')); + $description = trim((string)($ac['description'] ?? '')); + $desc = $title !== '' ? $title : 'Additional charge'; + if ($description !== '' && $description !== $title) { + $desc .= ' - ' . $description; } + $desc = $typeLabel . ': ' . $desc; $additionalChargesTotal += $signed; @@ -1912,6 +1965,20 @@ class InvoiceController extends ResourceController } } + // Additional charges are stored separately from the base invoice rows. They + // must be added explicitly to the PDF timeline; previously they were only + // used to calculate the fallback tuition amount and summary subtotal. + foreach ($additionalChargeLines as $line) { + $amount = (float)($line['amount'] ?? 0.0); + if (abs($amount) < 0.00001) { + continue; + } + + $dt = $toLocal($line['date'] ?? ($invoice['created_at'] ?? null), false); + $description = trim((string)($line['description'] ?? 'Additional charge')); + $push($dt, $description !== '' ? $description : 'Additional charge', $amount, 'additional'); + } + // --- Payments (negative) — stored in local time foreach ($payments as $payment) { $dt = $toLocal($payment['payment_date'] ?? null, false /* local */); @@ -1996,12 +2063,31 @@ class InvoiceController extends ResourceController // ======== SUMMARY (bottom) ======== $ledger = $data['ledger'] ?? []; - $totalAmount = (float) ($ledger['total_amount'] ?? 0.0); + $chargeCategories = ['registration', 'event', 'additional', 'other']; + $totalAmount = round(array_reduce( + $transactions, + static function (float $sum, array $transaction) use ($chargeCategories): float { + $amount = (float)($transaction['amount'] ?? 0.0); + $isCharge = in_array((string)($transaction['cat'] ?? 'other'), $chargeCategories, true); + + // Total Charges is gross: only positive charge rows belong here. + // Negative adjustments remain visible and reduce Balance Due. + return $isCharge && $amount > 0 ? $sum + $amount : $sum; + }, + 0.0 + ), 2); $totalDiscount = (float) ($ledger['discount_total'] ?? $totalDiscount); $totalPaid = (float) ($ledger['paid_amount'] ?? $totalPaid); $totalRefund = (float) ($ledger['refund_paid_total'] ?? 0.0); - $displayBalance = (float) ($ledger['balance'] ?? 0.0); - $creditOverpay = (float) ($ledger['customer_credit'] ?? 0.0); + // The PDF balance must reconcile exactly to its visible rows: positive + // amounts add to the balance and negative amounts deduct from it. + $signedRowBalance = round(array_reduce( + $transactions, + static fn (float $sum, array $transaction): float => $sum + (float)($transaction['amount'] ?? 0.0), + 0.0 + ), 2); + $displayBalance = max(0.0, $signedRowBalance); + $creditOverpay = max(0.0, -$signedRowBalance); $pdf->Ln(5); $labelWidth = 165; diff --git a/app/Libraries/InvoiceLedgerService.php b/app/Libraries/InvoiceLedgerService.php index 4aacf19..0b531f7 100644 --- a/app/Libraries/InvoiceLedgerService.php +++ b/app/Libraries/InvoiceLedgerService.php @@ -452,6 +452,29 @@ class InvoiceLedgerService protected function calculateTuitionTotal(array $invoice): float { + $invoiceId = (int) ($invoice['id'] ?? 0); + if ( + $invoiceId > 0 + && $this->invoiceStudentListModel->db->tableExists('invoice_students_list') + && $this->invoiceStudentListModel->db->fieldExists('tuition_fee', 'invoice_students_list') + ) { + $snapshot = $this->invoiceStudentListModel + ->select( + 'COALESCE(SUM(tuition_fee),0) AS total_amount, ' + . 'COALESCE(SUM(CASE WHEN ABS(tuition_fee) > 0 THEN 1 ELSE 0 END),0) AS priced_rows', + false + ) + ->where('invoice_id', $invoiceId) + ->first(); + + // A priced snapshot is the amount actually issued to the family. It + // intentionally excludes later live configuration changes (including + // book fees that were not part of this invoice). + if ((int) ($snapshot['priced_rows'] ?? 0) > 0) { + return (float) ($snapshot['total_amount'] ?? 0); + } + } + $parentId = (int) ($invoice['parent_id'] ?? 0); $schoolYear = (string) ($invoice['school_year'] ?? ''); if ($parentId <= 0 || $schoolYear === '') { @@ -480,11 +503,14 @@ class InvoiceLedgerService } } + // InvoiceController enforces one invoice per parent and school year, and + // invoice generation includes the full year's event charges. Keep the + // ledger on that same scope so recalculation cannot drop another term's + // event charges from the invoice total. $rows = $this->eventChargesModel ->select('COALESCE(SUM(charged),0) AS total_amount') ->where('parent_id', (int) ($invoice['parent_id'] ?? 0)) ->where('school_year', (string) ($invoice['school_year'] ?? '')) - ->where('semester', (string) ($invoice['semester'] ?? '')) ->findAll(); return (float) ($rows[0]['total_amount'] ?? 0); @@ -493,12 +519,28 @@ class InvoiceLedgerService protected function calculateAdditionalCharges(int $invoiceId): float { $rows = $this->additionalChargeModel - ->select("COALESCE(SUM(CASE WHEN charge_type = 'deduct' THEN -ABS(amount) ELSE ABS(amount) END),0) AS total_amount", false) + ->select('charge_type, amount') ->where('invoice_id', $invoiceId) ->where('status', FinancialStatus::ADDITIONAL_CHARGE_APPLIED) ->findAll(); - return (float) ($rows[0]['total_amount'] ?? 0); + return array_reduce( + $rows, + static fn (float $sum, array $charge): float => $sum + self::signedAdditionalChargeAmount($charge), + 0.0 + ); + } + + public static function signedAdditionalChargeAmount(array $charge): float + { + $amount = (float) ($charge['amount'] ?? 0.0); + if ($amount < 0) { + return $amount; + } + + return (string) ($charge['charge_type'] ?? '') === 'deduct' + ? -abs($amount) + : $amount; } protected function calculateDiscounts(int $invoiceId): float diff --git a/app/Services/EnrollmentWithdrawalService.php b/app/Services/EnrollmentWithdrawalService.php index c02f583..3099200 100644 --- a/app/Services/EnrollmentWithdrawalService.php +++ b/app/Services/EnrollmentWithdrawalService.php @@ -57,11 +57,13 @@ public function buildRoster(string $selectedYear, string $semester): array $students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear); $removedPriorStatuses = $this->removedPriorYearStudentStatuses($selectedYear); + $makeUpExamStudentIds = array_fill_keys($this->makeUpExamStudentIds($selectedYear), true); service('studentYearStatus')->attachToStudents($students, $selectedYear); foreach ($students as &$s) { // ===== Ensure IDs needed by the modal ===== $s['student_id'] = (int)($s['id'] ?? 0); + $s['make_up_exam'] = isset($makeUpExamStudentIds[$s['student_id']]) ? 'Yes' : 'No'; $priorRemovedStatus = $removedPriorStatuses[$s['student_id']] ?? null; $s['removed_previous_year'] = $priorRemovedStatus !== null ? 'Yes' : 'No'; $s['prior_removed_status'] = $priorRemovedStatus; @@ -528,6 +530,54 @@ private function getPreviousSchoolYear(string $schoolYear): string return ''; } +/** + * Return students whose latest deliberation decision for the source school year + * requires a make-up exam. + */ +private function makeUpExamStudentIds(string $selectedYear): array +{ + $sourceYear = $this->getPreviousSchoolYear($selectedYear); + if ($sourceYear === '' || ! $this->db->tableExists('student_decisions')) { + return []; + } + + $select = ['student_id', 'decision']; + $hasStandardDecision = $this->db->fieldExists('deliberation_decision_standard', 'student_decisions'); + if ($hasStandardDecision) { + $select[] = 'deliberation_decision_standard'; + } + + $rows = $this->db->table('student_decisions') + ->select($select) + ->where('school_year', $sourceYear) + ->orderBy('updated_at', 'DESC') + ->orderBy('id', 'DESC') + ->get() + ->getResultArray(); + + $latestDecisionSeen = []; + $studentIds = []; + + foreach ($rows as $row) { + $studentId = (int) ($row['student_id'] ?? 0); + if ($studentId <= 0 || isset($latestDecisionSeen[$studentId])) { + continue; + } + + $latestDecisionSeen[$studentId] = true; + $decision = $hasStandardDecision + ? DeliberationDecision::normalize($row['deliberation_decision_standard'] ?? null) + : null; + $decision ??= DeliberationDecision::normalize($row['decision'] ?? null); + + if ($decision === DeliberationDecision::MAKE_UP_EXAM) { + $studentIds[] = $studentId; + } + } + + return $studentIds; +} + private function getSchoolYearStartYear(string $schoolYear): ?int { $schoolYear = trim($schoolYear); diff --git a/app/Services/SchoolYearClosingService.php b/app/Services/SchoolYearClosingService.php index 2eab10d..163d754 100644 --- a/app/Services/SchoolYearClosingService.php +++ b/app/Services/SchoolYearClosingService.php @@ -57,14 +57,16 @@ final class SchoolYearClosingService $findings[] = $this->finding( 'blocking', 'Students missing promotion decisions', - $promotion['summary']['missing_decision'] . ' active student(s) do not have a saved promotion decision for this school year.' + $promotion['summary']['missing_decision'] . ' active student(s) do not have a saved promotion decision for this school year.', + ['students' => $this->promotionStudentsWithStatus($promotion['rows'] ?? [], 'missing')] ); } if (($promotion['summary']['pending_decision'] ?? 0) > 0) { $findings[] = $this->finding( 'blocking', 'Students with pending promotion decisions', - $promotion['summary']['pending_decision'] . ' active student(s) still have pending promotion decisions.' + $promotion['summary']['pending_decision'] . ' active student(s) still have pending promotion decisions.', + ['students' => $this->promotionStudentsWithStatus($promotion['rows'] ?? [], 'pending')] ); } if (($promotion['summary']['missing_queue'] ?? 0) > 0) { @@ -337,6 +339,128 @@ final class SchoolYearClosingService ->first(); } + /** + * Add carry-forward items that were omitted from an already executed batch. + * Existing items and invoices are never rewritten, which keeps this repair + * idempotent and preserves the original financial audit trail. + */ + public function repairMissingCarryForward(int $sourceYearId, ?int $userId = null): array + { + $this->assertClosingTablesExist(); + + $batch = $this->latestBatch($sourceYearId); + if ($batch === null || ! in_array((string) ($batch['status'] ?? ''), ['executed', 'completed'], true)) { + throw new InvalidArgumentException('An executed or completed closing batch is required for carry-forward repair.'); + } + + $targetYearId = (int) ($batch['target_school_year_id'] ?? 0); + $source = $this->requireYear($sourceYearId); + $target = $this->requireYear($targetYearId); + $preview = $this->preview($sourceYearId, $targetYearId); + $batchId = (int) $batch['id']; + + $this->db->transBegin(); + try { + $lockedBatch = $this->db->query( + 'SELECT id FROM school_year_closing_batches WHERE id = ? FOR UPDATE', + [$batchId] + )->getRowArray(); + if ($lockedBatch === null) { + throw new InvalidArgumentException('Closing batch was not found.'); + } + + $existingItems = $this->itemModel + ->select('family_id') + ->where('closing_batch_id', $batchId) + ->findAll(); + $existingFamilyIds = array_fill_keys(array_map( + static fn (array $item): int => (int) ($item['family_id'] ?? 0), + $existingItems + ), true); + + $repaired = []; + foreach ($preview['carry_forward'] as $row) { + $familyId = (int) ($row['family_id'] ?? 0); + if ($familyId <= 0 || isset($existingFamilyIds[$familyId])) { + continue; + } + + $item = [ + 'closing_batch_id' => $batchId, + 'family_id' => $familyId, + 'source_balance' => $row['source_balance'], + 'credit_amount' => $row['credit_amount'], + 'adjustment_amount' => $row['adjustment_amount'] ?? 0, + 'carry_forward_amount' => $row['carry_forward_amount'], + 'status' => 'pending', + 'school_year' => (string) ($target['name'] ?? ''), + ]; + $itemId = $this->itemModel->insert($item, true); + if (! $itemId) { + throw new RuntimeException('Unable to create the missing carry-forward item.'); + } + + $item['id'] = (int) $itemId; + $targetInvoiceId = $this->createCarryForwardInvoice( + $item, + (string) ($source['name'] ?? ''), + (string) ($target['name'] ?? ''), + $userId + ); + $this->itemModel->update((int) $itemId, [ + 'target_invoice_id' => $targetInvoiceId, + 'status' => 'completed', + 'error_message' => null, + ]); + + $repaired[] = [ + 'family_id' => $familyId, + 'amount' => round((float) ($row['carry_forward_amount'] ?? 0), 2), + 'target_invoice_id' => $targetInvoiceId, + ]; + $existingFamilyIds[$familyId] = true; + } + + if ($repaired !== []) { + $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), + ]); + $this->managementService->log( + $sourceYearId, + (string) ($source['status'] ?? SchoolYearStatus::CLOSED), + (string) ($source['status'] ?? SchoolYearStatus::CLOSED), + 'carry_forward_repair', + $userId, + [ + 'closing_batch_id' => $batchId, + 'target_school_year_id' => $targetYearId, + 'repaired_items' => $repaired, + ] + ); + } + + if ($this->db->transStatus() === false) { + throw new RuntimeException('Unable to repair missing carry-forward balances.'); + } + $this->db->transCommit(); + + return [ + 'closing_batch_id' => $batchId, + 'source_school_year' => (string) ($source['name'] ?? ''), + 'target_school_year' => (string) ($target['name'] ?? ''), + 'repaired_count' => count($repaired), + 'repaired_amount' => round(array_sum(array_column($repaired, 'amount')), 2), + 'items' => $repaired, + ]; + } catch (\Throwable $e) { + $this->db->transRollback(); + throw $e; + } + } + private function requireYear(int $id): array { $year = $this->schoolYearModel->find($id); @@ -402,7 +526,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) + ->where("LOWER(REPLACE(TRIM(status), '_', ' ')) IN ('unpaid', 'partially paid')", null, false) ->countAllResults(); return $count > 0 @@ -421,7 +545,7 @@ final class SchoolYearClosingService ->select('COALESCE(SUM(i.balance), 0) AS source_balance') ->where('i.school_year', $schoolYear) ->where('i.balance !=', 0) - ->where("LOWER(i.status) IN ('unpaid', 'partially paid')", null, false); + ->where("LOWER(REPLACE(TRIM(i.status), '_', ' ')) IN ('unpaid', 'partially paid')", null, false); if ($this->db->tableExists('users')) { $builder @@ -1133,6 +1257,53 @@ final class SchoolYearClosingService ]; } + // Older decisions may exist only in below_sixty_decisions. The manual + // decision screen used that table before it also synchronized the + // consolidated student_decisions row, so treating those records as + // missing creates a false closing blocker. + if ($this->db->tableExists('below_sixty_decisions')) { + $fallbackRows = $this->db->table('below_sixty_decisions') + ->select('student_id, decision, notes') + ->where('school_year', $schoolYear) + ->where('LOWER(TRIM(semester))', 'year') + ->whereIn('student_id', $studentIds) + ->orderBy('updated_at', 'DESC') + ->orderBy('id', 'DESC') + ->get() + ->getResultArray(); + + $decisions = $this->mergeFallbackPromotionDecisions($decisions, $fallbackRows); + } + + return $decisions; + } + + private function mergeFallbackPromotionDecisions(array $decisions, array $fallbackRows): array + { + foreach ($fallbackRows as $row) { + $studentId = (int) ($row['student_id'] ?? 0); + $decision = trim((string) ($row['decision'] ?? '')); + $existing = $decisions[$studentId] ?? null; + + if ( + $studentId <= 0 + || $decision === '' + || ($existing !== null && ($existing['status'] ?? '') === 'decided') + ) { + continue; + } + + $decisions[$studentId] = [ + 'class_section_name' => '', + 'year_score' => null, + 'decision' => $decision, + 'normalized_decision' => DeliberationDecision::normalize($decision), + 'source' => 'manual', + 'notes' => (string) ($row['notes'] ?? ''), + 'status' => 'decided', + ]; + } + return $decisions; } @@ -1326,13 +1497,29 @@ final class SchoolYearClosingService } } - private function finding(string $severity, string $title, string $detail): array + private function promotionStudentsWithStatus(array $rows, string $status): array { - return [ + return array_values(array_map( + static fn (array $row): array => [ + 'student_id' => (int) ($row['student_id'] ?? 0), + 'student_name' => trim((string) ($row['student_name'] ?? '')), + 'school_id' => trim((string) ($row['school_id'] ?? '')), + 'class_section_name' => trim((string) ($row['class_section_name'] ?? '')), + ], + array_filter( + $rows, + static fn (array $row): bool => (string) ($row['status'] ?? '') === $status + ) + )); + } + + private function finding(string $severity, string $title, string $detail, array $context = []): array + { + return array_merge([ 'severity' => $severity, 'title' => $title, 'detail' => $detail, - ]; + ], $context); } private function hashPreview(array $preview): string diff --git a/app/Views/enroll_withdraw/enrollment_withdrawal.php b/app/Views/enroll_withdraw/enrollment_withdrawal.php index 562ddb3..583488c 100644 --- a/app/Views/enroll_withdraw/enrollment_withdrawal.php +++ b/app/Views/enroll_withdraw/enrollment_withdrawal.php @@ -41,6 +41,7 @@ School ID Age New Student + Make Up Exam Registered Class Current Class Actual Status @@ -101,6 +102,15 @@ + + + + Yes + + No + + + @@ -191,7 +201,7 @@ - No students available. + No students available. @@ -389,8 +399,8 @@ // Disable sort/search on interactive columns (status select, assign select) columnDefs: [ - { targets: [9, 10], orderable: false, searchable: false }, - { targets: [0, 1, 2, 3, 4, 5, 6, 7, 8], render: function(data, type) { + { targets: [10, 11], orderable: false, searchable: false }, + { targets: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], render: function(data, type) { if (type === 'filter' || type === 'sort' || type === 'type') { return stripHtml(data); } @@ -450,7 +460,7 @@ } const CLASS_COL_INDEX = findColIndexByHeader('Current Class'); // was index 4 const STATUS_COL_INDEX = findColIndexByHeader('Actual Status'); // was index 5 - const STATUS_SELECT_COL_INDEX = findColIndexByHeader('Update Enrollment Status'); + const STATUS_SELECT_COL_INDEX = findColIndexByHeader('Update Status'); const ASSIGN_COL_INDEX = findColIndexByHeader('Assign Class'); /* ===== Helpers ===== */ diff --git a/app/Views/school_years/closing_preview.php b/app/Views/school_years/closing_preview.php index b315594..6fceb20 100644 --- a/app/Views/school_years/closing_preview.php +++ b/app/Views/school_years/closing_preview.php @@ -199,7 +199,30 @@ @@ -328,7 +351,7 @@ } $ageSepOne = $ageBySchoolYearSecondSeptember($row); ?> - + @@ -501,7 +524,30 @@
Resolve these blockers before the year can be closed.
diff --git a/scripts/compare_sql.py b/scripts/compare_sql.py new file mode 100644 index 0000000..ce43d46 --- /dev/null +++ b/scripts/compare_sql.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 + +import re +import sys +from collections import defaultdict +from pathlib import Path + + +# Matches common SQL statements containing a table name. +TABLE_PATTERNS = [ + re.compile( + r'^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"\[]?([^`"\]\s(.]+)', + re.IGNORECASE, + ), + re.compile( + r'^\s*INSERT\s+INTO\s+[`"\[]?([^`"\]\s(.]+)', + re.IGNORECASE, + ), + re.compile( + r'^\s*UPDATE\s+[`"\[]?([^`"\]\s(.]+)', + re.IGNORECASE, + ), + re.compile( + r'^\s*ALTER\s+TABLE\s+[`"\[]?([^`"\]\s(.]+)', + re.IGNORECASE, + ), + re.compile( + r'^\s*DELETE\s+FROM\s+[`"\[]?([^`"\]\s(.]+)', + re.IGNORECASE, + ), +] + + +def extract_table_name(line): + """Return table name if the line starts a recognizable table statement.""" + for pattern in TABLE_PATTERNS: + match = pattern.search(line) + if match: + return match.group(1) + return None + + +def normalize_line(line): + """ + Normalize a line for comparison. + + Removes leading/trailing whitespace but otherwise leaves SQL intact. + """ + return line.strip() + + +def parse_sql_file(filename): + """ + Parse SQL into table -> list of (line_number, original_line). + + Once a table-related statement is detected, following lines are associated + with that table until another table statement begins. + """ + tables = defaultdict(list) + + current_table = None + + with open(filename, "r", encoding="utf-8", errors="replace") as f: + for line_number, line in enumerate(f, start=1): + table = extract_table_name(line) + + if table: + current_table = table + + if current_table: + cleaned = normalize_line(line) + + # Ignore completely blank lines. + if cleaned: + tables[current_table].append( + (line_number, line.rstrip("\n")) + ) + + return tables + + +def line_multiset(lines): + """ + Convert lines into: + normalized_line -> list of occurrences + + Keeping occurrences means duplicate INSERT rows are handled correctly. + """ + result = defaultdict(list) + + for line_number, text in lines: + result[normalize_line(text)].append((line_number, text)) + + return result + + +def compare_table(table, lines1, lines2, file1, file2): + data1 = line_multiset(lines1) + data2 = line_multiset(lines2) + + all_lines = sorted(set(data1) | set(data2)) + + only_file1 = [] + only_file2 = [] + + for normalized in all_lines: + occurrences1 = data1.get(normalized, []) + occurrences2 = data2.get(normalized, []) + + common_count = min(len(occurrences1), len(occurrences2)) + + only_file1.extend(occurrences1[common_count:]) + only_file2.extend(occurrences2[common_count:]) + + if not only_file1 and not only_file2: + return False + + print() + print("=" * 100) + print(f"TABLE: {table}") + print("=" * 100) + + if only_file1: + print(f"\nOnly in {file1}:") + for line_number, text in only_file1: + print(f" Line {line_number}: {text}") + + if only_file2: + print(f"\nOnly in {file2}:") + for line_number, text in only_file2: + print(f" Line {line_number}: {text}") + + return True + + +def compare_sql_files(file1, file2): + tables1 = parse_sql_file(file1) + tables2 = parse_sql_file(file2) + + all_tables = sorted(set(tables1) | set(tables2)) + + print(f"Comparing:") + print(f" File 1: {file1}") + print(f" File 2: {file2}") + print() + + differences = 0 + + for table in all_tables: + if table not in tables1: + print() + print("=" * 100) + print(f"TABLE: {table}") + print("=" * 100) + print(f"Table exists only in {file2}") + differences += 1 + continue + + if table not in tables2: + print() + print("=" * 100) + print(f"TABLE: {table}") + print("=" * 100) + print(f"Table exists only in {file1}") + differences += 1 + continue + + if compare_table( + table, + tables1[table], + tables2[table], + file1, + file2, + ): + differences += 1 + + print() + print("=" * 100) + + if differences == 0: + print("No table differences found.") + else: + print(f"{differences} table(s) contain differences.") + + +def main(): + if len(sys.argv) != 3: + script = Path(sys.argv[0]).name + print(f"Usage: python {script} file1.sql file2.sql") + sys.exit(1) + + file1 = sys.argv[1] + file2 = sys.argv[2] + + if not Path(file1).is_file(): + print(f"File not found: {file1}") + sys.exit(1) + + if not Path(file2).is_file(): + print(f"File not found: {file2}") + sys.exit(1) + + compare_sql_files(file1, file2) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/deploy_home.sh b/scripts/deploy_home.sh new file mode 100644 index 0000000..105d0d5 --- /dev/null +++ b/scripts/deploy_home.sh @@ -0,0 +1,247 @@ +#!/bin/bash + +# =============================== +# Al Rahma Sunday School Deployment Script +# =============================== + +# ----- Domain and App Info ----- +DM_NAME="home.alrahmaisgl.org" +domain_app="alrahma" + +# ----- Database credentials ----- +DB_HOST="localhost" +DB_NAME="u280815660_school" +DB_USER="u280815660_melabidi" +DB_PASS=">tNxlRzP/W8" + +# ----- Directories ----- +ZIP_FILE="$1" +BASE_DIR="/home/u280815660/domains" +DEPLOY_DIR="$BASE_DIR/$domain_app" +APP_DIR="$BASE_DIR/$DM_NAME/$domain_app" +PUBLIC_DIR="$BASE_DIR/$DM_NAME/public_html" +BACKUP_DIR="$BASE_DIR/archive" +SECRETS_DIR="$BASE_DIR/deploy_secrets" +TIMESTAMP=$(date +"%Y%m%d_%H%M%S") + +# ----- Runtime binaries ----- +# Hostinger/CloudLinux exposes PHP 8.5 here. The default CLI `php` may still be +# PHP 8.2, which cannot install this project's PHP 8.5 lock file. +PHP_BIN="${PHP_BIN:-/opt/alt/php85/usr/bin/php}" +COMPOSER_BIN="${COMPOSER_BIN:-$(command -v composer2 || command -v composer || true)}" + + +# =============================== +# 1. VALIDATE INPUT +# =============================== +if [ -z "$ZIP_FILE" ] || [ ! -f "$ZIP_FILE" ]; then + echo "? Please provide the ZIP file: ./deploy_home.sh alrahma_deploy.zip" + exit 1 +fi + +echo "?? Starting deployment of $ZIP_FILE..." + +if [ ! -x "$PHP_BIN" ]; then + echo "? PHP 8.5 binary was not found or is not executable: $PHP_BIN" + echo " Set PHP_BIN=/path/to/php85 and rerun the deployment." + exit 1 +fi + +if [ -z "$COMPOSER_BIN" ] || [ ! -e "$COMPOSER_BIN" ]; then + echo "? composer2/composer was not found in PATH." + echo " Set COMPOSER_BIN=/path/to/composer2 and rerun the deployment." + exit 1 +fi + +echo "?? Using PHP: $("$PHP_BIN" -v | head -n 1)" +echo "?? Using Composer: $COMPOSER_BIN" + +# =============================== +# 2. BACKUP EXISTING SITE +# =============================== +echo "??? Backing up current site..." +mkdir -p "$BACKUP_DIR" +cp -r "$PUBLIC_DIR" "$BACKUP_DIR/public_html_$TIMESTAMP" +cp -r "$APP_DIR" "$BACKUP_DIR/$domain_app_$TIMESTAMP" + +# =============================== +# 3. BACKUP PERSISTENT FILES +# =============================== +echo "?? Backing up persistent files..." +mkdir -p "$SECRETS_DIR" +PERSIST_BACKUP="$BASE_DIR/persist_backup_$TIMESTAMP" +mkdir -p "$PERSIST_BACKUP" + +# writable/ +if [ -d "$APP_DIR/writable" ]; then + echo " ? Backing up writable/" + cp -r "$APP_DIR/writable" "$PERSIST_BACKUP/writable" +fi + +# .env +if [ -f "$APP_DIR/.env" ]; then + echo " ? Backing up .env" + cp "$APP_DIR/.env" "$PERSIST_BACKUP/.env" +fi + +# .htaccess +if [ -f "$PUBLIC_DIR/.htaccess" ]; then + echo " ? Backing up .htaccess" + cp "$PUBLIC_DIR/.htaccess" "$PERSIST_BACKUP/.htaccess" +fi + +# index.php +if [ -f "$PUBLIC_DIR/index.php" ]; then + echo " ? Backing up index.php" + cp "$PUBLIC_DIR/index.php" "$PERSIST_BACKUP/index.php" +fi + +# =============================== +# 4. CLEAN & EXTRACT NEW DEPLOY +# =============================== +echo "?? Cleaning old deployment..." +rm -rf "$DEPLOY_DIR" +unzip "$ZIP_FILE" -d "$BASE_DIR" + +# =============================== +# 5. DEPLOY APP FILES +# =============================== +echo "?? Deploying new app files..." +rm -rf "$APP_DIR" +mkdir -p "$APP_DIR" +cp -r "$DEPLOY_DIR"/. "$APP_DIR" + +# =============================== +# 6. RESTORE PERSISTENT FILES +# =============================== +echo "?? Restoring persistent files..." + +# writable/ +if [ -d "$PERSIST_BACKUP/writable" ]; then + echo " ? Restoring writable/" + rm -rf "$APP_DIR/writable" + cp -r "$PERSIST_BACKUP/writable" "$APP_DIR/writable" +fi + +# .env +if [ -f "$PERSIST_BACKUP/.env" ]; then + echo " ? Restoring .env" + cp "$PERSIST_BACKUP/.env" "$APP_DIR/.env" +fi + +# .htaccess +if [ -f "$PERSIST_BACKUP/.htaccess" ]; then + echo " ? Restoring .htaccess" + cp "$PERSIST_BACKUP/.htaccess" "$PUBLIC_DIR/.htaccess" +fi + +# index.php +if [ -f "$PERSIST_BACKUP/index.php" ]; then + echo " ? Restoring index.php" + cp "$PERSIST_BACKUP/index.php" "$PUBLIC_DIR/index.php" +fi + +# =============================== +# 7. DEPLOY PUBLIC FILES +# =============================== +echo "?? Deploying public files..." +rm -rf "$PUBLIC_DIR"/* +cp -r "$APP_DIR/public/"* "$PUBLIC_DIR" + +# Re-apply .htaccess and index.php again (to ensure overwrite protection) +if [ -f "$PERSIST_BACKUP/.htaccess" ]; then + cp "$PERSIST_BACKUP/.htaccess" "$PUBLIC_DIR/.htaccess" +fi +if [ -f "$PERSIST_BACKUP/index.php" ]; then + cp "$PERSIST_BACKUP/index.php" "$PUBLIC_DIR/index.php" +fi + +# =============================== +# 8. FIX index.php PATH +# =============================== +echo "?? Fixing index.php path..." +sed -i "s|require FCPATH . '../app/Config/Paths.php';|require FCPATH . '../alrahma/app/Config/Paths.php';|" "$PUBLIC_DIR/index.php" + +# =============================== +# 9. UPDATE BASE URL & DB CONFIG +# =============================== +APP_CONFIG="$APP_DIR/app/Config/App.php" +DB_CONFIG="$APP_DIR/app/Config/Database.php" +echo "?? Updating configuration files..." + +# Base URL +sed -i "s|public string \$baseURL = .*|public string \$baseURL = 'https://$DM_NAME/';|" "$APP_CONFIG" + +# Database.php +sed -i "s|'hostname' => .*|'hostname' => '$DB_HOST',|" "$DB_CONFIG" +sed -i "s|'username' => .*|'username' => '$DB_USER',|" "$DB_CONFIG" +sed -i "s|'password' => .*|'password' => '$DB_PASS',|" "$DB_CONFIG" +sed -i "s|'database' => .*|'database' => '$DB_NAME',|" "$DB_CONFIG" + +# =============================== +# 10. UPDATE db_connection.php +# =============================== +DB_CONN_FILE="$APP_DIR/app/db_connection.php" +if [ -f "$DB_CONN_FILE" ]; then + echo "??? Updating db_connection.php..." + sed -i "s|\$host = '.*';|\$host = '$DB_HOST';|" "$DB_CONN_FILE" + sed -i "s|\$dbname = '.*';|\$dbname = '$DB_NAME';|" "$DB_CONN_FILE" + sed -i "s|\$username = '.*';|\$username = '$DB_USER';|" "$DB_CONN_FILE" + sed -i "s|\$password = '.*';|\$password = '$DB_PASS';|" "$DB_CONN_FILE" +fi + +# =============================== +# 11. FIX FPDF PATHS +# =============================== +echo "?? Fixing FPDF paths..." +grep -rl "ThirdParty\\\\fpdf\\\\fpdf.php" "$APP_DIR/app" | while read -r file; do + sed -i "s|ThirdParty\\\\fpdf\\\\fpdf.php|ThirdParty/fpdf/fpdf.php|g" "$file" + echo "? Fixed path in: $file" +done + +# =============================== +# 12. COMPOSER INSTALL +# =============================== +echo "?? Installing production Composer dependencies with PHP 8.5..." +cd "$APP_DIR" || exit + +export PATH="$(dirname "$PHP_BIN"):$PATH" +export COMPOSER_ALLOW_SUPERUSER=1 + +if "$PHP_BIN" "$COMPOSER_BIN" --version >/dev/null 2>&1; then + "$PHP_BIN" "$COMPOSER_BIN" install \ + --no-dev \ + --prefer-dist \ + --optimize-autoloader \ + --classmap-authoritative \ + --no-interaction \ + --no-progress +else + "$COMPOSER_BIN" install \ + --no-dev \ + --prefer-dist \ + --optimize-autoloader \ + --classmap-authoritative \ + --no-interaction \ + --no-progress +fi + +"$PHP_BIN" spark --version + +# =============================== +# 13. RESTORE PERMISSIONS +# =============================== +echo "?? Setting permissions..." +chmod 644 "$PUBLIC_DIR/.htaccess" +chmod 644 "$PUBLIC_DIR/index.php" +chmod -R 755 "$APP_DIR" +chmod 644 "$APP_DIR/app/db_connection.php" +chmod -R 777 "$APP_DIR/writable" + +# =============================== +# ? DONE +# =============================== +echo "? Deployment completed successfully at $TIMESTAMP" +echo "?? Backup stored in: $BACKUP_DIR" +echo "?? Persistent files restored from: $PERSIST_BACKUP" +echo "?? Writable, .env, .htaccess, and index.php preserved successfully!" \ No newline at end of file diff --git a/scripts/test_deploy.sh b/scripts/test_deploy.sh new file mode 100644 index 0000000..76dc2e4 --- /dev/null +++ b/scripts/test_deploy.sh @@ -0,0 +1,248 @@ +#!/bin/bash + +# =============================== +# Al Rahma Sunday School Deployment Script +# =============================== + +# ----- Domain and App Info ----- +DM_NAME="test.alrahmaisgl.org" +domain_app="alrahma" + +# ----- Database credentials ----- +DB_HOST="localhost" +DB_NAME="u280815660_adjust_balance" +DB_USER="u280815660_fixbalance" +DB_PASS="|bP9TF79+7" + + +# ----- Directories ----- +ZIP_FILE="$1" +BASE_DIR="/home/u280815660/domains" +DEPLOY_DIR="$BASE_DIR/$domain_app" +APP_DIR="$BASE_DIR/$DM_NAME/$domain_app" +PUBLIC_DIR="$BASE_DIR/$DM_NAME/public_html" +BACKUP_DIR="$BASE_DIR/archive" +SECRETS_DIR="$BASE_DIR/deploy_secrets" +TIMESTAMP=$(date +"%Y%m%d_%H%M%S") + +# ----- Runtime binaries ----- +# Hostinger/CloudLinux exposes PHP 8.5 here. The default CLI `php` may still be +# PHP 8.2, which cannot install this project's PHP 8.5 lock file. +PHP_BIN="${PHP_BIN:-/opt/alt/php85/usr/bin/php}" +COMPOSER_BIN="${COMPOSER_BIN:-$(command -v composer2 || command -v composer || true)}" + + +# =============================== +# 1. VALIDATE INPUT +# =============================== +if [ -z "$ZIP_FILE" ] || [ ! -f "$ZIP_FILE" ]; then + echo "? Please provide the ZIP file: ./deploy_home.sh alrahma_deploy.zip" + exit 1 +fi + +echo "?? Starting deployment of $ZIP_FILE..." + +if [ ! -x "$PHP_BIN" ]; then + echo "? PHP 8.5 binary was not found or is not executable: $PHP_BIN" + echo " Set PHP_BIN=/path/to/php85 and rerun the deployment." + exit 1 +fi + +if [ -z "$COMPOSER_BIN" ] || [ ! -e "$COMPOSER_BIN" ]; then + echo "? composer2/composer was not found in PATH." + echo " Set COMPOSER_BIN=/path/to/composer2 and rerun the deployment." + exit 1 +fi + +echo "?? Using PHP: $("$PHP_BIN" -v | head -n 1)" +echo "?? Using Composer: $COMPOSER_BIN" + +# =============================== +# 2. BACKUP EXISTING SITE +# =============================== +echo "??? Backing up current site..." +mkdir -p "$BACKUP_DIR" +cp -r "$PUBLIC_DIR" "$BACKUP_DIR/public_html_$TIMESTAMP" +cp -r "$APP_DIR" "$BACKUP_DIR/$domain_app_$TIMESTAMP" + +# =============================== +# 3. BACKUP PERSISTENT FILES +# =============================== +echo "?? Backing up persistent files..." +mkdir -p "$SECRETS_DIR" +PERSIST_BACKUP="$BASE_DIR/persist_backup_$TIMESTAMP" +mkdir -p "$PERSIST_BACKUP" + +# writable/ +if [ -d "$APP_DIR/writable" ]; then + echo " ? Backing up writable/" + cp -r "$APP_DIR/writable" "$PERSIST_BACKUP/writable" +fi + +# .env +if [ -f "$APP_DIR/.env" ]; then + echo " ? Backing up .env" + cp "$APP_DIR/.env" "$PERSIST_BACKUP/.env" +fi + +# .htaccess +if [ -f "$PUBLIC_DIR/.htaccess" ]; then + echo " ? Backing up .htaccess" + cp "$PUBLIC_DIR/.htaccess" "$PERSIST_BACKUP/.htaccess" +fi + +# index.php +if [ -f "$PUBLIC_DIR/index.php" ]; then + echo " ? Backing up index.php" + cp "$PUBLIC_DIR/index.php" "$PERSIST_BACKUP/index.php" +fi + +# =============================== +# 4. CLEAN & EXTRACT NEW DEPLOY +# =============================== +echo "?? Cleaning old deployment..." +rm -rf "$DEPLOY_DIR" +unzip "$ZIP_FILE" -d "$BASE_DIR" + +# =============================== +# 5. DEPLOY APP FILES +# =============================== +echo "?? Deploying new app files..." +rm -rf "$APP_DIR" +mkdir -p "$APP_DIR" +cp -r "$DEPLOY_DIR"/. "$APP_DIR" + +# =============================== +# 6. RESTORE PERSISTENT FILES +# =============================== +echo "?? Restoring persistent files..." + +# writable/ +if [ -d "$PERSIST_BACKUP/writable" ]; then + echo " ? Restoring writable/" + rm -rf "$APP_DIR/writable" + cp -r "$PERSIST_BACKUP/writable" "$APP_DIR/writable" +fi + +# .env +if [ -f "$PERSIST_BACKUP/.env" ]; then + echo " ? Restoring .env" + cp "$PERSIST_BACKUP/.env" "$APP_DIR/.env" +fi + +# .htaccess +if [ -f "$PERSIST_BACKUP/.htaccess" ]; then + echo " ? Restoring .htaccess" + cp "$PERSIST_BACKUP/.htaccess" "$PUBLIC_DIR/.htaccess" +fi + +# index.php +if [ -f "$PERSIST_BACKUP/index.php" ]; then + echo " ? Restoring index.php" + cp "$PERSIST_BACKUP/index.php" "$PUBLIC_DIR/index.php" +fi + +# =============================== +# 7. DEPLOY PUBLIC FILES +# =============================== +echo "?? Deploying public files..." +rm -rf "$PUBLIC_DIR"/* +cp -r "$APP_DIR/public/"* "$PUBLIC_DIR" + +# Re-apply .htaccess and index.php again (to ensure overwrite protection) +if [ -f "$PERSIST_BACKUP/.htaccess" ]; then + cp "$PERSIST_BACKUP/.htaccess" "$PUBLIC_DIR/.htaccess" +fi +if [ -f "$PERSIST_BACKUP/index.php" ]; then + cp "$PERSIST_BACKUP/index.php" "$PUBLIC_DIR/index.php" +fi + +# =============================== +# 8. FIX index.php PATH +# =============================== +echo "?? Fixing index.php path..." +sed -i "s|require FCPATH . '../app/Config/Paths.php';|require FCPATH . '../alrahma/app/Config/Paths.php';|" "$PUBLIC_DIR/index.php" + +# =============================== +# 9. UPDATE BASE URL & DB CONFIG +# =============================== +APP_CONFIG="$APP_DIR/app/Config/App.php" +DB_CONFIG="$APP_DIR/app/Config/Database.php" +echo "?? Updating configuration files..." + +# Base URL +sed -i "s|public string \$baseURL = .*|public string \$baseURL = 'https://$DM_NAME/';|" "$APP_CONFIG" + +# Database.php +sed -i "s|'hostname' => .*|'hostname' => '$DB_HOST',|" "$DB_CONFIG" +sed -i "s|'username' => .*|'username' => '$DB_USER',|" "$DB_CONFIG" +sed -i "s|'password' => .*|'password' => '$DB_PASS',|" "$DB_CONFIG" +sed -i "s|'database' => .*|'database' => '$DB_NAME',|" "$DB_CONFIG" + +# =============================== +# 10. UPDATE db_connection.php +# =============================== +DB_CONN_FILE="$APP_DIR/app/db_connection.php" +if [ -f "$DB_CONN_FILE" ]; then + echo "??? Updating db_connection.php..." + sed -i "s|\$host = '.*';|\$host = '$DB_HOST';|" "$DB_CONN_FILE" + sed -i "s|\$dbname = '.*';|\$dbname = '$DB_NAME';|" "$DB_CONN_FILE" + sed -i "s|\$username = '.*';|\$username = '$DB_USER';|" "$DB_CONN_FILE" + sed -i "s|\$password = '.*';|\$password = '$DB_PASS';|" "$DB_CONN_FILE" +fi + +# =============================== +# 11. FIX FPDF PATHS +# =============================== +echo "?? Fixing FPDF paths..." +grep -rl "ThirdParty\\\\fpdf\\\\fpdf.php" "$APP_DIR/app" | while read -r file; do + sed -i "s|ThirdParty\\\\fpdf\\\\fpdf.php|ThirdParty/fpdf/fpdf.php|g" "$file" + echo "? Fixed path in: $file" +done + +# =============================== +# 12. COMPOSER INSTALL +# =============================== +echo "?? Installing production Composer dependencies with PHP 8.5..." +cd "$APP_DIR" || exit + +export PATH="$(dirname "$PHP_BIN"):$PATH" +export COMPOSER_ALLOW_SUPERUSER=1 + +if "$PHP_BIN" "$COMPOSER_BIN" --version >/dev/null 2>&1; then + "$PHP_BIN" "$COMPOSER_BIN" install \ + --no-dev \ + --prefer-dist \ + --optimize-autoloader \ + --classmap-authoritative \ + --no-interaction \ + --no-progress +else + "$COMPOSER_BIN" install \ + --no-dev \ + --prefer-dist \ + --optimize-autoloader \ + --classmap-authoritative \ + --no-interaction \ + --no-progress +fi + +"$PHP_BIN" spark --version + +# =============================== +# 13. RESTORE PERMISSIONS +# =============================== +echo "?? Setting permissions..." +chmod 644 "$PUBLIC_DIR/.htaccess" +chmod 644 "$PUBLIC_DIR/index.php" +chmod -R 755 "$APP_DIR" +chmod 644 "$APP_DIR/app/db_connection.php" +chmod -R 777 "$APP_DIR/writable" + +# =============================== +# ? DONE +# =============================== +echo "? Deployment completed successfully at $TIMESTAMP" +echo "?? Backup stored in: $BACKUP_DIR" +echo "?? Persistent files restored from: $PERSIST_BACKUP" +echo "?? Writable, .env, .htaccess, and index.php preserved successfully!" \ No newline at end of file diff --git a/tests/app/Libraries/InvoiceLedgerServiceTest.php b/tests/app/Libraries/InvoiceLedgerServiceTest.php index b6885a5..802956b 100644 --- a/tests/app/Libraries/InvoiceLedgerServiceTest.php +++ b/tests/app/Libraries/InvoiceLedgerServiceTest.php @@ -81,6 +81,26 @@ class InvoiceLedgerServiceHarness extends InvoiceLedgerService class InvoiceLedgerServiceTest extends CIUnitTestCase { + public function testLegacyNegativeAdditionalChargeKeepsItsMinusSignWithoutAType(): void + { + $this->assertSame(-110.0, InvoiceLedgerService::signedAdditionalChargeAmount([ + 'charge_type' => '', + 'amount' => '-110.00', + ])); + } + + public function testTypedDeductionAndPositiveChargeAreNormalizedCorrectly(): void + { + $this->assertSame(-25.0, InvoiceLedgerService::signedAdditionalChargeAmount([ + 'charge_type' => 'deduct', + 'amount' => '25.00', + ])); + $this->assertSame(22.0, InvoiceLedgerService::signedAdditionalChargeAmount([ + 'charge_type' => '', + 'amount' => '22.00', + ])); + } + public function testInvoiceWithNoPaymentHasFullBalanceDue(): void { $service = new InvoiceLedgerServiceHarness([ diff --git a/tests/app/Services/SchoolYearClosingServiceTest.php b/tests/app/Services/SchoolYearClosingServiceTest.php new file mode 100644 index 0000000..13cc668 --- /dev/null +++ b/tests/app/Services/SchoolYearClosingServiceTest.php @@ -0,0 +1,80 @@ +newInstanceWithoutConstructor(); + + $students = $this->privateMethodInvoker($service, 'promotionStudentsWithStatus')([ + [ + 'student_id' => 17, + 'student_name' => 'Amina Rahman', + 'school_id' => 'S-1042', + 'class_section_name' => 'Grade 4 - A', + 'status' => 'missing', + ], + [ + 'student_id' => 18, + 'student_name' => 'Omar Ali', + 'school_id' => 'S-1043', + 'class_section_name' => 'Grade 5 - B', + 'status' => 'decided', + ], + ], 'missing'); + + $this->assertSame([[ + 'student_id' => 17, + 'student_name' => 'Amina Rahman', + 'school_id' => 'S-1042', + 'class_section_name' => 'Grade 4 - A', + ]], $students); + } + + public function testLegacyManualDecisionPreventsFalseMissingDecisionBlocker(): void + { + $service = (new ReflectionClass(SchoolYearClosingService::class))->newInstanceWithoutConstructor(); + $merge = $this->privateMethodInvoker($service, 'mergeFallbackPromotionDecisions'); + + $decisions = $merge([], [[ + 'student_id' => 191, + 'decision' => 'Make-up exam in fall', + 'notes' => 'Make-up exam approved.', + ]]); + + $this->assertSame('decided', $decisions[191]['status']); + $this->assertSame('MAKE_UP_EXAM', $decisions[191]['normalized_decision']); + $this->assertSame('manual', $decisions[191]['source']); + } + + public function testLegacyManualDecisionReplacesStalePendingConsolidatedDecision(): void + { + $service = (new ReflectionClass(SchoolYearClosingService::class))->newInstanceWithoutConstructor(); + $merge = $this->privateMethodInvoker($service, 'mergeFallbackPromotionDecisions'); + + $decisions = $merge([ + 191 => ['status' => 'pending', 'decision' => '', 'source' => 'pending'], + ], [[ + 'student_id' => 191, + 'decision' => 'Make-up exam in fall', + 'notes' => '', + ]]); + + $this->assertSame('decided', $decisions[191]['status']); + $this->assertSame('Make-up exam in fall', $decisions[191]['decision']); + } + + private function privateMethodInvoker(object $object, string $method): callable + { + $reflection = new ReflectionClass($object); + $reflectionMethod = $reflection->getMethod($method); + + return static fn (...$arguments) => $reflectionMethod->invoke($object, ...$arguments); + } +}