resolver->ensureCurrentTracked(); return SchoolYear::query() ->orderByDesc('start_date') ->orderByDesc('id') ->get() ->map(fn (SchoolYear $year) => $this->presentSchoolYear($year)) ->all(); } public function createYear(array $payload, ?int $actorId = null): array { $name = trim((string) ($payload['name'] ?? '')); $startDate = (string) ($payload['start_date'] ?? ''); $endDate = (string) ($payload['end_date'] ?? ''); $errors = []; if ($name === '') { $errors[] = 'School year name is required.'; } if ($startDate === '' || $endDate === '') { $errors[] = 'Start date and end date are required.'; } if ($startDate !== '' && $endDate !== '' && $startDate >= $endDate) { $errors[] = 'Start date must be before end date.'; } if (SchoolYear::query()->where('name', $name)->exists()) { $errors[] = sprintf('School year %s already exists.', $name); } if ($this->hasDateOverlap($startDate, $endDate)) { $errors[] = 'School year dates overlap an existing year.'; } if ($errors !== []) { throw ValidationException::withMessages(['school_year' => $errors]); } $year = SchoolYear::query()->create([ 'name' => $name, 'start_date' => $startDate, 'end_date' => $endDate, 'status' => SchoolYear::STATUS_DRAFT, 'is_current' => false, ]); $this->logAudit( $actorId, 'school_year_created', 'school_years', (int) $year->id, null, $year->toArray(), ['mode' => 'draft'] ); return $this->presentSchoolYear($year); } public function updateYear(int $id, array $payload, ?int $actorId = null): array { $year = $this->findOrFail($id); $oldState = $year->toArray(); $name = trim((string) ($payload['name'] ?? $year->name)); $startDate = (string) ($payload['start_date'] ?? optional($year->start_date)->toDateString()); $endDate = (string) ($payload['end_date'] ?? optional($year->end_date)->toDateString()); $errors = []; if ($startDate === '' || $endDate === '') { $errors[] = 'Start date and end date are required.'; } if ($startDate !== '' && $endDate !== '' && $startDate >= $endDate) { $errors[] = 'Start date must be before end date.'; } if ($name === '') { $errors[] = 'School year name is required.'; } if (SchoolYear::query()->where('name', $name)->where('id', '!=', $year->id)->exists()) { $errors[] = sprintf('School year %s already exists.', $name); } if ($this->hasDateOverlap($startDate, $endDate, (int) $year->id)) { $errors[] = 'School year dates overlap an existing year.'; } if ($name !== $year->name && $this->schoolYearHasOperationalRecords($year->name)) { $errors[] = 'This school year name is already referenced by operational records and cannot be renamed safely.'; } if ($errors !== []) { throw ValidationException::withMessages(['school_year' => $errors]); } $year->name = $name; $year->start_date = $startDate; $year->end_date = $endDate; $year->save(); if ($year->is_current) { Configuration::setConfigValueByKey('school_year', $year->name); } $this->logAudit( $actorId, 'school_year_updated', 'school_years', (int) $year->id, $oldState, $year->fresh()?->toArray(), null ); return $this->presentSchoolYear($year->fresh()); } public function summary(int $id): array { $year = $this->findOrFail($id); $balances = $this->parentBalances($id); $promotions = $this->promotionPreview($id); return [ 'school_year' => $this->presentSchoolYear($year), 'totals' => [ 'students_considered' => $promotions['summary']['total_students'], 'students_blocked' => $promotions['summary']['hold'], 'parents_with_balances' => $balances['summary']['parents_with_positive_balance'], 'parents_with_positive_balance' => $balances['summary']['parents_with_positive_balance'], 'parents_with_credit_balance' => $balances['summary']['parents_with_credit_balance'], 'total_positive_unpaid_balance' => $balances['summary']['total_positive_unpaid_balance'], 'total_credit_balance' => $balances['summary']['total_credit_balance'], 'net_balance_to_transfer' => $balances['summary']['net_balance_impact'], 'net_balance_impact' => $balances['summary']['net_balance_impact'], ], ]; } public function summaryByYearName(string $schoolYear): array { return $this->summary((int) $this->findByNameOrFail($schoolYear)->id); } public function closingReport(int $id): array { $year = $this->findOrFail($id); $audit = $this->latestClosureAudit((int) $year->id); $metadata = is_array($audit?->metadata) ? $audit->metadata : []; $newYearName = trim((string) ($metadata['new_school_year'] ?? '')); if ($newYearName === '') { $newYearName = $this->resolveClosedTargetYearName($year->name); } $newYear = $newYearName !== '' ? SchoolYear::query()->where('name', $newYearName)->first() : null; $promotionCounts = is_array($metadata['promotion_counts'] ?? null) ? $metadata['promotion_counts'] : []; $transferTotals = $this->transferTotalsForClosedYear($year->name, $newYearName !== '' ? $newYearName : null); $invoiceBalanceTotals = $this->positiveInvoiceBalanceTotals($year->name); $warnings = []; if (! $audit) { $warnings[] = 'No closure audit log was found for this school year. Totals were reconstructed from current records where possible.'; } if ($year->status !== SchoolYear::STATUS_CLOSED) { $warnings[] = sprintf('School year %s is currently %s, so this is a live preview rather than a final closure report.', $year->name, $year->status); } if ($newYearName !== '' && ! $newYear) { $warnings[] = sprintf('The closure target school year %s was referenced but no matching school_years row was found.', $newYearName); } return [ 'old_school_year' => $this->presentSchoolYear($year), 'new_school_year' => $this->presentSchoolYear($newYear), 'closed_by' => $this->resolveUserName((int) ($year->closed_by ?? $audit?->user_id ?? 0)), 'closed_at' => optional($year->closed_at ?? $audit?->created_at)->toDateTimeString(), 'audit_reference' => $audit ? sprintf('audit_logs:%d', (int) $audit->id) : null, 'totals' => [ 'students_promoted' => (int) ($promotionCounts['promote'] ?? 0), 'students_repeated' => (int) ($promotionCounts['repeat'] ?? 0), 'students_graduated' => (int) ($promotionCounts['graduate'] ?? 0), 'students_transferred' => (int) ($promotionCounts['transfer'] ?? 0), 'students_withdrawn' => (int) ($promotionCounts['withdraw'] ?? 0), 'parents_with_unpaid_balances' => $invoiceBalanceTotals['parents_with_unpaid_balances'], 'total_unpaid_balance_transferred' => $invoiceBalanceTotals['total_unpaid_balance'], 'parents_with_credit_balances' => $transferTotals['parents_with_credit_balances'], 'total_credit_carried_or_reported' => $transferTotals['total_credit_carried_or_reported'], 'total_credit_transferred' => $transferTotals['total_credit_carried_or_reported'], 'net_balance_impact' => round( $invoiceBalanceTotals['total_unpaid_balance'] - $transferTotals['total_credit_carried_or_reported'], 2 ), ], 'warnings' => $warnings, ]; } public function closingReportByYearName(string $schoolYear): array { return $this->closingReport((int) $this->findByNameOrFail($schoolYear)->id); } public function validateClose(int $id, array $payload): array { $year = $this->findOrFail($id); $newYear = $payload['new_school_year'] ?? []; $targetName = trim((string) ($newYear['name'] ?? $this->resolver->suggestNextName($year->name) ?? '')); $promotionPreview = $this->buildPromotionPreview($year->name, $targetName); $balances = $this->buildParentBalanceRows($year->name, $targetName); $errors = []; $warnings = []; if ($year->status !== SchoolYear::STATUS_ACTIVE) { $errors[] = sprintf('Only an active school year can be closed. %s is currently %s.', $year->name, $year->status); } $errors = array_merge($errors, $this->validateNewYearPayload($year, $newYear)); if ($promotionPreview['summary']['hold'] > 0) { $errors[] = sprintf('%d students are missing promotion decisions or target placement.', $promotionPreview['summary']['hold']); } $pendingPayments = $this->countPendingPayments($year->name); if ($pendingPayments > 0) { $errors[] = sprintf('%d payments are not posted or still pending.', $pendingPayments); } $draftInvoices = $this->countDraftInvoices($year->name); if ($draftInvoices > 0) { $errors[] = sprintf('%d invoices are still in draft status.', $draftInvoices); } $invalidInvoices = $this->countFinancialInconsistencies($year->name); if ($invalidInvoices > 0) { $errors[] = sprintf('%d invoices have inconsistent amount, paid amount, and balance values.', $invalidInvoices); } $duplicateAccounts = $this->countDuplicateParentAccounts(); if ($duplicateAccounts > 0) { $errors[] = sprintf('%d duplicate parent account rows exist.', $duplicateAccounts); } if ($balances['summary']['existing_transfer_conflicts'] > 0) { $errors[] = sprintf( '%d parent balance transfers already exist for the requested year transition.', $balances['summary']['existing_transfer_conflicts'] ); } if ($balances['summary']['parents_with_credit_balance'] > 0) { $warnings[] = sprintf( '%d parents have a credit balance that will be carried into the new year.', $balances['summary']['parents_with_credit_balance'] ); } return [ 'can_close' => $errors === [], 'errors' => $errors, 'warnings' => $warnings, 'promotion_summary' => $promotionPreview['summary'], 'financial_summary' => $balances['summary'], ]; } public function validateCloseByYearName(string $schoolYear, array $payload): array { return $this->validateClose((int) $this->findByNameOrFail($schoolYear)->id, $payload); } public function previewClose(int $id, array $payload): array { $year = $this->findOrFail($id); $newYear = $payload['new_school_year'] ?? []; $targetName = trim((string) ($newYear['name'] ?? $this->resolver->suggestNextName($year->name) ?? '')); return [ 'school_year' => $this->presentSchoolYear($year), 'validation' => $this->validateClose($id, $payload), 'promotion_preview' => $this->buildPromotionPreview($year->name, $targetName), 'parent_balances' => $this->buildParentBalanceRows($year->name, $targetName), ]; } public function previewCloseByYearName(string $schoolYear, array $payload): array { return $this->previewClose((int) $this->findByNameOrFail($schoolYear)->id, $payload); } public function close(int $id, array $payload, ?int $actorId): array { $year = $this->findOrFail($id); $validation = $this->validateClose($id, $payload); if (! $validation['can_close']) { throw ValidationException::withMessages(['school_year' => $validation['errors']]); } $confirmation = trim((string) ($payload['confirmation'] ?? '')); $expected = sprintf('CLOSE %s', $year->name); if ($confirmation !== $expected) { throw ValidationException::withMessages([ 'confirmation' => [sprintf('Confirmation must exactly match "%s".', $expected)], ]); } $newYearPayload = $payload['new_school_year']; $promotionPreview = $this->buildPromotionPreview($year->name, $newYearPayload['name']); $balances = ! empty($payload['transfer_unpaid_balances']) ? $this->buildParentBalanceRows($year->name, $newYearPayload['name']) : ['rows' => [], 'summary' => ['net_balance_to_transfer' => 0]]; return DB::transaction(function () use ($year, $payload, $actorId, $newYearPayload, $promotionPreview, $balances) { $newYear = SchoolYear::query()->firstOrCreate( ['name' => $newYearPayload['name']], [ 'start_date' => $newYearPayload['start_date'], 'end_date' => $newYearPayload['end_date'], 'status' => SchoolYear::STATUS_DRAFT, 'is_current' => false, ] ); if ($newYear->status === SchoolYear::STATUS_CLOSED) { throw new RuntimeException('The requested new school year already exists as closed and cannot be activated.'); } SchoolYear::query()->where('is_current', true)->update([ 'is_current' => false, 'updated_at' => now(), ]); $oldState = $year->toArray(); $year->status = SchoolYear::STATUS_CLOSED; $year->is_current = false; $year->closed_at = now(); $year->closed_by = $actorId; $year->save(); $newYear->start_date = $newYearPayload['start_date']; $newYear->end_date = $newYearPayload['end_date']; $newYear->status = SchoolYear::STATUS_ACTIVE; $newYear->is_current = true; $newYear->closed_at = null; $newYear->closed_by = null; $newYear->save(); Configuration::setConfigValueByKey('school_year', $newYear->name); $promotionCounts = $this->applyPromotions($promotionPreview['rows'], $newYear->name, $newYear->start_date, $actorId); $balanceCounts = ! empty($payload['transfer_unpaid_balances']) ? $this->applyBalanceTransfers($balances['rows'], $year->name, $newYear->name, $actorId) : [ 'transferred_parents' => 0, 'transferred_amount' => 0.0, 'credit_parents' => 0, 'credit_amount' => 0.0, ]; $this->logAudit( $actorId, 'school_year_closed', 'school_years', (int) $year->id, $oldState, $year->fresh()?->toArray(), [ 'new_school_year' => $newYear->name, 'promotion_counts' => $promotionCounts, 'balance_counts' => $balanceCounts, ] ); return [ 'closed_school_year' => $this->presentSchoolYear($year->fresh()), 'new_school_year' => $this->presentSchoolYear($newYear->fresh()), 'promotion_counts' => $promotionCounts, 'balance_counts' => $balanceCounts, ]; }); } public function closeByYearName(string $schoolYear, array $payload, ?int $actorId): array { return $this->close((int) $this->findByNameOrFail($schoolYear)->id, $payload, $actorId); } public function reopen(int $id, ?int $actorId): array { $year = $this->findOrFail($id); return DB::transaction(function () use ($year, $actorId) { $currentActive = SchoolYear::query()->where('is_current', true)->where('id', '!=', $year->id)->first(); if ($currentActive) { $currentActive->status = SchoolYear::STATUS_DRAFT; $currentActive->is_current = false; $currentActive->save(); } $oldState = $year->toArray(); $year->status = SchoolYear::STATUS_ACTIVE; $year->is_current = true; $year->closed_at = null; $year->closed_by = null; $year->save(); Configuration::setConfigValueByKey('school_year', $year->name); $this->logAudit( $actorId, 'school_year_reopened', 'school_years', (int) $year->id, $oldState, $year->fresh()?->toArray(), ['previous_active_year' => $currentActive?->name] ); return $this->presentSchoolYear($year->fresh()); }); } public function reopenByYearName(string $schoolYear, ?int $actorId): array { return $this->reopen((int) $this->findByNameOrFail($schoolYear)->id, $actorId); } public function parentBalances(int $id, ?string $toSchoolYear = null): array { $year = $this->findOrFail($id); return $this->buildParentBalanceRows( $year->name, $toSchoolYear ?: ($this->resolver->suggestNextName($year->name) ?? $year->name), false ); } public function parentBalancesByYearName(string $schoolYear, ?string $toSchoolYear = null): array { return $this->parentBalances((int) $this->findByNameOrFail($schoolYear)->id, $toSchoolYear); } public function promotionPreview(int $id, ?string $toSchoolYear = null): array { $year = $this->findOrFail($id); return $this->buildPromotionPreview( $year->name, $toSchoolYear ?: ($this->resolver->suggestNextName($year->name) ?? $year->name) ); } public function promotionPreviewByYearName(string $schoolYear, ?string $toSchoolYear = null): array { return $this->promotionPreview((int) $this->findByNameOrFail($schoolYear)->id, $toSchoolYear); } public function findOrFail(int $id): SchoolYear { $this->resolver->ensureCurrentTracked(); return SchoolYear::query()->findOrFail($id); } public function findByNameOrFail(string $schoolYear): SchoolYear { $this->resolver->ensureCurrentTracked(); return SchoolYear::query()->where('name', trim($schoolYear))->firstOrFail(); } private function validateNewYearPayload(SchoolYear $currentYear, array $newYear): array { $errors = []; $name = trim((string) ($newYear['name'] ?? '')); $startDate = $newYear['start_date'] ?? null; $endDate = $newYear['end_date'] ?? null; if ($name === '') { $errors[] = 'New school year name is required.'; } if (! $startDate || ! $endDate) { $errors[] = 'New school year start_date and end_date are required.'; return $errors; } if ($startDate >= $endDate) { $errors[] = 'New school year start date must be before end date.'; } $duplicateName = SchoolYear::query() ->where('name', $name) ->where('id', '!=', $currentYear->id) ->exists(); if ($duplicateName) { $errors[] = sprintf('School year %s already exists.', $name); } $overlap = $this->hasDateOverlap($startDate, $endDate, (int) $currentYear->id); if ($overlap) { $errors[] = 'New school year dates overlap an existing school year.'; } $otherActive = SchoolYear::query() ->where('status', SchoolYear::STATUS_ACTIVE) ->where('id', '!=', $currentYear->id) ->exists(); if ($otherActive) { $errors[] = 'Another active school year already exists.'; } return $errors; } private function hasDateOverlap(string $startDate, string $endDate, ?int $ignoreId = null): bool { if ($startDate === '' || $endDate === '') { return false; } return SchoolYear::query() ->when($ignoreId !== null, fn ($query) => $query->where('id', '!=', $ignoreId)) ->whereDate('start_date', '<=', $endDate) ->whereDate('end_date', '>=', $startDate) ->exists(); } private function schoolYearHasOperationalRecords(string $schoolYear): bool { $tables = [ ['table' => 'invoices', 'column' => 'school_year'], ['table' => 'enrollments', 'column' => 'school_year'], ['table' => 'student_class', 'column' => 'school_year'], ['table' => 'payments', 'column' => 'school_year'], ['table' => 'student_decisions', 'column' => 'school_year'], ['table' => 'classSection', 'column' => 'school_year'], ['table' => 'classes', 'column' => 'school_year'], ]; foreach ($tables as $mapping) { if (! Schema::hasTable($mapping['table'])) { continue; } if (DB::table($mapping['table'])->where($mapping['column'], $schoolYear)->exists()) { return true; } } return false; } private function buildPromotionPreview(string $currentYear, string $targetYear): array { $studentIds = collect(DB::table('student_class')->where('school_year', $currentYear)->pluck('student_id')) ->merge(DB::table('enrollments')->where('school_year', $currentYear)->pluck('student_id')) ->map(fn ($id) => (int) $id) ->unique() ->values(); $students = DB::table('students') ->whereIn('id', $studentIds->all()) ->get() ->keyBy('id'); $assignments = DB::table('student_class') ->where('school_year', $currentYear) ->orderByDesc('updated_at') ->orderByDesc('id') ->get(); $assignmentByStudent = []; foreach ($assignments as $assignment) { $studentId = (int) $assignment->student_id; if (! isset($assignmentByStudent[$studentId])) { $assignmentByStudent[$studentId] = $assignment; } } $classSections = DB::table('classSection')->get()->keyBy('class_section_id'); $classes = DB::table('classes')->get()->keyBy('id'); $enrollments = DB::table('enrollments') ->where('school_year', $currentYear) ->orderByDesc('updated_at') ->orderByDesc('id') ->get(); $enrollmentByStudent = []; foreach ($enrollments as $enrollment) { $studentId = (int) $enrollment->student_id; if (! isset($enrollmentByStudent[$studentId])) { $enrollmentByStudent[$studentId] = $enrollment; } } $decisions = Schema::hasTable('student_decisions') ? DB::table('student_decisions') ->where('school_year', $currentYear) ->orderByDesc('updated_at') ->orderByDesc('id') ->get() : collect(); $decisionByStudent = []; foreach ($decisions as $decision) { $studentId = (int) $decision->student_id; if (! isset($decisionByStudent[$studentId])) { $decisionByStudent[$studentId] = $decision; } } $rows = []; $summary = [ 'total_students' => 0, 'promote' => 0, 'repeat' => 0, 'graduate' => 0, 'transfer' => 0, 'withdraw' => 0, 'hold' => 0, ]; foreach ($studentIds as $studentId) { $student = $students->get($studentId); if (! $student) { continue; } $assignment = $assignmentByStudent[$studentId] ?? null; $enrollment = $enrollmentByStudent[$studentId] ?? null; $decision = $decisionByStudent[$studentId] ?? null; $section = $assignment ? $classSections->get($assignment->class_section_id) : null; $currentClass = $section ? $classes->get($section->class_id) : null; $progression = $assignment ? $this->progression->resolveByCurrentClassSectionId((int) $assignment->class_section_id) : null; $decisionText = strtolower(trim((string) ($decision->decision ?? ''))); $score = $decision && isset($decision->year_score) ? (float) $decision->year_score : null; $enrollmentStatus = strtolower(trim((string) ($enrollment->enrollment_status ?? ''))); $warnings = []; $action = 'hold'; if (in_array($enrollmentStatus, ['withdrawn', 'refund pending', 'withdraw under review', 'denied'], true)) { $action = 'withdraw'; } elseif (str_contains($decisionText, 'transfer')) { $action = 'transfer'; } elseif (str_contains($decisionText, 'withdraw')) { $action = 'withdraw'; } elseif (str_contains($decisionText, 'graduat')) { $action = 'graduate'; } elseif (in_array($decisionText, ['retained', 'repeat', 'repeated', 'not promoted'], true) || ($score !== null && $score < 60)) { $action = 'repeat'; } elseif ($this->isPassingSeventeenYearOld($student, $decisionText, $score)) { $action = 'graduate'; } elseif (($progression['is_terminal'] ?? false) === true) { $action = 'graduate'; } elseif ($decisionText !== '' || $score !== null) { $action = 'promote'; } $targetLevelId = null; $targetLevelName = null; if ($action === 'repeat') { $targetLevelId = $progression['current_level_id'] ?? ($section->class_id ?? null); $targetLevelName = $progression['current_level_name'] ?? ($currentClass->class_name ?? null); } elseif ($action === 'promote') { $targetLevelId = $progression['next_level_id'] ?? null; $targetLevelName = $progression['next_level_name'] ?? null; if ($this->isKindergartenAgeReadyForGradeOne($student, $currentClass, $section)) { [$targetLevelId, $targetLevelName] = $this->resolveGradeOneTarget(); } if ($targetLevelId === null) { $action = 'hold'; $warnings[] = 'No next grade mapping exists for this student.'; } } if ($action === 'hold' && $warnings === []) { $warnings[] = 'Missing promotion decision or score.'; } [$targetClassSectionId, $targetClassSectionName] = $this->resolveTargetSection( $targetLevelId, $targetYear ); $row = [ 'student_id' => $studentId, 'student_name' => trim((string) ($student->firstname ?? '').' '.(string) ($student->lastname ?? '')), 'parent_id' => (int) ($student->parent_id ?? 0) ?: null, 'current_grade' => $progression['current_level_name'] ?? ($currentClass->class_name ?? null), 'current_class' => $section->class_section_name ?? null, 'promotion_action' => $action, 'target_grade' => $targetLevelName, 'target_class' => $targetClassSectionName, 'target_class_section_id' => $targetClassSectionId, 'warnings' => $warnings, 'decision' => $decision->decision ?? null, 'score' => $score, ]; $rows[] = $row; $summary['total_students']++; $summary[$action]++; } usort($rows, static fn (array $a, array $b) => strcmp($a['student_name'], $b['student_name'])); return [ 'current_school_year' => $currentYear, 'target_school_year' => $targetYear, 'rows' => $rows, 'summary' => $summary, ]; } private function isPassingSeventeenYearOld(object $student, string $decisionText, ?float $score): bool { $age = (int) ($student->age ?? 0); if ($age !== 17) { return false; } if ($score !== null) { return $score >= 60; } return str_contains($decisionText, 'promot') || str_contains($decisionText, 'pass'); } private function isKindergartenAgeReadyForGradeOne(object $student, ?object $currentClass, ?object $section): bool { $age = (int) ($student->age ?? 0); if ($age + 1 < 6) { return false; } $className = strtolower(trim((string) ($currentClass->class_name ?? ''))); $sectionName = strtolower(trim((string) ($section->class_section_name ?? ''))); return in_array($className, ['kg', 'kindergarten'], true) || in_array($sectionName, ['kg', 'kindergarten'], true); } private function resolveGradeOneTarget(): array { $target = DB::table('classes') ->whereIn(DB::raw('LOWER(class_name)'), ['1', 'grade 1', 'class 1']) ->orderByRaw("CASE WHEN class_name = '1' THEN 0 ELSE 1 END") ->orderBy('id') ->first(); return $target ? [(int) $target->id, (string) $target->class_name] : [null, null]; } private function buildParentBalanceRows(string $fromSchoolYear, string $toSchoolYear, bool $includeCreditOnlyRows = true): array { $invoiceQuery = $includeCreditOnlyRows ? $this->eligibleCarryoverInvoiceQuery($fromSchoolYear) : DB::table('invoices')->where('school_year', $fromSchoolYear); if ($includeCreditOnlyRows) { $invoiceQuery->whereRaw('ABS(COALESCE(balance, 0)) > 0.01'); } else { $invoiceQuery->whereRaw('COALESCE(balance, 0) > 0.01'); } $invoices = $invoiceQuery->get(['id', 'parent_id', 'balance']); $balancesByParent = []; foreach ($invoices as $invoice) { $parentId = (int) ($invoice->parent_id ?? 0); if ($parentId <= 0) { continue; } $balance = round((float) ($invoice->balance ?? 0), 2); $balancesByParent[$parentId] ??= [ 'positive_unpaid_balance' => 0.0, 'credit_balance' => 0.0, 'positive_invoice_ids' => [], 'credit_invoice_ids' => [], ]; if ($balance > 0.01) { $balancesByParent[$parentId]['positive_unpaid_balance'] += $balance; $balancesByParent[$parentId]['positive_invoice_ids'][] = (int) $invoice->id; } elseif ($balance < -0.01) { $balancesByParent[$parentId]['credit_balance'] += abs($balance); $balancesByParent[$parentId]['credit_invoice_ids'][] = (int) $invoice->id; } } $rows = []; $summary = [ 'parents_with_positive_balance' => 0, 'parents_with_credit_balance' => 0, 'parents_with_net_non_zero_balance' => 0, 'existing_transfer_conflicts' => 0, 'total_positive_unpaid_balance' => 0.0, 'total_credit_balance' => 0.0, 'net_balance_impact' => 0.0, // Deprecated aliases retained for older UI/tests during the migration. 'parents_with_non_zero_balance' => 0, 'total_old_unpaid_balance' => 0.0, 'net_balance_to_transfer' => 0.0, ]; foreach ($balancesByParent as $parentId => $balanceRow) { $positive = round((float) $balanceRow['positive_unpaid_balance'], 2); $credit = round((float) $balanceRow['credit_balance'], 2); $net = round($positive - $credit, 2); if ($positive < 0.01 && $credit < 0.01) { continue; } $existing = $positive > 0.01 ? ParentBalanceTransfer::query() ->where('parent_id', $parentId) ->where('from_school_year', $fromSchoolYear) ->where('to_school_year', $toSchoolYear) ->first() : null; if (! $includeCreditOnlyRows && $positive < 0.01) { if ($credit > 0.01) { $summary['parents_with_credit_balance']++; $summary['total_credit_balance'] += $credit; } if (abs($net) > 0.01) { $summary['parents_with_net_non_zero_balance']++; } continue; } $row = [ 'parent_id' => $parentId, 'parent_name' => $this->resolveParentName($parentId), 'student_names' => $this->resolveStudentNames($parentId, $fromSchoolYear), 'unpaid_invoice_count' => count($balanceRow['positive_invoice_ids']), 'positive_unpaid_balance' => $positive, 'old_unpaid_balance' => $positive, 'old_unpaid_amount' => $positive, 'credit_balance' => $credit, 'net_balance' => $net, 'amount_to_transfer' => $positive, 'old_school_year' => $fromSchoolYear, 'new_school_year' => $toSchoolYear, 'new_old_balance_invoice_id' => $existing?->new_invoice_id ?? $existing?->old_balance_invoice_id ?? null, 'transfer_date' => optional($existing?->created_at)->toDateTimeString(), 'created_by' => $existing?->created_by, 'transfer_status' => $existing?->status ?? ($positive > 0.01 ? 'pending' : 'credit_reported'), 'existing_transfer_id' => $existing?->id, 'existing_transfer_conflict' => $existing !== null, 'positive_invoice_ids' => $balanceRow['positive_invoice_ids'], 'credit_invoice_ids' => $balanceRow['credit_invoice_ids'], 'source_invoice_ids' => $balanceRow['positive_invoice_ids'], ]; $rows[] = $row; if ($positive > 0.01) { $summary['parents_with_positive_balance']++; $summary['total_positive_unpaid_balance'] += $positive; } if ($credit > 0.01) { $summary['parents_with_credit_balance']++; $summary['total_credit_balance'] += $credit; } if (abs($net) > 0.01) { $summary['parents_with_net_non_zero_balance']++; } if ($row['existing_transfer_conflict']) { $summary['existing_transfer_conflicts']++; } } usort($rows, static fn (array $a, array $b) => $b['amount_to_transfer'] <=> $a['amount_to_transfer']); $summary['total_positive_unpaid_balance'] = round($summary['total_positive_unpaid_balance'], 2); $summary['total_credit_balance'] = round($summary['total_credit_balance'], 2); $summary['net_balance_impact'] = round($summary['total_positive_unpaid_balance'] - $summary['total_credit_balance'], 2); $summary['parents_with_non_zero_balance'] = $summary['parents_with_net_non_zero_balance']; $summary['total_old_unpaid_balance'] = $summary['total_positive_unpaid_balance']; $summary['net_balance_to_transfer'] = $summary['net_balance_impact']; $summary['parent_count'] = $summary['parents_with_positive_balance']; $summary['total_transferred'] = $summary['total_positive_unpaid_balance']; $summary['total_credit'] = $summary['total_credit_balance']; return [ 'from_school_year' => $fromSchoolYear, 'to_school_year' => $toSchoolYear, 'rows' => $rows, 'summary' => $summary, ]; } private function eligibleCarryoverInvoiceQuery(string $schoolYear) { return DB::table('invoices') ->where('school_year', $schoolYear) ->whereNotIn(DB::raw("LOWER(COALESCE(status, ''))"), [ 'void', 'voided', 'cancelled', 'canceled', 'draft', 'pending', 'paid', 'full', 'full payment', 'complete', 'completed', ]); } private function resolveTargetSection(?int $targetLevelId, string $schoolYear): array { if ($targetLevelId === null || $targetLevelId <= 0) { return [null, null]; } $section = DB::table('classSection') ->where('class_id', $targetLevelId) ->where(function ($query) use ($schoolYear) { $query->whereNull('school_year') ->orWhere('school_year', $schoolYear); }) ->orderBy('class_section_id') ->first(); if (! $section) { return [null, null]; } return [(int) $section->class_section_id, $section->class_section_name]; } private function applyPromotions(array $rows, string $newSchoolYear, string $enrollmentDate, ?int $actorId): array { $semester = trim((string) (Configuration::getConfig('semester') ?? '')); $counts = [ 'promote' => 0, 'repeat' => 0, 'graduate' => 0, 'transfer' => 0, 'withdraw' => 0, ]; foreach ($rows as $row) { $action = $row['promotion_action']; if (! isset($counts[$action])) { continue; } $counts[$action]++; if (! in_array($action, ['promote', 'repeat'], true)) { $this->logAudit( $actorId, 'school_year_promotion_decision_applied', 'students', (int) $row['student_id'], null, null, ['action' => $action, 'new_school_year' => $newSchoolYear] ); continue; } $existingEnrollment = DB::table('enrollments') ->where('student_id', $row['student_id']) ->where('school_year', $newSchoolYear) ->first(); if (! $existingEnrollment) { DB::table('enrollments')->insert([ 'student_id' => $row['student_id'], 'class_section_id' => $row['target_class_section_id'], 'parent_id' => $row['parent_id'], 'enrollment_date' => $enrollmentDate, 'enrollment_status' => 'enrolled', 'withdrawal_date' => null, 'is_withdrawn' => 0, 'admission_status' => 'accepted', 'semester' => $semester !== '' ? $semester : null, 'school_year' => $newSchoolYear, 'created_at' => now(), 'updated_at' => now(), ]); } if ($row['target_class_section_id']) { $existingAssignment = DB::table('student_class') ->where('student_id', $row['student_id']) ->where('school_year', $newSchoolYear) ->where('class_section_id', $row['target_class_section_id']) ->exists(); if (! $existingAssignment) { DB::table('student_class')->insert([ 'student_id' => $row['student_id'], 'class_section_id' => $row['target_class_section_id'], 'is_event_only' => 0, 'semester' => $semester !== '' ? $semester : 'Fall', 'school_year' => $newSchoolYear, 'created_at' => now(), 'updated_at' => now(), 'updated_by' => $actorId, ]); } } if ($row['parent_id']) { ParentAccount::query()->firstOrCreate( ['parent_id' => $row['parent_id'], 'school_year' => $newSchoolYear], ['opening_balance' => 0, 'current_balance' => 0] ); } $this->logAudit( $actorId, 'school_year_promotion_decision_applied', 'students', (int) $row['student_id'], null, null, [ 'action' => $action, 'new_school_year' => $newSchoolYear, 'target_class_section_id' => $row['target_class_section_id'], ] ); } return $counts; } private function applyBalanceTransfers(array $rows, string $fromSchoolYear, string $toSchoolYear, ?int $actorId): array { $counts = [ 'transferred_parents' => 0, 'transferred_amount' => 0.0, 'credit_parents' => 0, 'credit_amount' => 0.0, ]; foreach ($rows as $row) { $amount = round((float) $row['amount_to_transfer'], 2); $credit = round((float) ($row['credit_balance'] ?? 0), 2); if ($amount < 0.01) { if ($credit > 0.01) { $counts['credit_parents']++; $counts['credit_amount'] += $credit; } continue; } $transfer = ParentBalanceTransfer::query()->where([ 'parent_id' => $row['parent_id'], 'from_school_year' => $fromSchoolYear, 'to_school_year' => $toSchoolYear, ])->lockForUpdate()->first(); if ($transfer) { throw new RuntimeException(sprintf( 'Parent %d already has a balance transfer for %s -> %s.', $row['parent_id'], $fromSchoolYear, $toSchoolYear )); } $newAccount = ParentAccount::query()->firstOrCreate( ['parent_id' => $row['parent_id'], 'school_year' => $toSchoolYear], ['opening_balance' => 0, 'current_balance' => 0] ); if (Schema::hasColumn('parent_accounts', 'school_year_id') && $newAccount->school_year_id === null) { $newAccount->school_year_id = $this->schoolYearIdForName($toSchoolYear); } $newAccount->opening_balance = round((float) $newAccount->opening_balance + $amount, 2); $newAccount->current_balance = round((float) $newAccount->current_balance + $amount, 2); $newAccount->save(); $transferPayload = [ 'parent_id' => $row['parent_id'], 'from_school_year' => $fromSchoolYear, 'to_school_year' => $toSchoolYear, 'amount' => $amount, 'status' => 'transferred', 'source_summary_json' => [ 'source_invoice_ids' => $row['positive_invoice_ids'] ?? $row['source_invoice_ids'] ?? [], 'positive_invoice_ids' => $row['positive_invoice_ids'] ?? $row['source_invoice_ids'] ?? [], 'credit_invoice_ids' => $row['credit_invoice_ids'] ?? [], 'old_unpaid_balance' => $row['old_unpaid_balance'] ?? $amount, 'positive_unpaid_balance' => $row['positive_unpaid_balance'] ?? $amount, 'credit_balance' => $credit, 'net_balance' => $row['net_balance'] ?? ($amount - $credit), ], 'created_by' => $actorId, ]; if (Schema::hasColumn('parent_balance_transfers', 'source_summary')) { $transferPayload['source_summary'] = $transferPayload['source_summary_json']; } $fromYearId = $this->schoolYearIdForName($fromSchoolYear); $toYearId = $this->schoolYearIdForName($toSchoolYear); if (Schema::hasColumn('parent_balance_transfers', 'from_school_year_id')) { $transferPayload['from_school_year_id'] = $fromYearId; } if (Schema::hasColumn('parent_balance_transfers', 'to_school_year_id')) { $transferPayload['to_school_year_id'] = $toYearId; } $transfer = ParentBalanceTransfer::query()->create($transferPayload); $newInvoiceId = null; if ($amount > 0) { $invoicePayload = [ 'parent_id' => $row['parent_id'], 'invoice_number' => sprintf('OB-%s-%d', str_replace('-', '', $toSchoolYear), $transfer->id), 'total_amount' => $amount, 'balance' => $amount, 'paid_amount' => 0, 'has_discount' => 0, 'issue_date' => now()->toDateString(), 'due_date' => null, 'status' => 'unpaid', 'description' => sprintf('Opening balance transferred from %s', $fromSchoolYear), 'school_year' => $toSchoolYear, 'created_at' => now(), 'updated_at' => now(), 'updated_by' => $actorId, 'semester' => Configuration::getConfig('semester'), 'balance_transfer_id' => $transfer->id, ]; if (Schema::hasColumn('invoices', 'invoice_type')) { $invoicePayload['invoice_type'] = 'opening_balance'; } if (Schema::hasColumn('invoices', 'school_year_id')) { $invoicePayload['school_year_id'] = $toYearId; } $newInvoiceId = DB::table('invoices')->insertGetId($invoicePayload); $transfer->new_invoice_id = $newInvoiceId; $transfer->save(); $counts['transferred_parents']++; $counts['transferred_amount'] += $amount; if ($credit > 0.01) { $counts['credit_parents']++; $counts['credit_amount'] += $credit; } } $this->logAudit( $actorId, 'school_year_balance_transferred', 'parent_balance_transfers', (int) $transfer->id, null, $transfer->fresh()?->toArray(), ['new_invoice_id' => $newInvoiceId] ); } $counts['transferred_amount'] = round($counts['transferred_amount'], 2); $counts['credit_amount'] = round($counts['credit_amount'], 2); return $counts; } private function countPendingPayments(string $schoolYear): int { if (! Schema::hasTable('payments')) { return 0; } return DB::table('payments') ->where('school_year', $schoolYear) ->whereIn(DB::raw('LOWER(COALESCE(status, ""))'), ['pending', 'processing', 'draft', 'unallocated']) ->count(); } private function countDraftInvoices(string $schoolYear): int { return DB::table('invoices') ->where('school_year', $schoolYear) ->whereIn(DB::raw('LOWER(COALESCE(status, ""))'), ['draft', 'pending']) ->count(); } private function schoolYearIdForName(string $schoolYear): ?int { if (! Schema::hasTable('school_years')) { return null; } $id = SchoolYear::query()->where('name', $schoolYear)->value('id'); return $id ? (int) $id : null; } private function countFinancialInconsistencies(string $schoolYear): int { return DB::table('invoices') ->where('school_year', $schoolYear) ->whereRaw('ABS(COALESCE(total_amount, 0) - COALESCE(paid_amount, 0) - COALESCE(balance, 0)) > 0.01') ->count(); } private function countDuplicateParentAccounts(): int { if (! Schema::hasTable('parent_accounts')) { return 0; } return DB::table('parent_accounts') ->select('parent_id', 'school_year') ->groupBy('parent_id', 'school_year') ->havingRaw('COUNT(*) > 1') ->count(); } private function resolveParentName(int $parentId): ?string { $row = DB::table('users')->where('id', $parentId)->first(); if (! $row) { return null; } return trim((string) ($row->firstname ?? '').' '.(string) ($row->lastname ?? '')); } private function resolveStudentNames(int $parentId, string $schoolYear): array { $studentIds = DB::table('enrollments') ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->pluck('student_id') ->map(fn ($id) => (int) $id) ->all(); if ($studentIds === []) { $studentIds = DB::table('students')->where('parent_id', $parentId)->pluck('id')->map(fn ($id) => (int) $id)->all(); } return DB::table('students') ->whereIn('id', $studentIds) ->get() ->map(fn ($student) => trim((string) ($student->firstname ?? '').' '.(string) ($student->lastname ?? ''))) ->filter() ->values() ->all(); } private function latestClosureAudit(int $schoolYearId): ?AuditLog { if (! Schema::hasTable('audit_logs')) { return null; } return AuditLog::query() ->where('action', 'school_year_closed') ->where(function ($query) use ($schoolYearId) { $query->where('record_id', $schoolYearId) ->orWhere(function ($nested) use ($schoolYearId) { $nested->where('table_name', 'school_years') ->where('record_id', $schoolYearId); }); }) ->orderByDesc('created_at') ->orderByDesc('id') ->first(); } private function resolveClosedTargetYearName(string $fromSchoolYear): string { if (Schema::hasTable('parent_balance_transfers')) { $target = DB::table('parent_balance_transfers') ->where('from_school_year', $fromSchoolYear) ->whereNotNull('to_school_year') ->orderByDesc('created_at') ->value('to_school_year'); if (is_string($target) && trim($target) !== '') { return trim($target); } } return $this->resolver->suggestNextName($fromSchoolYear) ?? ''; } private function transferTotalsForClosedYear(string $fromSchoolYear, ?string $toSchoolYear = null): array { $totals = [ 'parents_with_unpaid_balances' => 0, 'total_unpaid_balance_transferred' => 0.0, 'parents_with_credit_balances' => 0, 'total_credit_carried_or_reported' => 0.0, 'total_credit_transferred' => 0.0, 'net_balance_impact' => 0.0, ]; if (! Schema::hasTable('parent_balance_transfers')) { return $totals; } $columns = ['amount']; if (Schema::hasColumn('parent_balance_transfers', 'source_summary_json')) { $columns[] = 'source_summary_json'; } if (Schema::hasColumn('parent_balance_transfers', 'source_summary')) { $columns[] = 'source_summary'; } $rows = DB::table('parent_balance_transfers') ->where('from_school_year', $fromSchoolYear) ->when($toSchoolYear !== null && trim($toSchoolYear) !== '', fn ($query) => $query->where('to_school_year', $toSchoolYear)) ->get($columns); foreach ($rows as $row) { $amount = round((float) ($row->amount ?? 0), 2); if ($amount > 0.01) { $totals['parents_with_unpaid_balances']++; $totals['total_unpaid_balance_transferred'] += $amount; } $sourceSummary = $this->decodeTransferSourceSummary($row); $credit = round((float) ($sourceSummary['credit_balance'] ?? 0), 2); if ($credit > 0.01) { $totals['parents_with_credit_balances']++; $totals['total_credit_carried_or_reported'] += $credit; } } $totals['total_unpaid_balance_transferred'] = round($totals['total_unpaid_balance_transferred'], 2); $totals['total_credit_carried_or_reported'] = round($totals['total_credit_carried_or_reported'], 2); $totals['total_credit_transferred'] = $totals['total_credit_carried_or_reported']; $totals['net_balance_impact'] = round( $totals['total_unpaid_balance_transferred'] - $totals['total_credit_carried_or_reported'], 2 ); return $totals; } private function positiveInvoiceBalanceTotals(string $schoolYear): array { $rows = DB::table('invoices') ->where('school_year', $schoolYear) ->whereRaw('COALESCE(balance, 0) > 0.01') ->select('parent_id', DB::raw('SUM(COALESCE(balance, 0)) as total_balance')) ->groupBy('parent_id') ->get(); return [ 'parents_with_unpaid_balances' => $rows->count(), 'total_unpaid_balance' => round((float) $rows->sum('total_balance'), 2), ]; } private function decodeTransferSourceSummary(object $row): array { $raw = $row->source_summary_json ?? $row->source_summary ?? null; if (is_array($raw)) { return $raw; } if (! is_string($raw) || trim($raw) === '') { return []; } $decoded = json_decode($raw, true); return is_array($decoded) ? $decoded : []; } private function resolveUserName(int $userId): string|int|null { if ($userId <= 0) { return null; } if (! Schema::hasTable('users')) { return $userId; } $user = DB::table('users')->where('id', $userId)->first(); if (! $user) { return $userId; } $name = trim((string) ($user->firstname ?? '').' '.(string) ($user->lastname ?? '')); if ($name !== '') { return $name; } $email = trim((string) ($user->email ?? '')); return $email !== '' ? $email : $userId; } public function archive(int $id, ?int $actorId): array { $year = $this->findOrFail($id); if ($year->status === SchoolYear::STATUS_ARCHIVED) { throw ValidationException::withMessages([ 'school_year' => ['This school year is already archived.'], ]); } $oldState = $year->toArray(); $year->status = SchoolYear::STATUS_ARCHIVED; $year->is_current = false; $year->save(); $this->logAudit( $actorId, 'school_year_archived', 'school_years', (int) $year->id, $oldState, $year->fresh()?->toArray(), null ); return $this->presentSchoolYear($year->fresh()); } public function archiveByYearName(string $schoolYear, ?int $actorId): array { return $this->archive((int) $this->findByNameOrFail($schoolYear)->id, $actorId); } private function presentSchoolYear(?SchoolYear $year): ?array { if (! $year) { return null; } return [ 'id' => (int) $year->id, 'name' => $year->name, 'start_date' => optional($year->start_date)->toDateString(), 'end_date' => optional($year->end_date)->toDateString(), 'status' => $year->status, 'is_current' => (bool) $year->is_current, 'closed_at' => optional($year->closed_at)->toDateTimeString(), 'closed_by' => $year->closed_by, ]; } private function logAudit( ?int $userId, string $action, ?string $tableName, ?int $recordId, ?array $oldValue, ?array $newValue, ?array $metadata = null ): void { if (! Schema::hasTable('audit_logs')) { return; } AuditLog::query()->create([ 'user_id' => $userId, 'action' => $action, 'table_name' => $tableName, 'record_id' => $recordId, 'old_value' => $oldValue, 'new_value' => $newValue, 'metadata' => $metadata, 'created_at' => now(), ]); } }