ensureTables(); $date = $this->dateOnly($filters['date'] ?? now()->toDateString()); $query = DB::table('attendance_management_events')->whereDate('event_date', $date); foreach (['person_type', 'attendance_status', 'report_status', 'risk_level', 'entry_method', 'exit_method'] as $field) { $value = trim((string) ($filters[$field] ?? '')); if ($value !== '') { $query->where($field, $value); } } if (array_key_exists('follow_up_required', $filters) && $filters['follow_up_required'] !== '') { $query->where('follow_up_required', filter_var($filters['follow_up_required'], FILTER_VALIDATE_BOOLEAN)); } if (array_key_exists('follow_up_completed', $filters) && $filters['follow_up_completed'] !== '') { $query->where('follow_up_completed', filter_var($filters['follow_up_completed'], FILTER_VALIDATE_BOOLEAN)); } $search = trim((string) ($filters['q'] ?? '')); if ($search !== '') { $query->where(function ($q) use ($search) { $q->where('person_name', 'like', "%{$search}%") ->orWhere('badge_id', 'like', "%{$search}%") ->orWhere('role_grade', 'like', "%{$search}%") ->orWhere('combination_code', 'like', "%{$search}%"); }); } $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') ->get() ->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, '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, ]; } public function manualEntry(array $data, ?User $actor = null): array { $this->ensureTables(); $person = $this->resolvePerson($data); $entryAt = $this->dateTime($data['entry_time'] ?? $data['manual_entry_time'] ?? now()); $start = $this->schoolStartFor($entryAt); $isLate = $entryAt->gt($start); $reportStatus = $this->normalizeReportStatus($data['report_status'] ?? null); $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'], 'person_id' => $person['id'], 'person_name' => $person['name'], '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, 'official_entry_time' => $entryAt, 'entry_method' => 'manual_entry', 'manual_reason' => trim((string) ($data['manual_reason'] ?? $data['reason_for_manual_entry'] ?? 'manual verification')), 'reason' => $data['reason'] ?? null, 'entered_by' => $actor?->id, 'absence_count' => $counts['absence_count'], 'late_count' => $counts['late_count'], 'early_dismissal_count' => $counts['early_dismissal_count'], 'badge_exception_count' => $badgeExceptionCount, 'combination_code' => $this->combinationCode($counts['absence_count'], $counts['late_count'], $counts['early_dismissal_count'], $badgeExceptionCount), 'risk_level' => $this->riskLevel($counts['absence_count'], $counts['late_count'], $counts['early_dismissal_count'], $badgeExceptionCount), 'follow_up_required' => $reportStatus !== 'reported' || $badgeExceptionCount >= 3 || $isLate, 'action_needed' => $this->actionNeeded($status, $reportStatus, $badgeExceptionCount), 'notes' => $data['notes'] ?? null, 'final_decision' => $isLate ? 'Late slip printed' : 'Present by verified manual entry', 'audit' => json_encode(['source' => 'manual_entry', 'actor_id' => $actor?->id]), 'created_at' => now(), 'updated_at' => now(), ]; $id = DB::table('attendance_management_events')->insertGetId($row); $row['id'] = $id; $this->recordBadgeException((int) $id, $person, $entryAt, $row['manual_reason'], $badgeExceptionCount, $actor, $data['notes'] ?? null); if ($person['type'] === 'student' && $isLate) { $row['late_slip'] = $this->printLateSlip($row, $actor); } return $row; } public function badgeScan(array $data, ?User $actor = null): array { $this->ensureTables(); $badge = trim((string) ($data['badge_scan'] ?? $data['badge_id'] ?? '')); if ($badge === '') { throw new \InvalidArgumentException('Badge scan value is required.'); } $person = $this->resolvePerson(['badge_id' => $badge]); $entryAt = $this->dateTime($data['scan_time'] ?? now()); $scanType = strtolower(trim((string) ($data['scan_type'] ?? 'entry'))); $reportStatus = $this->normalizeReportStatus($data['report_status'] ?? null); if ($scanType === 'exit') { return $this->exitEntry([ 'person_type' => $person['type'], 'person_id' => $person['id'], 'exit_time' => $entryAt->toDateTimeString(), 'exit_method' => 'badge_scan', 'report_status' => $reportStatus, 'exit_location' => $data['location'] ?? null, 'authorized' => $data['authorized'] ?? false, 'reason' => $data['reason'] ?? null, ], $actor); } $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'], 'person_name' => $person['name'], '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, 'official_entry_time' => $entryAt, 'entry_method' => 'badge_scan', 'scan_location' => $data['location'] ?? null, 'reason' => $data['reason'] ?? null, 'entered_by' => $actor?->id, 'absence_count' => $counts['absence_count'], 'late_count' => $counts['late_count'], 'early_dismissal_count' => $counts['early_dismissal_count'], 'badge_exception_count' => $this->badgeExceptionCount($person['type'], $person['id'], $entryAt->toDateString()), 'combination_code' => $this->combinationCode($counts['absence_count'], $counts['late_count'], $counts['early_dismissal_count'], 0), 'risk_level' => $this->riskLevel($counts['absence_count'], $counts['late_count'], $counts['early_dismissal_count'], 0), 'follow_up_required' => $status === self::STATUS_LATE && $reportStatus !== 'reported', 'action_needed' => $this->actionNeeded($status, $reportStatus, 0), 'final_decision' => $status === self::STATUS_LATE ? 'Late slip printed' : 'Present by badge scan', 'audit' => json_encode(['source' => 'badge_scan', 'actor_id' => $actor?->id]), 'created_at' => now(), 'updated_at' => now(), ]; $id = DB::table('attendance_management_events')->insertGetId($row); $row['id'] = $id; if ($person['type'] === 'student' && $status === self::STATUS_LATE) { $row['late_slip'] = $this->printLateSlip($row, $actor); } return $row; } public function exitEntry(array $data, ?User $actor = null): array { $this->ensureTables(); $person = $this->resolvePerson($data); $exitAt = $this->dateTime($data['exit_time'] ?? $data['manual_exit_time'] ?? now()); $authorized = filter_var($data['authorized'] ?? false, FILTER_VALIDATE_BOOLEAN); $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'], 'person_name' => $person['name'], '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, 'official_exit_time' => $exitAt, 'exit_method' => $data['exit_method'] ?? 'manual_exit', 'exit_location' => $data['exit_location'] ?? null, 'authorized_by' => $data['authorized_by'] ?? null, 'reason' => $data['reason'] ?? null, 'entered_by' => $actor?->id, 'absence_count' => $counts['absence_count'], 'late_count' => $counts['late_count'], 'early_dismissal_count' => $counts['early_dismissal_count'], 'badge_exception_count' => $badgeExceptions, 'combination_code' => $this->combinationCode($counts['absence_count'], $counts['late_count'], $counts['early_dismissal_count'], $badgeExceptions), 'risk_level' => $this->riskLevel($counts['absence_count'], $counts['late_count'], $counts['early_dismissal_count'], $badgeExceptions), 'follow_up_required' => ! $authorized || $reportStatus !== 'reported', 'action_needed' => ! $authorized ? 'Review unauthorized early dismissal' : 'Record and monitor', 'final_decision' => $authorized ? 'Reported early dismissal, excused' : 'Unauthorized early dismissal', 'audit' => json_encode(['source' => $data['exit_method'] ?? 'manual_exit', 'actor_id' => $actor?->id]), 'created_at' => now(), 'updated_at' => now(), ]; $id = DB::table('attendance_management_events')->insertGetId($row); $row['id'] = $id; if (($data['exit_method'] ?? '') === 'manual_exit') { $this->recordBadgeException((int) $id, $person, $exitAt, 'manual exit entry', $badgeExceptions, $actor, $data['notes'] ?? null); } return $row; } public function markAbsent(array $data, ?User $actor = null): array { $this->ensureTables(); $person = $this->resolvePerson($data); $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'], 'person_name' => $person['name'], '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, 'entered_by' => $actor?->id, 'absence_count' => $counts['absence_count'], 'late_count' => $counts['late_count'], 'early_dismissal_count' => $counts['early_dismissal_count'], 'badge_exception_count' => $this->badgeExceptionCount($person['type'], $person['id'], $date), 'combination_code' => $this->combinationCode($counts['absence_count'], $counts['late_count'], $counts['early_dismissal_count'], 0), 'risk_level' => $this->riskLevel($counts['absence_count'], $counts['late_count'], $counts['early_dismissal_count'], 0), 'follow_up_required' => $reportStatus !== 'reported' || $counts['absence_count'] >= 3, 'action_needed' => $this->actionNeeded(self::STATUS_ABSENT, $reportStatus, 0), 'final_decision' => $reportStatus === 'reported' ? 'Reported absence, pending decision' : 'Not-reported absence, pending clarification', 'audit' => json_encode(['source' => 'absence_mark', 'actor_id' => $actor?->id]), 'created_at' => now(), 'updated_at' => now(), ]; $id = DB::table('attendance_management_events')->insertGetId($row); $row['id'] = $id; return $row; } public function completeFollowUp(int $id, array $data, ?User $actor = null): array { $this->ensureTables(); $row = DB::table('attendance_management_events')->where('id', $id)->first(); if (! $row) { throw new \RuntimeException('Attendance management event not found.'); } $update = [ 'follow_up_completed' => true, 'final_decision' => trim((string) ($data['final_decision'] ?? 'Resolved after follow-up')), 'notes' => trim((string) ($data['notes'] ?? ($row->notes ?? ''))), 'updated_at' => now(), ]; if (isset($data['report_status'])) { $update['report_status'] = $this->normalizeReportStatus($data['report_status']); } DB::table('attendance_management_events')->where('id', $id)->update($update); return (array) DB::table('attendance_management_events')->where('id', $id)->first(); } public function reprintLateSlip(int $eventId, array $data, ?User $actor = null): array { $this->ensureTables(); $event = (array) DB::table('attendance_management_events')->where('id', $eventId)->first(); if (! $event) { 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, 'student_id' => $event['person_type'] === 'student' ? $event['person_id'] : null, '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, 'created_at' => now(), 'updated_at' => now(), ]; $row['id'] = DB::table('late_slip_reprints')->insertGetId($row); DB::table('attendance_management_events')->where('id', $eventId)->update([ 'final_decision' => 'Late slip reprinted', 'updated_at' => now(), ]); return $row; } public function ensureTables(): void { foreach (['attendance_management_events', 'badge_exceptions', 'late_slip_reprints'] as $table) { if (! Schema::hasTable($table)) { throw new \RuntimeException("Missing table {$table}. Run migrations."); } } } private function resolvePerson(array $data): array { $type = strtolower(trim((string) ($data['person_type'] ?? ''))); $id = (int) ($data['person_id'] ?? 0); $badge = trim((string) ($data['badge_id'] ?? $data['badge_scan'] ?? '')); $student = null; $user = null; if ($type === 'student' && $id > 0) { $student = Student::query()->find($id); } if (in_array($type, ['staff', 'user', 'teacher', 'administrator'], true) && $id > 0) { $user = User::query()->find($id); } if (! $student && $badge !== '') { $student = Student::query()->where('rfid_tag', $badge)->first(); } if (! $student && ! $user && $badge !== '') { $user = User::query()->where('rfid_tag', $badge)->first(); } if (! $student && ! $user && $id > 0) { $student = Student::query()->find($id); } if (! $student && ! $user && $id > 0) { $user = User::query()->find($id); } if ($student) { return [ 'type' => 'student', 'id' => (int) $student->id, 'name' => trim(($student->firstname ?? '').' '.($student->lastname ?? '')), 'role_grade' => $student->registration_grade ?? null, 'badge_id' => $student->rfid_tag ?? $badge, ]; } if ($user) { return [ 'type' => 'staff', 'id' => (int) $user->id, 'name' => trim(($user->firstname ?? '').' '.($user->lastname ?? '')) ?: ($user->email ?? 'Staff #'.$user->id), 'role_grade' => $user->user_type ?? 'staff', 'badge_id' => $user->rfid_tag ?? $badge, ]; } $name = trim((string) ($data['person_name'] ?? '')); if ($name === '') { throw new \InvalidArgumentException('Person could not be resolved. Provide person_id, badge_id, or person_name.'); } return [ 'type' => $type !== '' ? $type : 'visitor', 'id' => $id > 0 ? $id : null, 'name' => $name, 'role_grade' => $data['role_grade'] ?? null, 'badge_id' => $badge !== '' ? $badge : null, ]; } private function printLateSlip(array $event, ?User $actor): array { $payload = [ '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'), 'grade' => $event['role_grade'] ?? '', 'reason' => $event['reason'] ?? ($event['manual_reason'] ?? ''), 'admin_name' => $actor ? trim(($actor->firstname ?? '').' '.($actor->lastname ?? '')) : 'System', ]; $logged = LateSlipLog::logSlip($payload, $actor?->id); return [ 'printed' => $logged, 'slip_number' => 'AME-'.($event['id'] ?? time()), 'payload' => $payload, ]; } 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, 'action' => $this->badgeExceptionAction($count), 'decision' => $this->badgeExceptionDecision($count), 'recorded_by' => $actor?->id, 'notes' => $notes, 'created_at' => now(), 'updated_at' => now(), ]); } private function countsFor(string $personType, $personId, string $date, string $newStatus): array { $q = DB::table('attendance_management_events') ->where('person_type', $personType) ->where('person_id', $personId) ->whereDate('event_date', '<=', $date); $absence = (clone $q)->where('attendance_status', self::STATUS_ABSENT)->count(); $late = (clone $q)->whereIn('attendance_status', [self::STATUS_LATE, self::STATUS_LATE_WITHOUT_SLIP])->count(); $ed = (clone $q)->where('attendance_status', self::STATUS_EARLY_DISMISSAL)->count(); if ($newStatus === self::STATUS_ABSENT) { $absence++; } if (in_array($newStatus, [self::STATUS_LATE, self::STATUS_LATE_WITHOUT_SLIP], true)) { $late++; } if ($newStatus === self::STATUS_EARLY_DISMISSAL) { $ed++; } return ['absence_count' => $absence, 'late_count' => $late, 'early_dismissal_count' => $ed]; } private function badgeExceptionCount(string $personType, $personId, string $date): int { return (int) DB::table('badge_exceptions') ->where('person_type', $personType) ->where('person_id', $personId) ->whereDate('exception_date', '<=', $date) ->count(); } private function summaryForDate(string $date, array $rows = [], string $schoolYear = '', string $semester = ''): array { $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' => $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 $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, ]; } private function schoolStartFor(Carbon $date): Carbon { $configured = Configuration::getConfigValueByKey('school_start_time') ?: config('attendance.school_start_time', '09:00:00'); 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(); } private function normalizeReportStatus($value): string { $v = strtolower(str_replace(['-', ' '], '_', trim((string) $value))); 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 = []; if ($abs > 0) { $parts[] = min($abs, 5).'ABS'; } if ($late > 0) { $parts[] = min($late, 5).'Late'; } if ($ed > 0) { $parts[] = min($ed, 3).'ED'; } if ($badge > 0) { $parts[] = min($badge, 5).'Badge Exceptions'; } return $parts ? implode(' + ', $parts) : 'Clear'; } private function riskLevel(int $abs, int $late, int $ed, int $badge): string { if ($abs >= 5 && $late >= 5) { return 'severe'; } if ($abs >= 5 || $late >= 5) { return 'critical'; } if ($abs + $late + $ed + $badge >= 6 || $abs >= 4 || $late >= 4) { return 'very_high'; } if ($abs + $late + $ed + $badge >= 4 || $abs >= 3 || $late >= 3 || $badge >= 4) { return 'high'; } if ($abs + $late + $ed + $badge >= 2 || $badge >= 2) { return 'medium'; } return 'low'; } private function actionNeeded(string $status, string $reportStatus, int $badgeCount): string { if ($badgeCount >= 5) { return 'Leadership review for badge compliance'; } if ($badgeCount >= 4) { return 'Formal badge follow-up required'; } if ($status === self::STATUS_LATE && $reportStatus !== 'reported') { return 'Print late slip and contact parent or staff'; } if ($status === self::STATUS_ABSENT && $reportStatus !== 'reported') { return 'Same-day contact required'; } if ($status === self::STATUS_EARLY_DISMISSAL && $reportStatus !== 'reported') { return 'Verify authorization and contact guardian/supervisor'; } return 'Record and monitor'; } private function badgeExceptionAction(int $count): string { return match (true) { $count >= 5 => 'Leadership review', $count === 4 => 'Contact parent, supervisor, or HR', $count === 3 => 'Admin warning', $count === 2 => 'Issue reminder', default => 'Record and allow manual entry', }; } private function badgeExceptionDecision(int $count): string { return match (true) { $count >= 5 => 'Badge replacement, disciplinary action, or intervention review', $count === 4 => 'Formal follow-up required', $count === 3 => 'Badge compliance concern', $count === 2 => 'Monitor', default => 'No formal action', }; } }