Files
alrahma_sunday_school_api/app/Services/Parents/ParentAttendanceReportService.php
T
2026-06-09 00:03:03 -04:00

793 lines
30 KiB
PHP

<?php
namespace App\Services\Parents;
use App\Controllers\View\ParentAttendanceReportController;
use App\Http\Controllers\Api\Reports\FilesController;
use App\Models\AttendanceData;
use App\Models\AttendanceRecord;
use App\Models\ClassSection;
use App\Models\EarlyDismissalSignature;
use App\Models\ParentAttendanceReport;
use App\Models\Student;
use App\Services\SemesterRangeService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
class ParentAttendanceReportService
{
public function __construct(
private ParentConfigService $configService,
private ParentAttendanceReportCalendarService $calendarService,
private SemesterRangeService $semesterRangeService
) {}
public function formData(int $parentId): array
{
$context = $this->configService->context();
$students = Student::query()
->where('parent_id', $parentId)
->orderBy('firstname')
->orderBy('lastname')
->get()
->toArray();
[$sundays, $defaultDate] = $this->calendarService->computeSundays();
$today = now()->toDateString();
$schoolYear = (string) ($context['school_year'] ?? '');
$previewRows = DB::table('parent_attendance_reports as par')
->select('par.*', 's.firstname', 's.lastname')
->leftJoin('students as s', 's.id', '=', 'par.student_id')
->where('par.parent_id', $parentId)
->where('par.school_year', $schoolYear)
->where('par.report_date', '>=', $today)
->orderBy('par.report_date')
->orderBy('s.firstname')
->orderBy('s.lastname')
->get()
->map(fn ($row) => (array) $row)
->all();
$cutoffThreshold = $this->calendarService->normalizeCutoffTime(
(string) ($context['parent_report_cutoff'] ?? ''),
'09:00'
);
return [
'students' => $students,
'today' => $today,
'sundays' => $sundays,
'defaultDate' => $defaultDate,
'myReports' => $previewRows,
'cutoffThreshold' => $cutoffThreshold,
];
}
public function submit(int $parentId, array $payload): array
{
$context = $this->configService->context();
$schoolYear = (string) ($context['school_year'] ?? '');
$semester = (string) ($context['semester'] ?? '');
$studentIds = array_values(array_filter(array_map('intval', (array) ($payload['student_ids'] ?? []))));
if (empty($studentIds)) {
throw new \RuntimeException('At least select one student.');
}
$datesInput = $payload['dates'] ?? ($payload['date'] ?? null);
$rawDates = [];
if (is_array($datesInput)) {
$rawDates = $datesInput;
} elseif ($datesInput !== null) {
$rawDates = [$datesInput];
}
$rawDates = array_values(array_unique(array_filter(array_map(static function ($val) {
return substr((string) $val, 0, 10);
}, $rawDates))));
if (empty($rawDates)) {
throw new \RuntimeException('Please select at least one Sunday date.');
}
$type = (string) ($payload['type'] ?? '');
if (! in_array($type, ['absent', 'late', 'early_dismissal'], true)) {
throw new \RuntimeException('Invalid report type.');
}
$arrivalTime = $payload['arrival_time'] ?? null;
$dismissTime = $payload['dismiss_time'] ?? null;
$reason = isset($payload['reason']) ? trim((string) $payload['reason']) : null;
$todayCheck = new \DateTime('today');
$cap = $this->calendarService->firstSundayOfJune($schoolYear);
$validDates = [];
foreach ($rawDates as $entry) {
$dt = \DateTime::createFromFormat('Y-m-d', $entry);
if (! $dt) {
throw new \RuntimeException('Invalid date selected: '.$entry);
}
if ((int) $dt->format('w') !== 0) {
throw new \RuntimeException('Please select Sunday dates only.');
}
if ($dt < $todayCheck) {
throw new \RuntimeException('Past dates are not allowed. Please select today or a future Sunday.');
}
if ($dt > $cap) {
throw new \RuntimeException('The last available date is the first Sunday of June.');
}
$validDates[$entry] = $dt;
}
if ($type === 'late' && (! is_string($arrivalTime) || $arrivalTime === '')) {
throw new \RuntimeException('Please provide an expected arrival time for late reporting.');
}
if ($type === 'early_dismissal' && (! is_string($dismissTime) || $dismissTime === '')) {
throw new \RuntimeException('Please provide a dismissal time for early dismissal.');
}
if ($type !== 'early_dismissal') {
if ($reason === null || $reason === '') {
throw new \RuntimeException('Please provide a reason for absence or late arrival.');
}
}
$inserted = 0;
$blocked = [];
$successDetails = [];
foreach ($studentIds as $sid) {
if ($sid <= 0) {
continue;
}
$sectionRow = DB::table('student_class')
->select('class_section_id')
->where('student_id', $sid)
->where('school_year', $schoolYear)
->orderByDesc('updated_at')
->first();
$classSectionId = $sectionRow->class_section_id ?? null;
$student = Student::query()->select('firstname', 'lastname')->find($sid);
$studentName = $student ? trim($student->firstname.' '.$student->lastname) : ('Student #'.$sid);
$classLabel = $this->resolveClassSectionLabel($classSectionId);
foreach ($validDates as $reportDate => $_dt) {
$semesterForDate = $this->semesterRangeService->getSemesterForDate($reportDate) ?: $semester;
$saved = false;
$qb = ParentAttendanceReport::query()
->where('student_id', $sid)
->where('report_date', $reportDate);
if ($type === 'early_dismissal') {
$existingAL = (clone $qb)
->whereIn('type', ['absent', 'late'])
->first();
if ($existingAL) {
$blocked[] = $studentName.' ('.$reportDate.')';
} else {
$existingED = (clone $qb)->where('type', 'early_dismissal')->first();
if ($existingED) {
$existingED->update([
'dismiss_time' => $dismissTime ?: $existingED->dismiss_time,
'status' => 'new',
]);
$saved = true;
$inserted++;
} else {
ParentAttendanceReport::query()->create([
'parent_id' => $parentId,
'student_id' => $sid,
'class_section_id' => $classSectionId,
'report_date' => $reportDate,
'type' => 'early_dismissal',
'dismiss_time' => $dismissTime ?: null,
'reason' => $reason ?: null,
'semester' => $semesterForDate,
'school_year' => $schoolYear,
'status' => 'new',
]);
$saved = true;
$inserted++;
}
}
} else {
$existingAny = (clone $qb)
->whereIn('type', ['absent', 'late', 'early_dismissal'])
->first();
if ($existingAny) {
$blocked[] = $studentName.' ('.$reportDate.')';
} else {
ParentAttendanceReport::query()->create([
'parent_id' => $parentId,
'student_id' => $sid,
'class_section_id' => $classSectionId,
'report_date' => $reportDate,
'type' => $type,
'arrival_time' => ($type === 'late') ? ($arrivalTime ?: null) : null,
'reason' => $reason ?: null,
'semester' => $semesterForDate,
'school_year' => $schoolYear,
'status' => 'new',
]);
$saved = true;
$inserted++;
}
}
if ($saved && in_array($type, ['absent', 'late'], true)) {
$this->reflectToAttendanceData(
studentId: $sid,
reportDate: $reportDate,
type: $type,
semester: $semesterForDate,
schoolYear: $schoolYear,
classSectionId: $classSectionId,
reason: $reason,
arrivalTime: $arrivalTime
);
}
if ($saved) {
$successDetails[] = [
'student_id' => $sid,
'name' => $studentName,
'class_label' => $classLabel,
'type' => $type,
'arrival_time' => $arrivalTime,
'dismiss_time' => $dismissTime,
'date' => $reportDate,
];
}
}
}
if ($inserted <= 0) {
$err = ! empty($blocked)
? ('Already submitted and cannot be changed for: '.implode(', ', array_slice($blocked, 0, 5)).(count($blocked) > 5 ? '…' : ''))
: 'Could not save your report. Please try again.';
throw new \RuntimeException($err);
}
return [
'inserted' => $inserted,
'blocked' => $blocked,
'students' => $successDetails,
'dates' => array_keys($validDates),
];
}
public function listReports(string $start, ?string $end, ?string $schoolYear, ?string $semester, int $parentId): array
{
return ParentAttendanceReport::listForDateRange($start, $end, $schoolYear, $semester, $parentId);
}
public function checkExisting(int $parentId, array $payload): array
{
$datesPost = $payload['dates'] ?? null;
$dateSingle = $payload['date'] ?? null;
$dateValues = [];
if (is_array($datesPost)) {
foreach ($datesPost as $d) {
$dateValues[] = substr((string) $d, 0, 10);
}
} elseif ($dateSingle !== null) {
$dateValues[] = substr((string) $dateSingle, 0, 10);
}
$dateValues = array_values(array_unique(array_filter($dateValues, static function ($d) {
return preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $d);
})));
$type = (string) ($payload['type'] ?? '');
if (empty($dateValues) || ! in_array($type, ['absent', 'late', 'early_dismissal'], true)) {
throw new \RuntimeException('Invalid date or type.');
}
$studentIds = array_values(array_unique(array_map('intval', (array) ($payload['student_ids'] ?? []))));
$studentIds = array_filter($studentIds, static fn ($v) => $v > 0);
if (empty($studentIds)) {
return [];
}
$uniqueIds = array_values(array_unique($studentIds));
$ownedCount = Student::query()
->where('parent_id', $parentId)
->whereIn('id', $uniqueIds)
->count();
if ($ownedCount !== count($uniqueIds)) {
throw new \RuntimeException('Invalid student selection.');
}
$rows = DB::table('parent_attendance_reports as par')
->select('par.student_id', 'par.type', 'par.report_date', 's.firstname', 's.lastname')
->leftJoin('students as s', 's.id', '=', 'par.student_id')
->whereIn('par.report_date', $dateValues)
->whereIn('par.student_id', $studentIds)
->get()
->map(fn ($row) => (array) $row)
->all();
$map = [];
foreach ($rows as $row) {
$sid = (int) ($row['student_id'] ?? 0);
if ($sid <= 0) {
continue;
}
if (! isset($map[$sid])) {
$map[$sid] = [
'student_id' => $sid,
'firstname' => (string) ($row['firstname'] ?? ''),
'lastname' => (string) ($row['lastname'] ?? ''),
'dates' => [],
];
}
$dVal = substr((string) ($row['report_date'] ?? ''), 0, 10);
if ($dVal === '') {
continue;
}
$map[$sid]['dates'][$dVal] = $map[$sid]['dates'][$dVal] ?? [];
$t = (string) ($row['type'] ?? '');
if ($t !== '' && ! in_array($t, $map[$sid]['dates'][$dVal], true)) {
$map[$sid]['dates'][$dVal][] = $t;
}
}
return array_values($map);
}
public function updateReport(int $parentId, int $reportId, array $payload): void
{
$row = ParentAttendanceReport::query()->find($reportId);
if (! $row || (int) $row->parent_id !== $parentId) {
throw new \RuntimeException('Report not found.');
}
if ((string) ($row->status ?? '') !== 'new') {
throw new \RuntimeException('This report can no longer be edited.');
}
$tzName = (string) (config('School')->attendance['timezone'] ?? 'UTC');
$tz = new \DateTimeZone($tzName ?: 'UTC');
$cutoff = new \DateTime($row->report_date.' 09:00:00', $tz);
$now = new \DateTime('now', $tz);
if ($now >= $cutoff) {
throw new \RuntimeException('Editing window closed at 9:00 AM on the report date.');
}
$type = (string) $row->type;
$reason = trim((string) ($payload['reason'] ?? ''));
$arrival = (string) ($payload['arrival_time'] ?? '');
$dismiss = (string) ($payload['dismiss_time'] ?? '');
$update = [
'reason' => $reason !== '' ? $reason : null,
];
if ($type === 'late' && $arrival !== '') {
if (! preg_match('/^\\d{2}:\\d{2}(:\\d{2})?$/', $arrival)) {
throw new \RuntimeException('Invalid arrival time format.');
}
$update['arrival_time'] = $arrival;
}
if ($type === 'early_dismissal' && $dismiss !== '') {
if (! preg_match('/^\\d{2}:\\d{2}(:\\d{2})?$/', $dismiss)) {
throw new \RuntimeException('Invalid dismissal time format.');
}
$update['dismiss_time'] = $dismiss;
}
if (empty($update)) {
throw new \RuntimeException('Nothing to update.');
}
$row->update($update);
}
/**
* Staff index: early dismissal rows grouped by date (+ signature metadata per date).
*
* Parity with legacy {@see ParentAttendanceReportController::earlyDismissals}.
*
* @return array{groups: array<string, array<int, array<string, mixed>>>, school_year: string, semester: string, signature_by_date: array<string, array<string, mixed>>}
*/
public function staffEarlyDismissalsIndex(?string $schoolYearParam, ?string $semesterParam): array
{
$context = $this->configService->context();
$schoolYear = filled($schoolYearParam) ? trim((string) $schoolYearParam) : (string) ($context['school_year'] ?? '');
$semester = filled($semesterParam) ? trim((string) $semesterParam) : '';
$b = DB::table('parent_attendance_reports as par')
->select('par.*', 's.firstname', 's.lastname', 'cs.class_section_name')
->leftJoin('students as s', 's.id', '=', 'par.student_id')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'par.class_section_id')
->where('par.type', '=', 'early_dismissal');
if ($schoolYear !== '') {
$b->where('par.school_year', $schoolYear);
}
if ($semester !== '') {
$b->where('par.semester', $semester);
}
$rows = $b->orderByDesc('par.report_date')
->orderBy('par.dismiss_time')
->get()
->map(fn ($r) => (array) $r)
->all();
$groups = [];
foreach ($rows as $r) {
$d = substr((string) ($r['report_date'] ?? ''), 0, 10);
if ($d === '') {
$d = 'Unknown';
}
$groups[$d][] = $r;
}
uksort($groups, static function ($a, $b) {
if ($a === 'Unknown' && $b === 'Unknown') {
return 0;
}
if ($a === 'Unknown') {
return 1;
}
if ($b === 'Unknown') {
return -1;
}
return strcmp((string) $b, (string) $a);
});
$signatureByDate = [];
$dates = array_values(array_filter(array_keys($groups), static function ($d) {
return preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $d) === 1;
}));
if ($dates !== []) {
$sigQ = EarlyDismissalSignature::query()->whereIn('report_date', $dates);
if ($schoolYear !== '') {
$sigQ->where('school_year', $schoolYear);
}
if ($semester !== '') {
$sigQ->where('semester', $semester);
}
foreach ($sigQ->orderBy('report_date')->get() as $sig) {
$d = $sig->report_date instanceof \DateTimeInterface
? $sig->report_date->format('Y-m-d')
: substr((string) $sig->report_date, 0, 10);
if ($d !== '') {
$signatureByDate[$d] = $sig->toArray();
}
}
}
return [
'groups' => $groups,
'school_year' => $schoolYear,
'semester' => $semester,
'signature_by_date' => $signatureByDate,
];
}
/**
* Parity with legacy {@see ParentAttendanceReportController::addEarlyDismissalForm}.
*
* @return array{students: array<int, array<string, mixed>>, default_date: string, school_year: string, semester: string}
*/
public function staffAddEarlyDismissalFormData(): array
{
$context = $this->configService->context();
$schoolYear = (string) ($context['school_year'] ?? '');
$semester = (string) ($context['semester'] ?? '');
try {
$students = DB::table('students as s')
->select('s.id', 's.firstname', 's.lastname', 's.parent_id', 'cs.class_section_name', 'sc.class_section_id')
->leftJoin('student_class as sc', function ($join) use ($schoolYear) {
$join->on('sc.student_id', '=', 's.id')
->where('sc.school_year', '=', $schoolYear);
})
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->orderBy('s.lastname')
->orderBy('s.firstname')
->get()
->map(fn ($r) => (array) $r)
->all();
} catch (\Throwable) {
$students = [];
}
[, $defaultDate] = $this->calendarService->computeSundays(0, 26);
return [
'students' => $students,
'default_date' => $defaultDate,
'school_year' => $schoolYear,
'semester' => $semester,
];
}
/**
* Parity with legacy {@see ParentAttendanceReportController::saveEarlyDismissal}.
*/
public function saveStaffEarlyDismissal(int $studentId, string $reportDate, string $dismissTime, string $reason): void
{
$context = $this->configService->context();
$schoolYear = (string) ($context['school_year'] ?? '');
$semester = (string) ($context['semester'] ?? '');
$dt = \DateTime::createFromFormat('Y-m-d', $reportDate);
if (! $dt || (int) $dt->format('w') !== 0) {
throw new \RuntimeException('Please select a Sunday date.');
}
$stu = Student::query()->select('id', 'parent_id', 'firstname', 'lastname')->find($studentId);
if (! $stu || empty($stu->parent_id)) {
throw new \RuntimeException('Selected student not found or has no primary parent assigned.');
}
$parentId = (int) $stu->parent_id;
$sectionRow = DB::table('student_class')
->select('class_section_id')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->orderByDesc('updated_at')
->first();
$classSectionId = $sectionRow ? (int) $sectionRow->class_section_id : null;
$qb = ParentAttendanceReport::query()
->where('student_id', $studentId)
->whereDate('report_date', $reportDate)
->where('school_year', $schoolYear);
$existingAl = (clone $qb)->where(function ($q) {
$q->where('type', 'absent')->orWhere('type', 'late');
})->first();
if ($existingAl) {
throw new \RuntimeException('Absent/Late already submitted for this student on the selected date.');
}
$existingEd = (clone $qb)->where('type', 'early_dismissal')->first();
if ($existingEd) {
$existingEd->update([
'dismiss_time' => $dismissTime !== '' ? $dismissTime : $existingEd->dismiss_time,
'reason' => $reason !== '' ? $reason : $existingEd->reason,
'status' => 'new',
]);
return;
}
ParentAttendanceReport::query()->create([
'parent_id' => $parentId,
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'report_date' => $reportDate,
'type' => 'early_dismissal',
'dismiss_time' => $dismissTime,
'reason' => $reason !== '' ? $reason : null,
'semester' => $semester,
'school_year' => $schoolYear,
'status' => 'new',
]);
}
/**
* Parity with legacy {@see ParentAttendanceReportController::uploadEarlyDismissalSignature}.
* Files live under {@see storage_path('uploads/early_dismissal_signatures')} (same as {@see FilesController::earlyDismissalSignature}).
*/
public function storeEarlyDismissalSignature(
UploadedFile $file,
string $reportDate,
?string $schoolYearPost,
?string $semesterPost,
int $uploadedByUserId,
): void {
$dt = \DateTime::createFromFormat('Y-m-d', $reportDate);
if (! $dt || (int) $dt->format('w') !== 0) {
throw new \RuntimeException('Please select a Sunday date.');
}
$context = $this->configService->context();
$schoolYear = $schoolYearPost !== null && $schoolYearPost !== ''
? (string) $schoolYearPost
: (string) ($context['school_year'] ?? '');
$semesterRaw = (string) ($semesterPost ?? '');
$hasSemesterFilter = $semesterRaw !== '';
$semester = $hasSemesterFilter ? $semesterRaw : null;
$existsQ = ParentAttendanceReport::query()
->where('type', 'early_dismissal')
->whereDate('report_date', $reportDate);
if ($schoolYear !== '') {
$existsQ->where('school_year', $schoolYear);
}
if ($hasSemesterFilter) {
$existsQ->where('semester', $semester);
}
if (! $existsQ->exists()) {
throw new \RuntimeException('No early dismissal records found for that date.');
}
$targetDir = storage_path('uploads/early_dismissal_signatures');
if (! is_dir($targetDir) && ! @mkdir($targetDir, 0755, true) && ! is_dir($targetDir)) {
throw new \RuntimeException('Upload directory is not available.');
}
$originalName = $file->getClientOriginalName();
$mimeType = $file->getClientMimeType();
$fileSize = (int) $file->getSize();
$filename = $file->hashName();
$file->move($targetDir, $filename);
$payload = [
'report_date' => $reportDate,
'school_year' => $schoolYear !== '' ? $schoolYear : null,
'semester' => $semester,
'filename' => $filename,
'original_name' => $originalName,
'mime_type' => $mimeType,
'file_size' => $fileSize,
'uploaded_by' => $uploadedByUserId,
];
$existingQ = EarlyDismissalSignature::query()->whereDate('report_date', $reportDate);
if ($schoolYear !== '') {
$existingQ->where('school_year', $schoolYear);
}
if ($hasSemesterFilter) {
$existingQ->where('semester', $semester);
}
$existing = $existingQ->first();
if ($existing) {
$oldFile = (string) ($existing->filename ?? '');
$existing->update($payload);
if ($oldFile !== '' && $oldFile !== $filename) {
@unlink($targetDir.DIRECTORY_SEPARATOR.$oldFile);
}
} else {
EarlyDismissalSignature::query()->create($payload);
}
}
private function resolveClassSectionLabel(?int $classSectionId): string
{
$cid = (int) ($classSectionId ?? 0);
if ($cid <= 0) {
return 'Not Assigned';
}
$name = ClassSection::query()
->where('class_section_id', $cid)
->value('class_section_name');
return $name ?: 'Not Assigned';
}
private function reflectToAttendanceData(
int $studentId,
string $reportDate,
string $type,
string $semester,
string $schoolYear,
?int $classSectionId,
?string $reason = null,
?string $arrivalTime = null
): void {
$status = ($type === 'late') ? 'late' : (($type === 'absent') ? 'absent' : null);
if ($status === null) {
return;
}
if (! $classSectionId) {
$row = DB::table('student_class')
->select('class_section_id')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->orderByDesc('updated_at')
->first();
$classSectionId = (int) ($row->class_section_id ?? 0);
}
if ($classSectionId <= 0) {
return;
}
$existing = AttendanceData::query()
->where('student_id', $studentId)
->where('class_section_id', $classSectionId)
->where('date', $reportDate)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
$reasonParts = ['Parent-reported'];
if ($type === 'late' && $arrivalTime) {
$reasonParts[] = 'ETA '.substr($arrivalTime, 0, 5);
}
if (! empty($reason)) {
$reasonParts[] = trim($reason);
}
$reasonText = implode(' — ', $reasonParts);
if (! $existing) {
$classId = ClassSection::query()
->where('class_section_id', $classSectionId)
->value('class_id');
$schoolId = Student::query()->whereKey($studentId)->value('school_id');
if (! $classId || ! $schoolId) {
return;
}
AttendanceData::query()->create([
'class_id' => $classId,
'class_section_id' => $classSectionId,
'student_id' => $studentId,
'school_id' => $schoolId,
'date' => $reportDate,
'status' => $status,
'reason' => $reasonText,
'semester' => $semester,
'school_year' => $schoolYear,
]);
$this->bumpAttendanceRecord($studentId, $classSectionId, $schoolId, $status, $semester, $schoolYear);
return;
}
$existingStatus = strtolower((string) ($existing->status ?? ''));
if ($existingStatus === 'present' || $existingStatus === '') {
$existing->update([
'status' => $status,
'reason' => $existing->reason ?: $reasonText,
]);
}
}
private function bumpAttendanceRecord(
int $studentId,
int $classSectionId,
string $schoolId,
string $status,
string $semester,
string $schoolYear
): void {
$record = AttendanceRecord::query()
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
if ($record) {
$updates = [
'total_presence' => (int) ($record->total_presence ?? 0),
'total_absence' => (int) ($record->total_absence ?? 0),
'total_late' => (int) ($record->total_late ?? 0),
'total_attendance' => (int) ($record->total_attendance ?? 0) + 1,
];
if ($status === 'present') {
$updates['total_presence']++;
} elseif ($status === 'absent') {
$updates['total_absence']++;
} elseif ($status === 'late') {
$updates['total_late']++;
}
$record->update($updates);
return;
}
AttendanceRecord::query()->create([
'class_section_id' => $classSectionId,
'student_id' => $studentId,
'school_id' => $schoolId,
'semester' => $semester,
'school_year' => $schoolYear,
'total_presence' => ($status === 'present') ? 1 : 0,
'total_absence' => ($status === 'absent') ? 1 : 0,
'total_late' => ($status === 'late') ? 1 : 0,
'total_attendance' => 1,
]);
}
}