debugCompute; } public function computeViolations( array $students, array $attendanceData, string $schoolYear, ?string $semester = null, ?array $weekRowsFallback = null ): array { $this->debugCompute = ['active_weeks' => 0, 'by_student' => 0]; $studentCodeToId = []; foreach ($students as $stu) { $code = trim((string) ($stu['student_code'] ?? $stu['school_id'] ?? '')); if ($code !== '') { $studentCodeToId[$code] = (int) ($stu['id'] ?? 0); } } $byStudent = []; foreach ($attendanceData as $r) { $rawSid = $r['student_id'] ?? null; $sid = is_numeric($rawSid) ? (int) $rawSid : ($studentCodeToId[trim((string) $rawSid)] ?? 0); if ($sid <= 0) { continue; } $ymd = substr((string) ($r['date'] ?? ''), 0, 10); $stat = strtolower(trim((string) ($r['status'] ?? ''))); if (!in_array($stat, ['absent', 'late'], true)) { continue; } $byStudent[$sid][$stat][] = $ymd; } $this->debugCompute['by_student'] = count($byStudent); [$syStart, $syEnd] = $this->deriveSchoolYearBounds($schoolYear); $weekRowsSource = null; if (!empty($weekRowsFallback)) { foreach ($weekRowsFallback as $r) { $st = strtolower(trim((string) ($r['status'] ?? ''))); if ($st !== '' && !in_array($st, ['absent', 'late'], true)) { $weekRowsSource = $weekRowsFallback; break; } } } $activeWeekKeys = $this->getActiveWeeksFromAttendanceData($syStart, $syEnd, $schoolYear, $semester, $weekRowsSource); $this->debugCompute['active_weeks'] = count($activeWeekKeys); if (empty($activeWeekKeys)) { return []; } $weekIndex = array_flip($activeWeekKeys); $currentWeekIdx = count($activeWeekKeys) - 1; $windowStartIdx = max(0, $currentWeekIdx - 4); $requiredWeeksCount = $currentWeekIdx - $windowStartIdx + 1; $keepLastWeeks = function (array $dates) use ($weekIndex, $windowStartIdx, $currentWeekIdx): array { $out = []; foreach ($dates as $d) { $key = date('o-\WW', strtotime($d)); if (isset($weekIndex[$key])) { $idx = $weekIndex[$key]; $ymd = substr((string) $d, 0, 10); if ($idx >= $windowStartIdx && $idx <= $currentWeekIdx) { $out[] = $ymd; } } } $out = array_values(array_unique($out)); rsort($out); return $out; }; $classByStudent = []; try { $studentIdsForClass = array_values(array_unique(array_map( static fn($s) => (int) ($s['id'] ?? 0), $students ))); if (!empty($studentIdsForClass)) { $rows = DB::table('student_class as sc') ->select([ 'sc.student_id', 'sc.class_section_id', 'cs.class_section_name', 'cs.class_id', 'c.class_name', ]) ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id') ->leftJoin('classes as c', 'c.id', '=', 'cs.class_id') ->whereIn('sc.student_id', $studentIdsForClass) ->where('sc.school_year', $schoolYear) ->orderByDesc('sc.id') ->get(); foreach ($rows as $row) { $sid = (int) ($row->student_id ?? 0); if ($sid <= 0) { continue; } if (!isset($classByStudent[$sid])) { $classByStudent[$sid] = [ 'class_section_id' => $row->class_section_id ?? null, 'class_section_name' => (string) ($row->class_section_name ?? ''), 'class_id' => $row->class_id ?? null, 'class_name' => (string) ($row->class_name ?? ''), ]; } } } } catch (Throwable $e) { Log::debug('computeViolations(): class prefetch failed: ' . $e->getMessage()); } $out = []; $absCountLast5 = 0; $lateCountLast5 = 0; foreach ($students as $stu) { $sid = (int) ($stu['id'] ?? 0); $name = trim(($stu['firstname'] ?? '') . ' ' . ($stu['lastname'] ?? '')); $absDatesAll = array_values(array_unique($byStudent[$sid]['absent'] ?? [])); $lateDatesAll = array_values(array_unique($byStudent[$sid]['late'] ?? [])); $absDates = $keepLastWeeks($absDatesAll); $lateDates = $keepLastWeeks($lateDatesAll); if (empty($absDates) && empty($lateDates)) { continue; } $absWeekIdx = $this->datesToWeekIndices($absDates, $weekIndex); $lateWeekIdx = $this->datesToWeekIndices($lateDates, $weekIndex); $absCountLast5 += count($absDates); $lateCountLast5 += count($lateDates); $absenceViolation = null; if (!empty($absDates)) { $lastAbsDate = $absDates[0]; $A2row = $this->hasNConsecutiveItems($absDates, 2, 7); $A3row = $this->hasNConsecutiveItems($absDates, 3, 7); $A4row = $this->hasNConsecutiveItems($absDates, 4, 7); $A_in3w2 = $this->hasNInWActiveWeeks($absWeekIdx, 2, 3); $A_in4w3 = $this->hasNInWActiveWeeks($absWeekIdx, 3, 4); $A_in5w4 = $this->hasNInWActiveWeeks($absWeekIdx, 4, 5); if ($A4row || $A_in5w4) { $absenceViolation = [ 'type' => 'absence', 'code' => 'ABS_4', 'severity' => 4, 'action' => 'expel_notify', 'color' => '#ff4d4f', 'title' => '4 Absences (in a row or within last 5 active weeks)', 'description' => 'Notify team to inform parent about expulsion and send email.', 'last_date' => $lastAbsDate, ]; } elseif ($A3row || $A_in4w3) { $absenceViolation = [ 'type' => 'absence', 'code' => 'ABS_3', 'severity' => 3, 'action' => 'team_notify', 'color' => '#fa8c16', 'title' => '3 Absences (in a row or within last 4 active weeks)', 'description' => 'Team calls parent and sends email.', 'last_date' => $lastAbsDate, ]; } elseif ($A2row || $A_in3w2) { $absenceViolation = [ 'type' => 'absence', 'code' => 'ABS_2', 'severity' => 2, 'action' => 'team_notify', 'color' => '#fadb14', 'title' => '2 Absences (in a row or within last 3 active weeks)', 'description' => 'Team calls parent and sends email.', 'last_date' => $lastAbsDate, ]; } } $lateViolation = null; if (!empty($lateDates)) { $lastLateDate = $lateDates[0]; $L2row = $this->hasNConsecutiveItems($lateDates, 2, 7); $L3row = $this->hasNConsecutiveItems($lateDates, 3, 7); $L4row = $this->hasNConsecutiveItems($lateDates, 4, 7); $L_in3w2 = $this->hasNInWActiveWeeks($lateWeekIdx, 2, 3); $L_in4w3 = $this->hasNInWActiveWeeks($lateWeekIdx, 3, 4); $lateCoversAllWindowWeeks = false; if ($requiredWeeksCount >= 4) { $lateWeeksSet = array_flip($lateWeekIdx); $lateCoversAllWindowWeeks = true; for ($i = $windowStartIdx; $i <= $currentWeekIdx; $i++) { if (!isset($lateWeeksSet[$i])) { $lateCoversAllWindowWeeks = false; break; } } } if ($L4row || $lateCoversAllWindowWeeks) { $lateViolation = [ 'type' => 'late', 'code' => 'LATE_4', 'severity' => 4, 'action' => 'team_notify', 'color' => '#ff4d4f', 'title' => '4 Lates in a row OR in each of the last 4 active weeks', 'description' => 'Team calls parent and sends email.', 'last_date' => $lastLateDate, ]; } else { $twoLatesOneAbsWithin4 = $this->twoLatesOneAbsInWWeeks($lateWeekIdx, $absWeekIdx, 4); if ($L3row || $L_in4w3 || $twoLatesOneAbsWithin4) { $lateViolation = [ 'type' => $twoLatesOneAbsWithin4 ? 'mix' : 'late', 'code' => $twoLatesOneAbsWithin4 ? 'MIX_L2A1' : 'LATE_3', 'severity' => 3, 'action' => 'team_notify', 'color' => '#002766', 'title' => $twoLatesOneAbsWithin4 ? '2 Lates + 1 Absence (within last 4 active weeks)' : '3 Lates (in a row or within last 4 active weeks)', 'description' => 'Team calls parent and sends email.', 'last_date' => $lastLateDate, ]; } elseif ($L2row || $L_in3w2) { $lateViolation = [ 'type' => 'late', 'code' => 'LATE_2', 'severity' => 1, 'action' => 'auto_email', 'color' => '#bae7ff', 'title' => '2 Lates (in a row or within last 3 active weeks)', 'description' => 'Send automated email to parent.', 'last_date' => $lastLateDate, ]; } } } $chosen = $this->chooseHigherSeverity($absenceViolation, $lateViolation); if (!$chosen && count($absDates) === 1) { $chosen = [ 'type' => 'absence', 'code' => 'ABS_1', 'severity' => 1, 'action' => 'auto_email', 'color' => '#e6f7ff', 'title' => '1 Unreported Absence', 'description' => 'Send automated email to parent.', 'last_date' => $absDates[0], ]; } if (!$chosen) { continue; } $parent = $this->parentLookupService->getPrimaryParentForStudent($sid); [$start, $end] = $this->dayBounds($chosen['last_date']); $trackingQ = $this->attendanceTrackingModel->query() ->where('student_id', $sid) ->where('school_year', $schoolYear) ->where('date', '>=', $start) ->where('date', '<', $end) ->where('reason', 'like', '%' . $chosen['code'] . '%'); if ($semester !== null) { $trackingQ->where('semester', $semester); } $tracking = $trackingQ->orderByDesc('date')->first(); $tracking = $tracking?->toArray() ?? []; $isNotified = (bool) ($tracking['is_notified'] ?? 0); $notifCnt = (int) ($tracking['notif_counter'] ?? 0); $cls = $classByStudent[$sid] ?? []; $classLabel = (string) ($cls['class_section_name'] ?? ''); if ($classLabel === '' && !empty($cls['class_name'])) { $classLabel = (string) $cls['class_name']; } $out[] = [ 'id' => $sid, 'name' => $name, 'class_name' => $classLabel, 'class_section_name' => (string) ($cls['class_section_name'] ?? ''), 'className' => (string) ($cls['class_name'] ?? ''), 'class_id' => $cls['class_id'] ?? null, 'class_section_id' => $cls['class_section_id'] ?? null, 'parent_email' => $parent['email'] ?? '', 'parent_name' => $parent['parent_name'] ?? '', 'parent_phone' => $parent['phone'] ?? null, 'violation' => $chosen['title'], 'violation_code' => $chosen['code'], 'type' => $chosen['type'], 'severity' => $chosen['severity'], 'action' => $chosen['action'], 'color' => $chosen['color'], 'last_absence' => $absDates[0] ?? null, 'last_late' => $lateDates[0] ?? null, 'last_date' => $chosen['last_date'], 'is_notified' => $isNotified, 'notif_counter' => $notifCnt, 'absences' => $absDates, 'lates' => $lateDates, ]; } $this->debugCompute['abs_last5'] = $absCountLast5; $this->debugCompute['late_last5'] = $lateCountLast5; return $out; } public function chooseHigherSeverity(?array $a, ?array $b): ?array { if ($a && $b) { if ($a['severity'] === $b['severity']) { $prio = ['expel_notify' => 3, 'team_notify' => 2, 'auto_email' => 1]; return ($prio[$a['action']] ?? 0) >= ($prio[$b['action']] ?? 0) ? $a : $b; } return $a['severity'] > $b['severity'] ? $a : $b; } return $a ?: $b; } public function attendanceReportedColumns(string $table = 'attendance_data'): array { if ($this->attendanceReportedColumns !== null) { return $this->attendanceReportedColumns; } $this->attendanceReportedColumns = [ 'is_reported' => Schema::hasColumn($table, 'is_reported'), 'reported' => Schema::hasColumn($table, 'reported'), ]; return $this->attendanceReportedColumns; } public function applyUnreportedAttendanceFilter($qb, string $table = 'attendance_data'): void { $cols = $this->attendanceReportedColumns($table); if (!empty($cols['is_reported'])) { $qb->whereRaw("(LOWER(TRIM(COALESCE(is_reported, ''))) NOT IN ('yes','1','true','y','on'))"); } if (!empty($cols['reported'])) { $qb->whereRaw("(LOWER(TRIM(COALESCE(reported, ''))) NOT IN ('yes','1','true','y','on'))"); } $qb->whereRaw("(LOWER(COALESCE(reason, '')) NOT LIKE '%parent-reported%')") ->whereRaw("(LOWER(COALESCE(reason, '')) NOT LIKE '%parent reported%')"); if (Schema::hasTable('parent_attendance_reports')) { $qb->whereRaw("NOT EXISTS ( SELECT 1 FROM parent_attendance_reports par WHERE par.student_id = {$table}.student_id AND DATE(par.report_date) = DATE({$table}.date) AND par.type IN ('absent','late') )"); } } public function deriveSchoolYearBounds(string $schoolYear): array { if (!preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $m)) { $y = (int) date('Y'); $startY = (int) (date('n') >= 8 ? $y : $y - 1); return [sprintf('%d-08-01', $startY), sprintf('%d-07-31', $startY + 1)]; } $startY = (int) $m[1]; $endY = (int) $m[2]; return [sprintf('%d-08-01', $startY), sprintf('%d-07-31', $endY)]; } public function schoolYearForDate(string $ymd, ?string $fallbackSchoolYear = null): string { $ts = strtotime($ymd); if ($ts === false) { return (string) $fallbackSchoolYear; } $y = (int) date('Y', $ts); $m = (int) date('n', $ts); $startY = ($m >= 8) ? $y : $y - 1; return sprintf('%d-%d', $startY, $startY + 1); } public function dayBounds(string $ymd): array { $d = substr($ymd, 0, 10); return [ $d . ' 00:00:00', date('Y-m-d', strtotime($d . ' +1 day')) . ' 00:00:00', ]; } public function getActiveWeeksFromAttendanceData( string $startYmd, string $endYmd, ?string $schoolYear = null, ?string $semester = null, ?array $weekRowsFallback = null ): array { $weeks = []; if (!empty($weekRowsFallback)) { foreach ($weekRowsFallback as $r) { $d = $r['date'] ?? null; if (!$d) { continue; } $ts = strtotime($d); if ($ts === false) { continue; } $ymd = date('Y-m-d', $ts); if ($ymd < $startYmd || $ymd > $endYmd) { continue; } $wk = date('o-\WW', $ts); $weeks[$wk] = true; } } if (empty($weeks)) { $rows = DB::table('attendance_data') ->selectRaw('DATE(date) AS ymd') ->where('school_year', $schoolYear) ->whereRaw('DATE(date) >= ?', [$startYmd]) ->whereRaw('DATE(date) <= ?', [$endYmd]) ->where(function ($q) { $q->whereNull('reason') ->orWhere(function ($q2) { $q2->where('reason', 'not like', '%break%') ->where('reason', 'not like', '%cancel%'); }); }) ->when($semester !== null && $semester !== '', fn($q) => $q->where('semester', $semester)) ->groupBy(DB::raw('DATE(date)')) ->orderBy('ymd') ->get(); foreach ($rows as $r) { $wk = date('o-\WW', strtotime($r->ymd)); $weeks[$wk] = true; } } $keys = array_keys($weeks); sort($keys, SORT_NATURAL); return $keys; } public function datesToWeekIndices(array $dates, array $weekIndex): array { $set = []; foreach ($dates as $ymd) { $wk = date('o-\WW', strtotime($ymd)); if (isset($weekIndex[$wk])) { $set[$weekIndex[$wk]] = true; } } $idx = array_keys($set); sort($idx); return $idx; } public function windowWeeksForViolationCode(string $code): int { $code = strtoupper(trim($code)); if ($code === '') { return 5; } if (Str::startsWith($code, 'ABS_2') || Str::startsWith($code, 'LATE_2')) { return 3; } if (Str::startsWith($code, 'ABS_3') || Str::startsWith($code, 'LATE_3') || Str::startsWith($code, 'MIX')) { return 4; } if (Str::startsWith($code, 'LATE_4')) { return 4; } if (Str::startsWith($code, 'ABS_4')) { return 5; } return 5; } public function isoWeekStartYmd(string $weekKey): string { if (preg_match('/^(\d{4})-W(\d{2})$/', $weekKey, $m)) { return date('Y-m-d', strtotime($m[1] . '-W' . $m[2] . '-1')); } return date('Y-m-d'); } public function activeWeekWindowStartYmd( string $endYmd, int $windowWeeks, string $schoolYear, ?string $semester ): string { $windowWeeks = max(1, $windowWeeks); [$syStart, $syEnd] = $this->deriveSchoolYearBounds($schoolYear); $endBound = $endYmd; if ($syEnd !== '' && $endBound > $syEnd) { $endBound = $syEnd; } $activeWeekKeys = $this->getActiveWeeksFromAttendanceData($syStart, $endBound, $schoolYear, $semester, null); if (empty($activeWeekKeys)) { $days = ($windowWeeks * 7) - 1; return date('Y-m-d', strtotime($endBound . ' -' . $days . ' days')); } $activeWeekKeys = array_values($activeWeekKeys); $weekIndex = array_flip($activeWeekKeys); $endWeekKey = date('o-\WW', strtotime($endBound)); if (!isset($weekIndex[$endWeekKey])) { $endWeekKey = null; for ($i = count($activeWeekKeys) - 1; $i >= 0; $i--) { $wkStart = $this->isoWeekStartYmd($activeWeekKeys[$i]); if ($wkStart <= $endBound) { $endWeekKey = $activeWeekKeys[$i]; break; } } if ($endWeekKey === null) { $endWeekKey = end($activeWeekKeys); } } $currentIdx = $weekIndex[$endWeekKey] ?? (count($activeWeekKeys) - 1); $windowStartIdx = max(0, $currentIdx - ($windowWeeks - 1)); $startWeekKey = $activeWeekKeys[$windowStartIdx] ?? $activeWeekKeys[0]; return $this->isoWeekStartYmd($startWeekKey); } public function hasNConsecutiveItems(array $datesDesc, int $n, int $daysApart): bool { $N = count($datesDesc); if ($N < $n) { return false; } $dates = $datesDesc; sort($dates); $run = 1; for ($i = 1; $i < $N; $i++) { $prev = Carbon::parse($dates[$i - 1]); $curr = Carbon::parse($dates[$i]); $diff = $prev->diffInDays($curr); if ($diff === $daysApart) { $run++; if ($run >= $n) { return true; } } else { $run = 1; } } return false; } public function hasNInWActiveWeeks(array $weekIdx, int $n, int $w): bool { $N = count($weekIdx); if ($N < $n) { return false; } $l = 0; for ($r = 0; $r < $N; $r++) { while ($weekIdx[$r] - $weekIdx[$l] > ($w - 1)) { $l++; } if (($r - $l + 1) >= $n) { return true; } } return false; } public function twoLatesOneAbsInWWeeks(array $lateIdx, array $absIdx, int $w): bool { if (count($lateIdx) < 2 || count($absIdx) < 1) { return false; } $L = $lateIdx; $A = $absIdx; $iL1 = 0; $iL2 = 1; $iA = 0; while ($iL2 < count($L)) { $windowStart = $L[$iL1]; $windowEnd = $windowStart + ($w - 1); if ($L[$iL2] > $windowEnd) { $iL1++; $iL2 = $iL1 + 1; continue; } while ($iA < count($A) && $A[$iA] < $windowStart) { $iA++; } if ($iA < count($A) && $A[$iA] <= $windowEnd) { return true; } $iL2++; if ($iL2 >= count($L)) { $iL1++; $iL2 = $iL1 + 1; } } return false; } }