requireYear($sourceYearId); $target = $targetYearId !== null ? $this->schoolYearModel->find($targetYearId) : $this->nextDraftYear((string) $source['name']); $sourceName = (string) $source['name']; $finance = $this->financialSummary($sourceName); $overview = [ 'students' => $this->countBySchoolYear('student_class', $sourceName, 'student_id'), 'families' => $this->countFamilies($sourceName), 'classes' => $this->countBySchoolYear('classSection', $sourceName, 'class_id'), 'teachers' => $this->countBySchoolYear('teacher_class', $sourceName, 'teacher_id'), 'invoices' => $finance['invoice_count'], 'total_invoiced' => $finance['total_invoiced'], 'total_paid' => $finance['total_paid'], 'total_outstanding' => $finance['total_outstanding'], ]; $findings = []; if ($target === null) { $findings[] = $this->finding('blocking', 'Missing target year', 'Create a draft target school year before starting closing.'); } elseif (! in_array((string) $target['status'], [SchoolYearStatus::DRAFT, SchoolYearStatus::ACTIVE], true)) { $findings[] = $this->finding('blocking', 'Invalid target year', 'The target school year must be draft or active.'); } foreach ($this->unpaidInvoiceFindings($sourceName) as $finding) { $findings[] = $finding; } if ($this->countMissingSchoolYearRows('invoices') > 0) { $findings[] = $this->finding('blocking', 'Invoices missing school year', 'Some invoice records are not assigned to a school year.'); } $carryForward = $this->carryForwardFamilies($sourceName); $warnings = array_values(array_filter($findings, static fn (array $f): bool => $f['severity'] === 'warning')); $blockers = array_values(array_filter($findings, static fn (array $f): bool => $f['severity'] === 'blocking')); $result = [ 'source' => $source, 'target' => $target, 'overview' => $overview, 'finance' => $finance, 'findings' => $findings, 'blockers' => $blockers, 'warnings' => $warnings, 'carry_forward' => $carryForward, 'generated_at' => date('Y-m-d H:i:s'), ]; $result['hash'] = $this->hashPreview($result); return $result; } public function start(int $sourceYearId, int $targetYearId, ?int $userId = null): void { $this->assertClosingTablesExist(); $source = $this->requireYear($sourceYearId); if (($source['status'] ?? '') !== SchoolYearStatus::ACTIVE) { throw new InvalidArgumentException('Only an active school year can begin closing.'); } $preview = $this->preview($sourceYearId, $targetYearId); if ($preview['blockers'] !== []) { throw new InvalidArgumentException('Resolve blocking closing issues before starting closing.'); } $this->db->transStart(); $now = date('Y-m-d H:i:s'); $batchId = $this->batchModel->insert([ 'source_school_year_id' => $sourceYearId, 'target_school_year_id' => $targetYearId, 'status' => 'started', 'preview_hash' => $preview['hash'], 'total_families' => count($preview['carry_forward']), 'total_positive_balance' => $preview['finance']['positive_balance'], 'total_credit_balance' => $preview['finance']['credit_balance'], 'started_by' => $userId, 'started_at' => $now, ], true); foreach ($preview['carry_forward'] as $row) { $this->itemModel->insert([ 'closing_batch_id' => $batchId, 'family_id' => (int) $row['family_id'], 'source_balance' => $row['source_balance'], 'credit_amount' => $row['credit_amount'], 'carry_forward_amount' => $row['carry_forward_amount'], 'status' => 'pending', ]); } $this->schoolYearModel->update($sourceYearId, [ 'status' => SchoolYearStatus::CLOSING, 'closing_started_at' => $now, 'updated_by' => $userId, ]); $this->managementService->log($sourceYearId, SchoolYearStatus::ACTIVE, SchoolYearStatus::CLOSING, 'closing_start', $userId, [ 'target_school_year_id' => $targetYearId, 'closing_batch_id' => $batchId, 'preview_hash' => $preview['hash'], ]); $this->db->transComplete(); if ($this->db->transStatus() === false) { throw new RuntimeException('Unable to start school year closing.'); } } public function execute(int $sourceYearId, ?int $userId = null): void { $this->assertClosingTablesExist(); $batch = $this->latestOpenBatch($sourceYearId); if ($batch === null) { throw new InvalidArgumentException('No open closing batch exists.'); } $preview = $this->preview($sourceYearId, (int) $batch['target_school_year_id']); if ($preview['hash'] !== (string) $batch['preview_hash']) { throw new InvalidArgumentException('Closing preview has changed. Refresh and restart closing before executing carry-forward.'); } $this->db->transStart(); $items = $this->itemModel->where('closing_batch_id', (int) $batch['id'])->findAll(); foreach ($items as $item) { if (($item['status'] ?? '') === 'completed') { continue; } $this->itemModel->update((int) $item['id'], ['status' => 'completed']); } $this->batchModel->update((int) $batch['id'], ['status' => 'executed']); $this->managementService->log($sourceYearId, SchoolYearStatus::CLOSING, SchoolYearStatus::CLOSING, 'carry_forward_execute', $userId, [ 'closing_batch_id' => (int) $batch['id'], 'note' => 'Marked previewed carry-forward items complete. Target accounting records require the dedicated opening-balance schema.', ]); $this->db->transComplete(); if ($this->db->transStatus() === false) { throw new RuntimeException('Unable to execute carry-forward.'); } } public function complete(int $sourceYearId, ?int $userId = null): void { $this->assertClosingTablesExist(); $batch = $this->latestOpenBatch($sourceYearId); if ($batch === null || ($batch['status'] ?? '') !== 'executed') { throw new InvalidArgumentException('Carry-forward must be executed before completing closing.'); } $pending = $this->itemModel ->where('closing_batch_id', (int) $batch['id']) ->where('status !=', 'completed') ->countAllResults(); if ($pending > 0) { throw new InvalidArgumentException('All closing batch items must complete before the year can be closed.'); } $this->db->transStart(); $now = date('Y-m-d H:i:s'); $this->batchModel->update((int) $batch['id'], [ 'status' => 'completed', 'completed_by' => $userId, 'completed_at' => $now, ]); $this->schoolYearModel->update($sourceYearId, [ 'status' => SchoolYearStatus::CLOSED, 'closed_at' => $now, 'updated_by' => $userId, ]); $this->managementService->log($sourceYearId, SchoolYearStatus::CLOSING, SchoolYearStatus::CLOSED, 'closing_complete', $userId, [ 'closing_batch_id' => (int) $batch['id'], ]); $this->db->transComplete(); if ($this->db->transStatus() === false) { throw new RuntimeException('Unable to complete closing.'); } } public function cancel(int $sourceYearId, ?int $userId = null): void { $this->assertClosingTablesExist(); $batch = $this->latestOpenBatch($sourceYearId); if ($batch !== null && in_array((string) $batch['status'], ['executed', 'completed'], true)) { throw new InvalidArgumentException('Closing cannot be cancelled after carry-forward has executed.'); } $this->db->transStart(); if ($batch !== null) { $this->batchModel->update((int) $batch['id'], ['status' => 'cancelled']); } $this->schoolYearModel->update($sourceYearId, [ 'status' => SchoolYearStatus::ACTIVE, 'updated_by' => $userId, ]); $this->managementService->log($sourceYearId, SchoolYearStatus::CLOSING, SchoolYearStatus::ACTIVE, 'closing_cancel', $userId, [ 'closing_batch_id' => $batch['id'] ?? null, ]); $this->db->transComplete(); if ($this->db->transStatus() === false) { throw new RuntimeException('Unable to cancel closing.'); } } public function latestBatch(int $sourceYearId): ?array { if (! $this->db->tableExists('school_year_closing_batches')) { return null; } return $this->batchModel ->where('source_school_year_id', $sourceYearId) ->orderBy('id', 'DESC') ->first(); } private function requireYear(int $id): array { $year = $this->schoolYearModel->find($id); if ($year === null) { throw new InvalidArgumentException('School year not found.'); } return $year; } private function nextDraftYear(string $sourceName): ?array { if (! preg_match('/^(\d{4})-(\d{4})$/', $sourceName, $matches)) { return null; } $nextName = $matches[2] . '-' . ((int) $matches[2] + 1); return $this->schoolYearModel ->where('name', $nextName) ->first(); } private function financialSummary(string $schoolYear): array { $summary = [ 'invoice_count' => 0, 'total_invoiced' => 0.0, 'total_paid' => 0.0, 'total_outstanding' => 0.0, 'positive_balance' => 0.0, 'credit_balance' => 0.0, ]; if (! $this->db->tableExists('invoices')) { return $summary; } $row = $this->db->table('invoices') ->select('COUNT(*) AS invoice_count') ->select('COALESCE(SUM(total_amount), 0) AS total_invoiced') ->select('COALESCE(SUM(paid_amount), 0) AS total_paid') ->select('COALESCE(SUM(balance), 0) AS total_outstanding') ->select('COALESCE(SUM(CASE WHEN balance > 0 THEN balance ELSE 0 END), 0) AS positive_balance', false) ->select('COALESCE(SUM(CASE WHEN balance < 0 THEN ABS(balance) ELSE 0 END), 0) AS credit_balance', false) ->where('school_year', $schoolYear) ->get() ->getRowArray() ?? []; foreach ($summary as $key => $value) { $summary[$key] = $key === 'invoice_count' ? (int) ($row[$key] ?? 0) : round((float) ($row[$key] ?? 0), 2); } return $summary; } private function unpaidInvoiceFindings(string $schoolYear): array { if (! $this->db->tableExists('invoices')) { return []; } $count = $this->db->table('invoices') ->where('school_year', $schoolYear) ->where('balance >', 0) ->countAllResults(); return $count > 0 ? [$this->finding('warning', 'Outstanding balances exist', "{$count} invoice(s) still have a positive balance and will require carry-forward review.")] : []; } private function carryForwardFamilies(string $schoolYear): array { if (! $this->db->tableExists('invoices')) { return []; } $rows = $this->db->table('invoices i') ->select('i.parent_id AS family_id') ->select('COALESCE(SUM(i.balance), 0) AS source_balance') ->where('i.school_year', $schoolYear) ->groupBy('i.parent_id') ->having('source_balance !=', 0) ->orderBy('i.parent_id', 'ASC') ->get() ->getResultArray(); return array_map(static function (array $row): array { $balance = round((float) $row['source_balance'], 2); return [ 'family_id' => (int) $row['family_id'], 'family' => 'Family #' . (int) $row['family_id'], 'source_balance' => $balance, 'credit_amount' => $balance < 0 ? abs($balance) : 0.0, 'adjustment_amount' => 0.0, 'carry_forward_amount' => $balance, ]; }, $rows); } private function countBySchoolYear(string $table, string $schoolYear, string $distinctField): int { if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) { return 0; } $row = $this->db->table($table) ->select("COUNT(DISTINCT {$distinctField}) AS total", false) ->where('school_year', $schoolYear) ->get() ->getRowArray(); return (int) ($row['total'] ?? 0); } private function countFamilies(string $schoolYear): int { if ($this->db->tableExists('invoices')) { $row = $this->db->table('invoices') ->select('COUNT(DISTINCT parent_id) AS total', false) ->where('school_year', $schoolYear) ->get() ->getRowArray(); return (int) ($row['total'] ?? 0); } return 0; } private function countMissingSchoolYearRows(string $table): int { if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) { return 0; } return $this->db->table($table) ->groupStart() ->where('school_year', null) ->orWhere('school_year', '') ->groupEnd() ->countAllResults(); } private function latestOpenBatch(int $sourceYearId): ?array { return $this->batchModel ->where('source_school_year_id', $sourceYearId) ->whereIn('status', ['started', 'executed']) ->orderBy('id', 'DESC') ->first(); } private function assertClosingTablesExist(): void { foreach (['school_year_closing_batches', 'school_year_closing_items', 'school_year_transition_logs'] as $table) { if (! $this->db->tableExists($table)) { throw new RuntimeException('School year lifecycle tables are missing. Run database migrations before closing a school year.'); } } } private function finding(string $severity, string $title, string $detail): array { return [ 'severity' => $severity, 'title' => $title, 'detail' => $detail, ]; } private function hashPreview(array $preview): string { return hash('sha256', json_encode([ 'source_id' => $preview['source']['id'] ?? null, 'target_id' => $preview['target']['id'] ?? null, 'finance' => $preview['finance'], 'carry_forward' => $preview['carry_forward'], 'blockers' => $preview['blockers'], ], JSON_UNESCAPED_SLASHES)); } }