reconstruction of the project

This commit is contained in:
root
2026-03-08 16:33:24 -04:00
parent 23b7db1107
commit c8de5f7edc
9157 changed files with 77877 additions and 1073823 deletions
@@ -0,0 +1,75 @@
<?php
namespace App\Services\Attendance;
use Illuminate\Contracts\Auth\Authenticatable;
class AttendancePolicyService
{
public function canEditDay(array $dayRow, array $currentUser): bool
{
$status = strtolower((string)($dayRow['status'] ?? 'draft'));
$roles = array_map([$this, 'normalizeRole'], (array)($currentUser['roles'] ?? []));
$permissions = array_map('strtolower', (array)($currentUser['permissions'] ?? []));
$isAdmin = $this->isAdminLike($roles, $permissions);
$isTeacher = in_array('teacher', $roles, true) || in_array('ta', $roles, true) || in_array('teacher_assistant', $roles, true);
if ($status === 'finalized' || $status === 'published') {
return $isAdmin;
}
if ($status === 'submitted') {
return $isAdmin;
}
if ($status === 'draft') {
return $isAdmin || $isTeacher;
}
return $isAdmin;
}
public function isTeacher(array $roles): bool
{
$roles = array_map([$this, 'normalizeRole'], $roles);
return in_array('teacher', $roles, true)
|| in_array('ta', $roles, true)
|| in_array('teacher_assistant', $roles, true)
|| in_array('assistant_teacher', $roles, true);
}
public function isAdminLike(array $roles, array $permissions = []): bool
{
$roles = array_map([$this, 'normalizeRole'], $roles);
$excluded = [
'parent',
'guest',
'teacher',
'teacher_assistant',
'assistant_teacher',
'ta',
];
foreach ($roles as $role) {
if ($role !== '' && !in_array($role, $excluded, true)) {
return true;
}
}
foreach ($permissions as $permission) {
$permission = strtolower(trim((string)$permission));
if (str_contains($permission, 'attendance.manage') || str_contains($permission, 'attendance.admin')) {
return true;
}
}
return false;
}
public function normalizeRole(?string $role): string
{
return str_replace([' ', '-'], '_', strtolower(trim((string)$role)));
}
}
@@ -0,0 +1,604 @@
<?php
namespace App\Services\Attendance;
use App\Models\AttendanceData;
use App\Models\AttendanceDay;
use App\Models\AttendanceRecord;
use App\Models\Calendar;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\Student;
use App\Models\StudentClass;
use App\Models\TeacherClass;
use App\Models\User;
use App\Models\UserRole;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class AttendanceQueryService
{
public function __construct(
protected Configuration $configuration,
protected AttendanceData $attendanceData,
protected AttendanceDay $attendanceDay,
protected AttendanceRecord $attendanceRecord,
protected Student $student,
protected StudentClass $studentClass,
protected ClassSection $classSection,
protected TeacherClass $teacherClass,
protected Calendar $calendar,
protected User $user,
protected UserRole $userRole,
protected AttendanceService $attendanceService,
protected SemesterRangeService $semesterRangeService,
) {}
public function teacherGrid(string $semester, string $schoolYear, string $date, int $sectionCode): array
{
$bySection = $this->teacherClass->assignedBySectionForTerm($semester, $schoolYear);
$sectionCodes = array_keys($bySection);
$labels = [];
if (!empty($sectionCodes)) {
$rows = $this->classSection
->query()
->select(['class_section_id', 'class_section_name'])
->whereIn('class_section_id', $sectionCodes)
->orderBy('class_section_id')
->get()
->toArray();
foreach ($rows as $row) {
$code = (int)$row['class_section_id'];
$name = trim((string)($row['class_section_name'] ?? ''));
$labels[$code] = $name !== '' ? $name : 'Section #' . $code;
}
foreach ($sectionCodes as $code) {
$labels[$code] ??= 'Section #' . $code;
}
}
$grid = [];
$locked = false;
if ($sectionCode > 0) {
$assigned = $this->teacherClass->assignedForSectionTerm($sectionCode, $semester, $schoolYear);
$teacherIds = array_map(fn($r) => (int)$r['teacher_id'], $assigned);
$statusMap = [];
if (!empty($teacherIds)) {
$rows = DB::table('staff_attendance as sa')
->select('sa.user_id', 'sa.status', 'sa.reason')
->where('sa.semester', $semester)
->where('sa.school_year', $schoolYear)
->whereDate('sa.date', $date)
->whereIn('sa.user_id', $teacherIds)
->get();
foreach ($rows as $row) {
$statusMap[(int)$row->user_id] = [
'status' => $row->status,
'reason' => $row->reason,
];
}
}
foreach ($assigned as $teacher) {
$teacherId = (int)$teacher['teacher_id'];
$position = strtolower((string)($teacher['position'] ?? 'main'));
$position = in_array($position, ['main', 'ta'], true) ? $position : 'main';
$name = trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? ''));
$cell = $statusMap[$teacherId] ?? ['status' => null, 'reason' => null];
$grid[] = [
'teacher_id' => $teacherId,
'name' => $name !== '' ? $name : 'User#' . $teacherId,
'position' => $position,
'status' => $cell['status'],
'reason' => $cell['reason'],
];
}
try {
$locked = method_exists($this->attendanceDay, 'isFinalized')
? $this->attendanceDay->isFinalized($sectionCode, $date, $semester, $schoolYear)
: false;
} catch (\Throwable) {
$locked = false;
}
}
return [
'semester' => $semester,
'school_year' => $schoolYear,
'date' => $date,
'class_section_id' => $sectionCode,
'sections' => $labels,
'grid' => $grid,
'locked' => $locked,
];
}
public function teacherAttendanceFormData(?int $userId, int $requestedSectionId = 0): array
{
if (!$userId) {
throw new RuntimeException('User not logged in.');
}
$teacher = $this->user->find($userId);
$teacherName = $teacher
? trim(($teacher->firstname ?? '') . ' ' . ($teacher->lastname ?? ''))
: 'Unknown Teacher';
$semester = $this->attendanceService->currentSemester();
$schoolYear = $this->attendanceService->currentSchoolYear();
$assignments = $this->teacherClass->getClassAssignmentsByUserId($userId, $schoolYear, $semester);
if (empty($assignments)) {
throw new RuntimeException('You do not have an assigned class yet.');
}
$activeSection = null;
foreach ($assignments as $assignment) {
$cid = (int)($assignment['class_section_id'] ?? 0);
$pk = (int)($assignment['class_section_pk'] ?? 0);
if ($requestedSectionId > 0 && ($requestedSectionId === $cid || $requestedSectionId === $pk)) {
$activeSection = $assignment;
break;
}
}
$activeSection ??= $assignments[0];
$classSectionCode = (int)($activeSection['class_section_id'] ?? 0);
$classSectionPk = (int)($activeSection['class_section_pk'] ?? 0);
$classSectionId = $classSectionCode > 0 ? $classSectionCode : $classSectionPk;
if ($classSectionId <= 0) {
throw new RuntimeException('Unable to determine your class section.');
}
$today = Carbon::today();
$anchorSunday = $today->dayOfWeek === Carbon::SUNDAY ? $today->copy() : $today->copy()->next(Carbon::SUNDAY);
$sundayDates = [
$anchorSunday->copy()->subWeeks(2)->toDateString(),
$anchorSunday->copy()->subWeek()->toDateString(),
$anchorSunday->toDateString(),
];
$currentSunday = $sundayDates[2];
$students = $this->student->getByClassAndYear($classSectionId, $schoolYear, $semester);
if (empty($students) && $classSectionPk > 0 && $classSectionPk !== $classSectionId) {
$students = $this->student->getByClassAndYear($classSectionPk, $schoolYear, $semester);
}
$attendanceRows = [];
$lockedByStudent = [];
foreach ($students as $student) {
$studentId = (int)$student['id'];
$rows = AttendanceData::query()
->select(['date', 'status', 'is_reported', 'reason', 'modified_by'])
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('semester', $semester)
->where(function ($q) use ($classSectionId) {
$q->where('class_section_id', $classSectionId)
->orWhere('class_section_id', (int)$classSectionId);
})
->whereIn('date', $sundayDates)
->get()
->map(fn($row) => (array)$row)
->all();
$rows = $this->attendanceService->normalizeAttendanceEntries($rows);
$recentRows = AttendanceData::query()
->select(['date', 'status'])
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('semester', $semester)
->where(function ($q) use ($classSectionId) {
$q->where('class_section_id', $classSectionId)
->orWhere('class_section_id', (int)$classSectionId);
})
->orderByDesc('date')
->limit(3)
->get()
->toArray();
$recentHistory = [];
foreach ($recentRows as $row) {
$recentHistory[] = [
'date' => substr((string)($row['date'] ?? ''), 0, 10),
'status' => strtolower((string)($row['status'] ?? '')),
];
}
$snapshot = [];
$rowsByDate = [];
foreach ($rows as $entry) {
$d = (string)($entry['date'] ?? '');
$snapshot[$d] = $entry['status'] ?? null;
$rowsByDate[$d] = $entry;
}
$statusHistory = [];
foreach ($sundayDates as $date) {
$statusHistory[$date] = array_key_exists($date, $snapshot)
? $snapshot[$date]
: ($date === $currentSunday ? null : 'N/A');
}
$todayRow = $rowsByDate[$currentSunday] ?? null;
$lockInfo = $this->attendanceService->detectExternalSubmission($todayRow);
$todayReason = $todayRow['reason'] ?? null;
$attendanceRows[] = [
'student' => $student,
'sunday_statuses' => $statusHistory,
'today' => $statusHistory[$currentSunday] ?? null,
'today_reason' => $todayReason,
'locked' => $lockInfo['locked'],
'locked_source' => $lockInfo['source'],
'recent_three' => $recentHistory,
];
$lockedByStudent[$studentId] = [
'locked' => $lockInfo['locked'],
'source' => $lockInfo['source'],
'reason' => $todayReason,
'status' => $statusHistory[$currentSunday] ?? null,
];
}
$assigned = $this->teacherClass->assignedForSectionTerm($classSectionId, $semester, $schoolYear);
$teacherRows = [];
if (!empty($assigned)) {
$teacherIds = array_map(static fn($r) => (int)$r['teacher_id'], $assigned);
$statusMap = [];
if (!empty($teacherIds)) {
$rows = DB::table('staff_attendance as sa')
->select('sa.user_id', 'sa.date', 'sa.status', 'sa.reason')
->where('sa.semester', $semester)
->where('sa.school_year', $schoolYear)
->whereIn('sa.user_id', $teacherIds)
->whereIn('sa.date', $sundayDates)
->get();
foreach ($rows as $row) {
$statusMap[(int)$row->user_id][(string)$row->date] = [
'status' => $row->status,
'reason' => $row->reason,
];
}
}
foreach ($assigned as $teacher) {
$teacherId = (int)$teacher['teacher_id'];
$position = strtolower((string)($teacher['position'] ?? 'main'));
$position = in_array($position, ['main', 'ta'], true) ? $position : 'main';
$name = trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? ''));
$history = [];
foreach ($sundayDates as $date) {
$history[$date] = isset($statusMap[$teacherId][$date]['status'])
? $statusMap[$teacherId][$date]['status']
: ($date === $currentSunday ? null : 'N/A');
}
$teacherRows[] = [
'teacher_id' => $teacherId,
'position' => $position,
'name' => $name !== '' ? $name : 'User#' . $teacherId,
'sunday_statuses' => $history,
'today' => $history[$currentSunday] ?? null,
];
}
}
$noSchoolDays = [];
$calendarRows = $this->calendar->query()
->where('no_school', 1)
->whereIn('date', $sundayDates)
->where('semester', $semester)
->where('school_year', $schoolYear)
->get()
->toArray();
foreach ($calendarRows as $row) {
$d = (string)($row['date'] ?? '');
$noSchoolDays[$d] = [
'title' => $row['title'] ?? 'No School',
'description' => $row['description'] ?? '',
];
}
return [
'teacher_name' => $teacherName,
'students' => $attendanceRows,
'teachers' => $teacherRows,
'class_section_id' => $classSectionId,
'sunday_dates' => $sundayDates,
'current_sunday' => $currentSunday,
'enable_attendance' => (int)$this->configuration->getConfig('enable_attendance'),
'can_edit' => ((int)$this->configuration->getConfig('enable_attendance') === 1),
'class_id' => (int)$this->classSection->getClassId($classSectionId),
'no_school_days' => $noSchoolDays,
'today' => $currentSunday,
'locked_by_student' => $lockedByStudent,
];
}
public function buildDailyAttendanceData(string $termSemester, string $termYear): array
{
$classSections = $this->classSection
->query()
->select('classSection.id', 'classSection.class_id', 'classSection.class_section_id', 'classSection.class_section_name')
->join('student_class as sc', 'sc.class_section_id', '=', 'classSection.class_section_id')
->where('sc.school_year', $termYear)
->groupBy('classSection.id', 'classSection.class_id', 'classSection.class_section_id', 'classSection.class_section_name')
->get()
->map(fn($row) => (array)$row)
->all();
$attendanceData = [];
$attendanceRecord = [];
$studentsBySection = [];
$grades = [];
$datesBySection = [];
$studentSchoolMap = [];
$sectionCounts = $this->studentClass->getStudentCountsBySection($termYear);
foreach ($classSections as $classSection) {
$secPk = (int)($classSection['id'] ?? 0);
$secCodeRaw = (string)($classSection['class_section_id'] ?? '');
$secCode = $secCodeRaw !== '' ? $secCodeRaw : (string)$secPk;
$classId = (int)($classSection['class_id'] ?? 0);
$studentsBySection[$secCode] = [];
$datesBySection[$secCode] = [];
$attendanceData[$secCode] = [];
$attendanceRecord[$secCode] = [];
$countForCode = (int)($sectionCounts[$secCode] ?? 0);
$countForPk = (int)($sectionCounts[$secPk] ?? 0);
$sectionHasStudents = ($countForCode + $countForPk) > 0;
$students = $this->studentClass->getClassStudents($secCode, $termYear);
if (!$students && $secPk && (string)$secPk !== (string)$secCode) {
$students = $this->studentClass->getClassStudents($secPk, $termYear);
}
if (!$sectionHasStudents || !$students) {
continue;
}
$hasRoster = false;
foreach ($students as $sc) {
$studentId = (int)$sc['student_id'];
$student = $this->student
->query()
->select(['id', 'firstname', 'lastname', 'school_id'])
->where('id', $studentId)
->where('is_active', 1)
->first();
if (!$student) {
continue;
}
$student = $student->toArray();
$studentsBySection[$secCode][] = $student;
$studentSchoolMap[$studentId] = (string)($student['school_id'] ?? '');
$hasRoster = true;
$entries = AttendanceData::query()
->where('student_id', $studentId)
->where(function ($q) use ($secCode, $secPk) {
$q->where('class_section_id', $secCode);
if ($secPk && (string)$secPk !== (string)$secCode) {
$q->orWhere('class_section_id', $secPk);
}
})
->when($termSemester !== '', fn($q) => $q->where('semester', $termSemester))
->when($termYear !== '', fn($q) => $q->where('school_year', $termYear))
->orderBy('date')
->get()
->map(fn($row) => $row->toArray())
->all();
$entries = $this->attendanceService->normalizeAttendanceEntries($entries);
$attendanceData[$secCode][$studentId] = $entries;
foreach ($entries as $entry) {
$d = (string)($entry['date'] ?? '');
if ($d !== '') {
$datesBySection[$secCode][$d] = true;
}
}
$summary = AttendanceRecord::query()
->where('student_id', $studentId)
->where(function ($q) use ($secCode, $secPk) {
$q->where('class_section_id', $secCode);
if ($secPk && (string)$secPk !== (string)$secCode) {
$q->orWhere('class_section_id', $secPk);
}
})
->when($termSemester !== '', fn($q) => $q->where('semester', $termSemester))
->when($termYear !== '', fn($q) => $q->where('school_year', $termYear))
->first();
$attendanceRecord[$secCode][$studentId] = $summary?->toArray() ?? [
'total_presence' => 0,
'total_late' => 0,
'total_absence' => 0,
'total_attendance' => 0,
];
}
if (!$hasRoster) {
unset($studentsBySection[$secCode], $datesBySection[$secCode], $attendanceData[$secCode], $attendanceRecord[$secCode]);
continue;
}
$grades[$classId][] = $classSection;
}
foreach ($attendanceData as $secId => &$studentEntries) {
foreach ($studentEntries as &$entries) {
usort($entries, static fn($a, $b) => strcmp((string)($a['date'] ?? ''), (string)($b['date'] ?? '')));
}
}
unset($studentEntries, $entries);
foreach ($datesBySection as $sec => $set) {
$arr = array_keys($set);
sort($arr, SORT_STRING);
$datesBySection[$sec] = $arr;
}
ksort($grades, SORT_NUMERIC);
[$rangeStart, $rangeEnd] = $this->semesterRangeService->getSchoolYearRange($termYear);
$semesterNorm = $this->semesterRangeService->normalizeSemester($termSemester);
if ($semesterNorm !== '') {
$semRange = $this->semesterRangeService->getSemesterRange($termYear, $semesterNorm);
if ($semRange) {
[$rangeStart, $rangeEnd] = $semRange;
}
}
$dateList = $this->semesterRangeService->buildSundayList($rangeStart, $rangeEnd);
$noSchoolDays = [];
$events = $this->calendar->query()
->where('no_school', 1)
->where('date', '>=', $rangeStart)
->where('date', '<=', $rangeEnd)
->get()
->toArray();
foreach ($events as $event) {
$d = substr((string)($event['date'] ?? ''), 0, 10);
if ($d !== '') {
$noSchoolDays[$d] = true;
}
}
$anchorSunday = now()->dayOfWeek === Carbon::SUNDAY
? now()->toDateString()
: now()->next(Carbon::SUNDAY)->toDateString();
$passedDatesSet = [];
foreach ($dateList as $d) {
if ($d <= $anchorSunday && empty($noSchoolDays[$d])) {
$passedDatesSet[$d] = true;
}
}
$totalPassedDays = count($passedDatesSet);
if ($totalPassedDays > 0 && $termYear !== '' && $termSemester !== '') {
foreach ($attendanceData as $secCode => $studentEntries) {
foreach ($studentEntries as $studentId => $entries) {
$statusByDate = [];
foreach ($entries as $entry) {
$d = substr((string)($entry['date'] ?? ''), 0, 10);
if ($d === '' || empty($passedDatesSet[$d])) {
continue;
}
$st = strtolower(trim((string)($entry['status'] ?? '')));
if (!in_array($st, ['present', 'absent', 'late'], true)) {
continue;
}
$statusByDate[$d] = $st;
}
$p = 0;
$l = 0;
$a = 0;
foreach ($statusByDate as $st) {
if ($st === 'present') {
$p++;
} elseif ($st === 'late') {
$l++;
} elseif ($st === 'absent') {
$a++;
}
}
$sum = $p + $l + $a;
$record = $attendanceRecord[$secCode][$studentId] ?? [];
$storedSum = (int)($record['total_presence'] ?? 0)
+ (int)($record['total_late'] ?? 0)
+ (int)($record['total_absence'] ?? 0);
if ($storedSum !== $totalPassedDays && $storedSum !== $sum) {
DB::table('attendance_record')
->updateOrInsert(
[
'student_id' => $studentId,
'semester' => $termSemester,
'school_year' => $termYear,
],
[
'class_section_id' => $secCode,
'school_id' => $studentSchoolMap[$studentId] ?? '',
'total_presence' => $p,
'total_late' => $l,
'total_absence' => $a,
'total_attendance' => $sum,
'modified_by' => auth()->id(),
'updated_at' => now(),
'created_at' => now(),
]
);
$attendanceRecord[$secCode][$studentId]['total_presence'] = $p;
$attendanceRecord[$secCode][$studentId]['total_late'] = $l;
$attendanceRecord[$secCode][$studentId]['total_absence'] = $a;
$attendanceRecord[$secCode][$studentId]['total_attendance'] = $sum;
}
}
}
}
$adminName = '';
if (auth()->id()) {
$u = $this->user->query()->select('firstname', 'lastname')->find(auth()->id());
if ($u) {
$adminName = trim(($u->firstname ?? '') . ' ' . ($u->lastname ?? ''));
}
}
return [
'attendanceData' => $attendanceData,
'attendanceRecord' => $attendanceRecord,
'studentsBySection' => $studentsBySection,
'grades' => $grades,
'datesBySection' => $datesBySection,
'dateList' => $dateList,
'noSchoolDays' => $noSchoolDays,
'totalPassedDays' => $totalPassedDays,
'semester' => $termSemester,
'schoolYear' => $termYear,
'currentAdminName' => $adminName,
];
}
}
@@ -0,0 +1,153 @@
<?php
namespace App\Services\Attendance;
use App\Models\AttendanceRecord;
class AttendanceRecordSyncService
{
public function __construct(
protected AttendanceRecord $attendanceRecord
) {}
public function updateAttendanceRecord(
int $studentId,
string $schoolId,
string $newStatus,
string $semester,
string $schoolYear,
?string $oldStatus,
?int $modifiedBy = null
): void {
$record = AttendanceRecord::query()
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
if (!$record) {
return;
}
$newStatus = strtolower($newStatus);
$oldStatus = strtolower((string)$oldStatus);
$presence = (int)($record->total_presence ?? 0);
$absence = (int)($record->total_absence ?? 0);
$late = (int)($record->total_late ?? 0);
if ($oldStatus === 'present') {
$presence--;
} elseif ($oldStatus === 'absent') {
$absence--;
} elseif ($oldStatus === 'late') {
$late--;
}
if ($newStatus === 'present') {
$presence++;
} elseif ($newStatus === 'absent') {
$absence++;
} elseif ($newStatus === 'late') {
$late++;
}
$record->update([
'total_presence' => max(0, $presence),
'total_absence' => max(0, $absence),
'total_late' => max(0, $late),
'updated_at' => now(),
'modified_by' => $modifiedBy,
]);
}
public function addNewAttendanceRecord(
int $classSectionId,
int $studentId,
string $schoolId,
string $status,
string $semester,
string $schoolYear,
?int $modifiedBy = null
): void {
$status = strtolower($status);
$record = AttendanceRecord::query()
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
if ($record) {
$record->update([
'total_presence' => (int)($record->total_presence ?? 0) + ($status === 'present' ? 1 : 0),
'total_absence' => (int)($record->total_absence ?? 0) + ($status === 'absent' ? 1 : 0),
'total_late' => (int)($record->total_late ?? 0) + ($status === 'late' ? 1 : 0),
'total_attendance' => (int)($record->total_attendance ?? 0) + 1,
'updated_at' => now(),
'modified_by' => $modifiedBy,
]);
return;
}
AttendanceRecord::query()->create([
'class_section_id' => $classSectionId,
'student_id' => $studentId,
'school_id' => $schoolId,
'semester' => $semester,
'school_year' => $schoolYear,
'created_at' => now(),
'updated_at' => now(),
'modified_by' => $modifiedBy,
'total_presence' => $status === 'present' ? 1 : 0,
'total_absence' => $status === 'absent' ? 1 : 0,
'total_late' => $status === 'late' ? 1 : 0,
'total_attendance' => 1,
]);
}
public function upsertAttendanceRecordTotals(
int $studentId,
string $classSectionId,
string $schoolId,
string $semester,
string $schoolYear,
int $totalPresence,
int $totalLate,
int $totalAbsence,
int $totalAttendance,
?int $modifiedBy = null
): bool {
if ($semester === '' || $schoolYear === '') {
return false;
}
$record = AttendanceRecord::query()
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
$payload = [
'total_presence' => max(0, $totalPresence),
'total_late' => max(0, $totalLate),
'total_absence' => max(0, $totalAbsence),
'total_attendance' => max(0, $totalAttendance),
'updated_at' => now(),
'modified_by' => $modifiedBy,
];
if ($record) {
return $record->update($payload);
}
$payload['class_section_id'] = (int)$classSectionId;
$payload['student_id'] = $studentId;
$payload['school_id'] = $schoolId;
$payload['semester'] = $semester;
$payload['school_year'] = $schoolYear;
$payload['created_at'] = now();
return (bool)AttendanceRecord::query()->create($payload);
}
}
@@ -0,0 +1,275 @@
<?php
namespace App\Services\Attendance;
use App\Models\AttendanceData;
use App\Models\AttendanceDay;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\UserRole;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class AttendanceService
{
public function __construct(
protected Configuration $configuration,
protected AttendanceData $attendanceData,
protected AttendanceDay $attendanceDay,
protected ClassSection $classSection,
protected UserRole $userRole,
protected AttendancePolicyService $attendancePolicyService,
protected StudentAttendanceWriterService $studentAttendanceWriterService,
protected TeacherAttendanceSubmissionService $teacherAttendanceSubmissionService,
protected AttendanceRecordSyncService $attendanceRecordSyncService,
) {}
public function currentSemester(): string
{
return (string)($this->configuration->getConfig('semester') ?? 'Fall');
}
public function currentSchoolYear(): string
{
return (string)($this->configuration->getConfig('school_year') ?? now()->year . '-' . (now()->year + 1));
}
public function updateAttendanceManagement(Authenticatable $user, array $data): array
{
$classSectionId = (int)$data['class_section_id'];
$studentId = (int)$data['student_id'];
$status = strtolower((string)$data['status']);
$date = (string)$data['date'];
$reason = $data['reason'] ?? null;
$classId = $data['class_id'] ?? null;
$semester = (string)($data['semester'] ?? $this->currentSemester());
$schoolYear = (string)($data['school_year'] ?? $this->currentSchoolYear());
$dayRow = AttendanceDay::query()
->where('class_section_id', $classSectionId)
->whereDate('date', $date)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
$dayPolicyRow = $dayRow?->toArray() ?? [
'status' => 'draft',
'class_section_id' => $classSectionId,
'date' => $date,
'semester' => $semester,
'school_year' => $schoolYear,
];
$currentUser = [
'id' => $user->id,
'roles' => method_exists($user, 'roles') ? $user->roles->pluck('name')->all() : [],
'permissions' => method_exists($user, 'permissions') ? $user->permissions->pluck('name')->all() : [],
];
if (!$this->attendancePolicyService->canEditDay($dayPolicyRow, $currentUser)) {
throw new RuntimeException('Attendance is locked for your role.');
}
$reportedRaw = strtolower((string)($data['is_reported'] ?? ''));
$isReported = $status === 'present'
? 'no'
: (in_array($reportedRaw, ['1', 'yes', 'y', 'true', 'on'], true) ? 'yes' : 'no');
DB::transaction(function () use (
$user,
$classSectionId,
$studentId,
$status,
$date,
$reason,
$classId,
$semester,
$schoolYear,
$isReported
) {
$this->studentAttendanceWriterService->saveOrUpdateStudentAttendance([
'class_section_id' => $classSectionId,
'student_id' => $studentId,
'school_id' => null,
'status' => $status,
'reason' => $reason,
'is_reported' => $isReported,
'semester' => $semester,
'school_year' => $schoolYear,
'date' => $date,
'class_id' => (int)$classId,
'user_id' => $user->id,
]);
AttendanceDay::query()->firstOrCreate(
[
'class_section_id' => $classSectionId,
'date' => $date,
'semester' => $semester,
'school_year' => $schoolYear,
],
[
'status' => 'draft',
'created_at' => now(),
'updated_at' => now(),
]
);
});
return [
'ok' => true,
'message' => 'Attendance updated successfully.',
];
}
public function submitTeacherAttendance(Authenticatable $user, array $data): array
{
return $this->teacherAttendanceSubmissionService->submit(
$user,
$data,
fn (?array $row) => $this->detectExternalSubmission($row),
fn () => $this->currentSemester(),
fn () => $this->currentSchoolYear(),
);
}
public function adminAddEntry(Authenticatable $user, array $data): array
{
$classSectionId = (int)$data['class_section_id'];
$studentId = (int)$data['student_id'];
$status = strtolower((string)$data['status']);
$date = (string)($data['date'] ?? now()->toDateString());
$reason = $data['reason'] ?? null;
$isReported = in_array(strtolower((string)($data['is_reported'] ?? 'no')), ['yes', 'no'], true)
? strtolower((string)($data['is_reported'] ?? 'no'))
: 'no';
$section = $this->classSection
->query()
->select('class_id', 'id', 'class_section_id')
->where('id', $classSectionId)
->orWhere('class_section_id', $classSectionId)
->first();
$resolvedClassId = (int)($section->class_id ?? 0);
if ($resolvedClassId <= 0) {
throw new RuntimeException('Cannot resolve class_id for this section.');
}
DB::transaction(function () use (
$user,
$classSectionId,
$studentId,
$status,
$date,
$reason,
$resolvedClassId,
$isReported
) {
AttendanceDay::query()->firstOrCreate(
[
'class_section_id' => $classSectionId,
'date' => $date,
'semester' => $this->currentSemester(),
'school_year' => $this->currentSchoolYear(),
],
[
'status' => 'draft',
'created_at' => now(),
'updated_at' => now(),
]
);
$this->studentAttendanceWriterService->saveOrUpdateStudentAttendance([
'class_section_id' => $classSectionId,
'student_id' => $studentId,
'school_id' => $data['school_id'] ?? null,
'status' => $status,
'reason' => $reason,
'is_reported' => $isReported,
'semester' => $this->currentSemester(),
'school_year' => $this->currentSchoolYear(),
'date' => $date,
'class_id' => $resolvedClassId,
'user_id' => $user->id,
]);
});
return [
'ok' => true,
'message' => 'Entry saved successfully.',
];
}
public function normalizeAttendanceEntries(array $entries): array
{
foreach ($entries as &$row) {
$row['status'] = $this->normalizeStatus($row['status'] ?? '');
$reportedRaw = $row['is_reported'] ?? ($row['reported'] ?? '');
$row['is_reported'] = $this->normalizeReportedFlag($reportedRaw);
}
return $entries;
}
public function normalizeStatus(?string $status): string
{
$status = strtolower(trim((string)$status));
return in_array($status, ['present', 'absent', 'late'], true) ? $status : $status;
}
public function normalizeReportedFlag(mixed $flag): string
{
$v = strtolower(trim((string)$flag));
return in_array($v, ['yes', '1', 'true', 'y', 'on'], true) ? 'yes' : 'no';
}
public function detectExternalSubmission(?array $row): array
{
if (empty($row) || !is_array($row)) {
return ['locked' => false, 'source' => null];
}
$reportedRaw = strtolower((string)($row['is_reported'] ?? ''));
$reason = strtolower((string)($row['reason'] ?? ''));
$isParent = in_array($reportedRaw, ['yes', '1', 'true', 'y'], true)
|| str_contains($reason, 'parent-reported')
|| str_contains($reason, 'parent reported');
if ($isParent) {
return ['locked' => true, 'source' => 'parent'];
}
$modifierId = (int)($row['modified_by'] ?? 0);
if ($modifierId > 0 && $this->isAdminLikeUserId($modifierId)) {
return ['locked' => true, 'source' => 'admin'];
}
return ['locked' => false, 'source' => null];
}
public function isAdminLikeUserId(int $userId): bool
{
if ($userId <= 0) {
return false;
}
$roles = $this->userRole->getRolesByUserId($userId);
if (empty($roles)) {
return false;
}
$excluded = ['parent', 'guest', 'teacher', 'teacher_assistant', 'assistant_teacher', 'ta'];
foreach ($roles as $role) {
$name = strtolower(str_replace([' ', '-'], '_', (string)($role['role_name'] ?? '')));
if ($name !== '' && !in_array($name, $excluded, true)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,77 @@
<?php
namespace App\Services\Attendance;
use Carbon\Carbon;
class SemesterRangeService
{
public function normalizeSemester(?string $semester): string
{
$value = strtolower(trim((string)$semester));
return match ($value) {
'fall', 'autumn' => 'Fall',
'spring' => 'Spring',
default => '',
};
}
public function getSchoolYearRange(string $schoolYear): array
{
[$startYear, $endYear] = $this->parseSchoolYear($schoolYear);
return [
Carbon::create($startYear, 9, 1)->toDateString(),
Carbon::create($endYear, 5, 31)->toDateString(),
];
}
public function getSemesterRange(string $schoolYear, string $semester): ?array
{
[$startYear, $endYear] = $this->parseSchoolYear($schoolYear);
$semester = $this->normalizeSemester($semester);
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,
};
}
public function buildSundayList(string $startDate, string $endDate): array
{
$start = Carbon::parse($startDate)->startOfDay();
$end = Carbon::parse($endDate)->startOfDay();
if ($start->dayOfWeek !== Carbon::SUNDAY) {
$start = $start->next(Carbon::SUNDAY);
}
$dates = [];
$cursor = $start->copy();
while ($cursor->lte($end)) {
$dates[] = $cursor->toDateString();
$cursor->addWeek();
}
return $dates;
}
protected function parseSchoolYear(string $schoolYear): array
{
if (preg_match('/^\s*(\d{4})\s*-\s*(\d{4})\s*$/', $schoolYear, $m)) {
return [(int)$m[1], (int)$m[2]];
}
$year = (int) date('Y');
return [$year, $year + 1];
}
}
@@ -0,0 +1,415 @@
<?php
namespace App\Services\Attendance;
use App\Models\Configuration;
use App\Models\StaffAttendance;
use App\Models\TeacherClass;
use App\Models\ClassSection;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\StreamedResponse;
class StaffAttendanceService
{
public function __construct(
protected Configuration $configuration,
protected StaffAttendance $staffAttendance,
protected TeacherClass $teacherClass,
protected ClassSection $classSection,
protected SemesterRangeService $semesterRangeService,
) {}
public function monthData(?string $semester, ?string $schoolYear): array
{
$semester = $semester ?: (string)$this->configuration->getConfig('semester');
$schoolYear = $schoolYear ?: (string)$this->configuration->getConfig('school_year');
[$rangeStart, $rangeEnd] = $this->semesterRangeService->getSchoolYearRange($schoolYear);
$semesterNorm = $this->semesterRangeService->normalizeSemester($semester);
if ($semesterNorm !== '') {
$semesterRange = $this->semesterRangeService->getSemesterRange($schoolYear, $semesterNorm);
if ($semesterRange) {
[$rangeStart, $rangeEnd] = $semesterRange;
}
}
$days = $this->semesterRangeService->buildSundayList($rangeStart, $rangeEnd);
$assignedBySection = $semesterNorm !== ''
? $this->teacherClass->assignedBySectionForTerm($semesterNorm, $schoolYear)
: $this->teacherClass->assignedBySectionForTerm($semester, $schoolYear);
$sectionCodes = array_keys($assignedBySection);
$labels = [];
if (!empty($sectionCodes)) {
$rows = $this->classSection->query()
->select(['class_section_id', 'class_section_name'])
->whereIn('class_section_id', $sectionCodes)
->get()
->toArray();
foreach ($rows as $row) {
$labels[(int)$row['class_section_id']] = trim((string)$row['class_section_name']) ?: 'Section #' . (int)$row['class_section_id'];
}
}
$teacherIds = [];
foreach ($assignedBySection as $rows) {
foreach ($rows as $row) {
$teacherIds[(int)$row['teacher_id']] = true;
}
}
$teacherIds = array_keys($teacherIds);
$statusByTeacher = [];
if (!empty($teacherIds) && !empty($days)) {
$query = DB::table('staff_attendance')
->select('user_id', 'date', 'status', 'reason')
->where('school_year', $schoolYear)
->whereIn('user_id', $teacherIds)
->whereIn('date', $days);
if ($semesterNorm !== '') {
$query->where('semester', $semesterNorm);
}
$rows = $query->get();
foreach ($rows as $row) {
$statusByTeacher[(int)$row->user_id][(string)$row->date] = [
'status' => strtolower((string)$row->status),
'reason' => $row->reason,
];
}
}
$noSchoolRows = DB::table('calendar_events')
->select('date')
->where('school_year', $schoolYear)
->where('no_school', 1)
->where('date', '>=', $rangeStart)
->where('date', '<=', $rangeEnd)
->get();
$noSchoolDays = [];
foreach ($noSchoolRows as $row) {
$noSchoolDays[] = (string)$row->date;
}
$sections = [];
foreach ($sectionCodes as $code) {
$teachersPayload = [];
foreach (($assignedBySection[$code] ?? []) as $row) {
$teacherId = (int)$row['teacher_id'];
$name = trim(($row['firstname'] ?? '') . ' ' . ($row['lastname'] ?? '')) ?: ('User#' . $teacherId);
$position = strtolower((string)($row['position'] ?? 'main'));
$position = in_array($position, ['main', 'ta'], true) ? $position : 'main';
$cells = [];
$totals = ['p' => 0, 'a' => 0, 'l' => 0];
foreach ($days as $date) {
$cell = $statusByTeacher[$teacherId][$date] ?? null;
if (!$cell || empty($cell['status'])) {
continue;
}
$cells[$date] = $cell;
if ($cell['status'] === 'present') {
$totals['p']++;
} elseif ($cell['status'] === 'absent') {
$totals['a']++;
} elseif ($cell['status'] === 'late') {
$totals['l']++;
}
}
$teachersPayload[] = [
'teacher_id' => $teacherId,
'name' => $name,
'position' => $position,
'cells' => $cells,
'totals' => $totals,
];
}
$sections[] = [
'section_code' => (int)$code,
'label' => $labels[$code] ?? ('Section #' . (int)$code),
'teachers' => $teachersPayload,
];
}
return [
'filters' => [
'semester' => $semester,
'school_year' => $schoolYear,
'range_start' => $rangeStart,
'range_end' => $rangeEnd,
],
'sundays' => $days,
'noSchoolDays' => $noSchoolDays,
'sections' => $sections,
'admins' => [],
];
}
public function adminsAttendanceData(?string $date, ?string $semester, ?string $schoolYear): array
{
$date = $date ?: now()->toDateString();
$semester = $semester ?: (string)$this->configuration->getConfig('semester');
$schoolYear = $schoolYear ?: (string)$this->configuration->getConfig('school_year');
$allStaff = $this->staffAttendance->staffWithStatusForDay($date, $semester, $schoolYear, []);
$userIds = [];
foreach ($allStaff as $row) {
$uid = (int)($row['user_id'] ?? 0);
if ($uid > 0) {
$userIds[$uid] = true;
}
}
$userIds = array_keys($userIds);
$rolesRows = DB::table('user_roles as ur')
->select('ur.user_id', 'r.id as role_id', 'r.name as role_name', 'r.slug as role_slug', 'r.priority')
->join('roles as r', 'r.id', '=', 'ur.role_id')
->where('r.is_active', 1)
->whereIn('ur.user_id', $userIds)
->get();
$rolesByUser = [];
foreach ($rolesRows as $row) {
$rolesByUser[(int)$row->user_id][] = (array)$row;
}
foreach ($rolesByUser as &$list) {
usort($list, fn($a, $b) => ((int)($a['priority'] ?? 100)) <=> ((int)($b['priority'] ?? 100)));
}
$excludeSlugs = ['parent', 'guest', 'teacher', 'teacher_assistant', 'ta', 'assistant_teacher'];
$staffByUser = [];
foreach ($allStaff as $row) {
$uid = (int)($row['user_id'] ?? 0);
if ($uid > 0 && !isset($staffByUser[$uid])) {
$staffByUser[$uid] = $row;
}
}
$adminsGrid = [];
foreach ($userIds as $uid) {
$roles = $rolesByUser[$uid] ?? [];
$picked = null;
foreach ($roles as $role) {
$slug = strtolower((string)($role['role_slug'] ?? $role['role_name'] ?? ''));
if (!in_array($slug, $excludeSlugs, true)) {
$picked = $role;
break;
}
}
if (!$picked) {
continue;
}
$base = $staffByUser[$uid] ?? ['user_id' => $uid];
$base['role_name'] = $picked['role_name'] ?? 'Admin';
$base['role_slug'] = $picked['role_slug'] ?? null;
$adminsGrid[] = $base;
}
usort($adminsGrid, function ($a, $b) {
$an = trim(($a['firstname'] ?? '') . ' ' . ($a['lastname'] ?? ''));
$bn = trim(($b['firstname'] ?? '') . ' ' . ($b['lastname'] ?? ''));
return strcasecmp($an, $bn);
});
return [
'semester' => $semester,
'school_year' => $schoolYear,
'date' => $date,
'admins_grid' => $adminsGrid,
];
}
public function saveAdmins(Authenticatable $user, array $data): array
{
$date = (string)$data['date'];
$semester = (string)$data['semester'];
$schoolYear = (string)$data['school_year'];
$admins = (array)($data['admins'] ?? $data['staff'] ?? []);
$allowedStatuses = ['present', 'absent', 'late'];
$excludedRoleSlugs = ['parent', 'guest', 'teacher', 'teacher_assistant'];
DB::transaction(function () use ($admins, $date, $semester, $schoolYear, $allowedStatuses, $excludedRoleSlugs) {
foreach ($admins as $uid => $row) {
$uid = (int)$uid;
$status = strtolower(trim((string)($row['status'] ?? '')));
$reason = trim((string)($row['reason'] ?? ''));
$roleName = (string)($row['role_name'] ?? '');
$roleSlug = strtolower(str_replace(['-', ' '], '_', (string)($row['role_slug'] ?? $roleName)));
if (in_array($roleSlug, $excludedRoleSlugs, true)) {
continue;
}
if ($status === '') {
DB::table('staff_attendance')
->where('user_id', $uid)
->whereDate('date', $date)
->where('semester', $semester)
->where('school_year', $schoolYear)
->delete();
continue;
}
if (!in_array($status, $allowedStatuses, true)) {
continue;
}
DB::table('staff_attendance')->updateOrInsert(
[
'user_id' => $uid,
'date' => $date,
'semester' => $semester,
'school_year' => $schoolYear,
],
[
'role_name' => $roleName !== '' ? $roleName : 'Admin',
'status' => $status,
'reason' => $reason !== '' ? $reason : null,
'updated_at' => now(),
'created_at' => now(),
]
);
}
});
return [
'ok' => true,
'message' => 'Admins attendance saved successfully.',
];
}
public function saveCell(Authenticatable $user, array $data): array
{
$date = (string)$data['date'];
$status = (string)($data['status'] ?? '');
$targetId = (int)$data['user_id'];
$roleName = trim((string)($data['role_name'] ?? ''));
$semester = (string)$data['semester'];
$schoolYear = (string)$data['school_year'];
$reason = trim((string)($data['reason'] ?? ''));
if ($status === '') {
DB::table('staff_attendance')
->where('user_id', $targetId)
->whereDate('date', $date)
->where('semester', $semester)
->where('school_year', $schoolYear)
->delete();
return ['ok' => true];
}
DB::table('staff_attendance')->updateOrInsert(
[
'user_id' => $targetId,
'date' => $date,
'semester' => $semester,
'school_year' => $schoolYear,
],
[
'role_name' => $roleName !== '' ? $roleName : 'Admin',
'status' => $status,
'reason' => $reason !== '' ? $reason : null,
'updated_at' => now(),
'created_at' => now(),
]
);
return ['ok' => true];
}
public function monthCsv(?string $semester, ?string $schoolYear, ?string $scope): StreamedResponse
{
$semester = $semester ?: (string)$this->configuration->getConfig('semester');
$schoolYear = $schoolYear ?: (string)$this->configuration->getConfig('school_year');
$scope = strtolower((string)$scope);
[$rangeStart, $rangeEnd] = $this->semesterRangeService->getSchoolYearRange($schoolYear);
$semesterNorm = $this->semesterRangeService->normalizeSemester($semester);
if ($semesterNorm !== '') {
$semesterRange = $this->semesterRangeService->getSemesterRange($schoolYear, $semesterNorm);
if ($semesterRange) {
[$rangeStart, $rangeEnd] = $semesterRange;
}
}
$daysList = $this->semesterRangeService->buildSundayList($rangeStart, $rangeEnd);
$filename = ($scope === 'admins' ? 'admin_attendance_' : 'teacher_attendance_')
. $semester . '_' . str_replace('/', '-', $schoolYear) . '.csv';
return response()->streamDownload(function () use ($semester, $schoolYear, $scope, $daysList, $semesterNorm, $rangeStart, $rangeEnd) {
$out = fopen('php://output', 'w');
if ($scope === 'admins') {
fputcsv($out, ['User ID', 'Date', 'Status', 'Reason']);
$query = DB::table('staff_attendance')
->select('user_id', 'date', 'status', 'reason')
->where('school_year', $schoolYear)
->where('date', '>=', $rangeStart)
->where('date', '<=', $rangeEnd);
if ($semesterNorm !== '') {
$query->where('semester', $semesterNorm);
}
foreach ($query->orderBy('date')->get() as $row) {
fputcsv($out, [
$row->user_id,
$row->date,
strtoupper(substr((string)$row->status, 0, 1)),
$row->reason,
]);
}
fclose($out);
return;
}
fputcsv($out, ['Teacher ID', 'Date', 'Status', 'Reason']);
$query = DB::table('staff_attendance')
->select('user_id', 'date', 'status', 'reason')
->where('school_year', $schoolYear)
->where('date', '>=', $rangeStart)
->where('date', '<=', $rangeEnd);
if ($semesterNorm !== '') {
$query->where('semester', $semesterNorm);
}
foreach ($query->orderBy('date')->get() as $row) {
fputcsv($out, [
$row->user_id,
$row->date,
strtoupper(substr((string)$row->status, 0, 1)),
$row->reason,
]);
}
fclose($out);
}, $filename);
}
}
@@ -0,0 +1,181 @@
<?php
namespace App\Services\Attendance;
use App\Models\AttendanceData;
use App\Models\Student;
use RuntimeException;
class StudentAttendanceWriterService
{
public function __construct(
protected AttendanceData $attendanceData,
protected Student $student,
protected AttendanceRecordSyncService $attendanceRecordSyncService,
) {}
public function updateAttendanceData(
int $attendanceId,
string $newStatus,
?string $newReason = null,
?string $reported = null,
?int $userId = null
): void {
$payload = [
'status' => strtolower($newStatus),
'reason' => $newReason ?: null,
'modified_by' => $userId,
'updated_at' => now(),
];
if ($reported !== null) {
$payload['is_reported'] = in_array(strtolower($reported), ['yes', 'no'], true)
? strtolower($reported)
: 'no';
}
AttendanceData::query()->whereKey($attendanceId)->update($payload);
}
public function insertAttendanceData(
int $classSectionId,
int $studentId,
string $studentSchoolId,
string $status,
?string $reason,
string $reported,
string $semester,
string $schoolYear,
string $date,
int $classId,
?int $userId = null
): void {
AttendanceData::query()->create([
'class_id' => $classId,
'class_section_id' => $classSectionId,
'student_id' => $studentId,
'school_id' => $studentSchoolId,
'status' => strtolower($status),
'reason' => $reason ?: null,
'is_reported' => $reported,
'is_notified' => 'no',
'semester' => $semester,
'school_year' => $schoolYear,
'date' => $date,
'modified_by' => $userId,
'created_at' => now(),
'updated_at' => now(),
]);
}
public function resolveStudentSchoolId(int $studentId, ?string $studentSchoolId = null): string
{
$studentSchoolId = trim((string)$studentSchoolId);
if ($studentSchoolId !== '') {
return $studentSchoolId;
}
$studentRow = $this->student->query()->select('school_id')->find($studentId);
if (!$studentRow || empty($studentRow->school_id)) {
throw new RuntimeException('Student school ID not found.');
}
return (string)$studentRow->school_id;
}
public function saveOrUpdateStudentAttendance(array $payload): array
{
$classSectionId = (int)$payload['class_section_id'];
$studentId = (int)$payload['student_id'];
$studentSchoolId = $this->resolveStudentSchoolId($studentId, $payload['school_id'] ?? null);
$status = strtolower((string)$payload['status']);
$reason = $payload['reason'] ?? null;
$reported = $payload['is_reported'] ?? null;
$semester = (string)$payload['semester'];
$schoolYear = (string)$payload['school_year'];
$date = (string)$payload['date'];
$classId = (int)$payload['class_id'];
$userId = $payload['user_id'] ?? null;
$existing = AttendanceData::query()
->where('student_id', $studentId)
->whereDate('date', $date)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
if ($existing) {
$oldStatus = $existing->status;
$this->updateAttendanceData(
(int)$existing->id,
$status,
$reason,
$reported,
$userId
);
if ($status !== $oldStatus) {
$this->attendanceRecordSyncService->updateAttendanceRecord(
$studentId,
$studentSchoolId,
$status,
$semester,
$schoolYear,
$oldStatus,
$userId
);
}
return [
'mode' => 'updated',
'attendance_id' => (int)$existing->id,
'old_status' => $oldStatus,
'new_status' => $status,
'school_id' => $studentSchoolId,
];
}
$this->insertAttendanceData(
$classSectionId,
$studentId,
$studentSchoolId,
$status,
$reason,
$reported ?? 'no',
$semester,
$schoolYear,
$date,
$classId,
$userId
);
$this->attendanceRecordSyncService->addNewAttendanceRecord(
$classSectionId,
$studentId,
$studentSchoolId,
$status,
$semester,
$schoolYear,
$userId
);
$created = AttendanceData::query()
->where('student_id', $studentId)
->whereDate('date', $date)
->where('semester', $semester)
->where('school_year', $schoolYear)
->latest('id')
->first();
return [
'mode' => 'created',
'attendance_id' => (int)($created->id ?? 0),
'old_status' => null,
'new_status' => $status,
'school_id' => $studentSchoolId,
];
}
}
@@ -0,0 +1,220 @@
<?php
namespace App\Services\Attendance;
use App\Libraries\AttendanceAutoPublish;
use App\Models\AttendanceData;
use App\Models\AttendanceDay;
use App\Models\Student;
use App\Models\TeacherClass;
use Carbon\Carbon;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class TeacherAttendanceSubmissionService
{
public function __construct(
protected AttendanceDay $attendanceDay,
protected AttendanceData $attendanceData,
protected TeacherClass $teacherClass,
protected Student $student,
protected AttendancePolicyService $attendancePolicyService,
protected StudentAttendanceWriterService $studentAttendanceWriterService,
) {}
public function submit(
Authenticatable $user,
array $data,
callable $detectExternalSubmission,
callable $currentSemester,
callable $currentSchoolYear
): array {
$rawSection = trim((string)$data['class_section_id']);
if ($rawSection === '') {
throw new RuntimeException('Missing or invalid class section.');
}
$sectionRow = DB::table('classSection')
->select('id', 'class_id', 'class_section_id')
->where('class_section_id', $rawSection)
->first();
if (!$sectionRow && ctype_digit($rawSection)) {
$sectionRow = DB::table('classSection')
->select('id', 'class_id', 'class_section_id')
->where('id', (int)$rawSection)
->first();
}
if (!$sectionRow) {
throw new RuntimeException('Invalid class section.');
}
$classSectionId = (int)$sectionRow->class_section_id;
$classId = (int)$sectionRow->class_id;
$attendanceData = $data['attendance'] ?? [];
$teachersData = $data['teachers'] ?? [];
if (empty($attendanceData) || !is_array($attendanceData)) {
throw new RuntimeException('No student attendance data provided.');
}
if (empty($teachersData) || !is_array($teachersData)) {
throw new RuntimeException('No teacher attendance data provided.');
}
$rawDate = $data['date'] ?? now()->toDateString();
$attendDate = Carbon::parse($rawDate)->toDateString();
$semester = (string)$currentSemester();
$schoolYear = (string)$currentSchoolYear();
$dayRow = AttendanceDay::query()
->where('class_section_id', $classSectionId)
->whereDate('date', $attendDate)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
$currentUser = [
'id' => $user->id,
'roles' => method_exists($user, 'roles') ? $user->roles->pluck('name')->all() : [],
'permissions' => method_exists($user, 'permissions') ? $user->permissions->pluck('name')->all() : [],
];
$dayPolicyRow = $dayRow?->toArray() ?? [
'status' => 'draft',
'class_section_id' => $classSectionId,
'date' => $attendDate,
'semester' => $semester,
'school_year' => $schoolYear,
];
if (!$this->attendancePolicyService->canEditDay($dayPolicyRow, $currentUser)) {
throw new RuntimeException('Attendance is locked for your role.');
}
$existingRows = AttendanceData::query()
->where('class_section_id', $classSectionId)
->whereDate('date', $attendDate)
->where('semester', $semester)
->where('school_year', $schoolYear)
->get();
$existingByStudent = [];
foreach ($existingRows as $row) {
$existingByStudent[(int)$row->student_id] = $row->toArray();
}
$allowedStatuses = ['present', 'absent', 'late'];
$assignedTeachers = $this->teacherClass->assignedForSectionTerm($classSectionId, $semester, $schoolYear);
foreach ($assignedTeachers as $teacher) {
$teacherId = (int)$teacher['teacher_id'];
$status = strtolower((string)($teachersData[$teacherId]['status'] ?? ''));
if (!in_array($status, $allowedStatuses, true)) {
throw new RuntimeException('Please fill teacher attendance for all assigned teachers.');
}
}
$isTeacher = $this->attendancePolicyService->isTeacher($currentUser['roles']);
DB::transaction(function () use (
$user,
$assignedTeachers,
$teachersData,
$attendanceData,
$existingByStudent,
$classSectionId,
$classId,
$attendDate,
$semester,
$schoolYear,
$isTeacher,
$detectExternalSubmission
) {
$dayRow = AttendanceDay::query()->firstOrCreate(
[
'class_section_id' => $classSectionId,
'date' => $attendDate,
'semester' => $semester,
'school_year' => $schoolYear,
],
[
'status' => 'draft',
'created_at' => now(),
'updated_at' => now(),
]
);
foreach ($assignedTeachers as $teacher) {
$teacherId = (int)$teacher['teacher_id'];
$position = strtolower((string)($teacher['position'] ?? 'main'));
$position = $position === 'ta' ? 'ta' : 'main';
DB::table('staff_attendance')->updateOrInsert(
[
'user_id' => $teacherId,
'date' => $attendDate,
'semester' => $semester,
'school_year' => $schoolYear,
],
[
'role_name' => 'Teacher',
'class_section_id' => $classSectionId,
'position' => $position,
'status' => strtolower((string)($teachersData[$teacherId]['status'] ?? 'present')),
'reason' => ($teachersData[$teacherId]['reason'] ?? null) ?: null,
'updated_at' => now(),
'created_at' => now(),
]
);
}
foreach ($attendanceData as $row) {
$studentId = (int)($row['student_id'] ?? 0);
$status = strtolower(trim((string)($row['status'] ?? '')));
$reason = trim((string)($row['reason'] ?? ''));
$existing = $existingByStudent[$studentId] ?? null;
if ($isTeacher && $existing) {
$lockInfo = $detectExternalSubmission($existing);
if (($lockInfo['locked'] ?? false) === true) {
continue;
}
}
$this->studentAttendanceWriterService->saveOrUpdateStudentAttendance([
'class_section_id' => $classSectionId,
'student_id' => $studentId,
'school_id' => $row['school_id'] ?? null,
'status' => $status,
'reason' => $reason,
'is_reported' => 'no',
'semester' => $semester,
'school_year' => $schoolYear,
'date' => $attendDate,
'class_id' => $classId,
'user_id' => $user->id,
]);
}
$autoPublishAt = AttendanceAutoPublish::secondSundayAfterEndOfDay($attendDate);
$dayRow->update([
'status' => 'submitted',
'submitted_by' => $user->id,
'submitted_at' => now(),
'auto_publish_at' => $autoPublishAt,
'updated_at' => now(),
]);
});
return [
'ok' => true,
'message' => 'Attendance submitted successfully.',
];
}
}