693 lines
32 KiB
PHP
693 lines
32 KiB
PHP
<?php
|
|
|
|
namespace App\Services\AttendanceManagement;
|
|
|
|
use App\Models\Configuration;
|
|
use App\Models\LateSlipLog;
|
|
use App\Models\Student;
|
|
use App\Models\User;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
|
|
class AttendanceManagementService
|
|
{
|
|
public const STATUS_PRESENT = 'present';
|
|
public const STATUS_ABSENT_PENDING = 'absent_pending_verification';
|
|
public const STATUS_ABSENT = 'absent';
|
|
public const STATUS_LATE = 'late';
|
|
public const STATUS_EARLY_DISMISSAL = 'early_dismissal';
|
|
public const STATUS_LATE_WITHOUT_SLIP = 'late_without_slip';
|
|
|
|
public function dashboard(array $filters = []): array
|
|
{
|
|
$this->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',
|
|
};
|
|
}
|
|
}
|