fix the enrollement-carryover balance
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 49s
Tests / PHPUnit (push) Successful in 1m21s

This commit is contained in:
root
2026-09-09 21:40:01 -04:00
parent 2d5b151234
commit 36e8ffe56d
12 changed files with 1365 additions and 39 deletions
@@ -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());
}
}
}
+108 -22
View File
@@ -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;
+45 -3
View File
@@ -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
@@ -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);
+194 -7
View File
@@ -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
@@ -41,6 +41,7 @@
<th>School ID</th>
<th>Age</th>
<th>New Student</th>
<th>Make Up Exam</th>
<th>Registered Class</th>
<th>Current Class</th>
<th>Actual Status</th>
@@ -101,6 +102,15 @@
<?php endif; ?>
</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 -->
<td><?= esc(trim((string)($student['registration_grade'] ?? '')) !== '' ? (string)$student['registration_grade'] : '-') ?></td>
@@ -191,7 +201,7 @@
<?php endforeach; ?>
<?php else: ?>
<tr>
<td colspan="11">No students available.</td>
<td colspan="12">No students available.</td>
</tr>
<?php endif; ?>
</tbody>
@@ -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 ===== */
+49 -3
View File
@@ -199,7 +199,30 @@
<?php else: ?>
<ul class="mb-0">
<?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; ?>
</ul>
<?php endif; ?>
@@ -328,7 +351,7 @@
}
$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['school_id'] ?? '') ?></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>
<ul class="mb-0">
<?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; ?>
</ul>
</div>