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; } $promotion = $this->promotionPreview($sourceName, $target !== null ? (string) ($target['name'] ?? '') : null); if (($promotion['summary']['missing_decision'] ?? 0) > 0) { $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.' ); } 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.' ); } if (($promotion['summary']['missing_queue'] ?? 0) > 0) { $findings[] = $this->finding( 'warning', 'Passed students missing promotion queue', $promotion['summary']['missing_queue'] . ' passed student(s) are not queued for the target school year. This will not block closing.' ); } 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, 'promotion' => $promotion, '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.'); } $targetYearId = (int) ($batch['target_school_year_id'] ?? 0); $target = $targetYearId > 0 ? $this->schoolYearModel->find($targetYearId) : null; if ($target === null) { throw new InvalidArgumentException('A target school year is required before completing closing.'); } $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->schoolYearModel->update($targetYearId, [ 'status' => SchoolYearStatus::ACTIVE, 'previous_school_year_id' => $sourceYearId, 'activated_at' => $target['activated_at'] ?? $now, 'updated_by' => $userId, ]); $this->schoolYearModel->update($sourceYearId, [ 'next_school_year_id' => $targetYearId, ]); $targetName = (string) ($target['name'] ?? ''); $this->configurationModel->setConfigValueByKey('school_year', $targetName); $this->syncActiveYearSession($targetName); $this->managementService->log($sourceYearId, SchoolYearStatus::CLOSING, SchoolYearStatus::CLOSED, 'closing_complete', $userId, [ 'closing_batch_id' => (int) $batch['id'], 'activated_school_year_id' => $targetYearId, ]); $this->managementService->log($targetYearId, (string) ($target['status'] ?? SchoolYearStatus::DRAFT), SchoolYearStatus::ACTIVE, 'activate_after_closing', $userId, [ 'closed_school_year_id' => $sourceYearId, '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 []; } $builder = $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); if ($this->db->tableExists('users')) { $builder ->select('u.firstname AS parent_firstname, u.lastname AS parent_lastname, u.email AS parent_email') ->join('users u', 'u.id = i.parent_id', 'left') ->groupBy('i.parent_id, u.firstname, u.lastname, u.email'); } else { $builder->groupBy('i.parent_id'); } $rows = $builder ->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'], 'parent' => trim((string) ($row['parent_firstname'] ?? '') . ' ' . (string) ($row['parent_lastname'] ?? '')) ?: 'Parent #' . (int) $row['family_id'], 'parent_email' => (string) ($row['parent_email'] ?? ''), 'source_balance' => $balance, 'credit_amount' => $balance < 0 ? abs($balance) : 0.0, 'adjustment_amount' => 0.0, 'carry_forward_amount' => $balance, ]; }, $rows); } private function promotionPreview(string $schoolYear, ?string $targetSchoolYear): array { $summary = [ 'total_students' => 0, 'with_decision' => 0, 'missing_decision' => 0, 'pending_decision' => 0, 'pass' => 0, 'other_decision' => 0, 'queued' => 0, 'assigned' => 0, 'applied' => 0, 'missing_queue' => 0, ]; if (! $this->db->tableExists('student_class') || ! $this->db->tableExists('students')) { return [ 'summary' => $summary, 'rows' => [], ]; } $hasEventOnly = $this->db->fieldExists('is_event_only', 'student_class'); $hasActive = $this->db->fieldExists('is_active', 'students'); $hasDob = $this->db->fieldExists('dob', 'students'); $hasRegistrationGrade = $this->db->fieldExists('registration_grade', 'students'); $builder = $this->db->table('student_class sc') ->select('sc.student_id, sc.class_section_id, sc.created_at, sc.updated_at') ->select('s.school_id, s.firstname, s.lastname') ->select('cs.class_section_name') ->join('students s', 's.id = sc.student_id', 'inner') ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') ->where('sc.school_year', $schoolYear) ->where('sc.class_section_id IS NOT NULL', null, false); if ($hasDob) { $builder->select('s.dob'); } if ($hasRegistrationGrade) { $builder->select('s.registration_grade'); } if ($hasEventOnly) { $builder->groupStart() ->where('sc.is_event_only', 0) ->orWhere('sc.is_event_only', null) ->groupEnd(); } if ($hasActive) { $builder->where('s.is_active', 1); } $assignmentRows = $builder ->orderBy('sc.student_id', 'ASC') ->orderBy('sc.updated_at', 'DESC') ->orderBy('sc.created_at', 'DESC') ->get() ->getResultArray(); $students = []; foreach ($assignmentRows as $row) { $studentId = (int) ($row['student_id'] ?? 0); if ($studentId <= 0 || isset($students[$studentId])) { continue; } $students[$studentId] = [ 'student_id' => $studentId, 'school_id' => (string) ($row['school_id'] ?? ''), 'student_name' => trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')), 'class_section_id' => (int) ($row['class_section_id'] ?? 0), 'class_section_name' => (string) ($row['class_section_name'] ?? ''), 'dob' => (string) ($row['dob'] ?? ''), 'registration_grade' => (string) ($row['registration_grade'] ?? ''), 'auto_kg_pass' => false, 'year_score' => null, 'decision' => '', 'source' => 'missing', 'notes' => '', 'status' => 'missing', 'queue_status' => '', 'target_class' => '', 'target_section' => '', ]; } if ($students === []) { return [ 'summary' => $summary, 'rows' => [], ]; } $decisions = $this->promotionDecisionRows(array_keys($students), $schoolYear); $queueRows = $this->promotionQueueRows(array_keys($students), $schoolYear, $targetSchoolYear); foreach ($students as $studentId => &$student) { $decision = $decisions[$studentId] ?? null; if ($decision !== null) { $student['year_score'] = $decision['year_score']; $student['decision'] = $decision['decision']; $student['source'] = $decision['source']; $student['notes'] = $decision['notes']; $student['status'] = $decision['status']; if (($decision['class_section_name'] ?? '') !== '') { $student['class_section_name'] = $decision['class_section_name']; } } if ($decision === null && $this->isKgStudent($student)) { $kgAgeStatus = $this->kgAgeStatusByTargetYearCutoff((string) $student['dob'], $targetSchoolYear); if ($kgAgeStatus === 'pass') { $student['decision'] = 'Pass'; $student['source'] = 'automatic_kg_age'; $student['notes'] = 'Auto-pass KG student: age 6 or older by Dec 31 of the next school year start year.'; $student['status'] = 'decided'; $student['auto_kg_pass'] = true; } elseif ($kgAgeStatus === 'keep_kg') { $student['decision'] = 'Keep KG'; $student['source'] = 'automatic_kg_age'; $student['notes'] = 'Auto-keep KG student: younger than 6 by Dec 31 of the next school year start year.'; $student['status'] = 'decided'; } } $queue = $queueRows[$studentId] ?? null; if ($queue !== null) { $student['queue_status'] = $queue['status']; $student['target_class'] = $queue['target_class']; $student['target_section'] = $queue['target_section']; } $summary['total_students']++; if ($student['status'] === 'missing') { $summary['missing_decision']++; } elseif ($student['status'] === 'pending') { $summary['pending_decision']++; } else { $summary['with_decision']++; if (strcasecmp((string) $student['decision'], 'Pass') === 0) { $summary['pass']++; if ($queue === null && $student['auto_kg_pass'] !== true) { $summary['missing_queue']++; } } else { $summary['other_decision']++; } } if ($queue !== null && isset($summary[$queue['status']])) { $summary[$queue['status']]++; } } unset($student); $rows = array_values($students); usort($rows, static function (array $a, array $b): int { return [$a['class_section_name'], $a['student_name'], $a['student_id']] <=> [$b['class_section_name'], $b['student_name'], $b['student_id']]; }); return [ 'summary' => $summary, 'rows' => $rows, ]; } private function isKgStudent(array $student): bool { foreach (['class_section_name', 'registration_grade'] as $field) { $value = strtoupper(trim((string) ($student[$field] ?? ''))); if ($value === 'KG' || str_starts_with($value, 'KG-') || str_contains($value, 'KINDERGARTEN')) { return true; } } return false; } private function kgAgeStatusByTargetYearCutoff(string $dob, ?string $targetSchoolYear): string { $dob = trim($dob); if ($dob === '' || $targetSchoolYear === null || ! preg_match('/^(\d{4})-\d{4}$/', $targetSchoolYear, $matches)) { return ''; } try { $birthDate = new \DateTimeImmutable($dob); $cutoff = new \DateTimeImmutable($matches[1] . '-12-31'); } catch (\Throwable) { return ''; } if ($birthDate > $cutoff) { return ''; } $age = $birthDate->diff($cutoff)->y; if ($age >= 6) { return 'pass'; } return 'keep_kg'; } private function promotionDecisionRows(array $studentIds, string $schoolYear): array { if (! $this->db->tableExists('student_decisions')) { return []; } $select = ['student_id', 'class_section_name', 'decision', 'source', 'notes']; $scoreField = null; if ($this->db->fieldExists('year_score', 'student_decisions')) { $scoreField = 'year_score'; } elseif ($this->db->fieldExists('semester_score', 'student_decisions')) { $scoreField = 'semester_score'; } if ($scoreField !== null) { $select[] = $scoreField . ' AS year_score'; } $rows = $this->db->table('student_decisions') ->select($select) ->where('school_year', $schoolYear) ->whereIn('student_id', $studentIds) ->orderBy('updated_at', 'DESC') ->orderBy('id', 'DESC') ->get() ->getResultArray(); $decisions = []; foreach ($rows as $row) { $studentId = (int) ($row['student_id'] ?? 0); if ($studentId <= 0 || isset($decisions[$studentId])) { continue; } $decision = trim((string) ($row['decision'] ?? '')); $source = trim((string) ($row['source'] ?? '')); $status = $decision === '' || $source === 'pending' ? 'pending' : 'decided'; $decisions[$studentId] = [ 'class_section_name' => (string) ($row['class_section_name'] ?? ''), 'year_score' => is_numeric($row['year_score'] ?? null) ? round((float) $row['year_score'], 2) : null, 'decision' => $decision, 'source' => $source !== '' ? $source : ($status === 'pending' ? 'pending' : 'manual'), 'notes' => (string) ($row['notes'] ?? ''), 'status' => $status, ]; } return $decisions; } private function promotionQueueRows(array $studentIds, string $sourceSchoolYear, ?string $targetSchoolYear): array { if ( $targetSchoolYear === null || $targetSchoolYear === '' || ! $this->db->tableExists('promotion_queue') ) { return []; } $builder = $this->db->table('promotion_queue pq') ->select('pq.student_id, pq.to_class_id, pq.to_class_section_id, pq.status') ->select('c.class_name AS target_class') ->select('cs.class_section_name AS target_section') ->join('classes c', 'c.id = pq.to_class_id', 'left') ->join('classSection cs', 'cs.class_section_id = pq.to_class_section_id', 'left') ->where('pq.school_year_from', $sourceSchoolYear) ->where('pq.school_year_to', $targetSchoolYear) ->whereIn('pq.student_id', $studentIds) ->orderBy('pq.updated_at', 'DESC') ->orderBy('pq.id', 'DESC'); $rows = $builder->get()->getResultArray(); $queueRows = []; foreach ($rows as $row) { $studentId = (int) ($row['student_id'] ?? 0); if ($studentId <= 0 || isset($queueRows[$studentId])) { continue; } $targetClass = trim((string) ($row['target_class'] ?? '')); if ($targetClass === '' && (int) ($row['to_class_id'] ?? 0) > 0) { $targetClass = 'Class #' . (int) $row['to_class_id']; } $targetSection = trim((string) ($row['target_section'] ?? '')); if ($targetSection === '' && (int) ($row['to_class_section_id'] ?? 0) > 0) { $targetSection = 'Section #' . (int) $row['to_class_section_id']; } $queueRows[$studentId] = [ 'status' => (string) ($row['status'] ?? ''), 'target_class' => $targetClass, 'target_section' => $targetSection, ]; } return $queueRows; } 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'], 'promotion' => $preview['promotion'], 'carry_forward' => $preview['carry_forward'], 'blockers' => $preview['blockers'], ], JSON_UNESCAPED_SLASHES)); } private function syncActiveYearSession(string $schoolYearName): void { if (session_status() !== PHP_SESSION_ACTIVE) { return; } session()->set('school_year', $schoolYearName); session()->remove('selected_school_year_id'); session()->remove('selected_school_year'); } }