fix the enrollement-carryover balance
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user