Files
alrahma_sunday_school_api/app/Services/AttendanceTracking/ViolationRuleEngineService.php
T
2026-06-08 23:45:55 -04:00

690 lines
25 KiB
PHP

<?php
namespace App\Services\AttendanceTracking;
use App\Models\AttendanceTracking;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Throwable;
class ViolationRuleEngineService
{
protected array $debugCompute = [];
protected ?array $attendanceReportedColumns = null;
public function __construct(
protected AttendanceTracking $attendanceTrackingModel,
protected AttendanceParentLookupService $parentLookupService
) {
}
public function getDebugCompute(): array
{
return $this->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;
}
}