fix attendance
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\AttendanceManagement;
|
||||
|
||||
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||
use App\Services\AttendanceManagement\AttendanceManagementService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AttendanceManagementController extends BaseApiController
|
||||
{
|
||||
public function __construct(private AttendanceManagementService $service)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function dashboard(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
return $this->success($this->service->dashboard($request->query()));
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function manualEntry(Request $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'person_type' => ['nullable', 'string', 'max:32'],
|
||||
'person_id' => ['nullable', 'integer'],
|
||||
'person_name' => ['nullable', 'string', 'max:255'],
|
||||
'role_grade' => ['nullable', 'string', 'max:128'],
|
||||
'badge_id' => ['nullable', 'string', 'max:128'],
|
||||
'entry_time' => ['nullable', 'date'],
|
||||
'manual_entry_time' => ['nullable', 'date'],
|
||||
'manual_reason' => ['nullable', 'string', 'max:255'],
|
||||
'reason_for_manual_entry' => ['nullable', 'string', 'max:255'],
|
||||
'report_status' => ['nullable', 'string', 'max:48'],
|
||||
'reason' => ['nullable', 'string', 'max:255'],
|
||||
'notes' => ['nullable', 'string'],
|
||||
]);
|
||||
if ($validator->fails()) return $this->respondValidationError($validator->errors()->toArray());
|
||||
try {
|
||||
return $this->success(['event' => $this->service->manualEntry($validator->validated(), $request->user())], 'Manual attendance entry recorded.', Response::HTTP_CREATED);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->error($e->getMessage(), Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function scan(Request $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'badge_scan' => ['nullable', 'string', 'max:255'],
|
||||
'badge_id' => ['nullable', 'string', 'max:255'],
|
||||
'scan_time' => ['nullable', 'date'],
|
||||
'scan_type' => ['nullable', 'string', 'max:32'],
|
||||
'location' => ['nullable', 'string', 'max:255'],
|
||||
'report_status' => ['nullable', 'string', 'max:48'],
|
||||
'authorized' => ['nullable'],
|
||||
'reason' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
if ($validator->fails()) return $this->respondValidationError($validator->errors()->toArray());
|
||||
try {
|
||||
return $this->success(['event' => $this->service->badgeScan($validator->validated(), $request->user())], 'Badge scan classified.');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->error($e->getMessage(), Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function exitEntry(Request $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'person_type' => ['nullable', 'string', 'max:32'],
|
||||
'person_id' => ['nullable', 'integer'],
|
||||
'person_name' => ['nullable', 'string', 'max:255'],
|
||||
'role_grade' => ['nullable', 'string', 'max:128'],
|
||||
'badge_id' => ['nullable', 'string', 'max:128'],
|
||||
'exit_time' => ['nullable', 'date'],
|
||||
'manual_exit_time' => ['nullable', 'date'],
|
||||
'exit_method' => ['nullable', 'string', 'max:64'],
|
||||
'exit_location' => ['nullable', 'string', 'max:255'],
|
||||
'authorized' => ['nullable'],
|
||||
'authorized_by' => ['nullable', 'string', 'max:255'],
|
||||
'report_status' => ['nullable', 'string', 'max:48'],
|
||||
'reason' => ['nullable', 'string', 'max:255'],
|
||||
'notes' => ['nullable', 'string'],
|
||||
]);
|
||||
if ($validator->fails()) return $this->respondValidationError($validator->errors()->toArray());
|
||||
try {
|
||||
return $this->success(['event' => $this->service->exitEntry($validator->validated(), $request->user())], 'Exit attendance entry recorded.', Response::HTTP_CREATED);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->error($e->getMessage(), Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function absent(Request $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'person_type' => ['nullable', 'string', 'max:32'],
|
||||
'person_id' => ['nullable', 'integer'],
|
||||
'person_name' => ['nullable', 'string', 'max:255'],
|
||||
'role_grade' => ['nullable', 'string', 'max:128'],
|
||||
'badge_id' => ['nullable', 'string', 'max:128'],
|
||||
'date' => ['nullable', 'date'],
|
||||
'report_status' => ['nullable', 'string', 'max:48'],
|
||||
'reason' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
if ($validator->fails()) return $this->respondValidationError($validator->errors()->toArray());
|
||||
try {
|
||||
return $this->success(['event' => $this->service->markAbsent($validator->validated(), $request->user())], 'Absence recorded.', Response::HTTP_CREATED);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->error($e->getMessage(), Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function completeFollowUp(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'report_status' => ['nullable', 'string', 'max:48'],
|
||||
'final_decision' => ['nullable', 'string', 'max:255'],
|
||||
'notes' => ['nullable', 'string'],
|
||||
]);
|
||||
if ($validator->fails()) return $this->respondValidationError($validator->errors()->toArray());
|
||||
try {
|
||||
return $this->success(['event' => $this->service->completeFollowUp($id, $validator->validated(), $request->user())], 'Follow-up completed.');
|
||||
} catch (\RuntimeException $e) {
|
||||
return $this->error($e->getMessage(), Response::HTTP_NOT_FOUND);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function reprintLateSlip(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'reason' => ['nullable', 'string', 'max:255'],
|
||||
'slip_number' => ['nullable', 'string', 'max:64'],
|
||||
'late_slip_log_id' => ['nullable', 'integer'],
|
||||
]);
|
||||
if ($validator->fails()) return $this->respondValidationError($validator->errors()->toArray());
|
||||
try {
|
||||
return $this->success(['reprint' => $this->service->reprintLateSlip($id, $validator->validated(), $request->user())], 'Late slip reprint logged.', Response::HTTP_CREATED);
|
||||
} catch (\RuntimeException $e) {
|
||||
return $this->error($e->getMessage(), Response::HTTP_NOT_FOUND);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,10 @@ class StaffMonthResource extends JsonResource
|
||||
'no_school_days' => $this['noSchoolDays'] ?? [],
|
||||
'sections' => $this['sections'] ?? [],
|
||||
'admins' => $this['admins'] ?? [],
|
||||
'schoolYears' => $this['schoolYears'] ?? [],
|
||||
'currentYear' => $this['currentYear'] ?? null,
|
||||
'isCurrentYear' => $this['isCurrentYear'] ?? true,
|
||||
'missingYear' => $this['missingYear'] ?? false,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,31 +20,21 @@ class SemesterRangeService
|
||||
|
||||
public function getSchoolYearRange(string $schoolYear): array
|
||||
{
|
||||
$fallStart = Configuration::getConfig('fall_semester_start');
|
||||
$lastDay = Configuration::getConfig('last_school_day');
|
||||
|
||||
if ($fallStart && $lastDay) {
|
||||
return [
|
||||
Carbon::parse($fallStart)->toDateString(),
|
||||
Carbon::parse($lastDay)->toDateString(),
|
||||
];
|
||||
}
|
||||
|
||||
[$startYear, $endYear] = $this->parseSchoolYear($schoolYear);
|
||||
$config = $this->ensureAttendanceConfigs($schoolYear);
|
||||
|
||||
return [
|
||||
Carbon::create($startYear, 9, 1)->toDateString(),
|
||||
Carbon::create($endYear, 5, 31)->toDateString(),
|
||||
Carbon::parse($config['fall_semester_start'])->toDateString(),
|
||||
Carbon::parse($config['last_school_day'])->toDateString(),
|
||||
];
|
||||
}
|
||||
|
||||
public function getSemesterRange(string $schoolYear, string $semester): ?array
|
||||
{
|
||||
$semester = $this->normalizeSemester($semester);
|
||||
|
||||
$fallStart = Configuration::getConfig('fall_semester_start');
|
||||
$springStart = Configuration::getConfig('spring_semester_start');
|
||||
$lastDay = Configuration::getConfig('last_school_day');
|
||||
$config = $this->ensureAttendanceConfigs($schoolYear);
|
||||
$fallStart = $config['fall_semester_start'];
|
||||
$springStart = $config['spring_semester_start'];
|
||||
$lastDay = $config['last_school_day'];
|
||||
|
||||
if ($semester === 'Fall' && $fallStart && $springStart) {
|
||||
return [
|
||||
@@ -60,20 +50,7 @@ class SemesterRangeService
|
||||
];
|
||||
}
|
||||
|
||||
// Fallback to hardcoded logic when config keys are missing
|
||||
[$startYear, $endYear] = $this->parseSchoolYear($schoolYear);
|
||||
|
||||
return match ($semester) {
|
||||
'Fall' => [
|
||||
Carbon::create($startYear, 9, 21)->toDateString(),
|
||||
Carbon::create($endYear, 1, 18)->toDateString(),
|
||||
],
|
||||
'Spring' => [
|
||||
Carbon::create($endYear, 1, 25)->toDateString(),
|
||||
Carbon::create($endYear, 5, 31)->toDateString(),
|
||||
],
|
||||
default => null,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
public function buildSundayList(string $startDate, string $endDate): array
|
||||
@@ -102,7 +79,54 @@ class SemesterRangeService
|
||||
return [(int)$m[1], (int)$m[2]];
|
||||
}
|
||||
|
||||
$configured = trim((string) Configuration::getConfig('school_year'));
|
||||
if (preg_match('/^\s*(\d{4})\s*-\s*(\d{4})\s*$/', $configured, $m)) {
|
||||
return [(int) $m[1], (int) $m[2]];
|
||||
}
|
||||
|
||||
$year = (int) date('Y');
|
||||
$derived = $year . '-' . ($year + 1);
|
||||
Configuration::setConfigValueByKey('school_year', $derived);
|
||||
|
||||
return [$year, $year + 1];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the attendance range config keys exist in `configuration`.
|
||||
*
|
||||
* @return array{
|
||||
* school_year:string,
|
||||
* fall_semester_start:string,
|
||||
* spring_semester_start:string,
|
||||
* last_school_day:string
|
||||
* }
|
||||
*/
|
||||
protected function ensureAttendanceConfigs(string $schoolYear): array
|
||||
{
|
||||
[$startYear, $endYear] = $this->parseSchoolYear($schoolYear);
|
||||
|
||||
$resolvedSchoolYear = trim($schoolYear);
|
||||
if ($resolvedSchoolYear === '') {
|
||||
$resolvedSchoolYear = sprintf('%04d-%04d', $startYear, $endYear);
|
||||
}
|
||||
|
||||
$defaults = [
|
||||
'school_year' => $resolvedSchoolYear,
|
||||
'fall_semester_start' => Carbon::create($startYear, 9, 1)->toDateString(),
|
||||
'spring_semester_start' => Carbon::create($endYear, 1, 25)->toDateString(),
|
||||
'last_school_day' => Carbon::create($endYear, 5, 31)->toDateString(),
|
||||
];
|
||||
|
||||
$resolved = [];
|
||||
foreach ($defaults as $key => $defaultValue) {
|
||||
$value = trim((string) Configuration::getConfig($key));
|
||||
if ($value === '') {
|
||||
Configuration::setConfigValueByKey($key, $defaultValue);
|
||||
$value = $defaultValue;
|
||||
}
|
||||
$resolved[$key] = $value;
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\AttendanceDay;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\StaffAttendance;
|
||||
use App\Models\TeacherClass;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use App\Models\ClassSection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -181,7 +182,34 @@ class StaffAttendanceService
|
||||
public function monthData(?string $semester, ?string $schoolYear): array
|
||||
{
|
||||
$semester = $semester ?: (string)$this->configuration->getConfig('semester');
|
||||
$schoolYear = $schoolYear ?: (string)$this->configuration->getConfig('school_year');
|
||||
[
|
||||
'school_year' => $schoolYear,
|
||||
'current_year' => $currentYear,
|
||||
'school_years' => $schoolYears,
|
||||
'missing_year' => $missingYear,
|
||||
'is_current_year' => $isCurrentYear,
|
||||
] = $this->resolveMonthSchoolYearContext($schoolYear);
|
||||
|
||||
if ($schoolYear === '') {
|
||||
return [
|
||||
'filters' => [
|
||||
'semester' => $semester,
|
||||
'school_year' => '',
|
||||
'range_start' => null,
|
||||
'range_end' => null,
|
||||
'range_label' => 'School Year',
|
||||
'month_label' => 'School Year',
|
||||
],
|
||||
'sundays' => [],
|
||||
'noSchoolDays' => [],
|
||||
'sections' => [],
|
||||
'admins' => [],
|
||||
'schoolYears' => $schoolYears,
|
||||
'currentYear' => $currentYear,
|
||||
'isCurrentYear' => false,
|
||||
'missingYear' => $missingYear,
|
||||
];
|
||||
}
|
||||
|
||||
[$rangeStart, $rangeEnd] = $this->semesterRangeService->getSchoolYearRange($schoolYear);
|
||||
$semesterNorm = $this->semesterRangeService->normalizeSemester($semester);
|
||||
@@ -193,6 +221,8 @@ class StaffAttendanceService
|
||||
}
|
||||
}
|
||||
|
||||
$rangeLabel = $this->buildRangeLabel($schoolYear, $semesterNorm, $rangeStart, $rangeEnd);
|
||||
|
||||
$days = $this->semesterRangeService->buildSundayList($rangeStart, $rangeEnd);
|
||||
|
||||
$assignedBySection = $semesterNorm !== ''
|
||||
@@ -411,14 +441,115 @@ class StaffAttendanceService
|
||||
'school_year' => $schoolYear,
|
||||
'range_start' => $rangeStart,
|
||||
'range_end' => $rangeEnd,
|
||||
'range_label' => $rangeLabel,
|
||||
'month_label' => $rangeLabel,
|
||||
],
|
||||
'sundays' => $days,
|
||||
'noSchoolDays' => $noSchoolDays,
|
||||
'sections' => $sections,
|
||||
'admins' => $admins,
|
||||
'schoolYears' => $schoolYears,
|
||||
'currentYear' => $currentYear,
|
||||
'isCurrentYear' => $isCurrentYear,
|
||||
'missingYear' => $missingYear,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Match the legacy month page behavior:
|
||||
* - prefer the requested year
|
||||
* - else use configured school year
|
||||
* - else fall back to the latest year present in staff_attendance / teacher_class
|
||||
*
|
||||
* @return array{
|
||||
* school_year:string,
|
||||
* current_year:string,
|
||||
* school_years:array<int,string>,
|
||||
* missing_year:bool,
|
||||
* is_current_year:bool
|
||||
* }
|
||||
*/
|
||||
protected function resolveMonthSchoolYearContext(?string $requestedSchoolYear): array
|
||||
{
|
||||
$requestedSchoolYear = trim((string) $requestedSchoolYear);
|
||||
$currentYear = trim((string) $this->configuration->getConfig('school_year'));
|
||||
|
||||
$schoolYears = [];
|
||||
|
||||
try {
|
||||
$schoolYears = DB::table('staff_attendance')
|
||||
->select('school_year')
|
||||
->distinct()
|
||||
->whereNotNull('school_year')
|
||||
->orderByDesc('school_year')
|
||||
->pluck('school_year')
|
||||
->map(static fn ($value) => trim((string) $value))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
} catch (\Throwable) {
|
||||
$schoolYears = [];
|
||||
}
|
||||
|
||||
if ($schoolYears === []) {
|
||||
try {
|
||||
$schoolYears = DB::table('teacher_class')
|
||||
->select('school_year')
|
||||
->distinct()
|
||||
->whereNotNull('school_year')
|
||||
->orderByDesc('school_year')
|
||||
->pluck('school_year')
|
||||
->map(static fn ($value) => trim((string) $value))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
} catch (\Throwable) {
|
||||
$schoolYears = [];
|
||||
}
|
||||
}
|
||||
|
||||
if ($currentYear !== '' && !in_array($currentYear, $schoolYears, true)) {
|
||||
array_unshift($schoolYears, $currentYear);
|
||||
}
|
||||
|
||||
$schoolYears = array_values(array_unique(array_filter($schoolYears, static fn ($value) => $value !== '')));
|
||||
|
||||
if ($currentYear === '' && $schoolYears !== []) {
|
||||
$currentYear = (string) $schoolYears[0];
|
||||
Configuration::setConfigValueByKey('school_year', $currentYear);
|
||||
}
|
||||
|
||||
$resolvedYear = $requestedSchoolYear !== ''
|
||||
? $requestedSchoolYear
|
||||
: ($currentYear !== '' ? $currentYear : ($schoolYears[0] ?? ''));
|
||||
|
||||
if ($resolvedYear !== '' && !in_array($resolvedYear, $schoolYears, true)) {
|
||||
array_unshift($schoolYears, $resolvedYear);
|
||||
}
|
||||
|
||||
return [
|
||||
'school_year' => $resolvedYear,
|
||||
'current_year' => $currentYear,
|
||||
'school_years' => array_values(array_unique($schoolYears)),
|
||||
'missing_year' => $currentYear === '',
|
||||
'is_current_year' => $currentYear !== '' && $resolvedYear === $currentYear,
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildRangeLabel(string $schoolYear, string $semesterNorm, string $rangeStart, string $rangeEnd): string
|
||||
{
|
||||
if ($semesterNorm === '') {
|
||||
return 'School Year ' . $schoolYear;
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'%s %s–%s',
|
||||
$semesterNorm,
|
||||
Carbon::parse($rangeStart)->format('d/m'),
|
||||
Carbon::parse($rangeEnd)->format('d/m')
|
||||
);
|
||||
}
|
||||
|
||||
public function adminsAttendanceData(?string $date, ?string $semester, ?string $schoolYear): array
|
||||
{
|
||||
$date = $date ?: now()->toDateString();
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
<?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}%");
|
||||
});
|
||||
}
|
||||
|
||||
$rows = $query->orderByDesc('follow_up_required')
|
||||
->orderBy('follow_up_completed')
|
||||
->orderBy('person_name')
|
||||
->limit((int) ($filters['limit'] ?? 250))
|
||||
->get()
|
||||
->map(fn ($r) => (array) $r)
|
||||
->all();
|
||||
|
||||
return [
|
||||
'date' => $date,
|
||||
'summary' => $this->summaryForDate($date),
|
||||
'filters' => $this->availableFilters(),
|
||||
'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;
|
||||
|
||||
$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(),
|
||||
'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);
|
||||
$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(),
|
||||
'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);
|
||||
$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(),
|
||||
'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);
|
||||
$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,
|
||||
'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;
|
||||
$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(),
|
||||
'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' => Configuration::getConfigValueByKey('school_year') ?? '',
|
||||
'semester' => Configuration::getConfigValueByKey('semester') ?? '',
|
||||
'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
|
||||
{
|
||||
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(),
|
||||
'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
|
||||
{
|
||||
$base = DB::table('attendance_management_events')->whereDate('event_date', $date);
|
||||
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(),
|
||||
];
|
||||
}
|
||||
|
||||
private function availableFilters(): array
|
||||
{
|
||||
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'],
|
||||
];
|
||||
}
|
||||
|
||||
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 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 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -101,14 +101,8 @@ class BadgePdfService
|
||||
}
|
||||
|
||||
try {
|
||||
$binary = $this->renderWithDompdf(
|
||||
$rows,
|
||||
$schoolName,
|
||||
$motto,
|
||||
$footerBlock
|
||||
);
|
||||
} catch (Throwable) {
|
||||
// Keep the fixed-coordinate FPDF renderer as a fallback when Dompdf fails.
|
||||
// Dompdf is substantially heavier here and can fatally exhaust memory on larger badge runs.
|
||||
// Prefer the fixed-coordinate FPDF renderer first, and keep Dompdf only as a secondary fallback.
|
||||
$binary = $this->renderWithFpdf(
|
||||
$rows,
|
||||
$schoolName,
|
||||
@@ -117,6 +111,13 @@ class BadgePdfService
|
||||
$footerBlock,
|
||||
$logoPath
|
||||
);
|
||||
} catch (Throwable) {
|
||||
$binary = $this->renderWithDompdf(
|
||||
$rows,
|
||||
$schoolName,
|
||||
$motto,
|
||||
$footerBlock
|
||||
);
|
||||
}
|
||||
|
||||
$logIds = array_values(array_unique(array_merge($studentIds, $userIds)));
|
||||
|
||||
@@ -8,6 +8,7 @@ class HealthCheckService
|
||||
{
|
||||
public function check(): array
|
||||
{
|
||||
$migrationTable = (string) (config('database.migrations.table') ?? 'migrations');
|
||||
$uploadsBase = storage_path('app/uploads');
|
||||
$paths = [
|
||||
'uploads' => $uploadsBase,
|
||||
@@ -30,7 +31,8 @@ class HealthCheckService
|
||||
$dbChecks = [
|
||||
'user_preferences_exists' => Schema::hasTable('user_preferences'),
|
||||
'settings_exists' => Schema::hasTable('settings'),
|
||||
'migrations_exists' => Schema::hasTable('migrations'),
|
||||
'migrations_exists' => Schema::hasTable($migrationTable),
|
||||
'migrations_table' => $migrationTable,
|
||||
];
|
||||
|
||||
$dbChecks['user_preferences_has_timezone'] = $dbChecks['user_preferences_exists']
|
||||
|
||||
Reference in New Issue
Block a user