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
+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