Files
alrahma_sunday_school_api/app/Services/SchoolYears/SchoolYearClosureService.php
T
2026-06-09 02:32:58 -04:00

1067 lines
39 KiB
PHP

<?php
namespace App\Services\SchoolYears;
use App\Models\AuditLog;
use App\Models\Configuration;
use App\Models\ParentAccount;
use App\Models\ParentBalanceTransfer;
use App\Models\SchoolYear;
use App\Services\Promotions\LevelProgressionService;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
use RuntimeException;
class SchoolYearClosureService
{
public function __construct(
private SchoolYearResolver $resolver,
private LevelProgressionService $progression
) {
}
public function list(): array
{
$this->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_non_zero_balance'],
'net_balance_to_transfer' => $balances['summary']['net_balance_to_transfer'],
],
];
}
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 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 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 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 parentBalances(int $id, ?string $toSchoolYear = null): array
{
$year = $this->findOrFail($id);
return $this->buildParentBalanceRows(
$year->name,
$toSchoolYear ?: ($this->resolver->suggestNextName($year->name) ?? $year->name)
);
}
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 findOrFail(int $id): SchoolYear
{
$this->resolver->ensureCurrentTracked();
return SchoolYear::query()->findOrFail($id);
}
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 (($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 ($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 buildParentBalanceRows(string $fromSchoolYear, string $toSchoolYear): array
{
$invoices = DB::table('invoices')
->where('school_year', $fromSchoolYear)
->whereNotIn(DB::raw('LOWER(COALESCE(status, ""))'), ['void', 'voided', 'cancelled', 'canceled'])
->get();
$byParent = [];
foreach ($invoices as $invoice) {
$parentId = (int) ($invoice->parent_id ?? 0);
if ($parentId <= 0) {
continue;
}
$balance = $invoice->balance !== null
? (float) $invoice->balance
: (float) ($invoice->total_amount ?? 0) - (float) ($invoice->paid_amount ?? 0);
if (!isset($byParent[$parentId])) {
$existing = ParentBalanceTransfer::query()
->where('parent_id', $parentId)
->where('from_school_year', $fromSchoolYear)
->where('to_school_year', $toSchoolYear)
->first();
$byParent[$parentId] = [
'parent_id' => $parentId,
'parent_name' => $this->resolveParentName($parentId),
'student_names' => $this->resolveStudentNames($parentId, $fromSchoolYear),
'unpaid_invoice_count' => 0,
'old_unpaid_balance' => 0.0,
'credit_balance' => 0.0,
'net_balance' => 0.0,
'amount_to_transfer' => 0.0,
'transfer_status' => $existing?->status,
'existing_transfer_id' => $existing?->id,
'source_invoice_ids' => [],
];
}
$byParent[$parentId]['source_invoice_ids'][] = (int) $invoice->id;
$byParent[$parentId]['net_balance'] += $balance;
if ($balance > 0) {
$byParent[$parentId]['unpaid_invoice_count']++;
}
}
$rows = [];
$summary = [
'parents_with_non_zero_balance' => 0,
'parents_with_credit_balance' => 0,
'existing_transfer_conflicts' => 0,
'total_old_unpaid_balance' => 0.0,
'total_credit_balance' => 0.0,
'net_balance_to_transfer' => 0.0,
];
foreach ($byParent as $row) {
$net = round((float) $row['net_balance'], 2);
if (abs($net) < 0.01) {
continue;
}
$row['old_unpaid_balance'] = $net > 0 ? $net : 0.0;
$row['credit_balance'] = $net < 0 ? abs($net) : 0.0;
$row['amount_to_transfer'] = $net;
$row['transfer_status'] = $row['transfer_status'] ?? ($net > 0 ? 'pending' : 'credit_pending');
$rows[] = $row;
$summary['parents_with_non_zero_balance']++;
$summary['net_balance_to_transfer'] += $net;
$summary['total_old_unpaid_balance'] += $row['old_unpaid_balance'];
$summary['total_credit_balance'] += $row['credit_balance'];
if ($row['credit_balance'] > 0) {
$summary['parents_with_credit_balance']++;
}
if ($row['existing_transfer_id']) {
$summary['existing_transfer_conflicts']++;
}
}
usort($rows, static fn (array $a, array $b) => $b['amount_to_transfer'] <=> $a['amount_to_transfer']);
$summary['net_balance_to_transfer'] = round($summary['net_balance_to_transfer'], 2);
$summary['total_old_unpaid_balance'] = round($summary['total_old_unpaid_balance'], 2);
$summary['total_credit_balance'] = round($summary['total_credit_balance'], 2);
return [
'from_school_year' => $fromSchoolYear,
'to_school_year' => $toSchoolYear,
'rows' => $rows,
'summary' => $summary,
];
}
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);
if (abs($amount) < 0.01) {
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
));
}
ParentAccount::query()->updateOrCreate(
['parent_id' => $row['parent_id'], 'school_year' => $fromSchoolYear],
['opening_balance' => 0, 'current_balance' => $amount]
);
$newAccount = ParentAccount::query()->firstOrCreate(
['parent_id' => $row['parent_id'], 'school_year' => $toSchoolYear],
['opening_balance' => 0, 'current_balance' => 0]
);
$newAccount->opening_balance = round((float) $newAccount->opening_balance + $amount, 2);
$newAccount->current_balance = round((float) $newAccount->current_balance + $amount, 2);
$newAccount->save();
$transfer = ParentBalanceTransfer::query()->create([
'parent_id' => $row['parent_id'],
'from_school_year' => $fromSchoolYear,
'to_school_year' => $toSchoolYear,
'amount' => $amount,
'status' => 'transferred',
'source_summary_json' => [
'source_invoice_ids' => $row['source_invoice_ids'],
'old_unpaid_balance' => $row['old_unpaid_balance'],
'credit_balance' => $row['credit_balance'],
],
'created_by' => $actorId,
]);
$newInvoiceId = null;
if ($amount > 0) {
$newInvoiceId = DB::table('invoices')->insertGetId([
'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,
]);
$transfer->new_invoice_id = $newInvoiceId;
$transfer->save();
$counts['transferred_parents']++;
$counts['transferred_amount'] += $amount;
} else {
$counts['credit_parents']++;
$counts['credit_amount'] += abs($amount);
}
$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 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 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(),
]);
}
}