431 lines
16 KiB
PHP
431 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\SchoolYearClosingBatchModel;
|
|
use App\Models\SchoolYearClosingItemModel;
|
|
use App\Models\SchoolYearModel;
|
|
use App\Support\SchoolYear\SchoolYearStatus;
|
|
use CodeIgniter\Database\BaseConnection;
|
|
use InvalidArgumentException;
|
|
use RuntimeException;
|
|
|
|
final class SchoolYearClosingService
|
|
{
|
|
public function __construct(
|
|
private readonly SchoolYearModel $schoolYearModel,
|
|
private readonly SchoolYearClosingBatchModel $batchModel,
|
|
private readonly SchoolYearClosingItemModel $itemModel,
|
|
private readonly SchoolYearManagementService $managementService,
|
|
private readonly BaseConnection $db,
|
|
) {
|
|
}
|
|
|
|
public function preview(int $sourceYearId, ?int $targetYearId = null): array
|
|
{
|
|
$source = $this->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));
|
|
}
|
|
}
|