fix the enrollement-carryover balance
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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