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
+
+
+
+
+
+
+
+
+
+
Home / administrator / school-years
+
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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,
+ ]);
+ }
+}