add school year model

This commit is contained in:
root
2026-06-07 00:52:01 -04:00
parent a192ed433d
commit 6866aedf42
36 changed files with 4771 additions and 88 deletions
@@ -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
@@ -23,6 +23,8 @@ class EmailExtractorController extends BaseApiController
return response()->json([
'ok' => true,
'users' => $emails['users'] ?? [],
'parents' => $emails['parents'] ?? [],
'emails' => new EmailExtractorEmailsResource($emails),
]);
}
@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers\Api\SchoolYears;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\SchoolYears\CloseSchoolYearRequest;
use App\Http\Requests\SchoolYears\PreviewCloseSchoolYearRequest;
use App\Http\Requests\SchoolYears\SaveSchoolYearRequest;
use App\Services\SchoolYears\SchoolYearClosureService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;
class SchoolYearController extends BaseApiController
{
public function __construct(private SchoolYearClosureService $service)
{
parent::__construct();
}
public function index(): JsonResponse
{
return response()->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')),
]);
}
}
@@ -0,0 +1,149 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Services\SchoolYears\SchoolYearClosureService;
use App\Services\SchoolYears\SchoolYearResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth;
class SchoolYearAdminPageController extends Controller
{
public function __construct(
private SchoolYearClosureService $service,
private SchoolYearResolver $resolver
) {
}
public function show(Request $request): View
{
$years = $this->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'],
]);
}
}
@@ -0,0 +1,101 @@
<?php
namespace App\Http\Middleware;
use App\Models\Configuration;
use App\Services\SchoolYears\SchoolYearWriteGuard;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;
class EnsureSchoolYearEditable
{
public function __construct(private SchoolYearWriteGuard $guard)
{
}
public function handle(Request $request, Closure $next): Response
{
if (!in_array(strtoupper($request->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;
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Http\Requests\SchoolYears;
class CloseSchoolYearRequest extends PreviewCloseSchoolYearRequest
{
public function rules(): array
{
return array_merge(parent::rules(), [
'confirmation' => ['required', 'string', 'max:80'],
]);
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\SchoolYears;
use Illuminate\Foundation\Http\FormRequest;
class PreviewCloseSchoolYearRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'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'],
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\SchoolYears;
use Illuminate\Foundation\Http\FormRequest;
class SaveSchoolYearRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:50'],
'start_date' => ['required', 'date'],
'end_date' => ['required', 'date', 'after:start_date'],
];
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Models;
class AuditLog extends BaseModel
{
public $timestamps = false;
protected $table = 'audit_logs';
protected $fillable = [
'user_id',
'action',
'table_name',
'record_id',
'old_value',
'new_value',
'metadata',
'created_at',
];
protected $casts = [
'user_id' => 'integer',
'record_id' => 'integer',
'old_value' => 'array',
'new_value' => 'array',
'metadata' => 'array',
'created_at' => 'datetime',
];
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Models;
class ParentAccount extends BaseModel
{
protected $table = 'parent_accounts';
protected $fillable = [
'parent_id',
'school_year',
'opening_balance',
'current_balance',
];
protected $casts = [
'parent_id' => 'integer',
'opening_balance' => 'decimal:2',
'current_balance' => 'decimal:2',
];
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
class ParentBalanceTransfer extends BaseModel
{
protected $table = 'parent_balance_transfers';
protected $fillable = [
'parent_id',
'from_school_year',
'to_school_year',
'amount',
'status',
'source_summary_json',
'new_invoice_id',
'created_by',
];
protected $casts = [
'parent_id' => 'integer',
'amount' => 'decimal:2',
'source_summary_json' => 'array',
'new_invoice_id' => 'integer',
'created_by' => 'integer',
];
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
class SchoolYear extends BaseModel
{
public const STATUS_DRAFT = 'draft';
public const STATUS_ACTIVE = 'active';
public const STATUS_CLOSED = 'closed';
public const STATUS_ARCHIVED = 'archived';
protected $table = 'school_years';
protected $fillable = [
'name',
'start_date',
'end_date',
'status',
'is_current',
'closed_at',
'closed_by',
];
protected $casts = [
'start_date' => 'date',
'end_date' => 'date',
'is_current' => 'boolean',
'closed_at' => 'datetime',
'closed_by' => 'integer',
];
}
@@ -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));
}
}
}
@@ -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);
}
}
}
@@ -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)
? '<p>Outstanding items: ' . e($this->support->formatMissingItemsText($missingItems)) . '.</p>'
@@ -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 [];
}
}
@@ -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,
];
@@ -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 [];
}
+7 -1
View File
@@ -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`. */
@@ -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 = [];
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
<?php
namespace App\Services\SchoolYears;
use App\Models\Configuration;
use App\Models\SchoolYear;
class SchoolYearResolver
{
public function ensureCurrentTracked(): ?SchoolYear
{
$current = trim((string) (Configuration::getConfig('school_year') ?? ''));
if ($current === '') {
return SchoolYear::query()->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);
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Services\SchoolYears;
use App\Models\SchoolYear;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
class SchoolYearWriteGuard
{
public function assertEditable(array $schoolYears): void
{
if (!Schema::hasTable('school_years')) {
return;
}
$years = array_values(array_unique(array_filter(array_map(
static fn ($year) => 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
));
}
}
}
}