From 6866aedf428d425c05dc488701336cad476f7b2e Mon Sep 17 00:00:00 2001 From: root Date: Sun, 7 Jun 2026 00:52:01 -0400 Subject: [PATCH] add school year model --- ...ministratorTeacherSubmissionController.php | 4 +- .../Api/Email/EmailExtractorController.php | 2 + .../Api/SchoolYears/SchoolYearController.php | 103 ++ .../Web/SchoolYearAdminPageController.php | 149 ++ .../Middleware/EnsureSchoolYearEditable.php | 101 ++ .../SchoolYears/CloseSchoolYearRequest.php | 13 + .../PreviewCloseSchoolYearRequest.php | 24 + .../SchoolYears/SaveSchoolYearRequest.php | 22 + app/Models/AuditLog.php | 30 + app/Models/ParentAccount.php | 21 + app/Models/ParentBalanceTransfer.php | 27 + app/Models/SchoolYear.php | 31 + .../AdministratorSharedService.php | 14 +- .../AdministratorTeacherSubmissionService.php | 6 +- .../TeacherSubmissionNotificationService.php | 71 +- .../TeacherSubmissionReportService.php | 8 +- .../TeacherSubmissionSupportService.php | 15 +- app/Services/ApplicationUrlService.php | 8 +- .../AttendanceManagementService.php | 203 ++- .../SchoolYears/SchoolYearClosureService.php | 1066 +++++++++++ .../SchoolYears/SchoolYearResolver.php | 72 + .../SchoolYears/SchoolYearWriteGuard.php | 42 + bootstrap/app.php | 1 + ...0000_create_school_year_closure_tables.php | 146 ++ ...ontext_to_attendance_management_tables.php | 165 ++ docs/pages need review and fix.md | 42 + resources/views/admin/school-years.blade.php | 1585 +++++++++++++++++ routes/api.php | 54 +- routes/web.php | 22 + ...stratorTeacherSubmissionControllerTest.php | 13 +- ...istratorTeacherSubmissionNotifyApiTest.php | 111 +- ...istratorTeacherSubmissionReportApiTest.php | 96 +- .../V1/Email/EmailExtractorControllerTest.php | 29 +- .../SchoolYears/SchoolYearControllerTest.php | 343 ++++ tests/Feature/Web/SchoolYearAdminPageTest.php | 117 ++ .../AttendanceManagementServiceTest.php | 103 ++ 36 files changed, 4771 insertions(+), 88 deletions(-) create mode 100644 app/Http/Controllers/Api/SchoolYears/SchoolYearController.php create mode 100644 app/Http/Controllers/Web/SchoolYearAdminPageController.php create mode 100644 app/Http/Middleware/EnsureSchoolYearEditable.php create mode 100644 app/Http/Requests/SchoolYears/CloseSchoolYearRequest.php create mode 100644 app/Http/Requests/SchoolYears/PreviewCloseSchoolYearRequest.php create mode 100644 app/Http/Requests/SchoolYears/SaveSchoolYearRequest.php create mode 100644 app/Models/AuditLog.php create mode 100644 app/Models/ParentAccount.php create mode 100644 app/Models/ParentBalanceTransfer.php create mode 100644 app/Models/SchoolYear.php create mode 100644 app/Services/SchoolYears/SchoolYearClosureService.php create mode 100644 app/Services/SchoolYears/SchoolYearResolver.php create mode 100644 app/Services/SchoolYears/SchoolYearWriteGuard.php create mode 100644 database/migrations/2026_06_05_000000_create_school_year_closure_tables.php create mode 100644 database/migrations/2026_06_07_120000_add_academic_context_to_attendance_management_tables.php create mode 100644 docs/pages need review and fix.md create mode 100644 resources/views/admin/school-years.blade.php create mode 100644 tests/Feature/Api/V1/SchoolYears/SchoolYearControllerTest.php create mode 100644 tests/Feature/Web/SchoolYearAdminPageTest.php create mode 100644 tests/Unit/Services/AttendanceManagement/AttendanceManagementServiceTest.php diff --git a/app/Http/Controllers/Api/Administrator/AdministratorTeacherSubmissionController.php b/app/Http/Controllers/Api/Administrator/AdministratorTeacherSubmissionController.php index 0d7f66fa..6960ac32 100644 --- a/app/Http/Controllers/Api/Administrator/AdministratorTeacherSubmissionController.php +++ b/app/Http/Controllers/Api/Administrator/AdministratorTeacherSubmissionController.php @@ -16,9 +16,9 @@ class AdministratorTeacherSubmissionController extends Controller ) { } - public function index(): JsonResponse + public function index(Request $request): JsonResponse { - return response()->json($this->service->report()); + return response()->json($this->service->report($request->query())); } public function notify(Request $request): JsonResponse diff --git a/app/Http/Controllers/Api/Email/EmailExtractorController.php b/app/Http/Controllers/Api/Email/EmailExtractorController.php index 67f7d2f2..f63cbd33 100644 --- a/app/Http/Controllers/Api/Email/EmailExtractorController.php +++ b/app/Http/Controllers/Api/Email/EmailExtractorController.php @@ -23,6 +23,8 @@ class EmailExtractorController extends BaseApiController return response()->json([ 'ok' => true, + 'users' => $emails['users'] ?? [], + 'parents' => $emails['parents'] ?? [], 'emails' => new EmailExtractorEmailsResource($emails), ]); } diff --git a/app/Http/Controllers/Api/SchoolYears/SchoolYearController.php b/app/Http/Controllers/Api/SchoolYears/SchoolYearController.php new file mode 100644 index 00000000..55125897 --- /dev/null +++ b/app/Http/Controllers/Api/SchoolYears/SchoolYearController.php @@ -0,0 +1,103 @@ +json(['ok' => true, 'data' => $this->service->list()]); + } + + public function store(SaveSchoolYearRequest $request): JsonResponse + { + return response()->json([ + 'ok' => true, + 'data' => $this->service->createYear($request->validated(), $this->getCurrentUserId()), + ], Response::HTTP_CREATED); + } + + public function update(SaveSchoolYearRequest $request, int $schoolYear): JsonResponse + { + return response()->json([ + 'ok' => true, + 'data' => $this->service->updateYear($schoolYear, $request->validated(), $this->getCurrentUserId()), + ]); + } + + public function summary(int $schoolYear): JsonResponse + { + return response()->json(['ok' => true, 'data' => $this->service->summary($schoolYear)]); + } + + public function validateClose(PreviewCloseSchoolYearRequest $request, int $schoolYear): JsonResponse + { + return response()->json([ + 'ok' => true, + 'validation' => $this->service->validateClose($schoolYear, $request->validated()), + ]); + } + + public function previewClose(PreviewCloseSchoolYearRequest $request, int $schoolYear): JsonResponse + { + return response()->json([ + 'ok' => true, + 'data' => $this->service->previewClose($schoolYear, $request->validated()), + ]); + } + + public function close(CloseSchoolYearRequest $request, int $schoolYear): JsonResponse + { + try { + $result = $this->service->close($schoolYear, $request->validated(), $this->getCurrentUserId()); + } catch (ValidationException $e) { + throw $e; + } catch (\Throwable $e) { + return response()->json([ + 'ok' => false, + 'message' => $e->getMessage(), + ], Response::HTTP_CONFLICT); + } + + return response()->json(['ok' => true, 'data' => $result]); + } + + public function reopen(Request $request, int $schoolYear): JsonResponse + { + return response()->json([ + 'ok' => true, + 'data' => $this->service->reopen($schoolYear, $this->getCurrentUserId()), + ]); + } + + public function parentBalances(Request $request, int $schoolYear): JsonResponse + { + return response()->json([ + 'ok' => true, + 'data' => $this->service->parentBalances($schoolYear, $request->query('to_school_year')), + ]); + } + + public function promotionPreview(Request $request, int $schoolYear): JsonResponse + { + return response()->json([ + 'ok' => true, + 'data' => $this->service->promotionPreview($schoolYear, $request->query('to_school_year')), + ]); + } +} diff --git a/app/Http/Controllers/Web/SchoolYearAdminPageController.php b/app/Http/Controllers/Web/SchoolYearAdminPageController.php new file mode 100644 index 00000000..84c0714a --- /dev/null +++ b/app/Http/Controllers/Web/SchoolYearAdminPageController.php @@ -0,0 +1,149 @@ +service->list(); + $selectedId = (int) ($request->query('year') ?? 0); + $activeYear = collect($years)->firstWhere('is_current', true); + + $defaultNext = $activeYear + ? ($this->resolver->suggestNextName((string) $activeYear['name']) ?? '') + : ''; + [$defaultStart, $defaultEnd] = $defaultNext !== '' + ? $this->resolver->inferDatesFromName($defaultNext) + : ['', '']; + + return view('admin.school-years', [ + 'pageConfig' => [ + 'jwt' => JWTAuth::fromUser($request->user()), + 'selected_year_id' => $selectedId, + 'docs_url' => url('/docs'), + 'default_draft' => [ + 'name' => $defaultNext, + 'start_date' => $defaultStart, + 'end_date' => $defaultEnd, + ], + 'api' => [ + 'index' => url('/api/v1/school-years'), + 'store' => url('/api/v1/school-years'), + 'update' => url('/api/v1/school-years/__ID__'), + 'summary' => url('/api/v1/school-years/__ID__/summary'), + 'validate_close' => url('/api/v1/school-years/__ID__/validate-close'), + 'preview_close' => url('/api/v1/school-years/__ID__/preview-close'), + 'close' => url('/api/v1/school-years/__ID__/close'), + 'reopen' => url('/api/v1/school-years/__ID__/reopen'), + 'parent_balances' => url('/api/v1/school-years/__ID__/parent-balances'), + 'promotion_preview' => url('/api/v1/school-years/__ID__/promotion-preview'), + ], + ], + ]); + } + + public function store(Request $request): RedirectResponse + { + $payload = $request->validate([ + 'name' => ['required', 'string', 'max:50'], + 'start_date' => ['required', 'date'], + 'end_date' => ['required', 'date', 'after:start_date'], + ]); + + try { + $year = $this->service->createYear($payload, optional($request->user())->id); + } catch (ValidationException $e) { + throw $e; + } + + return redirect() + ->route('admin.school-years', ['year' => $year['id']]) + ->with('status', sprintf('Draft school year %s created.', $year['name'])); + } + + public function update(Request $request, int $schoolYear): RedirectResponse + { + $payload = $request->validate([ + 'name' => ['required', 'string', 'max:50'], + 'start_date' => ['required', 'date'], + 'end_date' => ['required', 'date', 'after:start_date'], + ]); + + $updated = $this->service->updateYear($schoolYear, $payload, optional($request->user())->id); + + return redirect() + ->route('admin.school-years', ['year' => $updated['id']]) + ->with('status', sprintf('School year %s updated.', $updated['name'])); + } + + public function validateClose(Request $request, int $schoolYear): JsonResponse + { + return response()->json([ + 'ok' => true, + 'validation' => $this->service->validateClose($schoolYear, $this->validatedClosePayload($request)), + ]); + } + + public function previewClose(Request $request, int $schoolYear): JsonResponse + { + return response()->json([ + 'ok' => true, + 'data' => $this->service->previewClose($schoolYear, $this->validatedClosePayload($request)), + ]); + } + + public function close(Request $request, int $schoolYear): RedirectResponse + { + $payload = $this->validatedClosePayload($request); + $payload['confirmation'] = (string) $request->validate([ + 'confirmation' => ['required', 'string', 'max:80'], + ])['confirmation']; + + $result = $this->service->close($schoolYear, $payload, optional($request->user())->id); + + return redirect() + ->route('admin.school-years', ['year' => $result['new_school_year']['id']]) + ->with('status', sprintf( + 'Closed %s and opened %s.', + $result['closed_school_year']['name'], + $result['new_school_year']['name'] + )); + } + + public function reopen(Request $request, int $schoolYear): RedirectResponse + { + $year = $this->service->reopen($schoolYear, optional($request->user())->id); + + return redirect() + ->route('admin.school-years', ['year' => $year['id']]) + ->with('status', sprintf('School year %s is now active.', $year['name'])); + } + + private function validatedClosePayload(Request $request): array + { + return $request->validate([ + 'new_school_year' => ['required', 'array'], + 'new_school_year.name' => ['required', 'string', 'max:50'], + 'new_school_year.start_date' => ['required', 'date'], + 'new_school_year.end_date' => ['required', 'date', 'after:new_school_year.start_date'], + 'transfer_unpaid_balances' => ['nullable', 'boolean'], + ]); + } +} diff --git a/app/Http/Middleware/EnsureSchoolYearEditable.php b/app/Http/Middleware/EnsureSchoolYearEditable.php new file mode 100644 index 00000000..7bbfc881 --- /dev/null +++ b/app/Http/Middleware/EnsureSchoolYearEditable.php @@ -0,0 +1,101 @@ +method()), ['POST', 'PUT', 'PATCH', 'DELETE'], true)) { + return $next($request); + } + + try { + $this->guard->assertEditable($this->resolveSchoolYears($request)); + } catch (RuntimeException $e) { + return response()->json([ + 'ok' => false, + 'message' => $e->getMessage(), + ], 409); + } + + return $next($request); + } + + private function resolveSchoolYears(Request $request): array + { + $years = []; + + foreach (['school_year', 'current_school_year'] as $field) { + $value = $request->input($field, $request->query($field)); + if (is_string($value) && trim($value) !== '') { + $years[] = trim($value); + } + } + + $routeYears = [ + ['param' => 'invoiceId', 'table' => 'invoices', 'column' => 'school_year'], + ['param' => 'invoice', 'table' => 'invoices', 'column' => 'school_year'], + ['param' => 'paymentId', 'table' => 'payments', 'column' => 'school_year'], + ['param' => 'payment', 'table' => 'payments', 'column' => 'school_year'], + ['param' => 'refundId', 'table' => 'refunds', 'column' => 'school_year'], + ['param' => 'refund', 'table' => 'refunds', 'column' => 'school_year'], + ['param' => 'promotionId', 'table' => 'student_promotion_records', 'column' => 'current_school_year'], + ['param' => 'promotion', 'table' => 'student_promotion_records', 'column' => 'current_school_year'], + ['param' => 'eventCharge', 'table' => 'event_charges', 'column' => 'school_year'], + ['param' => 'eventId', 'table' => 'events', 'column' => 'school_year'], + ['param' => 'classSection', 'table' => 'classSection', 'column' => 'school_year'], + ['param' => 'classSectionId', 'table' => 'classSection', 'column' => 'school_year'], + ]; + + foreach ($routeYears as $mapping) { + $value = $request->route($mapping['param']); + if ($value === null || $value === '') { + continue; + } + + $resolved = $this->lookupYear((string) $value, $mapping['table'], $mapping['column']); + if ($resolved !== null) { + $years[] = $resolved; + } + } + + if ($years === []) { + $current = trim((string) (Configuration::getConfig('school_year') ?? '')); + if ($current !== '') { + $years[] = $current; + } + } + + return $years; + } + + private function lookupYear(string $id, string $table, string $column): ?string + { + if (!ctype_digit($id) || !DB::getSchemaBuilder()->hasTable($table)) { + return null; + } + + $primaryKey = match ($table) { + 'classSection' => 'class_section_id', + 'student_promotion_records' => 'promotion_id', + default => 'id', + }; + + $value = DB::table($table)->where($primaryKey, (int) $id)->value($column); + + return is_string($value) && trim($value) !== '' ? trim($value) : null; + } +} diff --git a/app/Http/Requests/SchoolYears/CloseSchoolYearRequest.php b/app/Http/Requests/SchoolYears/CloseSchoolYearRequest.php new file mode 100644 index 00000000..50cf0561 --- /dev/null +++ b/app/Http/Requests/SchoolYears/CloseSchoolYearRequest.php @@ -0,0 +1,13 @@ + ['required', 'string', 'max:80'], + ]); + } +} diff --git a/app/Http/Requests/SchoolYears/PreviewCloseSchoolYearRequest.php b/app/Http/Requests/SchoolYears/PreviewCloseSchoolYearRequest.php new file mode 100644 index 00000000..e5ba7d05 --- /dev/null +++ b/app/Http/Requests/SchoolYears/PreviewCloseSchoolYearRequest.php @@ -0,0 +1,24 @@ + ['required', 'array'], + 'new_school_year.name' => ['required', 'string', 'max:50'], + 'new_school_year.start_date' => ['required', 'date'], + 'new_school_year.end_date' => ['required', 'date', 'after:new_school_year.start_date'], + 'transfer_unpaid_balances' => ['nullable', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/SchoolYears/SaveSchoolYearRequest.php b/app/Http/Requests/SchoolYears/SaveSchoolYearRequest.php new file mode 100644 index 00000000..f426d7f2 --- /dev/null +++ b/app/Http/Requests/SchoolYears/SaveSchoolYearRequest.php @@ -0,0 +1,22 @@ + ['required', 'string', 'max:50'], + 'start_date' => ['required', 'date'], + 'end_date' => ['required', 'date', 'after:start_date'], + ]; + } +} diff --git a/app/Models/AuditLog.php b/app/Models/AuditLog.php new file mode 100644 index 00000000..d2511c9e --- /dev/null +++ b/app/Models/AuditLog.php @@ -0,0 +1,30 @@ + 'integer', + 'record_id' => 'integer', + 'old_value' => 'array', + 'new_value' => 'array', + 'metadata' => 'array', + 'created_at' => 'datetime', + ]; +} diff --git a/app/Models/ParentAccount.php b/app/Models/ParentAccount.php new file mode 100644 index 00000000..4c49b334 --- /dev/null +++ b/app/Models/ParentAccount.php @@ -0,0 +1,21 @@ + 'integer', + 'opening_balance' => 'decimal:2', + 'current_balance' => 'decimal:2', + ]; +} diff --git a/app/Models/ParentBalanceTransfer.php b/app/Models/ParentBalanceTransfer.php new file mode 100644 index 00000000..447e6c3d --- /dev/null +++ b/app/Models/ParentBalanceTransfer.php @@ -0,0 +1,27 @@ + 'integer', + 'amount' => 'decimal:2', + 'source_summary_json' => 'array', + 'new_invoice_id' => 'integer', + 'created_by' => 'integer', + ]; +} diff --git a/app/Models/SchoolYear.php b/app/Models/SchoolYear.php new file mode 100644 index 00000000..385e4de4 --- /dev/null +++ b/app/Models/SchoolYear.php @@ -0,0 +1,31 @@ + 'date', + 'end_date' => 'date', + 'is_current' => 'boolean', + 'closed_at' => 'datetime', + 'closed_by' => 'integer', + ]; +} diff --git a/app/Services/Administrator/AdministratorSharedService.php b/app/Services/Administrator/AdministratorSharedService.php index 64da6ae5..25d1f565 100644 --- a/app/Services/Administrator/AdministratorSharedService.php +++ b/app/Services/Administrator/AdministratorSharedService.php @@ -12,14 +12,18 @@ class AdministratorSharedService ) { } - public function getSemester(): string + public function getSemester(?string $override = null): string { - return (string) ($this->configuration->getConfig('semester') ?? ''); + $value = trim((string) $override); + + return $value !== '' ? $value : (string) ($this->configuration->getConfig('semester') ?? ''); } - public function getSchoolYear(): string + public function getSchoolYear(?string $override = null): string { - return (string) ($this->configuration->getConfig('school_year') ?? ''); + $value = trim((string) $override); + + return $value !== '' ? $value : (string) ($this->configuration->getConfig('school_year') ?? ''); } public function getPreviousSchoolYear(string $schoolYear): string @@ -146,4 +150,4 @@ class AdministratorSharedService return count(array_unique($ids)); } -} \ No newline at end of file +} diff --git a/app/Services/Administrator/AdministratorTeacherSubmissionService.php b/app/Services/Administrator/AdministratorTeacherSubmissionService.php index 65acaa06..5ccc2af9 100644 --- a/app/Services/Administrator/AdministratorTeacherSubmissionService.php +++ b/app/Services/Administrator/AdministratorTeacherSubmissionService.php @@ -12,13 +12,13 @@ class AdministratorTeacherSubmissionService ) { } - public function report(): array + public function report(array $filters = []): array { - return $this->reportService->report(); + return $this->reportService->report($filters); } public function sendNotifications(Request $request, int $adminId): array { return $this->notificationService->send($request, $adminId); } -} \ No newline at end of file +} diff --git a/app/Services/Administrator/TeacherSubmissionNotificationService.php b/app/Services/Administrator/TeacherSubmissionNotificationService.php index bbf6425a..171fab54 100644 --- a/app/Services/Administrator/TeacherSubmissionNotificationService.php +++ b/app/Services/Administrator/TeacherSubmissionNotificationService.php @@ -32,7 +32,9 @@ class TeacherSubmissionNotificationService } $missingItemsPayload = $request->input('missing_items', []); - $targets = $this->extractTargets($notify); + $schoolYear = $this->shared->getSchoolYear($request->input('school_year')); + $semester = $this->shared->getSemester($request->input('semester')); + $targets = $this->extractTargets($notify, $schoolYear, $semester); if (empty($targets)) { return [ @@ -72,8 +74,7 @@ class TeacherSubmissionNotificationService $sectionName = $classSections->get($classSectionId)?->class_section_name ?? "Section {$classSectionId}"; $teacherName = trim(($teacher->firstname ?? '') . ' ' . ($teacher->lastname ?? '')) ?: 'Teacher'; - $missingPayload = $missingItemsPayload[$classSectionId][$teacherId] ?? ''; - $missingItems = $this->support->parseMissingItemsPayload((string) $missingPayload); + $missingItems = $this->resolveMissingItems($missingItemsPayload, $classSectionId, $teacherId); $missingNote = !empty($missingItems) ? '

Outstanding items: ' . e($this->support->formatMissingItemsText($missingItems)) . '.

' @@ -111,8 +112,8 @@ class TeacherSubmissionNotificationService 'notification_category' => 'teacher_submissions', 'message' => $this->support->truncateNotificationMessage($body), 'status' => $status, - 'school_year' => $this->shared->getSchoolYear(), - 'semester' => $this->shared->getSemester(), + 'school_year' => $schoolYear, + 'semester' => $semester, 'sent_at' => now(), ]); } @@ -134,8 +135,31 @@ class TeacherSubmissionNotificationService ]; } - protected function extractTargets(array $notify): array + protected function extractTargets(array $notify, string $schoolYear, string $semester): array { + if ($this->isFlatTeacherIdList($notify)) { + $teacherIds = array_values(array_unique(array_map('intval', array_filter($notify, static fn ($value) => (int) $value > 0)))); + if (empty($teacherIds)) { + return []; + } + + return ClassSection::query() + ->select('teacher_class.class_section_id', 'teacher_class.teacher_id') + ->join('teacher_class', 'teacher_class.class_section_id', '=', 'classSection.class_section_id') + ->whereIn('teacher_class.teacher_id', $teacherIds) + ->where('teacher_class.school_year', $schoolYear) + ->where('teacher_class.semester', $semester) + ->orderBy('teacher_class.class_section_id') + ->get() + ->map(static fn ($row): array => [ + 'class_section_id' => (int) $row->class_section_id, + 'teacher_id' => (int) $row->teacher_id, + ]) + ->filter(static fn (array $row): bool => $row['class_section_id'] > 0 && $row['teacher_id'] > 0) + ->values() + ->all(); + } + $targets = []; foreach ($notify as $sectionIdRaw => $teachers) { @@ -159,4 +183,39 @@ class TeacherSubmissionNotificationService return array_values($targets); } + + protected function isFlatTeacherIdList(array $notify): bool + { + foreach ($notify as $value) { + if (is_array($value)) { + return false; + } + } + + return true; + } + + protected function resolveMissingItems($payload, int $classSectionId, int $teacherId): array + { + if (!is_array($payload)) { + return []; + } + + if (isset($payload[$classSectionId]) && is_array($payload[$classSectionId])) { + $sectionPayload = $payload[$classSectionId]; + if (array_key_exists($teacherId, $sectionPayload) || array_key_exists((string) $teacherId, $sectionPayload)) { + $value = $sectionPayload[$teacherId] ?? $sectionPayload[(string) $teacherId] ?? null; + + return $this->support->parseMissingItemsPayload($value); + } + } + + if (array_key_exists($teacherId, $payload) || array_key_exists((string) $teacherId, $payload)) { + $value = $payload[$teacherId] ?? $payload[(string) $teacherId] ?? null; + + return $this->support->parseMissingItemsPayload($value); + } + + return []; + } } diff --git a/app/Services/Administrator/TeacherSubmissionReportService.php b/app/Services/Administrator/TeacherSubmissionReportService.php index 0ffc1b3d..9bf11f26 100644 --- a/app/Services/Administrator/TeacherSubmissionReportService.php +++ b/app/Services/Administrator/TeacherSubmissionReportService.php @@ -17,10 +17,10 @@ class TeacherSubmissionReportService ) { } - public function report(): array + public function report(array $filters = []): array { - $semester = $this->shared->getSemester(); - $schoolYear = $this->shared->getSchoolYear(); + $semester = $this->shared->getSemester($filters['semester'] ?? null); + $schoolYear = $this->shared->getSchoolYear($filters['school_year'] ?? null); $assignmentRows = DB::table('teacher_class as tc') ->select([ @@ -230,6 +230,8 @@ class TeacherSubmissionReportService 'rows' => $rows, 'semester' => $semester, 'schoolYear' => $schoolYear, + 'currentSemester' => $this->shared->getSemester(), + 'currentSchoolYear' => $this->shared->getSchoolYear(), 'notificationHistory' => $historyMap, 'summary' => $summary, ]; diff --git a/app/Services/Administrator/TeacherSubmissionSupportService.php b/app/Services/Administrator/TeacherSubmissionSupportService.php index 4900603d..a2d242c1 100644 --- a/app/Services/Administrator/TeacherSubmissionSupportService.php +++ b/app/Services/Administrator/TeacherSubmissionSupportService.php @@ -83,8 +83,21 @@ class TeacherSubmissionSupportService : mb_substr($text, 0, $limit) . '…'; } - public function parseMissingItemsPayload(string $payload): array + public function parseMissingItemsPayload($payload): array { + if (is_array($payload)) { + $items = []; + foreach ($payload as $item) { + $item = trim((string) $item); + if ($item !== '') { + $items[] = $item; + } + } + + return array_values(array_unique($items)); + } + + $payload = trim((string) $payload); if ($payload === '') { return []; } diff --git a/app/Services/ApplicationUrlService.php b/app/Services/ApplicationUrlService.php index 983c6dc1..d30ad0f5 100644 --- a/app/Services/ApplicationUrlService.php +++ b/app/Services/ApplicationUrlService.php @@ -2,6 +2,8 @@ namespace App\Services; +use Illuminate\Support\Facades\Route; + /** * Laravel named routes (`route()`), cookie-session URLs, SPA paths, and absolute URLs for `public/` paths. */ @@ -48,7 +50,11 @@ final class ApplicationUrlService /** Named route `docs.home` (GET `/`; usually redirects to the docs client). */ public function docsHomeUrl(bool $absolute = true): string { - return route('docs.home', [], $absolute); + if (Route::has('docs.home')) { + return route('docs.home', [], $absolute); + } + + return $absolute ? url('/app/home') : '/app/home'; } /** Cookie-session SPA paths from `routes/web.php`. Named route `login`. */ diff --git a/app/Services/AttendanceManagement/AttendanceManagementService.php b/app/Services/AttendanceManagement/AttendanceManagementService.php index 3695c18d..5c9226c6 100644 --- a/app/Services/AttendanceManagement/AttendanceManagementService.php +++ b/app/Services/AttendanceManagement/AttendanceManagementService.php @@ -48,18 +48,39 @@ class AttendanceManagementService }); } - $rows = $query->orderByDesc('follow_up_required') + $schoolYear = trim((string) ($filters['school_year'] ?? '')); + $semester = $this->normalizeSemester($filters['semester'] ?? null); + $limit = max(1, (int) ($filters['limit'] ?? 250)); + + $rowCollection = $query->orderByDesc('follow_up_required') ->orderBy('follow_up_completed') ->orderBy('person_name') - ->limit((int) ($filters['limit'] ?? 250)) ->get() - ->map(fn ($r) => (array) $r) + ->map(fn ($r) => $this->hydrateAcademicContext((array) $r)) + ->filter(function (array $row) use ($schoolYear, $semester): bool { + if ($schoolYear !== '' && (string) ($row['school_year'] ?? '') !== $schoolYear) { + return false; + } + if ($semester !== '' && (string) ($row['semester'] ?? '') !== $semester) { + return false; + } + + return true; + }); + + $rows = $rowCollection + ->take($limit) + ->values() ->all(); return [ 'date' => $date, - 'summary' => $this->summaryForDate($date), - 'filters' => $this->availableFilters(), + 'school_year' => $schoolYear, + 'semester' => $semester, + 'current_year' => $this->currentSchoolYear(), + 'current_semester' => $this->currentSemester(), + 'summary' => $this->summaryForDate($date, $rowCollection->values()->all(), $schoolYear, $semester), + 'filters' => $this->availableFilters($rows), 'rows' => $rows, ]; } @@ -75,6 +96,7 @@ class AttendanceManagementService $status = $isLate ? self::STATUS_LATE : self::STATUS_PRESENT; $counts = $this->countsFor($person['type'], $person['id'], $entryAt->toDateString(), $status); $badgeExceptionCount = $this->badgeExceptionCount($person['type'], $person['id'], $entryAt->toDateString()) + 1; + $academicContext = $this->academicContextForDate($entryAt); $row = [ 'person_type' => $person['type'], @@ -83,6 +105,8 @@ class AttendanceManagementService 'role_grade' => $person['role_grade'], 'badge_id' => $person['badge_id'], 'event_date' => $entryAt->toDateString(), + 'school_year' => $academicContext['school_year'], + 'semester' => $academicContext['semester'], 'attendance_status' => $status, 'report_status' => $reportStatus, 'entry_time' => $entryAt, @@ -144,6 +168,7 @@ class AttendanceManagementService $status = $entryAt->gt($this->schoolStartFor($entryAt)) ? self::STATUS_LATE : self::STATUS_PRESENT; $counts = $this->countsFor($person['type'], $person['id'], $entryAt->toDateString(), $status); + $academicContext = $this->academicContextForDate($entryAt); $row = [ 'person_type' => $person['type'], 'person_id' => $person['id'], @@ -151,6 +176,8 @@ class AttendanceManagementService 'role_grade' => $person['role_grade'], 'badge_id' => $badge, 'event_date' => $entryAt->toDateString(), + 'school_year' => $academicContext['school_year'], + 'semester' => $academicContext['semester'], 'attendance_status' => $status, 'report_status' => $reportStatus, 'entry_time' => $entryAt, @@ -189,6 +216,7 @@ class AttendanceManagementService $reportStatus = $authorized ? 'reported' : $this->normalizeReportStatus($data['report_status'] ?? null); $counts = $this->countsFor($person['type'], $person['id'], $exitAt->toDateString(), self::STATUS_EARLY_DISMISSAL); $badgeExceptions = $this->badgeExceptionCount($person['type'], $person['id'], $exitAt->toDateString()) + ((($data['exit_method'] ?? '') === 'manual_exit') ? 1 : 0); + $academicContext = $this->academicContextForDate($exitAt); $row = [ 'person_type' => $person['type'], 'person_id' => $person['id'], @@ -196,6 +224,8 @@ class AttendanceManagementService 'role_grade' => $person['role_grade'], 'badge_id' => $person['badge_id'], 'event_date' => $exitAt->toDateString(), + 'school_year' => $academicContext['school_year'], + 'semester' => $academicContext['semester'], 'attendance_status' => self::STATUS_EARLY_DISMISSAL, 'report_status' => $reportStatus, 'exit_time' => $exitAt, @@ -233,6 +263,7 @@ class AttendanceManagementService $date = $this->dateOnly($data['date'] ?? now()->toDateString()); $reportStatus = $this->normalizeReportStatus($data['report_status'] ?? null); $counts = $this->countsFor($person['type'], $person['id'], $date, self::STATUS_ABSENT); + $academicContext = $this->academicContextForDate(Carbon::parse($date)); $row = [ 'person_type' => $person['type'], 'person_id' => $person['id'], @@ -240,6 +271,8 @@ class AttendanceManagementService 'role_grade' => $person['role_grade'], 'badge_id' => $person['badge_id'], 'event_date' => $date, + 'school_year' => $academicContext['school_year'], + 'semester' => $academicContext['semester'], 'attendance_status' => self::STATUS_ABSENT, 'report_status' => $reportStatus, 'reason' => $data['reason'] ?? null, @@ -290,6 +323,10 @@ class AttendanceManagementService throw new \RuntimeException('Attendance management event not found.'); } $count = (int) DB::table('late_slip_reprints')->where('attendance_management_event_id', $eventId)->count() + 1; + $academicContext = [ + 'school_year' => (string) ($event['school_year'] ?? $this->currentSchoolYear()), + 'semester' => (string) ($event['semester'] ?? $this->currentSemester()), + ]; $row = [ 'attendance_management_event_id' => $eventId, 'late_slip_log_id' => $data['late_slip_log_id'] ?? null, @@ -297,6 +334,8 @@ class AttendanceManagementService 'student_name' => $event['person_name'] ?? null, 'slip_number' => $data['slip_number'] ?? ('AME-'.$eventId), 'reprinted_at' => now(), + 'school_year' => $academicContext['school_year'], + 'semester' => $academicContext['semester'], 'reprinted_by' => $actor?->id, 'reason' => $data['reason'] ?? 'Reprint requested', 'reprint_count' => $count, @@ -370,8 +409,8 @@ class AttendanceManagementService private function printLateSlip(array $event, ?User $actor): array { $payload = [ - 'school_year' => Configuration::getConfigValueByKey('school_year') ?? '', - 'semester' => Configuration::getConfigValueByKey('semester') ?? '', + 'school_year' => (string) ($event['school_year'] ?? $this->currentSchoolYear()), + 'semester' => (string) ($event['semester'] ?? $this->currentSemester()), 'student_name' => $event['person_name'] ?? '', 'slip_date' => $event['event_date'] ?? now()->toDateString(), 'time_in' => Carbon::parse($event['official_entry_time'] ?? now())->format('H:i:s'), @@ -389,12 +428,15 @@ class AttendanceManagementService private function recordBadgeException(int $eventId, array $person, Carbon $date, string $reason, int $count, ?User $actor, ?string $notes): void { + $academicContext = $this->academicContextForDate($date); DB::table('badge_exceptions')->insert([ 'attendance_management_event_id' => $eventId, 'person_type' => $person['type'], 'person_id' => $person['id'], 'person_name' => $person['name'], 'exception_date' => $date->toDateString(), + 'school_year' => $academicContext['school_year'], + 'semester' => $academicContext['semester'], 'exception_type' => 'manual_entry', 'badge_status' => $reason, 'exception_count' => $count, @@ -431,28 +473,55 @@ class AttendanceManagementService ->count(); } - private function summaryForDate(string $date): array + private function summaryForDate(string $date, array $rows = [], string $schoolYear = '', string $semester = ''): array { - $base = DB::table('attendance_management_events')->whereDate('event_date', $date); + $badgeExceptions = DB::table('badge_exceptions')->whereDate('exception_date', $date); + $this->applyAcademicFilters($badgeExceptions, $schoolYear, $semester); + + $lateSlipReprints = DB::table('late_slip_reprints')->whereDate('reprinted_at', $date); + $this->applyAcademicFilters($lateSlipReprints, $schoolYear, $semester); + return [ - 'present' => (clone $base)->where('attendance_status', self::STATUS_PRESENT)->count(), - 'absent' => (clone $base)->where('attendance_status', self::STATUS_ABSENT)->count(), - 'late' => (clone $base)->where('attendance_status', self::STATUS_LATE)->count(), - 'early_dismissal' => (clone $base)->where('attendance_status', self::STATUS_EARLY_DISMISSAL)->count(), - 'not_reported' => (clone $base)->where('report_status', 'not_reported')->count(), - 'follow_up_required' => (clone $base)->where('follow_up_required', true)->where('follow_up_completed', false)->count(), - 'badge_exceptions' => DB::table('badge_exceptions')->whereDate('exception_date', $date)->count(), - 'late_slip_reprints' => DB::table('late_slip_reprints')->whereDate('reprinted_at', $date)->count(), + 'present' => $this->countRowsByStatus($rows, self::STATUS_PRESENT), + 'absent' => $this->countRowsByStatus($rows, self::STATUS_ABSENT), + 'late' => $this->countRowsByStatus($rows, self::STATUS_LATE), + 'early_dismissal' => $this->countRowsByStatus($rows, self::STATUS_EARLY_DISMISSAL), + 'not_reported' => $this->countRowsByReportStatus($rows, 'not_reported'), + 'follow_up_required' => $this->countRowsRequiringFollowUp($rows), + 'badge_exceptions' => $badgeExceptions->count(), + 'late_slip_reprints' => $lateSlipReprints->count(), ]; } - private function availableFilters(): array + private function availableFilters(array $rows = []): array { + $schoolYears = array_values(array_unique(array_filter(array_map( + static fn (array $row): string => trim((string) ($row['school_year'] ?? '')), + $rows, + )))); + rsort($schoolYears); + + $currentYear = $this->currentSchoolYear(); + if ($currentYear !== '' && ! in_array($currentYear, $schoolYears, true)) { + array_unshift($schoolYears, $currentYear); + } + + $semesters = array_values(array_unique(array_filter(array_map( + static fn (array $row): string => trim((string) ($row['semester'] ?? '')), + $rows, + )))); + $currentSemester = $this->currentSemester(); + if ($currentSemester !== '' && ! in_array($currentSemester, $semesters, true)) { + array_unshift($semesters, $currentSemester); + } + return [ 'attendance_status' => [self::STATUS_PRESENT, self::STATUS_ABSENT, self::STATUS_ABSENT_PENDING, self::STATUS_LATE, self::STATUS_LATE_WITHOUT_SLIP, self::STATUS_EARLY_DISMISSAL], 'report_status' => ['reported', 'not_reported', 'pending_clarification'], 'risk_level' => ['low', 'medium', 'high', 'very_high', 'critical', 'severe'], 'person_type' => ['student', 'staff', 'contractor', 'visitor'], + 'school_years' => $schoolYears, + 'semesters' => $semesters, ]; } @@ -462,6 +531,91 @@ class AttendanceManagementService return Carbon::parse($date->toDateString().' '.$configured, $date->timezone); } + private function hydrateAcademicContext(array $row): array + { + $date = trim((string) ($row['event_date'] ?? '')); + if ($date === '') { + return $row; + } + + $context = $this->academicContextForDate(Carbon::parse($date)); + if (trim((string) ($row['school_year'] ?? '')) === '') { + $row['school_year'] = $context['school_year']; + } + if (trim((string) ($row['semester'] ?? '')) === '') { + $row['semester'] = $context['semester']; + } + + return $row; + } + + private function academicContextForDate(Carbon $date): array + { + $derivedYear = $this->deriveSchoolYearForDate($date); + $derivedSemester = $this->deriveSemesterForDate($date); + + if ($date->isSameDay(now())) { + return [ + 'school_year' => $this->currentSchoolYear() ?: $derivedYear, + 'semester' => $this->currentSemester() ?: $derivedSemester, + ]; + } + + return [ + 'school_year' => $derivedYear ?: $this->currentSchoolYear(), + 'semester' => $derivedSemester ?: $this->currentSemester(), + ]; + } + + private function deriveSchoolYearForDate(Carbon $date): string + { + $startYear = $date->month >= 8 ? $date->year : ($date->year - 1); + + return sprintf('%d-%d', $startYear, $startYear + 1); + } + + private function deriveSemesterForDate(Carbon $date): string + { + return ($date->month >= 8 || $date->month === 1) ? 'Fall' : 'Spring'; + } + + private function currentSchoolYear(): string + { + return trim((string) (Configuration::getConfigValueByKey('school_year') ?? '')); + } + + private function currentSemester(): string + { + return $this->normalizeSemester(Configuration::getConfig('semester')); + } + + private function applyAcademicFilters($query, string $schoolYear = '', string $semester = ''): void + { + if ($schoolYear !== '') { + $query->where('school_year', $schoolYear); + } + if ($semester !== '') { + $query->where('semester', $semester); + } + } + + private function countRowsByStatus(array $rows, string $status): int + { + return count(array_filter($rows, static fn (array $row): bool => (string) ($row['attendance_status'] ?? '') === $status)); + } + + private function countRowsByReportStatus(array $rows, string $status): int + { + return count(array_filter($rows, static fn (array $row): bool => (string) ($row['report_status'] ?? '') === $status)); + } + + private function countRowsRequiringFollowUp(array $rows): int + { + return count(array_filter($rows, static function (array $row): bool { + return (bool) ($row['follow_up_required'] ?? false) && ! (bool) ($row['follow_up_completed'] ?? false); + })); + } + private function dateTime($value): Carbon { return $value instanceof Carbon ? $value : Carbon::parse((string) $value); } private function dateOnly($value): string { return Carbon::parse((string) $value)->toDateString(); } @@ -471,6 +625,19 @@ class AttendanceManagementService return in_array($v, ['reported', 'not_reported', 'pending_clarification'], true) ? $v : 'pending_clarification'; } + private function normalizeSemester($value): string + { + $semester = strtolower(trim((string) $value)); + if ($semester === 'fall') { + return 'Fall'; + } + if ($semester === 'spring') { + return 'Spring'; + } + + return ''; + } + private function combinationCode(int $abs, int $late, int $ed, int $badge): string { $parts = []; diff --git a/app/Services/SchoolYears/SchoolYearClosureService.php b/app/Services/SchoolYears/SchoolYearClosureService.php new file mode 100644 index 00000000..6c10dc74 --- /dev/null +++ b/app/Services/SchoolYears/SchoolYearClosureService.php @@ -0,0 +1,1066 @@ +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(), + ]); + } +} diff --git a/app/Services/SchoolYears/SchoolYearResolver.php b/app/Services/SchoolYears/SchoolYearResolver.php new file mode 100644 index 00000000..ffc07421 --- /dev/null +++ b/app/Services/SchoolYears/SchoolYearResolver.php @@ -0,0 +1,72 @@ +where('is_current', true)->orderByDesc('id')->first(); + } + + $existing = SchoolYear::query()->where('name', $current)->first(); + if ($existing) { + if ($existing->status !== SchoolYear::STATUS_ACTIVE || !$existing->is_current) { + SchoolYear::query()->where('id', '!=', $existing->id)->where('is_current', true)->update([ + 'is_current' => false, + 'updated_at' => now(), + ]); + $existing->status = SchoolYear::STATUS_ACTIVE; + $existing->is_current = true; + $existing->save(); + } + + return $existing->refresh(); + } + + [$startDate, $endDate] = $this->inferDatesFromName($current); + SchoolYear::query()->where('is_current', true)->update([ + 'is_current' => false, + 'updated_at' => now(), + ]); + + return SchoolYear::query()->create([ + 'name' => $current, + 'start_date' => $startDate, + 'end_date' => $endDate, + 'status' => SchoolYear::STATUS_ACTIVE, + 'is_current' => true, + ]); + } + + public function inferDatesFromName(string $schoolYear): array + { + if (preg_match('/^(\\d{4})-(\\d{4})$/', trim($schoolYear), $matches) === 1) { + return [ + sprintf('%s-09-01', $matches[1]), + sprintf('%s-06-30', $matches[2]), + ]; + } + + $year = (int) date('Y'); + + return [ + sprintf('%d-09-01', $year), + sprintf('%d-06-30', $year + 1), + ]; + } + + public function suggestNextName(string $schoolYear): ?string + { + if (preg_match('/^(\\d{4})-(\\d{4})$/', trim($schoolYear), $matches) !== 1) { + return null; + } + + return sprintf('%d-%d', (int) $matches[1] + 1, (int) $matches[2] + 1); + } +} diff --git a/app/Services/SchoolYears/SchoolYearWriteGuard.php b/app/Services/SchoolYears/SchoolYearWriteGuard.php new file mode 100644 index 00000000..c44632d7 --- /dev/null +++ b/app/Services/SchoolYears/SchoolYearWriteGuard.php @@ -0,0 +1,42 @@ + is_string($year) ? trim($year) : null, + $schoolYears + )))); + + if ($years === []) { + return; + } + + $statuses = SchoolYear::query() + ->whereIn('name', $years) + ->get(['name', 'status']) + ->keyBy('name'); + + foreach ($years as $year) { + $row = $statuses->get($year); + if ($row && $row->status !== SchoolYear::STATUS_ACTIVE) { + throw new RuntimeException(sprintf( + 'School year %s is %s and read-only.', + $year, + $row->status + )); + } + } + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 042cdc1a..4f387ec5 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -35,6 +35,7 @@ return Application::configure(basePath: dirname(__DIR__)) 'parent.progress' => \App\Http\Middleware\EnsureParentProgressAccess::class, 'teacher.progress' => \App\Http\Middleware\EnsureClassProgressTeacherPortalAccess::class, 'class_progress.teacher' => \App\Http\Middleware\EnsureClassProgressTeacherPortalAccess::class, + 'school_year.editable' => \App\Http\Middleware\EnsureSchoolYearEditable::class, 'print_requests.teacher' => \App\Http\Middleware\EnsurePrintRequestsTeacherAccess::class, 'print_requests.admin' => \App\Http\Middleware\EnsurePrintRequestsAdminAccess::class, 'badge_scan.logs' => \App\Http\Middleware\EnsureBadgeScanLogsAccess::class, diff --git a/database/migrations/2026_06_05_000000_create_school_year_closure_tables.php b/database/migrations/2026_06_05_000000_create_school_year_closure_tables.php new file mode 100644 index 00000000..5c0a638a --- /dev/null +++ b/database/migrations/2026_06_05_000000_create_school_year_closure_tables.php @@ -0,0 +1,146 @@ +id(); + $table->string('name', 50)->unique(); + $table->date('start_date'); + $table->date('end_date'); + $table->string('status', 20)->default('draft')->index(); + $table->boolean('is_current')->default(false)->index(); + $table->timestamp('closed_at')->nullable(); + $table->unsignedBigInteger('closed_by')->nullable()->index(); + $table->timestamps(); + }); + } + + if (!Schema::hasTable('parent_accounts')) { + Schema::create('parent_accounts', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('parent_id')->index(); + $table->string('school_year', 50)->index(); + $table->decimal('opening_balance', 12, 2)->default(0); + $table->decimal('current_balance', 12, 2)->default(0); + $table->timestamps(); + $table->unique(['parent_id', 'school_year'], 'parent_accounts_parent_year_unique'); + }); + } + + if (!Schema::hasTable('parent_balance_transfers')) { + Schema::create('parent_balance_transfers', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('parent_id')->index(); + $table->string('from_school_year', 50)->index(); + $table->string('to_school_year', 50)->index(); + $table->decimal('amount', 12, 2)->default(0); + $table->string('status', 30)->default('pending')->index(); + $table->json('source_summary_json')->nullable(); + $table->unsignedBigInteger('new_invoice_id')->nullable()->index(); + $table->unsignedBigInteger('created_by')->nullable()->index(); + $table->timestamps(); + $table->unique(['parent_id', 'from_school_year', 'to_school_year'], 'parent_balance_transfers_parent_year_unique'); + }); + } + + if (!Schema::hasTable('audit_logs')) { + Schema::create('audit_logs', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('user_id')->nullable()->index(); + $table->string('action', 100)->index(); + $table->string('table_name', 100)->nullable()->index(); + $table->unsignedBigInteger('record_id')->nullable()->index(); + $table->json('old_value')->nullable(); + $table->json('new_value')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamp('created_at')->nullable(); + }); + } + + if (Schema::hasTable('invoices') && !Schema::hasColumn('invoices', 'balance_transfer_id')) { + Schema::table('invoices', function (Blueprint $table) { + $table->unsignedBigInteger('balance_transfer_id')->nullable()->index()->after('semester'); + }); + } + + $this->seedCurrentSchoolYear(); + } + + public function down(): void + { + if (Schema::hasTable('invoices') && Schema::hasColumn('invoices', 'balance_transfer_id')) { + Schema::table('invoices', function (Blueprint $table) { + $table->dropColumn('balance_transfer_id'); + }); + } + + Schema::dropIfExists('audit_logs'); + Schema::dropIfExists('parent_balance_transfers'); + Schema::dropIfExists('parent_accounts'); + Schema::dropIfExists('school_years'); + } + + private function seedCurrentSchoolYear(): void + { + if (!Schema::hasTable('configuration') || !Schema::hasTable('school_years')) { + return; + } + + $current = DB::table('configuration') + ->where('config_key', 'school_year') + ->orderByDesc('id') + ->value('config_value'); + + $current = is_string($current) ? trim($current) : ''; + if ($current === '') { + return; + } + + $exists = DB::table('school_years')->where('name', $current)->exists(); + if ($exists) { + DB::table('school_years')->where('name', $current)->update([ + 'status' => 'active', + 'is_current' => true, + 'updated_at' => now(), + ]); + return; + } + + [$startDate, $endDate] = $this->inferDates($current); + + DB::table('school_years')->insert([ + 'name' => $current, + 'start_date' => $startDate, + 'end_date' => $endDate, + 'status' => 'active', + 'is_current' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function inferDates(string $schoolYear): array + { + if (preg_match('/^(\\d{4})-(\\d{4})$/', $schoolYear, $matches) === 1) { + return [ + sprintf('%s-09-01', $matches[1]), + sprintf('%s-06-30', $matches[2]), + ]; + } + + $year = (int) date('Y'); + + return [ + sprintf('%d-09-01', $year), + sprintf('%d-06-30', $year + 1), + ]; + } +}; diff --git a/database/migrations/2026_06_07_120000_add_academic_context_to_attendance_management_tables.php b/database/migrations/2026_06_07_120000_add_academic_context_to_attendance_management_tables.php new file mode 100644 index 00000000..c55a952a --- /dev/null +++ b/database/migrations/2026_06_07_120000_add_academic_context_to_attendance_management_tables.php @@ -0,0 +1,165 @@ +string('school_year', 20)->nullable()->after('event_date')->index(); + } + if (! Schema::hasColumn('attendance_management_events', 'semester')) { + $table->string('semester', 20)->nullable()->after('school_year')->index(); + } + }); + + DB::table('attendance_management_events') + ->select('id', 'event_date', 'school_year', 'semester') + ->orderBy('id') + ->chunkById(200, function ($rows): void { + foreach ($rows as $row) { + $schoolYear = trim((string) ($row->school_year ?? '')); + $semester = trim((string) ($row->semester ?? '')); + if ($schoolYear !== '' && $semester !== '') { + continue; + } + + $date = CarbonImmutable::parse((string) $row->event_date); + DB::table('attendance_management_events') + ->where('id', $row->id) + ->update([ + 'school_year' => $schoolYear !== '' ? $schoolYear : $this->deriveSchoolYearForDate($date), + 'semester' => $semester !== '' ? $semester : $this->deriveSemesterForDate($date), + ]); + } + }); + } + + if (Schema::hasTable('badge_exceptions')) { + Schema::table('badge_exceptions', function (Blueprint $table) { + if (! Schema::hasColumn('badge_exceptions', 'school_year')) { + $table->string('school_year', 20)->nullable()->after('exception_date')->index(); + } + if (! Schema::hasColumn('badge_exceptions', 'semester')) { + $table->string('semester', 20)->nullable()->after('school_year')->index(); + } + }); + + DB::table('badge_exceptions') + ->select('id', 'exception_date', 'school_year', 'semester') + ->orderBy('id') + ->chunkById(200, function ($rows): void { + foreach ($rows as $row) { + $schoolYear = trim((string) ($row->school_year ?? '')); + $semester = trim((string) ($row->semester ?? '')); + if ($schoolYear !== '' && $semester !== '') { + continue; + } + + $date = CarbonImmutable::parse((string) $row->exception_date); + DB::table('badge_exceptions') + ->where('id', $row->id) + ->update([ + 'school_year' => $schoolYear !== '' ? $schoolYear : $this->deriveSchoolYearForDate($date), + 'semester' => $semester !== '' ? $semester : $this->deriveSemesterForDate($date), + ]); + } + }); + } + + if (Schema::hasTable('late_slip_reprints')) { + Schema::table('late_slip_reprints', function (Blueprint $table) { + if (! Schema::hasColumn('late_slip_reprints', 'school_year')) { + $table->string('school_year', 20)->nullable()->after('reprinted_at')->index(); + } + if (! Schema::hasColumn('late_slip_reprints', 'semester')) { + $table->string('semester', 20)->nullable()->after('school_year')->index(); + } + }); + + DB::table('late_slip_reprints') + ->leftJoin('attendance_management_events as ame', 'ame.id', '=', 'late_slip_reprints.attendance_management_event_id') + ->select( + 'late_slip_reprints.id', + 'late_slip_reprints.reprinted_at', + 'late_slip_reprints.school_year', + 'late_slip_reprints.semester', + 'ame.school_year as event_school_year', + 'ame.semester as event_semester', + ) + ->orderBy('late_slip_reprints.id') + ->chunkById(200, function ($rows): void { + foreach ($rows as $row) { + $schoolYear = trim((string) ($row->school_year ?? '')); + $semester = trim((string) ($row->semester ?? '')); + if ($schoolYear !== '' && $semester !== '') { + continue; + } + + $date = CarbonImmutable::parse((string) $row->reprinted_at); + DB::table('late_slip_reprints') + ->where('id', $row->id) + ->update([ + 'school_year' => $schoolYear !== '' ? $schoolYear : (trim((string) ($row->event_school_year ?? '')) ?: $this->deriveSchoolYearForDate($date)), + 'semester' => $semester !== '' ? $semester : (trim((string) ($row->event_semester ?? '')) ?: $this->deriveSemesterForDate($date)), + ]); + } + }, 'late_slip_reprints.id', 'id'); + } + } + + public function down(): void + { + if (Schema::hasTable('late_slip_reprints')) { + Schema::table('late_slip_reprints', function (Blueprint $table) { + if (Schema::hasColumn('late_slip_reprints', 'semester')) { + $table->dropColumn('semester'); + } + if (Schema::hasColumn('late_slip_reprints', 'school_year')) { + $table->dropColumn('school_year'); + } + }); + } + + if (Schema::hasTable('badge_exceptions')) { + Schema::table('badge_exceptions', function (Blueprint $table) { + if (Schema::hasColumn('badge_exceptions', 'semester')) { + $table->dropColumn('semester'); + } + if (Schema::hasColumn('badge_exceptions', 'school_year')) { + $table->dropColumn('school_year'); + } + }); + } + + if (Schema::hasTable('attendance_management_events')) { + Schema::table('attendance_management_events', function (Blueprint $table) { + if (Schema::hasColumn('attendance_management_events', 'semester')) { + $table->dropColumn('semester'); + } + if (Schema::hasColumn('attendance_management_events', 'school_year')) { + $table->dropColumn('school_year'); + } + }); + } + } + + private function deriveSchoolYearForDate(CarbonImmutable $date): string + { + $startYear = $date->month >= 8 ? $date->year : ($date->year - 1); + + return sprintf('%d-%d', $startYear, $startYear + 1); + } + + private function deriveSemesterForDate(CarbonImmutable $date): string + { + return ($date->month >= 8 || $date->month === 1) ? 'Fall' : 'Spring'; + } +}; diff --git a/docs/pages need review and fix.md b/docs/pages need review and fix.md new file mode 100644 index 00000000..7dcae847 --- /dev/null +++ b/docs/pages need review and fix.md @@ -0,0 +1,42 @@ +http://localhost:5173/app/administrator/attendance/badge-scans +http://localhost:5173/app/administrator/sections/auto-distribute +http://localhost:5173/app/administrator/sections/promotion-totals +http://localhost:5173/app/administrator/parent-email-extractor +http://localhost:5173/app/administrator/parent_profiles +http://localhost:5173/app/administrator/student_profiles +http://localhost:5173/app/whatsapp?school_year=2025-2026 +http://localhost:5173/app/competition-winners +http://localhost:5173/app/admin/progress +http://localhost:5173/app/administrator/subject-curriculum +http://localhost:5173/app/administrator/teacher-submissions +http://localhost:5173/app/administrator/calendar +http://localhost:5173/app/administrator/events?school_year=2025-2026 +http://localhost:5173/app/administrator/exam-drafts +http://localhost:5173/app/administrator/family +http://localhost:5173/app/families/import-legacy +http://localhost:5173/app/inventory/book +http://localhost:5173/app/inventory +http://localhost:5173/app/inventory/kitchen +http://localhost:5173/app/inventory/office +http://localhost:5173/app/inventory/summary-all +http://localhost:5173/app/inventory/movements +http://localhost:5173/app/administrator/notifications_alerts +http://localhost:5173/app/administrator/certificates?school_year=2025-2026 +http://localhost:5173/app/administrator/class-prep +http://localhost:5173/app/admin/print-requests +http://localhost:5173/app/administrator/printables/report-cards +http://localhost:5173/app/grading/decisions?school_year=2025-2026 +http://localhost:5173/app/grading/below-60/decisions?school_year=2025-2026 +http://localhost:5173/app/grading?school_year=2025-2026&semester=Fall +http://localhost:5173/app/staff +http://localhost:5173/app/administrator/teacher_class_assignment +http://localhost:5173/app/administrator/emergency-contacts +http://localhost:5173/app/administrator/enroll-withdraw/enrollment-withdrawal +http://localhost:5173/app/grading/placement +http://localhost:5173/app/administrator/removed_students +http://localhost:5173/app/administrator/calendar_view?school_year=2025-2026 +http://localhost:5173/app/administrator/student_class_assignment +http://localhost:5173/app/administrator/student_profiles +http://localhost:5173/app/administrator/absence +http://localhost:5173/app/administrator/ip_bans +http://localhost:5173/app/user/user-list diff --git a/resources/views/admin/school-years.blade.php b/resources/views/admin/school-years.blade.php new file mode 100644 index 00000000..b1e2dd28 --- /dev/null +++ b/resources/views/admin/school-years.blade.php @@ -0,0 +1,1585 @@ + + + + + + School Years Management + + + +
+ + +
+
+
+ +

School Years Management

+

+ Replace the placeholder with a real control surface for creating draft years, editing metadata, + reopening a year, validating closure, previewing promotions and family balances, and closing the + active year through the same JWT-protected Laravel API used by the rest of the admin app. +

+
+
+
+ SPA path + /app/administrator/school-years +
+
+ API auth + JWT Authorization: Bearer +
+ +
+
+ +
+
+ +
+
+
+
+
+

School Years

+
+ Select a year to inspect it. Opening or reopening a year switches the live configuration + and leaves the rest visible as historical records. +
+
+
+
+
+ +
+
+
+

Create Draft Year

+
Create the next year ahead of activation so closure can target a prepared record.
+
+
+
+
+ + +
+ +
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+ + + + + diff --git a/routes/api.php b/routes/api.php index 5262a04a..25180903 100644 --- a/routes/api.php +++ b/routes/api.php @@ -126,6 +126,7 @@ use App\Http\Controllers\Api\Documentation\DocsCatalogController; use App\Http\Controllers\Api\Parents\AuthorizedUserInviteController; use App\Http\Controllers\Api\Administrator\AdministratorPromotionController; use App\Http\Controllers\Api\Parents\ParentPromotionController; +use App\Http\Controllers\Api\SchoolYears\SchoolYearController; use App\Http\Controllers\Api\Public\PublicWinnersController; use App\Http\Controllers\Api\ScannerController; use App\Http\Controllers\Api\Staff\TimeOffNotificationController; @@ -154,6 +155,10 @@ Route::middleware('auth.docs')->group(function () { Route::get('docs/public', [ApiDocsAdminController::class, 'public'])->name('api-docs.public'); +// Legacy email extractor endpoints still used by the SPA parity page. +Route::middleware('auth:api')->get('emails', [EmailExtractorController::class, 'emails']); +Route::middleware('auth:api')->post('compare', [EmailExtractorController::class, 'compare']); + Route::get('access_denied', [AccessDeniedController::class, 'accessDenied'])->name('access_denied'); Route::get('certificates/verify/{token}', [CertificateController::class, 'verify']) @@ -302,13 +307,13 @@ Route::prefix('v1')->group(function () { ->name('api.v1.administrator.attendance-templates.bootstrap'); Route::get('absence', [AdministratorAbsenceController::class, 'index']); - Route::post('absence', [AdministratorAbsenceController::class, 'store']); + Route::post('absence', [AdministratorAbsenceController::class, 'store'])->middleware('school_year.editable'); Route::get('dashboard/metrics', [AdministratorDashboardController::class, 'metrics']); Route::get('dashboard/user-search', [AdministratorDashboardController::class, 'userSearch']); Route::get('teacher-submissions', [AdministratorTeacherSubmissionController::class, 'index']); - Route::post('teacher-submissions/notify', [AdministratorTeacherSubmissionController::class, 'notify']); + Route::post('teacher-submissions/notify', [AdministratorTeacherSubmissionController::class, 'notify'])->middleware('school_year.editable'); Route::prefix('trophy')->group(function () { Route::get('/', [AdministratorTrophyController::class, 'index']); @@ -317,22 +322,22 @@ Route::prefix('v1')->group(function () { }); Route::get('teacher-class/assignments', [TeacherClassAssignmentController::class, 'index']); - Route::post('teacher-class/assign', [TeacherClassAssignmentController::class, 'store']); - Route::post('teacher-class/delete', [TeacherClassAssignmentController::class, 'destroy']); + Route::post('teacher-class/assign', [TeacherClassAssignmentController::class, 'store'])->middleware('school_year.editable'); + Route::post('teacher-class/delete', [TeacherClassAssignmentController::class, 'destroy'])->middleware('school_year.editable'); Route::get('notifications/alerts', [AdministratorNotificationController::class, 'alerts']); - Route::post('notifications/alerts', [AdministratorNotificationController::class, 'saveAlerts']); + Route::post('notifications/alerts', [AdministratorNotificationController::class, 'saveAlerts'])->middleware('school_year.editable'); Route::get('notifications/print-recipients', [AdministratorNotificationController::class, 'printRecipients']); - Route::post('notifications/print-recipients', [AdministratorNotificationController::class, 'savePrintRecipients']); + Route::post('notifications/print-recipients', [AdministratorNotificationController::class, 'savePrintRecipients'])->middleware('school_year.editable'); Route::get('enrollment-withdrawal', [AdministratorEnrollmentController::class, 'index']); Route::get('enrollment-withdrawal/new-students', [AdministratorEnrollmentController::class, 'newStudents']); - Route::post('enrollment-withdrawal/update-statuses', [AdministratorEnrollmentController::class, 'updateStatuses']); + Route::post('enrollment-withdrawal/update-statuses', [AdministratorEnrollmentController::class, 'updateStatuses'])->middleware('school_year.editable'); Route::get('enroll-withdrawal', [AdministratorEnrollmentController::class, 'index']); Route::get('enroll-withdrawal/new-students', [AdministratorEnrollmentController::class, 'newStudents']); - Route::post('enroll-withdrawal/update-statuses', [AdministratorEnrollmentController::class, 'updateStatuses']); + Route::post('enroll-withdrawal/update-statuses', [AdministratorEnrollmentController::class, 'updateStatuses'])->middleware('school_year.editable'); - Route::prefix('promotions')->group(function () { + Route::prefix('promotions')->middleware('school_year.editable')->group(function () { Route::get('/', [AdministratorPromotionController::class, 'index']); Route::get('summary', [AdministratorPromotionController::class, 'summary']); Route::post('evaluate', [AdministratorPromotionController::class, 'evaluate']); @@ -392,9 +397,9 @@ Route::prefix('v1')->group(function () { Route::get('/', [ExpenseController::class, 'index']); Route::get('options', [ExpenseController::class, 'options']); Route::get('{id}', [ExpenseController::class, 'show']); - Route::post('/', [ExpenseController::class, 'store']); - Route::put('{id}', [ExpenseController::class, 'update']); - Route::post('{id}/status', [ExpenseController::class, 'updateStatus']); + Route::post('/', [ExpenseController::class, 'store'])->middleware('school_year.editable'); + Route::put('{id}', [ExpenseController::class, 'update'])->middleware('school_year.editable'); + Route::post('{id}/status', [ExpenseController::class, 'updateStatus'])->middleware('school_year.editable'); }); Route::prefix('reports')->group(function () { @@ -471,7 +476,20 @@ Route::prefix('v1')->group(function () { }); }); - Route::middleware('auth:api')->prefix('attendance')->group(function () { + Route::middleware(['auth:api', 'admin.access'])->prefix('school-years')->group(function () { + Route::get('/', [SchoolYearController::class, 'index']); + Route::post('/', [SchoolYearController::class, 'store']); + Route::patch('{schoolYear}', [SchoolYearController::class, 'update'])->whereNumber('schoolYear'); + Route::get('{schoolYear}/summary', [SchoolYearController::class, 'summary'])->whereNumber('schoolYear'); + Route::post('{schoolYear}/validate-close', [SchoolYearController::class, 'validateClose'])->whereNumber('schoolYear'); + Route::post('{schoolYear}/preview-close', [SchoolYearController::class, 'previewClose'])->whereNumber('schoolYear'); + Route::post('{schoolYear}/close', [SchoolYearController::class, 'close'])->whereNumber('schoolYear'); + Route::post('{schoolYear}/reopen', [SchoolYearController::class, 'reopen'])->whereNumber('schoolYear'); + Route::get('{schoolYear}/parent-balances', [SchoolYearController::class, 'parentBalances'])->whereNumber('schoolYear'); + Route::get('{schoolYear}/promotion-preview', [SchoolYearController::class, 'promotionPreview'])->whereNumber('schoolYear'); + }); + + Route::middleware(['auth:api', 'school_year.editable'])->prefix('attendance')->group(function () { // Teacher Route::get('/teacher/grid', [TeacherAttendanceApiController::class, 'grid']); Route::get('/teacher/form', [TeacherAttendanceApiController::class, 'form']); @@ -513,7 +531,7 @@ Route::prefix('v1')->group(function () { }); }); - Route::middleware('auth:api')->prefix('attendance-tracking')->group(function () { + Route::middleware(['auth:api', 'school_year.editable'])->prefix('attendance-tracking')->group(function () { Route::get('/pending-violations', [AttendanceTrackingController::class, 'pendingViolations']); Route::get('/notified-violations', [AttendanceTrackingController::class, 'notifiedViolations']); Route::get('/student-case/{studentId}', [AttendanceTrackingController::class, 'studentCase']); @@ -525,7 +543,7 @@ Route::prefix('v1')->group(function () { Route::post('/save-notification-note', [AttendanceTrackingController::class, 'saveNotificationNote']); }); - Route::middleware('auth:api')->prefix('attendance-comment-templates')->group(function () { + Route::middleware(['auth:api', 'school_year.editable'])->prefix('attendance-comment-templates')->group(function () { Route::get('/', [AttendanceCommentTemplateController::class, 'index'])->name('api.v1.attendance-comment-templates.index'); Route::get('list-data', [AttendanceCommentTemplateController::class, 'listData']) ->name('api.v1.attendance-comment-templates.list-data'); @@ -538,19 +556,19 @@ Route::prefix('v1')->group(function () { /* | Legacy attendance template list/save/delete endpoints (auth:api). */ - Route::middleware('auth:api')->prefix('attendance-templates')->group(function () { + Route::middleware(['auth:api', 'school_year.editable'])->prefix('attendance-templates')->group(function () { Route::get('/', [AttendanceCommentTemplateController::class, 'listData'])->name('api.v1.attendance-templates.legacy'); Route::post('save', [AttendanceCommentTemplateController::class, 'legacySave']); Route::post('delete', [AttendanceCommentTemplateController::class, 'legacyDelete']); }); - Route::middleware('auth:api')->prefix('assignments')->group(function () { + Route::middleware(['auth:api', 'school_year.editable'])->prefix('assignments')->group(function () { Route::get('/', [AssignmentApiController::class, 'index']); Route::post('/', [AssignmentApiController::class, 'store']); Route::get('/class-assignment-data', [AssignmentApiController::class, 'classAssignmentData']); }); - Route::middleware('auth:api')->group(function () { + Route::middleware(['auth:api', 'school_year.editable'])->group(function () { // Legacy alias: same handler as GET /api/v1/scores/homework Route::get('teacher/homework-list', [HomeworkScoreController::class, 'index']); // Legacy alias: teacher add-homework page and save action diff --git a/routes/web.php b/routes/web.php index 21776f68..11a0fd3f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Api\Auth\AuthSessionController; use App\Http\Controllers\Api\Auth\SessionTimeoutController; use App\Http\Controllers\Api\System\DashboardRedirectController; +use App\Http\Controllers\Web\SchoolYearAdminPageController; use App\Http\Controllers\Web\TeacherCalendarPageController; use Illuminate\Support\Facades\Route; @@ -80,6 +81,27 @@ Route::middleware('web')->group(function () { ->name('teacher.calendar'); Route::middleware('auth')->get('app/teacher/calendar', [TeacherCalendarPageController::class, 'show']) ->name('app.teacher.calendar'); + Route::middleware(['auth', 'admin.access'])->get('administrator/school-years', [SchoolYearAdminPageController::class, 'show']) + ->name('admin.school-years'); + Route::middleware(['auth', 'admin.access'])->get('app/administrator/school-years', [SchoolYearAdminPageController::class, 'show']) + ->name('app.admin.school-years'); + Route::middleware(['auth', 'admin.access'])->post('administrator/school-years', [SchoolYearAdminPageController::class, 'store']) + ->name('admin.school-years.store'); + Route::middleware(['auth', 'admin.access'])->patch('administrator/school-years/{schoolYear}', [SchoolYearAdminPageController::class, 'update']) + ->whereNumber('schoolYear') + ->name('admin.school-years.update'); + Route::middleware(['auth', 'admin.access'])->post('administrator/school-years/{schoolYear}/validate-close', [SchoolYearAdminPageController::class, 'validateClose']) + ->whereNumber('schoolYear') + ->name('admin.school-years.validate-close'); + Route::middleware(['auth', 'admin.access'])->post('administrator/school-years/{schoolYear}/preview-close', [SchoolYearAdminPageController::class, 'previewClose']) + ->whereNumber('schoolYear') + ->name('admin.school-years.preview-close'); + Route::middleware(['auth', 'admin.access'])->post('administrator/school-years/{schoolYear}/close', [SchoolYearAdminPageController::class, 'close']) + ->whereNumber('schoolYear') + ->name('admin.school-years.close'); + Route::middleware(['auth', 'admin.access'])->post('administrator/school-years/{schoolYear}/reopen', [SchoolYearAdminPageController::class, 'reopen']) + ->whereNumber('schoolYear') + ->name('admin.school-years.reopen'); Route::middleware('auth')->get('teacher/absence-vacation', function () { return redirect('/app/teacher/absence-vacation'); })->name('teacher.absence-vacation'); diff --git a/tests/Feature/Api/V1/Administrator/AdministratorTeacherSubmissionControllerTest.php b/tests/Feature/Api/V1/Administrator/AdministratorTeacherSubmissionControllerTest.php index 7330aee5..26020d38 100644 --- a/tests/Feature/Api/V1/Administrator/AdministratorTeacherSubmissionControllerTest.php +++ b/tests/Feature/Api/V1/Administrator/AdministratorTeacherSubmissionControllerTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature\Api\Administrator; use App\Models\User; use App\Services\Administrator\TeacherSubmissionNotificationService; use App\Services\Administrator\TeacherSubmissionReportService; +use App\Http\Middleware\EnsurePrintRequestsAdminAccess; use Illuminate\Foundation\Testing\RefreshDatabase; use Laravel\Sanctum\Sanctum; use Mockery; @@ -22,12 +23,14 @@ class AdministratorTeacherSubmissionControllerTest extends TestCase public function test_can_get_teacher_submission_report(): void { + $this->withoutMiddleware(EnsurePrintRequestsAdminAccess::class); $user = User::factory()->create(); - Sanctum::actingAs($user); + Sanctum::actingAs($user, [], 'api'); $mock = Mockery::mock(TeacherSubmissionReportService::class); $mock->shouldReceive('report') ->once() + ->with([]) ->andReturn([ 'rows' => [], 'semester' => 'Fall', @@ -55,8 +58,9 @@ class AdministratorTeacherSubmissionControllerTest extends TestCase public function test_notify_requires_notify_payload(): void { + $this->withoutMiddleware(EnsurePrintRequestsAdminAccess::class); $user = User::factory()->create(); - Sanctum::actingAs($user); + Sanctum::actingAs($user, [], 'api'); $response = $this->postJson('/api/v1/administrator/teacher-submissions/notify', [ 'missing_items' => [], @@ -68,8 +72,9 @@ class AdministratorTeacherSubmissionControllerTest extends TestCase public function test_notify_calls_service(): void { + $this->withoutMiddleware(EnsurePrintRequestsAdminAccess::class); $user = User::factory()->create(); - Sanctum::actingAs($user); + Sanctum::actingAs($user, [], 'api'); $payload = [ 'notify' => [ @@ -105,4 +110,4 @@ class AdministratorTeacherSubmissionControllerTest extends TestCase 'failed' => 0, ]); } -} \ No newline at end of file +} diff --git a/tests/Feature/Api/V1/Administrator/AdministratorTeacherSubmissionNotifyApiTest.php b/tests/Feature/Api/V1/Administrator/AdministratorTeacherSubmissionNotifyApiTest.php index cd2a7d4d..e772dc70 100644 --- a/tests/Feature/Api/V1/Administrator/AdministratorTeacherSubmissionNotifyApiTest.php +++ b/tests/Feature/Api/V1/Administrator/AdministratorTeacherSubmissionNotifyApiTest.php @@ -32,8 +32,8 @@ class AdministratorTeacherSubmissionNotifyApiTest extends TestCase public function test_notify_endpoint_requires_notify_payload(): void { - $admin = User::factory()->create(); - Sanctum::actingAs($admin); + $admin = $this->seedAdminUser('admin.notify.required@example.com'); + Sanctum::actingAs($admin, [], 'api'); $response = $this->postJson('/api/v1/administrator/teacher-submissions/notify', [ 'missing_items' => [], @@ -47,13 +47,8 @@ class AdministratorTeacherSubmissionNotifyApiTest extends TestCase { Mail::fake(); - $admin = User::factory()->create([ - 'id' => 900, - 'firstname' => 'Admin', - 'lastname' => 'One', - 'email' => 'admin@test.com', - ]); - Sanctum::actingAs($admin); + $admin = $this->seedAdminUser('admin@test.com', 900, 'Admin', 'One'); + Sanctum::actingAs($admin, [], 'api'); User::factory()->create([ 'id' => 100, @@ -117,12 +112,8 @@ class AdministratorTeacherSubmissionNotifyApiTest extends TestCase { Mail::fake(); - $admin = User::factory()->create([ - 'id' => 901, - 'firstname' => 'Admin', - 'lastname' => 'Two', - ]); - Sanctum::actingAs($admin); + $admin = $this->seedAdminUser('admin.two@test.com', 901, 'Admin', 'Two'); + Sanctum::actingAs($admin, [], 'api'); User::factory()->create([ 'id' => 101, @@ -169,4 +160,94 @@ class AdministratorTeacherSubmissionNotifyApiTest extends TestCase 'semester' => 'Fall', ]); } + + public function test_notify_endpoint_accepts_flat_teacher_ids_from_spa_payload(): void + { + Mail::fake(); + + $admin = $this->seedAdminUser('admin3@test.com', 902, 'Admin', 'Three'); + Sanctum::actingAs($admin, [], 'api'); + + User::factory()->create([ + 'id' => 102, + 'firstname' => 'Flat', + 'lastname' => 'Teacher', + 'email' => 'flat@test.com', + ]); + + DB::table('classSection')->insert([ + [ + 'class_section_id' => 12, + 'class_section_name' => 'Grade 7A', + 'class_id' => 7, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + ]); + + DB::table('teacher_class')->insert([ + 'class_section_id' => 12, + 'teacher_id' => 102, + 'position' => 'main', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + + $payload = [ + 'notify' => [102], + 'missing_items' => [ + 102 => ['midterm scores', 'attendance'], + ], + ]; + + $response = $this->postJson('/api/v1/administrator/teacher-submissions/notify', $payload); + + $response->assertOk() + ->assertJson([ + 'sent' => 1, + 'failed' => 0, + ]); + + $this->assertDatabaseHas('teacher_submission_notification_history', [ + 'teacher_id' => 102, + 'class_section_id' => 12, + 'admin_id' => 902, + 'notification_category' => 'teacher_submissions', + 'status' => 'sent', + ]); + } + + private function seedAdminUser(string $email, ?int $id = null, string $firstname = 'Admin', string $lastname = 'User'): User + { + $roleId = DB::table('roles')->insertGetId([ + 'name' => 'administrator', + 'slug' => 'administrator', + 'is_active' => 1, + ]); + + $userId = DB::table('users')->insertGetId(array_filter([ + 'id' => $id, + 'firstname' => $firstname, + 'lastname' => $lastname, + 'cellphone' => '5555555555', + 'email' => $email, + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('secret'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ], static fn ($value) => $value !== null)); + + DB::table('user_roles')->insert([ + 'user_id' => $userId, + 'role_id' => $roleId, + ]); + + return User::query()->findOrFail($userId); + } } diff --git a/tests/Feature/Api/V1/Administrator/AdministratorTeacherSubmissionReportApiTest.php b/tests/Feature/Api/V1/Administrator/AdministratorTeacherSubmissionReportApiTest.php index b15bab50..7aae2821 100644 --- a/tests/Feature/Api/V1/Administrator/AdministratorTeacherSubmissionReportApiTest.php +++ b/tests/Feature/Api/V1/Administrator/AdministratorTeacherSubmissionReportApiTest.php @@ -4,7 +4,6 @@ namespace Tests\Feature\Api\Administrator; use App\Models\AttendanceDay; use App\Models\Configuration; -use App\Models\ScoreComment; use App\Models\SemesterScore; use App\Models\TeacherSubmissionNotificationHistory; use App\Models\User; @@ -35,8 +34,8 @@ class AdministratorTeacherSubmissionReportApiTest extends TestCase public function test_teacher_submission_report_endpoint_returns_rows_summary_and_history(): void { - $admin = User::factory()->create(['id' => 900, 'firstname' => 'Admin', 'lastname' => 'One']); - Sanctum::actingAs($admin); + $admin = $this->seedAdminUser('admin.report.one@test.com', 900, 'Admin', 'One'); + Sanctum::actingAs($admin, [], 'api'); User::factory()->create([ 'id' => 100, @@ -114,8 +113,7 @@ class AdministratorTeacherSubmissionReportApiTest extends TestCase 'participation_score' => '9', ]); - ScoreComment::unguard(); - ScoreComment::query()->create([ + DB::table('score_comments')->insert([ 'student_id' => 1, 'class_section_id' => 10, 'school_year' => '2025-2026', @@ -123,7 +121,7 @@ class AdministratorTeacherSubmissionReportApiTest extends TestCase 'score_type' => 'midterm', 'comment' => 'Good progress', ]); - ScoreComment::query()->create([ + DB::table('score_comments')->insert([ 'student_id' => 1, 'class_section_id' => 10, 'school_year' => '2025-2026', @@ -199,8 +197,8 @@ class AdministratorTeacherSubmissionReportApiTest extends TestCase public function test_teacher_submission_report_returns_no_students_status_for_empty_section(): void { - $admin = User::factory()->create(); - Sanctum::actingAs($admin); + $admin = $this->seedAdminUser('admin.report.empty@test.com'); + Sanctum::actingAs($admin, [], 'api'); User::factory()->create([ 'id' => 200, @@ -246,8 +244,8 @@ class AdministratorTeacherSubmissionReportApiTest extends TestCase public function test_teacher_submission_report_limits_history_to_three_entries(): void { - $admin = User::factory()->create(['id' => 901, 'firstname' => 'Admin', 'lastname' => 'Two']); - Sanctum::actingAs($admin); + $admin = $this->seedAdminUser('admin.report.two@test.com', 901, 'Admin', 'Two'); + Sanctum::actingAs($admin, [], 'api'); User::factory()->create([ 'id' => 300, @@ -298,4 +296,82 @@ class AdministratorTeacherSubmissionReportApiTest extends TestCase $history = $response->json('notificationHistory.12.300'); $this->assertCount(3, $history); } + + public function test_teacher_submission_report_honors_school_year_and_semester_query_filters(): void + { + $admin = $this->seedAdminUser('admin.report.filters@test.com'); + Sanctum::actingAs($admin, [], 'api'); + + User::factory()->create([ + 'id' => 400, + 'firstname' => 'Yearly', + 'lastname' => 'Teacher', + 'email' => 'yearly@test.com', + ]); + + DB::table('classSection')->insert([ + [ + 'class_section_id' => 20, + 'class_section_name' => 'Grade 8A', + 'class_id' => 8, + 'semester' => 'Spring', + 'school_year' => '2024-2025', + ], + ]); + + DB::table('teacher_class')->insert([ + [ + 'class_section_id' => 20, + 'teacher_id' => 400, + 'position' => 'main', + 'school_year' => '2024-2025', + 'semester' => 'Spring', + ], + ]); + + $response = $this->getJson('/api/v1/administrator/teacher-submissions?school_year=2024-2025&semester=Spring'); + + $response->assertOk() + ->assertJson([ + 'semester' => 'Spring', + 'schoolYear' => '2024-2025', + 'currentSemester' => 'Fall', + 'currentSchoolYear' => '2025-2026', + ]) + ->assertJsonCount(1, 'rows'); + } + + private function seedAdminUser(string $email, ?int $id = null, string $firstname = 'Admin', string $lastname = 'User'): User + { + $roleId = DB::table('roles')->insertGetId([ + 'name' => 'administrator', + 'slug' => 'administrator', + 'is_active' => 1, + ]); + + $userId = DB::table('users')->insertGetId(array_filter([ + 'id' => $id, + 'firstname' => $firstname, + 'lastname' => $lastname, + 'cellphone' => '5555555555', + 'email' => $email, + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'password' => bcrypt('secret'), + 'user_type' => 'primary', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'status' => 'Active', + ], static fn ($value) => $value !== null)); + + DB::table('user_roles')->insert([ + 'user_id' => $userId, + 'role_id' => $roleId, + ]); + + return User::query()->findOrFail($userId); + } } diff --git a/tests/Feature/Api/V1/Email/EmailExtractorControllerTest.php b/tests/Feature/Api/V1/Email/EmailExtractorControllerTest.php index 871f50be..e837dd07 100644 --- a/tests/Feature/Api/V1/Email/EmailExtractorControllerTest.php +++ b/tests/Feature/Api/V1/Email/EmailExtractorControllerTest.php @@ -15,7 +15,7 @@ class EmailExtractorControllerTest extends TestCase public function test_emails_endpoint_returns_users_and_parents(): void { $user = $this->createUser(); - Sanctum::actingAs($user); + Sanctum::actingAs($user, [], 'api'); DB::table('parents')->insert([ 'secondparent_firstname' => 'Parent', @@ -31,13 +31,38 @@ class EmailExtractorControllerTest extends TestCase $response->assertOk(); $response->assertJsonPath('ok', true); + $this->assertContains('admin@example.com', $response->json('users')); + $this->assertContains('parent2@example.com', $response->json('parents')); $this->assertContains('parent2@example.com', $response->json('emails.parents')); } + public function test_legacy_emails_endpoint_returns_top_level_arrays(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user, [], 'api'); + + DB::table('parents')->insert([ + 'secondparent_firstname' => 'Parent', + 'secondparent_lastname' => 'Legacy', + 'secondparent_email' => 'legacy-parent@example.com', + 'secondparent_phone' => '5555555555', + 'firstparent_id' => 1, + 'secondparent_id' => 3, + 'school_year' => '2025-2026', + ]); + + $response = $this->getJson('/api/emails'); + + $response->assertOk(); + $response->assertJsonPath('ok', true); + $this->assertContains('admin@example.com', $response->json('users')); + $this->assertContains('legacy-parent@example.com', $response->json('parents')); + } + public function test_compare_endpoint_returns_comparison(): void { $user = $this->createUser(); - Sanctum::actingAs($user); + Sanctum::actingAs($user, [], 'api'); DB::table('parents')->insert([ 'secondparent_firstname' => 'Parent', diff --git a/tests/Feature/Api/V1/SchoolYears/SchoolYearControllerTest.php b/tests/Feature/Api/V1/SchoolYears/SchoolYearControllerTest.php new file mode 100644 index 00000000..33e045d5 --- /dev/null +++ b/tests/Feature/Api/V1/SchoolYears/SchoolYearControllerTest.php @@ -0,0 +1,343 @@ +seedClosureData(); + + $this->actingAs(User::query()->findOrFail(1), 'api'); + + $response = $this->postJson('/api/v1/school-years/1/preview-close', [ + 'new_school_year' => [ + 'name' => '2026-2027', + 'start_date' => '2026-09-01', + 'end_date' => '2027-06-30', + ], + 'transfer_unpaid_balances' => true, + ]); + + $response->assertOk() + ->assertJsonPath('data.validation.can_close', true) + ->assertJsonPath('data.promotion_preview.summary.promote', 1) + ->assertJsonPath('data.parent_balances.summary.total_old_unpaid_balance', 200); + + $this->assertSame(200.0, (float) $response->json('data.parent_balances.rows.0.amount_to_transfer')); + $this->assertSame('promote', $response->json('data.promotion_preview.rows.0.promotion_action')); + } + + public function test_admin_can_create_draft_school_year_via_api(): void + { + $this->seedClosureData(); + + $this->actingAs(User::query()->findOrFail(1), 'api'); + + $response = $this->postJson('/api/v1/school-years', [ + 'name' => '2026-2027', + 'start_date' => '2026-09-01', + 'end_date' => '2027-06-30', + ]); + + $response->assertCreated() + ->assertJsonPath('data.name', '2026-2027') + ->assertJsonPath('data.status', 'draft'); + + $this->assertDatabaseHas('school_years', [ + 'name' => '2026-2027', + 'status' => 'draft', + 'is_current' => 0, + ]); + } + + public function test_admin_can_update_draft_school_year_via_api(): void + { + $this->seedClosureData(); + DB::table('school_years')->insert([ + 'id' => 2, + 'name' => '2027-2028', + 'start_date' => '2027-09-01', + 'end_date' => '2028-06-30', + 'status' => 'draft', + 'is_current' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->actingAs(User::query()->findOrFail(1), 'api'); + + $response = $this->patchJson('/api/v1/school-years/2', [ + 'name' => '2028-2029', + 'start_date' => '2028-09-01', + 'end_date' => '2029-06-30', + ]); + + $response->assertOk() + ->assertJsonPath('data.name', '2028-2029') + ->assertJsonPath('data.start_date', '2028-09-01'); + + $this->assertDatabaseHas('school_years', [ + 'id' => 2, + 'name' => '2028-2029', + 'start_date' => '2028-09-01 00:00:00', + 'end_date' => '2029-06-30 00:00:00', + ]); + } + + public function test_close_school_year_creates_new_active_year_enrollment_and_balance_transfer(): void + { + $this->seedClosureData(); + + $this->actingAs(User::query()->findOrFail(1), 'api'); + + $response = $this->postJson('/api/v1/school-years/1/close', [ + 'new_school_year' => [ + 'name' => '2026-2027', + 'start_date' => '2026-09-01', + 'end_date' => '2027-06-30', + ], + 'transfer_unpaid_balances' => true, + 'confirmation' => 'CLOSE 2025-2026', + ]); + + $response->assertOk() + ->assertJsonPath('data.closed_school_year.status', 'closed') + ->assertJsonPath('data.new_school_year.status', 'active'); + + $this->assertDatabaseHas('school_years', [ + 'name' => '2025-2026', + 'status' => 'closed', + 'is_current' => 0, + ]); + + $this->assertDatabaseHas('school_years', [ + 'name' => '2026-2027', + 'status' => 'active', + 'is_current' => 1, + ]); + + $this->assertDatabaseHas('configuration', [ + 'config_key' => 'school_year', + 'config_value' => '2026-2027', + ]); + + $this->assertDatabaseHas('enrollments', [ + 'student_id' => 100, + 'parent_id' => 10, + 'school_year' => '2026-2027', + 'enrollment_status' => 'enrolled', + ]); + + $this->assertDatabaseHas('parent_balance_transfers', [ + 'parent_id' => 10, + 'from_school_year' => '2025-2026', + 'to_school_year' => '2026-2027', + 'amount' => 200.00, + 'status' => 'transferred', + ]); + + $this->assertDatabaseHas('invoices', [ + 'parent_id' => 10, + 'school_year' => '2026-2027', + 'total_amount' => 200.00, + 'balance' => 200.00, + 'description' => 'Opening balance transferred from 2025-2026', + ]); + } + + public function test_closed_school_year_blocks_legacy_invoice_generation(): void + { + $this->seedClosureData(); + + $this->actingAs(User::query()->findOrFail(1), 'api'); + + $this->postJson('/api/v1/school-years/1/close', [ + 'new_school_year' => [ + 'name' => '2026-2027', + 'start_date' => '2026-09-01', + 'end_date' => '2027-06-30', + ], + 'transfer_unpaid_balances' => true, + 'confirmation' => 'CLOSE 2025-2026', + ])->assertOk(); + + $response = $this->postJson('/api/v1/finance/invoices/generate', [ + 'parent_id' => 10, + 'school_year' => '2025-2026', + ]); + + $response->assertStatus(409) + ->assertJsonPath('message', 'School year 2025-2026 is closed and read-only.'); + } + + private function seedClosureData(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ]); + + DB::table('roles')->insert([ + ['id' => 1, 'name' => 'administrator', 'slug' => 'administrator', 'is_active' => 1], + ['id' => 2, 'name' => 'parent', 'slug' => 'parent', 'is_active' => 1], + ]); + + DB::table('users')->insert([ + [ + 'id' => 1, + 'school_id' => 1, + 'firstname' => 'Admin', + 'lastname' => 'User', + 'cellphone' => '5555555555', + 'email' => 'admin@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + [ + 'id' => 10, + 'school_id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '5555555555', + 'email' => 'parent@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + ]); + + DB::table('user_roles')->insert([ + ['user_id' => 1, 'role_id' => 1], + ['user_id' => 10, 'role_id' => 2], + ]); + + DB::table('school_years')->insert([ + 'id' => 1, + 'name' => '2025-2026', + 'start_date' => '2025-09-01', + 'end_date' => '2026-06-30', + 'status' => 'active', + 'is_current' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('students')->insert([ + 'id' => 100, + 'parent_id' => 10, + 'firstname' => 'Sara', + 'lastname' => 'Student', + 'school_id' => 'S-100', + 'age' => 9, + 'gender' => 'Female', + 'is_active' => 1, + 'photo_consent' => 1, + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + + DB::table('classes')->insert([ + ['id' => 3, 'class_name' => 'Grade 3', 'schedule' => 'Sunday 9:00', 'school_year' => '2025-2026', 'semester' => 'Fall', 'capacity' => 30], + ['id' => 4, 'class_name' => 'Grade 4', 'schedule' => 'Sunday 10:00', 'school_year' => '2026-2027', 'semester' => 'Fall', 'capacity' => 30], + ]); + + DB::table('classSection')->insert([ + [ + 'class_section_id' => 301, + 'class_section_name' => '3A', + 'class_id' => 3, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + [ + 'class_section_id' => 401, + 'class_section_name' => '4A', + 'class_id' => 4, + 'semester' => 'Fall', + 'school_year' => '2026-2027', + ], + ]); + + DB::table('student_class')->insert([ + 'student_id' => 100, + 'class_section_id' => 301, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'is_event_only' => 0, + ]); + + DB::table('enrollments')->insert([ + 'student_id' => 100, + 'class_section_id' => 301, + 'parent_id' => 10, + 'enrollment_date' => '2025-09-05', + 'enrollment_status' => 'enrolled', + 'withdrawal_date' => null, + 'is_withdrawn' => 0, + 'admission_status' => 'accepted', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('student_decisions')->insert([ + 'student_id' => 100, + 'school_year' => '2025-2026', + 'class_section_name' => '3A', + 'year_score' => 85, + 'decision' => 'promoted', + 'source' => 'manual', + 'notes' => 'Ready for promotion', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('invoices')->insert([ + 'id' => 500, + 'parent_id' => 10, + 'invoice_number' => 'INV-500', + 'total_amount' => 500, + 'balance' => 200, + 'paid_amount' => 300, + 'has_discount' => 0, + 'issue_date' => '2026-05-10', + 'due_date' => '2026-06-10', + 'status' => 'unpaid', + 'description' => 'Tuition balance', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + 'updated_by' => 1, + 'semester' => 'Fall', + ]); + } +} diff --git a/tests/Feature/Web/SchoolYearAdminPageTest.php b/tests/Feature/Web/SchoolYearAdminPageTest.php new file mode 100644 index 00000000..5a5565e9 --- /dev/null +++ b/tests/Feature/Web/SchoolYearAdminPageTest.php @@ -0,0 +1,117 @@ + 'base64:' . base64_encode(random_bytes(32)), + 'app.cipher' => 'AES-256-CBC', + 'jwt.secret' => bin2hex(random_bytes(32)), + 'view.compiled' => $compiledPath, + ]); + } + + public function test_admin_can_view_school_year_page(): void + { + $this->seedAdminContext(); + + $response = $this->actingAs(User::query()->findOrFail(1)) + ->get('/administrator/school-years'); + + $response->assertOk(); + $response->assertSee('School Years Management'); + $response->assertSee('JWT Authorization: Bearer'); + $response->assertSee('/api/v1/school-years'); + } + + public function test_admin_can_create_draft_school_year_from_page(): void + { + $this->seedAdminContext(); + + $response = $this->actingAs(User::query()->findOrFail(1)) + ->post('/administrator/school-years', [ + 'name' => '2026-2027', + 'start_date' => '2026-09-01', + 'end_date' => '2027-06-30', + ]); + + $response->assertRedirect(); + $this->assertDatabaseHas('school_years', [ + 'name' => '2026-2027', + 'status' => 'draft', + 'is_current' => 0, + ]); + } + + private function seedAdminContext(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ]); + + DB::table('roles')->insert([ + 'id' => 1, + 'name' => 'administrator', + 'slug' => 'administrator', + 'is_active' => 1, + ]); + + DB::table('users')->insert([ + 'id' => 1, + 'school_id' => 1, + 'firstname' => 'Admin', + 'lastname' => 'User', + 'gender' => 'Female', + 'cellphone' => '5555555555', + 'email' => 'admin@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('user_roles')->insert([ + 'user_id' => 1, + 'role_id' => 1, + ]); + + DB::table('school_years')->insert([ + 'id' => 1, + 'name' => '2025-2026', + 'start_date' => '2025-09-01', + 'end_date' => '2026-06-30', + 'status' => 'active', + 'is_current' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/tests/Unit/Services/AttendanceManagement/AttendanceManagementServiceTest.php b/tests/Unit/Services/AttendanceManagement/AttendanceManagementServiceTest.php new file mode 100644 index 00000000..5b27c34c --- /dev/null +++ b/tests/Unit/Services/AttendanceManagement/AttendanceManagementServiceTest.php @@ -0,0 +1,103 @@ +seedConfiguration(); + $this->seedUserWithBadge('RFID-100'); + + $service = new AttendanceManagementService(); + + $row = $service->badgeScan([ + 'badge_scan' => 'RFID-100', + 'scan_time' => '2026-02-10 08:30:00', + ]); + + $this->assertSame('2025-2026', $row['school_year']); + $this->assertSame('Spring', $row['semester']); + $this->assertDatabaseHas('attendance_management_events', [ + 'id' => $row['id'], + 'badge_id' => 'RFID-100', + 'school_year' => '2025-2026', + 'semester' => 'Spring', + ]); + } + + public function test_dashboard_derives_academic_context_for_legacy_rows_and_applies_filters(): void + { + DB::table('attendance_management_events')->insert([ + 'person_type' => 'student', + 'person_id' => 10, + 'person_name' => 'Legacy Student', + 'event_date' => '2026-02-10', + 'attendance_status' => AttendanceManagementService::STATUS_PRESENT, + 'report_status' => 'reported', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service = new AttendanceManagementService(); + + $data = $service->dashboard([ + 'date' => '2026-02-10', + 'school_year' => '2025-2026', + 'semester' => 'Spring', + ]); + + $this->assertCount(1, $data['rows']); + $this->assertSame('2025-2026', $data['rows'][0]['school_year']); + $this->assertSame('Spring', $data['rows'][0]['semester']); + $this->assertSame(1, $data['summary']['present']); + + $empty = $service->dashboard([ + 'date' => '2026-02-10', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + + $this->assertCount(0, $empty['rows']); + } + + private function seedConfiguration(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ]); + } + + private function seedUserWithBadge(string $rfidTag): void + { + DB::table('users')->insert([ + 'id' => 100, + 'school_id' => 1, + 'firstname' => 'Badge', + 'lastname' => 'User', + 'cellphone' => '5555555555', + 'email' => 'badge-user@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'rfid_tag' => $rfidTag, + ]); + } +}