fix the enrollement-carryover balance
This commit is contained in:
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Commands;
|
||||||
|
|
||||||
|
use CodeIgniter\CLI\BaseCommand;
|
||||||
|
use CodeIgniter\CLI\CLI;
|
||||||
|
|
||||||
|
final class RepairSchoolYearCarryForward extends BaseCommand
|
||||||
|
{
|
||||||
|
protected $group = 'School Year';
|
||||||
|
protected $name = 'school-year:repair-carry-forward';
|
||||||
|
protected $description = 'Find or repair family balances omitted from an executed school-year closing batch.';
|
||||||
|
protected $usage = 'php spark school-year:repair-carry-forward --source-year-id=1 [--actor-id=1 --commit]';
|
||||||
|
protected $options = [
|
||||||
|
'--source-year-id' => '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=<admin id> --commit to apply the repair.', 'yellow');
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
CLI::error($e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1064,13 +1064,66 @@ class InvoiceController extends ResourceController
|
|||||||
$description = $this->invoiceLedgerService->carryForwardDisplayDescription($invoice);
|
$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 [
|
return [
|
||||||
'parent_name' => trim((string) ($parent['firstname'] ?? '') . ' ' . (string) ($parent['lastname'] ?? '')),
|
'parent_name' => trim((string) ($parent['firstname'] ?? '') . ' ' . (string) ($parent['lastname'] ?? '')),
|
||||||
'parent_id' => $parentId,
|
'parent_id' => $parentId,
|
||||||
'enrolledKids' => $enrolledKids,
|
'enrolledKids' => $enrolledKids,
|
||||||
'withdrawnKids' => $withdrawnKids,
|
'withdrawnKids' => $withdrawnKids,
|
||||||
'invoice_amount' => $invoice !== null ? (float) ($invoice['total_amount'] ?? 0) : 0.0,
|
'invoice_amount' => $invoiceAmount,
|
||||||
'invoice_balance' => $invoice !== null ? (float) ($invoice['balance'] ?? 0) : 0.0,
|
'invoice_balance' => $ledger !== null
|
||||||
|
? (float) ($ledger['balance'] ?? 0)
|
||||||
|
: ($invoice !== null ? (float) ($invoice['balance'] ?? 0) : 0.0),
|
||||||
'refund_amount' => (float) ($refundSummary['amount'] ?? 0.0),
|
'refund_amount' => (float) ($refundSummary['amount'] ?? 0.0),
|
||||||
'refund_details' => $refundSummary['details'] ?? [],
|
'refund_details' => $refundSummary['details'] ?? [],
|
||||||
'last_updated' => $invoice['updated_at'] ?? null,
|
'last_updated' => $invoice['updated_at'] ?? null,
|
||||||
@@ -1078,7 +1131,9 @@ class InvoiceController extends ResourceController
|
|||||||
'invoice_id' => $invoiceId,
|
'invoice_id' => $invoiceId,
|
||||||
'invoice_number' => $invoice !== null ? (string) ($invoice['invoice_number'] ?? '') : '',
|
'invoice_number' => $invoice !== null ? (string) ($invoice['invoice_number'] ?? '') : '',
|
||||||
'invoice_description' => $description,
|
'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,
|
'is_carry_forward' => $isCarryForward,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -1352,7 +1407,10 @@ class InvoiceController extends ResourceController
|
|||||||
return ['error' => "Parent associated with the invoice was not found."];
|
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 = [];
|
$invoiceLines = [];
|
||||||
|
|
||||||
$registeredKids = [];
|
$registeredKids = [];
|
||||||
@@ -1409,12 +1467,12 @@ class InvoiceController extends ResourceController
|
|||||||
/* ============================================================
|
/* ============================================================
|
||||||
* ADDITIONAL CHARGES (itemized) for this invoice
|
* ADDITIONAL CHARGES (itemized) for this invoice
|
||||||
* - uses the additional_charges table for line items
|
* - 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
|
$acRows = $this->additionalChargeModel
|
||||||
->select('id, charge_type, title, description, amount, due_date, status, created_at')
|
->select('id, charge_type, title, description, amount, due_date, status, created_at')
|
||||||
->where('invoice_id', $invoiceId)
|
->where('invoice_id', $invoiceId)
|
||||||
->where('status !=', 'void')
|
->where('status', FinancialStatus::ADDITIONAL_CHARGE_APPLIED)
|
||||||
->orderBy('created_at', 'ASC')
|
->orderBy('created_at', 'ASC')
|
||||||
->orderBy('id', 'ASC')
|
->orderBy('id', 'ASC')
|
||||||
->findAll();
|
->findAll();
|
||||||
@@ -1423,26 +1481,21 @@ class InvoiceController extends ResourceController
|
|||||||
$additionalChargesTotal = 0.0;
|
$additionalChargesTotal = 0.0;
|
||||||
|
|
||||||
foreach ($acRows as $ac) {
|
foreach ($acRows as $ac) {
|
||||||
$signed = (float)($ac['amount'] ?? 0);
|
$signed = InvoiceLedgerService::signedAdditionalChargeAmount($ac);
|
||||||
$ctype = strtolower((string)($ac['charge_type'] ?? ''));
|
$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'])
|
$lineDate = !empty($ac['created_at'])
|
||||||
? date('Y-m-d', strtotime($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'));
|
: (!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';
|
$typeLabel = $signed < 0 ? 'Deduct' : 'Add';
|
||||||
$title = ''; //trim((string)($ac['title'] ?? 'Additional Charge'));
|
$title = trim((string)($ac['title'] ?? ''));
|
||||||
$desc = $typeLabel . ': ';
|
$description = trim((string)($ac['description'] ?? ''));
|
||||||
|
$desc = $title !== '' ? $title : 'Additional charge';
|
||||||
if (!empty($ac['description'])) {
|
if ($description !== '' && $description !== $title) {
|
||||||
$desc = $ac['description'];
|
$desc .= ' - ' . $description;
|
||||||
}
|
}
|
||||||
|
$desc = $typeLabel . ': ' . $desc;
|
||||||
|
|
||||||
$additionalChargesTotal += $signed;
|
$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
|
// --- Payments (negative) — stored in local time
|
||||||
foreach ($payments as $payment) {
|
foreach ($payments as $payment) {
|
||||||
$dt = $toLocal($payment['payment_date'] ?? null, false /* local */);
|
$dt = $toLocal($payment['payment_date'] ?? null, false /* local */);
|
||||||
@@ -1996,12 +2063,31 @@ class InvoiceController extends ResourceController
|
|||||||
|
|
||||||
// ======== SUMMARY (bottom) ========
|
// ======== SUMMARY (bottom) ========
|
||||||
$ledger = $data['ledger'] ?? [];
|
$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);
|
$totalDiscount = (float) ($ledger['discount_total'] ?? $totalDiscount);
|
||||||
$totalPaid = (float) ($ledger['paid_amount'] ?? $totalPaid);
|
$totalPaid = (float) ($ledger['paid_amount'] ?? $totalPaid);
|
||||||
$totalRefund = (float) ($ledger['refund_paid_total'] ?? 0.0);
|
$totalRefund = (float) ($ledger['refund_paid_total'] ?? 0.0);
|
||||||
$displayBalance = (float) ($ledger['balance'] ?? 0.0);
|
// The PDF balance must reconcile exactly to its visible rows: positive
|
||||||
$creditOverpay = (float) ($ledger['customer_credit'] ?? 0.0);
|
// 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);
|
$pdf->Ln(5);
|
||||||
$labelWidth = 165;
|
$labelWidth = 165;
|
||||||
|
|||||||
@@ -452,6 +452,29 @@ class InvoiceLedgerService
|
|||||||
|
|
||||||
protected function calculateTuitionTotal(array $invoice): float
|
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);
|
$parentId = (int) ($invoice['parent_id'] ?? 0);
|
||||||
$schoolYear = (string) ($invoice['school_year'] ?? '');
|
$schoolYear = (string) ($invoice['school_year'] ?? '');
|
||||||
if ($parentId <= 0 || $schoolYear === '') {
|
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
|
$rows = $this->eventChargesModel
|
||||||
->select('COALESCE(SUM(charged),0) AS total_amount')
|
->select('COALESCE(SUM(charged),0) AS total_amount')
|
||||||
->where('parent_id', (int) ($invoice['parent_id'] ?? 0))
|
->where('parent_id', (int) ($invoice['parent_id'] ?? 0))
|
||||||
->where('school_year', (string) ($invoice['school_year'] ?? ''))
|
->where('school_year', (string) ($invoice['school_year'] ?? ''))
|
||||||
->where('semester', (string) ($invoice['semester'] ?? ''))
|
|
||||||
->findAll();
|
->findAll();
|
||||||
|
|
||||||
return (float) ($rows[0]['total_amount'] ?? 0);
|
return (float) ($rows[0]['total_amount'] ?? 0);
|
||||||
@@ -493,12 +519,28 @@ class InvoiceLedgerService
|
|||||||
protected function calculateAdditionalCharges(int $invoiceId): float
|
protected function calculateAdditionalCharges(int $invoiceId): float
|
||||||
{
|
{
|
||||||
$rows = $this->additionalChargeModel
|
$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('invoice_id', $invoiceId)
|
||||||
->where('status', FinancialStatus::ADDITIONAL_CHARGE_APPLIED)
|
->where('status', FinancialStatus::ADDITIONAL_CHARGE_APPLIED)
|
||||||
->findAll();
|
->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
|
protected function calculateDiscounts(int $invoiceId): float
|
||||||
|
|||||||
@@ -57,11 +57,13 @@ public function buildRoster(string $selectedYear, string $semester): array
|
|||||||
$students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear);
|
$students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear);
|
||||||
|
|
||||||
$removedPriorStatuses = $this->removedPriorYearStudentStatuses($selectedYear);
|
$removedPriorStatuses = $this->removedPriorYearStudentStatuses($selectedYear);
|
||||||
|
$makeUpExamStudentIds = array_fill_keys($this->makeUpExamStudentIds($selectedYear), true);
|
||||||
service('studentYearStatus')->attachToStudents($students, $selectedYear);
|
service('studentYearStatus')->attachToStudents($students, $selectedYear);
|
||||||
|
|
||||||
foreach ($students as &$s) {
|
foreach ($students as &$s) {
|
||||||
// ===== Ensure IDs needed by the modal =====
|
// ===== Ensure IDs needed by the modal =====
|
||||||
$s['student_id'] = (int)($s['id'] ?? 0);
|
$s['student_id'] = (int)($s['id'] ?? 0);
|
||||||
|
$s['make_up_exam'] = isset($makeUpExamStudentIds[$s['student_id']]) ? 'Yes' : 'No';
|
||||||
$priorRemovedStatus = $removedPriorStatuses[$s['student_id']] ?? null;
|
$priorRemovedStatus = $removedPriorStatuses[$s['student_id']] ?? null;
|
||||||
$s['removed_previous_year'] = $priorRemovedStatus !== null ? 'Yes' : 'No';
|
$s['removed_previous_year'] = $priorRemovedStatus !== null ? 'Yes' : 'No';
|
||||||
$s['prior_removed_status'] = $priorRemovedStatus;
|
$s['prior_removed_status'] = $priorRemovedStatus;
|
||||||
@@ -528,6 +530,54 @@ private function getPreviousSchoolYear(string $schoolYear): string
|
|||||||
return '';
|
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
|
private function getSchoolYearStartYear(string $schoolYear): ?int
|
||||||
{
|
{
|
||||||
$schoolYear = trim($schoolYear);
|
$schoolYear = trim($schoolYear);
|
||||||
|
|||||||
@@ -57,14 +57,16 @@ final class SchoolYearClosingService
|
|||||||
$findings[] = $this->finding(
|
$findings[] = $this->finding(
|
||||||
'blocking',
|
'blocking',
|
||||||
'Students missing promotion decisions',
|
'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) {
|
if (($promotion['summary']['pending_decision'] ?? 0) > 0) {
|
||||||
$findings[] = $this->finding(
|
$findings[] = $this->finding(
|
||||||
'blocking',
|
'blocking',
|
||||||
'Students with pending promotion decisions',
|
'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) {
|
if (($promotion['summary']['missing_queue'] ?? 0) > 0) {
|
||||||
@@ -337,6 +339,128 @@ final class SchoolYearClosingService
|
|||||||
->first();
|
->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
|
private function requireYear(int $id): array
|
||||||
{
|
{
|
||||||
$year = $this->schoolYearModel->find($id);
|
$year = $this->schoolYearModel->find($id);
|
||||||
@@ -402,7 +526,7 @@ final class SchoolYearClosingService
|
|||||||
$count = $this->db->table('invoices')
|
$count = $this->db->table('invoices')
|
||||||
->where('school_year', $schoolYear)
|
->where('school_year', $schoolYear)
|
||||||
->where('balance >', 0)
|
->where('balance >', 0)
|
||||||
->where("LOWER(status) IN ('unpaid', 'partially paid')", null, false)
|
->where("LOWER(REPLACE(TRIM(status), '_', ' ')) IN ('unpaid', 'partially paid')", null, false)
|
||||||
->countAllResults();
|
->countAllResults();
|
||||||
|
|
||||||
return $count > 0
|
return $count > 0
|
||||||
@@ -421,7 +545,7 @@ final class SchoolYearClosingService
|
|||||||
->select('COALESCE(SUM(i.balance), 0) AS source_balance')
|
->select('COALESCE(SUM(i.balance), 0) AS source_balance')
|
||||||
->where('i.school_year', $schoolYear)
|
->where('i.school_year', $schoolYear)
|
||||||
->where('i.balance !=', 0)
|
->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')) {
|
if ($this->db->tableExists('users')) {
|
||||||
$builder
|
$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;
|
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,
|
'severity' => $severity,
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'detail' => $detail,
|
'detail' => $detail,
|
||||||
];
|
], $context);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function hashPreview(array $preview): string
|
private function hashPreview(array $preview): string
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
<th>School ID</th>
|
<th>School ID</th>
|
||||||
<th>Age</th>
|
<th>Age</th>
|
||||||
<th>New Student</th>
|
<th>New Student</th>
|
||||||
|
<th>Make Up Exam</th>
|
||||||
<th>Registered Class</th>
|
<th>Registered Class</th>
|
||||||
<th>Current Class</th>
|
<th>Current Class</th>
|
||||||
<th>Actual Status</th>
|
<th>Actual Status</th>
|
||||||
@@ -101,6 +102,15 @@
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
<!-- Make-up Exam -->
|
||||||
|
<td class="text-center">
|
||||||
|
<?php if (($student['make_up_exam'] ?? 'No') === 'Yes'): ?>
|
||||||
|
<span class="badge bg-warning text-dark">Yes</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="badge bg-secondary">No</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
|
||||||
<!-- Registered Class -->
|
<!-- Registered Class -->
|
||||||
<td><?= esc(trim((string)($student['registration_grade'] ?? '')) !== '' ? (string)$student['registration_grade'] : '-') ?></td>
|
<td><?= esc(trim((string)($student['registration_grade'] ?? '')) !== '' ? (string)$student['registration_grade'] : '-') ?></td>
|
||||||
|
|
||||||
@@ -191,7 +201,7 @@
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="11">No students available.</td>
|
<td colspan="12">No students available.</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -389,8 +399,8 @@
|
|||||||
|
|
||||||
// Disable sort/search on interactive columns (status select, assign select)
|
// Disable sort/search on interactive columns (status select, assign select)
|
||||||
columnDefs: [
|
columnDefs: [
|
||||||
{ targets: [9, 10], orderable: false, searchable: false },
|
{ targets: [10, 11], orderable: false, searchable: false },
|
||||||
{ targets: [0, 1, 2, 3, 4, 5, 6, 7, 8], render: function(data, type) {
|
{ targets: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], render: function(data, type) {
|
||||||
if (type === 'filter' || type === 'sort' || type === 'type') {
|
if (type === 'filter' || type === 'sort' || type === 'type') {
|
||||||
return stripHtml(data);
|
return stripHtml(data);
|
||||||
}
|
}
|
||||||
@@ -450,7 +460,7 @@
|
|||||||
}
|
}
|
||||||
const CLASS_COL_INDEX = findColIndexByHeader('Current Class'); // was index 4
|
const CLASS_COL_INDEX = findColIndexByHeader('Current Class'); // was index 4
|
||||||
const STATUS_COL_INDEX = findColIndexByHeader('Actual Status'); // was index 5
|
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');
|
const ASSIGN_COL_INDEX = findColIndexByHeader('Assign Class');
|
||||||
|
|
||||||
/* ===== Helpers ===== */
|
/* ===== Helpers ===== */
|
||||||
|
|||||||
@@ -199,7 +199,30 @@
|
|||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<ul class="mb-0">
|
<ul class="mb-0">
|
||||||
<?php foreach ($blockers as $finding): ?>
|
<?php foreach ($blockers as $finding): ?>
|
||||||
<li><strong><?= esc($finding['title']) ?>:</strong> <?= esc($finding['detail']) ?></li>
|
<li>
|
||||||
|
<strong><?= esc($finding['title']) ?>:</strong> <?= esc($finding['detail']) ?>
|
||||||
|
<?php if (($finding['students'] ?? []) !== []): ?>
|
||||||
|
<ul class="mt-1 mb-2">
|
||||||
|
<?php foreach ($finding['students'] as $student): ?>
|
||||||
|
<?php
|
||||||
|
$studentId = (int) ($student['student_id'] ?? 0);
|
||||||
|
$studentName = trim((string) ($student['student_name'] ?? ''));
|
||||||
|
$studentLabel = $studentName !== '' ? $studentName : 'Student #' . $studentId;
|
||||||
|
$studentDetails = array_values(array_filter([
|
||||||
|
trim((string) ($student['school_id'] ?? '')) !== '' ? 'School ID: ' . trim((string) $student['school_id']) : '',
|
||||||
|
trim((string) ($student['class_section_name'] ?? '')) !== '' ? 'Class: ' . trim((string) $student['class_section_name']) : '',
|
||||||
|
]));
|
||||||
|
?>
|
||||||
|
<li>
|
||||||
|
<a href="#promotion-student-<?= $studentId ?>"><?= esc($studentLabel) ?></a>
|
||||||
|
<?php if ($studentDetails !== []): ?>
|
||||||
|
<span class="text-muted"> — <?= esc(implode('; ', $studentDetails)) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
</li>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</ul>
|
</ul>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -328,7 +351,7 @@
|
|||||||
}
|
}
|
||||||
$ageSepOne = $ageBySchoolYearSecondSeptember($row);
|
$ageSepOne = $ageBySchoolYearSecondSeptember($row);
|
||||||
?>
|
?>
|
||||||
<tr>
|
<tr id="promotion-student-<?= (int) ($row['student_id'] ?? 0) ?>">
|
||||||
<td><?= esc($row['student_name'] ?? ('Student #' . (int) ($row['student_id'] ?? 0))) ?></td>
|
<td><?= esc($row['student_name'] ?? ('Student #' . (int) ($row['student_id'] ?? 0))) ?></td>
|
||||||
<td><?= esc($row['school_id'] ?? '') ?></td>
|
<td><?= esc($row['school_id'] ?? '') ?></td>
|
||||||
<td><?= esc($row['class_section_name'] ?? '') ?></td>
|
<td><?= esc($row['class_section_name'] ?? '') ?></td>
|
||||||
@@ -501,7 +524,30 @@
|
|||||||
<div class="fw-semibold mb-2">Resolve these blockers before the year can be closed.</div>
|
<div class="fw-semibold mb-2">Resolve these blockers before the year can be closed.</div>
|
||||||
<ul class="mb-0">
|
<ul class="mb-0">
|
||||||
<?php foreach ($blockers as $finding): ?>
|
<?php foreach ($blockers as $finding): ?>
|
||||||
<li><strong><?= esc($finding['title']) ?>:</strong> <?= esc($finding['detail']) ?></li>
|
<li>
|
||||||
|
<strong><?= esc($finding['title']) ?>:</strong> <?= esc($finding['detail']) ?>
|
||||||
|
<?php if (($finding['students'] ?? []) !== []): ?>
|
||||||
|
<ul class="mt-1 mb-2">
|
||||||
|
<?php foreach ($finding['students'] as $student): ?>
|
||||||
|
<?php
|
||||||
|
$studentId = (int) ($student['student_id'] ?? 0);
|
||||||
|
$studentName = trim((string) ($student['student_name'] ?? ''));
|
||||||
|
$studentLabel = $studentName !== '' ? $studentName : 'Student #' . $studentId;
|
||||||
|
$studentDetails = array_values(array_filter([
|
||||||
|
trim((string) ($student['school_id'] ?? '')) !== '' ? 'School ID: ' . trim((string) $student['school_id']) : '',
|
||||||
|
trim((string) ($student['class_section_name'] ?? '')) !== '' ? 'Class: ' . trim((string) $student['class_section_name']) : '',
|
||||||
|
]));
|
||||||
|
?>
|
||||||
|
<li>
|
||||||
|
<a href="#promotion-student-<?= $studentId ?>" data-bs-dismiss="modal"><?= esc($studentLabel) ?></a>
|
||||||
|
<?php if ($studentDetails !== []): ?>
|
||||||
|
<span class="text-muted"> — <?= esc(implode('; ', $studentDetails)) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
</li>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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!"
|
||||||
@@ -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!"
|
||||||
@@ -81,6 +81,26 @@ class InvoiceLedgerServiceHarness extends InvoiceLedgerService
|
|||||||
|
|
||||||
class InvoiceLedgerServiceTest extends CIUnitTestCase
|
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
|
public function testInvoiceWithNoPaymentHasFullBalanceDue(): void
|
||||||
{
|
{
|
||||||
$service = new InvoiceLedgerServiceHarness([
|
$service = new InvoiceLedgerServiceHarness([
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\App\Services;
|
||||||
|
|
||||||
|
use App\Services\SchoolYearClosingService;
|
||||||
|
use CodeIgniter\Test\CIUnitTestCase;
|
||||||
|
use ReflectionClass;
|
||||||
|
|
||||||
|
final class SchoolYearClosingServiceTest extends CIUnitTestCase
|
||||||
|
{
|
||||||
|
public function testPromotionBlockerStudentsIncludeIdentifyingInformation(): void
|
||||||
|
{
|
||||||
|
$service = (new ReflectionClass(SchoolYearClosingService::class))->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user