semester = (string) ($this->configModel->getConfig('semester') ?? ''); $this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? ''); } public function getPendingViolations(?string $schoolYearParam = null, ?string $semesterParam = null): array { $schoolYear = filled($schoolYearParam) ? (string) $schoolYearParam : (string) $this->schoolYear; $semester = filled($semesterParam) ? (string) $semesterParam : null; // disabled like legacy $debugInfo = [ 'school_year_param' => $schoolYear, 'semester_param' => $semester, 'class_students' => 0, 'student_ids' => 0, 'attendance_rows' => 0, 'filtered_rows' => 0, 'violations' => 0, 'active_weeks' => 0, 'abs_last5' => 0, 'late_last5' => 0, ]; $classStudentsQuery = $this->studentClassModel->query(); if (method_exists($this->studentClassModel, 'scopeActive')) { $classStudentsQuery->active(); } $classStudents = $classStudentsQuery ->where('school_year', $schoolYear) ->get() ->toArray(); $debugInfo['class_students'] = count($classStudents); if (empty($classStudents)) { $latestTerm = DB::table('student_class') ->select('school_year', 'semester') ->orderByDesc('id') ->first(); if ($latestTerm && !empty($latestTerm->school_year)) { $schoolYear = (string) $latestTerm->school_year; if (!empty($latestTerm->semester)) { $semester = (string) $latestTerm->semester; } $classStudentsQuery = $this->studentClassModel->query(); if (method_exists($this->studentClassModel, 'scopeActive')) { $classStudentsQuery->active(); } $classStudents = $classStudentsQuery ->where('school_year', $schoolYear) ->get() ->toArray(); } } $studentIds = []; $studentCodes = []; foreach ($classStudents as $row) { $raw = $row['student_id'] ?? null; if (is_numeric($raw)) { $studentIds[] = (int) $raw; } elseif (is_string($raw) && $raw !== '') { $studentCodes[] = trim($raw); } } if (empty($studentIds) && empty($studentCodes)) { $attendanceStudents = DB::table('attendance_data') ->selectRaw('DISTINCT student_id') ->when(!empty($schoolYear), fn ($q) => $q->where('school_year', $schoolYear)) ->orderBy('student_id') ->get(); foreach ($attendanceStudents as $r) { $raw = $r->student_id ?? null; if (is_numeric($raw)) { $studentIds[] = (int) $raw; } elseif (is_string($raw) && $raw !== '') { $studentCodes[] = trim($raw); } } } $studentIds = array_values(array_unique(array_filter($studentIds, fn ($v) => $v > 0))); $studentCodes = array_values(array_unique(array_filter($studentCodes, fn ($v) => $v !== ''))); if (!empty($studentIds)) { $inactiveRows = $this->studentModel->query() ->select('id') ->whereIn('id', $studentIds) ->where('is_active', 0) ->get() ->toArray(); $inactiveIdSet = array_flip(array_map( static fn ($r) => (int) ($r['id'] ?? 0), $inactiveRows )); $studentIds = array_values(array_filter( $studentIds, static fn ($id) => !isset($inactiveIdSet[$id]) )); } if (!empty($studentCodes)) { $inactiveCodeRows = $this->studentModel->query() ->select('school_id') ->whereIn('school_id', $studentCodes) ->where('is_active', 0) ->get() ->toArray(); $inactiveCodeSet = array_flip(array_map( static fn ($r) => (string) ($r['school_id'] ?? ''), $inactiveCodeRows )); $studentCodes = array_values(array_filter( $studentCodes, static fn ($code) => $code !== '' && !isset($inactiveCodeSet[$code]) )); } $debugInfo['student_ids'] = count($studentIds) + count($studentCodes); if (empty($studentIds) && empty($studentCodes)) { return [ 'students' => [], 'school_year' => $schoolYear, 'semester' => $semester, 'error' => 'No student identifiers found for the current school year.', 'debug' => $debugInfo, ]; } $students = []; if (!empty($studentIds)) { $students = $this->studentModel->query() ->whereIn('id', $studentIds) ->where('is_active', 1) ->get() ->toArray(); } if (!empty($studentCodes)) { $byCode = $this->studentModel->query() ->whereIn('school_id', $studentCodes) ->where('is_active', 1) ->get() ->toArray(); $seen = array_flip(array_map(fn ($s) => (int) ($s['id'] ?? 0), $students)); foreach ($byCode as $s) { $sid = (int) ($s['id'] ?? 0); if ($sid > 0 && !isset($seen[$sid])) { $students[] = $s; $seen[$sid] = true; } } } $knownCodes = []; foreach ($students as $s) { $code = trim((string) ($s['school_id'] ?? '')); if ($code !== '') { $knownCodes[$code] = true; } } foreach ($studentCodes as $code) { if (!isset($knownCodes[$code])) { $virtualId = abs(crc32($code)) ?: random_int(1000000, 1999999); $students[] = [ 'id' => $virtualId, 'school_id' => $code, 'firstname' => $code, 'lastname' => '', 'parent_id' => 0, 'class_name' => '', ]; } } if (empty($students)) { return [ 'students' => [], 'school_year' => $schoolYear, 'semester' => $semester, 'error' => 'No students found matching attendance records.', 'debug' => $debugInfo, ]; } $studentCodeToId = []; foreach ($students as $stu) { $code = trim((string) ($stu['student_code'] ?? $stu['school_id'] ?? '')); if ($code !== '') { $studentCodeToId[$code] = (int) ($stu['id'] ?? 0); } } $existingIds = array_flip(array_map(fn ($s) => (int) ($s['id'] ?? 0), $students)); foreach (array_merge($studentIds, $studentCodes) as $sidOrCode) { if (is_numeric($sidOrCode)) { $sid = (int) $sidOrCode; if ($sid > 0 && !isset($existingIds[$sid])) { $students[] = [ 'id' => $sid, 'school_id' => (string) $sid, 'firstname' => (string) $sid, 'lastname' => '', 'parent_id' => 0, 'class_name' => '', ]; $existingIds[$sid] = true; } } else { $code = (string) $sidOrCode; if ($code !== '' && !isset($studentCodeToId[$code])) { $virtualId = abs(crc32($code)) ?: random_int(2000000, 2999999); $students[] = [ 'id' => $virtualId, 'school_id' => $code, 'firstname' => $code, 'lastname' => '', 'parent_id' => 0, 'class_name' => '', ]; $studentCodeToId[$code] = $virtualId; $existingIds[$virtualId] = true; } } } $today = Carbon::today(); [$syStart, $syEnd] = $this->deriveSchoolYearBounds($schoolYear); $start = Carbon::parse($syStart)->startOfDay(); $rangeEnd = Carbon::parse(min($syEnd, $today->format('Y-m-d')))->startOfDay(); $endEx = $rangeEnd->copy()->addDay(); $studentCodes = array_keys($studentCodeToId); $qb = $this->attendanceDataModel->query() ->select([ 'id', 'student_id', 'class_id', 'class_section_id', 'date', 'status', 'reason', 'school_year', 'semester', 'is_reported', 'is_notified', ]) ->when(!empty($schoolYear), fn ($q) => $q->where('school_year', $schoolYear)) ->when(!empty($semester), fn ($q) => $q->where('semester', $semester)) ->where(function ($q) use ($studentIds, $studentCodes) { if (!empty($studentIds)) { $q->whereIn('student_id', $studentIds); } if (!empty($studentCodes)) { if (!empty($studentIds)) { $q->orWhereIn('student_id', $studentCodes); } else { $q->whereIn('student_id', $studentCodes); } } }); $this->applyUnreportedAttendanceFilter($qb); $qb->whereRaw("(LOWER(TRIM(COALESCE(is_notified, ''))) NOT IN ('yes','1'))") ->whereRaw("LOWER(TRIM(status)) IN ('absent','late')") ->where('date', '>=', $start->format('Y-m-d')) ->where('date', '<', $endEx->format('Y-m-d')); $termRows = $qb->orderByDesc('date')->get()->toArray(); if (empty($termRows)) { $latestAttendanceTerm = DB::table('attendance_data') ->select('school_year', 'semester', 'date') ->when(!empty($studentIds), fn ($q) => $q->whereIn('student_id', $studentIds)) ->orderByDesc('date') ->first(); if ($latestAttendanceTerm) { $schoolYear = !empty($latestAttendanceTerm->school_year) ? (string) $latestAttendanceTerm->school_year : $schoolYear; $semester = !empty($latestAttendanceTerm->semester) ? (string) $latestAttendanceTerm->semester : $semester; [$syStart, $syEnd] = $this->deriveSchoolYearBounds($schoolYear ?: $this->schoolYear); $start = Carbon::parse($syStart)->startOfDay(); $rangeEnd = Carbon::parse(min($syEnd, $today->format('Y-m-d')))->startOfDay(); $endEx = $rangeEnd->copy()->addDay(); $qb = $this->attendanceDataModel->query() ->select([ 'id', 'student_id', 'class_id', 'class_section_id', 'date', 'status', 'reason', 'school_year', 'semester', 'is_reported', 'is_notified', ]) ->when(!empty($schoolYear), fn ($q) => $q->where('school_year', $schoolYear)) ->when(!empty($semester), fn ($q) => $q->where('semester', $semester)) ->where(function ($q) use ($studentIds, $studentCodes) { if (!empty($studentIds)) { $q->whereIn('student_id', $studentIds); } if (!empty($studentCodes)) { if (!empty($studentIds)) { $q->orWhereIn('student_id', $studentCodes); } else { $q->whereIn('student_id', $studentCodes); } } }); $this->applyUnreportedAttendanceFilter($qb); $qb->whereRaw("(LOWER(TRIM(COALESCE(is_notified, ''))) NOT IN ('yes','1'))") ->whereRaw("LOWER(TRIM(status)) IN ('absent','late')") ->where('date', '>=', $start->format('Y-m-d')) ->where('date', '<', $endEx->format('Y-m-d')); $termRows = $qb->orderByDesc('date')->get()->toArray(); } if (empty($termRows)) { return [ 'students' => [], 'school_year' => $schoolYear, 'semester' => $semester, 'debug' => $debugInfo, ]; } } $attendanceRows = []; foreach ($termRows as $row) { $dStr = $row['date'] ?? null; if (!$dStr) { continue; } try { $d = Carbon::parse($dStr); } catch (Throwable) { continue; } if ($d->lt($start) || !$d->lt($endEx)) { continue; } $rawSid = $row['student_id'] ?? null; $sid = null; if (is_numeric($rawSid)) { $sid = (int) $rawSid; } elseif (is_string($rawSid)) { $code = trim($rawSid); $sid = $studentCodeToId[$code] ?? null; if ($sid === null) { $sid = abs(crc32($code)) ?: random_int(3000000, 3999999); $studentCodeToId[$code] = $sid; if (!isset($existingIds[$sid])) { $students[] = [ 'id' => $sid, 'school_id' => $code, 'firstname' => $code, 'lastname' => '', 'parent_id' => 0, ]; $existingIds[$sid] = true; } } } if ($sid === null || $sid <= 0) { continue; } $row['student_id'] = $sid; $row['status'] = strtolower(trim((string) ($row['status'] ?? ''))); $row['date'] = $d->format('Y-m-d'); $attendanceRows[] = $row; } foreach ($attendanceRows as $arow) { $sid = (int) ($arow['student_id'] ?? 0); if ($sid > 0 && !isset($existingIds[$sid])) { $students[] = [ 'id' => $sid, 'school_id' => (string) ($arow['student_code'] ?? $arow['student_id']), 'firstname' => (string) ($arow['student_code'] ?? $arow['student_id']), 'lastname' => '', 'parent_id' => 0, ]; $existingIds[$sid] = true; } } if (empty($attendanceRows)) { return [ 'students' => [], 'school_year' => $schoolYear, 'semester' => $semester, 'debug' => $debugInfo, ]; } $debugInfo['attendance_rows'] = count($termRows); $debugInfo['filtered_rows'] = count($attendanceRows); $violations = $this->computeViolations($students, $attendanceRows, $schoolYear, null, $attendanceRows); $debugInfo['violations'] = count($violations); $debugInfo['active_weeks'] = $this->debugCompute['active_weeks'] ?? 0; $debugInfo['by_student'] = $this->debugCompute['by_student'] ?? 0; $debugInfo['abs_last5'] = $this->debugCompute['abs_last5'] ?? 0; $debugInfo['late_last5'] = $this->debugCompute['late_last5'] ?? 0; return [ 'students' => $violations, 'school_year' => $schoolYear, 'semester' => $semester, 'debug' => $debugInfo, ]; } public function record(array $data): array { $sid = (int) $data['student_id']; $ymd = substr((string) $data['date'], 0, 10); $semester = $data['semester'] ?? $this->semester; $schoolYear = $data['school_year'] ?? $this->schoolYear; $subject = $data['subject_type'] ?? 'Absent'; $student = $this->studentModel->query()->find($sid)?->toArray() ?? []; $parentEmail = $data['parent_email'] ?? ''; $parentName = $data['parent_name'] ?? ''; if (!$parentEmail) { $parent = $this->getPrimaryParentForStudent($sid) ?? []; $parentEmail = $parent['email'] ?? ''; $parentName = $parent['parent_name'] ?? ''; } if (!$parentEmail) { return [ 'success' => false, 'message' => 'No parent email found for this student.', 'status' => 422, ]; } [$start, $end] = $this->dayBounds($ymd); $row = $this->attendanceTrackingModel->query() ->where('student_id', $sid) ->where('semester', $semester) ->where('school_year', $schoolYear) ->where('date', '>=', $start) ->where('date', '<', $end) ->orderByDesc('date') ->first(); $row = $row?->toArray(); $consequence = null; $reasonTxt = (string) ($row['reason'] ?? ''); if (preg_match('/\bABS_4\b|dismissal/i', $reasonTxt)) { $consequence = 'dismissal'; } elseif (preg_match('/\bABS_3\b|final_warning/i', $reasonTxt)) { $consequence = 'final_warning'; } elseif (preg_match('/\bABS_2\b|follow_up/i', $reasonTxt)) { $consequence = 'follow_up'; } $class = method_exists($this->studentClassModel, 'getClassSectionsByStudentId') ? $this->studentClassModel->getClassSectionsByStudentId($sid, $this->schoolYear) : []; $payload = [ 'student_id' => $sid, 'student' => $student, 'parent' => ['email' => $parentEmail, 'name' => $parentName], 'semester' => $semester, 'school_year' => $schoolYear, 'class' => $class, 'now' => now()->toDateTimeString(), 'date' => $ymd, 'subject' => $subject, 'consequence' => $consequence ?: 'follow_up', ]; try { $this->attendanceMailerService->queueAttendanceEvent($payload); if ($row && !empty($row['id']) && method_exists($this->attendanceTrackingModel, 'markAsNotified')) { $this->attendanceTrackingModel->markAsNotified((int) $row['id']); } return [ 'success' => true, 'message' => 'Notification queued.', 'data' => $payload, ]; } catch (Throwable $e) { Log::error('Attendance record queue failed: ' . $e->getMessage()); return [ 'success' => false, 'message' => $e->getMessage(), 'status' => 500, ]; } } public function getStudentCase( int $studentId, ?string $codeParam = null, ?string $incidentDate = null, bool $includeNotified = false, bool $pendingOnly = false, ?string $schoolYearFilter = null, ?string $semesterFilter = null ): array { $student = $this->studentModel->query()->find($studentId); if (!$student) { return [ 'success' => false, 'message' => 'Student not found', 'status' => 404, ]; } $student = $student->toArray(); $student['name'] = trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')); $codeParam = strtoupper((string) ($codeParam ?? '')); $incidentDate = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $incidentDate) ? (string) $incidentDate : ''; $statusFilter = ['absent', 'late']; if (Str::startsWith($codeParam, 'ABS')) { $statusFilter = ['absent']; } elseif (Str::startsWith($codeParam, 'LATE')) { $statusFilter = ['late']; } elseif (Str::startsWith($codeParam, 'MIX')) { $statusFilter = ['absent', 'late']; } $schoolYear = $schoolYearFilter ?? ($incidentDate !== '' ? $this->schoolYearForDate($incidentDate) : (string) $this->schoolYear); $semester = $semesterFilter; if ($semester === null && $codeParam === '' && $incidentDate === '') { $semester = (string) $this->semester; } [$syStart, $syEnd] = $this->deriveSchoolYearBounds($schoolYear ?: date('Y')); $today = Carbon::today(); $start = Carbon::parse($syStart)->startOfDay(); $rangeEnd = Carbon::parse(min($syEnd, $today->format('Y-m-d')))->startOfDay(); $windowWeeks = $this->windowWeeksForViolationCode($codeParam); $endYmd = $incidentDate !== '' ? $incidentDate : date('Y-m-d'); $startYmd = $this->activeWeekWindowStartYmd($endYmd, $windowWeeks, $schoolYear, $semester); $studentIdValues = [(int) $studentId]; $studentCodeRaw = trim((string) ($student['student_code'] ?? $student['school_id'] ?? '')); $studentCodeValues = []; if ($studentCodeRaw !== '') { if (is_numeric($studentCodeRaw)) { $studentCodeNum = (int) $studentCodeRaw; if ($studentCodeNum > 0 && $studentCodeNum !== (int) $studentId) { $studentIdValues[] = $studentCodeNum; } } else { $studentCodeValues[] = $studentCodeRaw; } } $studentIdValues = array_values(array_unique(array_filter($studentIdValues, fn ($v) => $v > 0))); $studentCodeValues = array_values(array_unique(array_filter($studentCodeValues, fn ($v) => $v !== ''))); $qb = $this->attendanceDataModel->query() ->select([ 'id', 'student_id', 'class_id', 'class_section_id', 'date', 'status', 'reason', 'school_year', 'semester', 'is_reported', 'is_notified', ]) ->where(function ($q) use ($studentIdValues, $studentCodeValues) { $q->whereIn('student_id', $studentIdValues); if (!empty($studentCodeValues)) { $q->orWhereIn('student_id', $studentCodeValues); } }) ->where('date', '>=', max($start->format('Y-m-d'), $startYmd)) ->where('date', '<', $this->dayBounds($endYmd)[1]); if ($statusFilter === ['absent']) { $qb->whereRaw("(LOWER(TRIM(status)) LIKE 'abs%' OR LOWER(TRIM(status)) = 'a')"); } elseif ($statusFilter === ['late']) { $qb->whereRaw("(LOWER(TRIM(status)) LIKE 'late%' OR LOWER(TRIM(status)) = 'l')"); } else { $qb->whereRaw("(LOWER(TRIM(status)) LIKE 'abs%' OR LOWER(TRIM(status)) LIKE 'late%' OR LOWER(TRIM(status)) IN ('a','l'))"); } if ($pendingOnly) { $this->applyUnreportedAttendanceFilter($qb); if (!$includeNotified) { $qb->whereRaw("(LOWER(TRIM(COALESCE(is_notified, ''))) NOT IN ('yes','1'))"); } } if ($schoolYearFilter !== null) { $qb->where('school_year', $schoolYearFilter); } if ($semesterFilter !== null) { $qb->where('semester', $semesterFilter); } $attendanceRows = $qb->orderByDesc('date')->get()->toArray(); $data = [ 'student' => $student, 'attendance' => $attendanceRows, 'violation_code' => $codeParam !== '' ? $codeParam : null, 'violation_date' => $incidentDate, 'violation_notified' => null, 'show_violation_summary' => true, 'school_year' => $schoolYear, 'semester' => $semester, ]; if ($data['violation_date'] === '' && !empty($data['attendance'][0]['date'])) { $data['violation_date'] = substr((string) $data['attendance'][0]['date'], 0, 10); } if (!empty($data['attendance'][0]['is_notified'])) { $data['violation_notified'] = $data['attendance'][0]['is_notified']; } if ($data['violation_date'] !== '') { foreach ($attendanceRows as $row) { $ymd = substr((string) ($row['date'] ?? ''), 0, 10); if ($ymd === $data['violation_date']) { $data['show_violation_summary'] = false; break; } } } return [ 'success' => true, 'data' => $data, ]; } public function sendAutoEmails(?string $variantOverride = null): array { $classStudentsQuery = $this->studentClassModel->query(); if (method_exists($this->studentClassModel, 'scopeActive')) { $classStudentsQuery->active(); } $classStudents = $classStudentsQuery ->where('school_year', $this->schoolYear) ->get() ->toArray(); if (empty($classStudents)) { return [ 'success' => true, 'sent' => 0, 'skipped' => 0, 'errors' => 0, 'details' => [], ]; } $studentIds = array_column($classStudents, 'student_id'); $students = $this->studentModel->query() ->whereIn('id', $studentIds) ->where('is_active', 1) ->get() ->toArray(); $attendanceQuery = $this->attendanceDataModel->query() ->whereIn('student_id', $studentIds) ->where('school_year', $this->schoolYear) ->where('date', '>=', Carbon::today()->subWeeks(5)->format('Y-m-d')); $this->applyUnreportedAttendanceFilter($attendanceQuery); $attendanceData = $attendanceQuery ->whereRaw("LOWER(TRIM(status)) IN ('absent','late')") ->orderByDesc('date') ->get() ->toArray(); $violations = $this->computeViolations($students, $attendanceData, $this->schoolYear, null, $attendanceData); $toSend = array_values(array_filter($violations, fn ($v) => ($v['action'] ?? '') === 'auto_email')); $sent = 0; $skipped = 0; $errors = 0; $details = []; foreach ($toSend as $v) { $sid = (int) ($v['id'] ?? 0); $code = (string) ($v['violation_code'] ?? $v['code'] ?? ''); $date = (string) ($v['last_date'] ?? ''); $to = trim((string) ($v['parent_email'] ?? '')); $pname = (string) ($v['parent_name'] ?? ''); if ($sid <= 0 || !$code || !$date || !$to) { $errors++; $details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'missing data']; continue; } if ($this->notificationAlreadySent($sid, $code, $date, 'email', $to)) { $skipped++; $details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'duplicate']; continue; } $parent = $this->getPrimaryParentForStudent($sid); $context = [ '{{parent_name}}' => (string) ($parent['parent_name'] ?? 'Parent/Guardian'), '{{student_name}}' => (string) ($v['name'] ?? ''), '{{incident_date}}' => (string) ($v['last_date'] ?? date('Y-m-d')), '{{school_phone}}' => '978-364-0219', '{{voicemail_phone}}' => (string) ($parent['phone'] ?? '—'), '{{class_time}}' => '10:00 AM', ]; $variant = $variantOverride ?: match ($code) { 'ABS_2', 'ABS_2_IN3W' => 'no_answer', default => 'default', }; $tpl = $this->renderTemplate($code, $variant, $context); if (!$tpl) { $subject = match ($code) { 'ABS_1' => 'Attendance Notice: Unreported Absence', 'LATE_2' => 'Attendance Notice: Repeated Lateness', default => 'Attendance Notice', }; $studentName = (string) ($v['name'] ?? ''); $body = "
Dear " . ($pname ?: 'Parent/Guardian') . "
" . "We'd like to inform you about your student's recent attendance:
" . "If you have any questions, please contact the school office.
"; } else { [$subject, $body] = $tpl; } $wrapped = $this->renderWithEmailLayout($subject, $body); $anyOk = false; try { $ok = $this->attendanceMailerService->send($to, $subject, $wrapped); if ($ok) { $anyOk = true; $details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'sent']; $this->logNotification($sid, $code, $date, 'email', $to, $subject, 'sent', 'OK'); } else { $errors++; $this->logNotification($sid, $code, $date, 'email', $to, $subject, 'failed', 'send returned false'); $details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'failed']; } } catch (Throwable $e) { $errors++; $this->logNotification($sid, $code, $date, 'email', $to, $subject, 'failed', $e->getMessage()); $details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'error: ' . $e->getMessage()]; } try { $sec = $this->getSecondaryParentForStudent($sid); $to2 = trim((string) ($sec['email'] ?? '')); if ($to2 !== '' && strcasecmp($to2, $to) !== 0) { if ($this->notificationAlreadySent($sid, $code, $date, 'email', $to2)) { $skipped++; $details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'duplicate_cc']; } else { $ok2 = $this->attendanceMailerService->send($to2, $subject, $wrapped); if ($ok2) { $anyOk = true; $this->logNotification($sid, $code, $date, 'email', $to2, $subject, 'sent', 'OK'); $details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'cc_sent']; } else { $errors++; $this->logNotification($sid, $code, $date, 'email', $to2, $subject, 'failed', 'send returned false'); $details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'cc_failed']; } } } } catch (Throwable $e) { $errors++; $details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'cc_error: ' . $e->getMessage()]; } if ($anyOk) { $sent++; if (method_exists($this->attendanceTrackingModel, 'markRuleAsNotified')) { $this->attendanceTrackingModel->markRuleAsNotified( $sid, $code, $date, $this->semester, $this->schoolYear ); } $allDates = []; $absDates = array_map(fn ($d) => substr((string) $d, 0, 10), (array) ($v['absences'] ?? [])); $lateDates = array_map(fn ($d) => substr((string) $d, 0, 10), (array) ($v['lates'] ?? [])); if (!empty($absDates)) { $allDates = array_merge($allDates, $absDates); } if (!empty($lateDates)) { $allDates = array_merge($allDates, $lateDates); } if (empty($allDates)) { $allDates[] = substr($date, 0, 10); } if (method_exists($this->attendanceDataModel, 'markReportedAndNotified')) { $this->attendanceDataModel->markReportedAndNotified( $sid, $allDates, $this->semester, $this->schoolYear ); } } } return [ 'success' => true, 'sent' => $sent, 'skipped' => $skipped, 'errors' => $errors, 'details' => $details, ]; } public function getNotifiedViolations(?string $schoolYearParam = null, ?string $semesterParam = null): array { $schoolYear = filled($schoolYearParam) ? (string) $schoolYearParam : (string) $this->schoolYear; $semester = filled($semesterParam) ? (string) $semesterParam : (string) $this->semester; $trackingData = $this->attendanceTrackingModel->query() ->where('school_year', $schoolYear) ->where('semester', $semester) ->where(function ($q) { $q->where('is_notified', 1) ->orWhere('is_notified', '1') ->orWhereRaw("LOWER(TRIM(COALESCE(is_notified, ''))) = 'yes'"); }) ->orderByDesc('date') ->get() ->toArray(); if (empty($trackingData)) { $latest = $this->attendanceTrackingModel->query() ->select(['school_year', 'semester']) ->where(function ($q) { $q->where('is_notified', 1) ->orWhere('is_notified', '1') ->orWhereRaw("LOWER(TRIM(COALESCE(is_notified, ''))) = 'yes'"); }) ->orderByDesc('date') ->orderByDesc('id') ->first(); if ($latest) { $schoolYear = (string) ($latest->school_year ?? $schoolYear); $semester = (string) ($latest->semester ?? $semester); $trackingData = $this->attendanceTrackingModel->query() ->where('school_year', $schoolYear) ->where('semester', $semester) ->where(function ($q) { $q->where('is_notified', 1) ->orWhere('is_notified', '1') ->orWhereRaw("LOWER(TRIM(COALESCE(is_notified, ''))) = 'yes'"); }) ->orderByDesc('date') ->get() ->toArray(); } } if (empty($trackingData)) { return [ 'students' => [], 'school_year' => $schoolYear, 'semester' => $semester, 'show_actions' => false, ]; } $studentIds = array_unique(array_map(fn ($r) => (int) ($r['student_id'] ?? 0), $trackingData)); $students = $this->studentModel->query() ->whereIn('id', $studentIds) ->where('is_active', 1) ->get() ->toArray(); $activeIdSet = array_flip(array_map( static fn ($row) => (int) ($row['id'] ?? 0), $students )); $trackingData = array_values(array_filter( $trackingData, static fn ($row) => isset($activeIdSet[(int) ($row['student_id'] ?? 0)]) )); $studentIds = array_keys($activeIdSet); if (empty($trackingData)) { return [ 'students' => [], 'school_year' => $schoolYear, 'semester' => $semester, 'show_actions' => false, ]; } $studentMap = []; foreach ($students as $s) { $studentMap[$s['id']] = $s; } $classByStudent = []; try { $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', $studentIds) ->where('sc.school_year', $schoolYear) ->orderByDesc('sc.id') ->get(); foreach ($rows as $row) { $sid = (int) ($row->student_id ?? 0); if ($sid > 0 && !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) { } $mergedData = []; foreach ($trackingData as $row) { $sid = (int) ($row['student_id'] ?? 0); if (!isset($studentMap[$sid])) { continue; } $merged = array_merge($studentMap[$sid], $row); if (isset($classByStudent[$sid])) { $merged['class_section_name'] = (string) ($classByStudent[$sid]['class_section_name'] ?? ''); $merged['class_name'] = (string) ($classByStudent[$sid]['class_name'] ?? ''); } elseif (!empty($merged['registration_grade'])) { $merged['grade'] = (string) $merged['registration_grade']; } try { $p = $this->getPrimaryParentForStudent($sid); if ($p) { $merged['parent_name'] = $p['parent_name'] ?? ''; $merged['parent_email'] = $p['email'] ?? ''; $merged['parent_phone'] = $p['phone'] ?? ''; } } catch (Throwable) { } $mergedData[] = $merged; } return [ 'students' => $mergedData, 'school_year' => $schoolYear, 'semester' => $semester, ]; } public function compose(int $studentId, string $code = 'ABS_1', string $variant = 'default', ?string $incidentDate = null): array { if ($studentId <= 0) { return [ 'success' => false, 'message' => 'Missing student_id.', 'status' => 422, ]; } $student = $this->studentModel->query()->find($studentId)?->toArray() ?? []; $parent = $this->getPrimaryParentForStudent($studentId); $secondary = $this->getSecondaryParentForStudent($studentId); $lastDate = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $incidentDate) ? $incidentDate : date('Y-m-d'); $violation = [ 'id' => $studentId, 'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')), 'last_date' => $lastDate, 'violation' => $code, 'violation_code' => $code, ]; $ctx = $this->buildTemplateContext($violation, $parent); $rendered = $this->renderTemplate($code, $variant, $ctx); if (!$rendered) { return [ 'success' => false, 'message' => "Template not found for {$code} ({$variant}).", 'status' => 404, ]; } [$subject, $body] = $rendered; return [ 'success' => true, 'data' => [ 'student_id' => $studentId, 'student_name' => $violation['name'], 'parent_email' => (string) ($parent['email'] ?? ''), 'parent_name' => (string) ($parent['parent_name'] ?? ''), 'secondary_email' => (string) ($secondary['email'] ?? ''), 'secondary_name' => (string) ($secondary['parent_name'] ?? ''), 'code' => $code, 'variant' => $variant, 'subject' => $subject, 'body_html' => $body, 'semester' => $this->semester, 'school_year' => $this->schoolYear, 'incident_date' => $lastDate, ], ]; } public function sendEmailManual(array $data): array { $sid = (int) $data['student_id']; $to = trim((string) $data['to']); $subject = (string) $data['subject']; $bodyInput = (string) $data['body_html']; $code = (string) $data['code']; $variant = (string) ($data['variant'] ?? 'default'); $ymdRaw = (string) ($data['incident_date'] ?? ''); $ymd = $ymdRaw !== '' ? substr($ymdRaw, 0, 10) : ''; if ($bodyInput !== '' && preg_match('/<[^>]+>/', $bodyInput)) { $safeHtmlBody = $bodyInput; } elseif ($bodyInput !== '' && stripos($bodyInput, '<') !== false) { $decoded = html_entity_decode($bodyInput, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $safeHtmlBody = preg_match('/<[^>]+>/', $decoded) ? $decoded : nl2br(e($bodyInput), false); } else { $safeHtmlBody = nl2br(e($bodyInput), false); } $wrapped = $this->renderWithEmailLayout($subject, $safeHtmlBody); $semester = $this->semester ?: (string) ($this->configModel->getConfig('semester') ?? ''); $schoolYear = $this->schoolYear ?: (string) ($this->configModel->getConfig('school_year') ?? ''); try { $anyOk = false; $ok = $this->attendanceMailerService->send($to, $subject, $wrapped); $this->logNotification( $sid, $code, $ymd, 'email', $to, $subject, $ok ? 'sent' : 'failed', $ok ? 'OK' : 'send returned false' ); if ($ok) { $anyOk = true; } $sec = $this->getSecondaryParentForStudent($sid); $to2 = trim((string) ($sec['email'] ?? '')); if ($to2 !== '' && strcasecmp($to2, $to) !== 0) { if (!$this->notificationAlreadySent($sid, $code, $ymd, 'email', $to2)) { $ok2 = $this->attendanceMailerService->send($to2, $subject, $wrapped); $this->logNotification( $sid, $code, $ymd, 'email', $to2, $subject, $ok2 ? 'sent' : 'failed', $ok2 ? 'OK' : 'send returned false' ); if ($ok2) { $anyOk = true; } } } if (!$anyOk) { return [ 'success' => false, 'message' => 'Failed to send email.', 'status' => 500, ]; } if (method_exists($this->attendanceTrackingModel, 'markRuleAsNotified')) { $this->attendanceTrackingModel->markRuleAsNotified( $sid, $code, $ymd !== '' ? $ymd : date('Y-m-d'), $semester, $schoolYear ); } $violationDates = $this->getViolationDatesForStudent($sid, $code, $ymd); if (method_exists($this->attendanceDataModel, 'markReportedAndNotified')) { $this->attendanceDataModel->markReportedAndNotified( $sid, $violationDates, $semester, $schoolYear ); } return [ 'success' => true, 'message' => 'Email sent successfully.', 'data' => [ 'student_id' => $sid, 'to' => $to, 'secondary_to' => $to2 ?? null, 'subject' => $subject, 'variant' => $variant, 'incident_date' => $ymd, ], ]; } catch (Throwable $e) { $this->logNotification($sid, $code, $ymd, 'email', $to, $subject, 'failed', $e->getMessage()); return [ 'success' => false, 'message' => 'Error: ' . $e->getMessage(), 'status' => 500, ]; } } public function parentsInfo(int $studentId): array { if ($studentId <= 0) { return [ 'success' => false, 'message' => 'Missing student_id', 'status' => 422, ]; } try { $stu = DB::table('students') ->select('id', 'parent_id', 'school_year', 'semester') ->where('id', $studentId) ->first(); if (!$stu) { return [ 'success' => false, 'message' => 'Student not found', 'status' => 404, ]; } $primaryId = (int) $stu->parent_id; $schoolYear = $stu->school_year ?: ($this->schoolYear ?? null); $u1 = DB::table('users') ->select('id', 'firstname', 'lastname', 'email', 'cellphone') ->where('id', $primaryId) ->first(); $primary = $u1 ? [ 'id' => (int) $u1->id, 'firstname' => (string) $u1->firstname, 'lastname' => (string) $u1->lastname, 'email' => (string) $u1->email, 'cellphone' => (string) $u1->cellphone, ] : null; $pb = DB::table('parents')->where('firstparent_id', $primaryId); if (!empty($schoolYear)) { $pb->where('school_year', $schoolYear); } $pRow = $pb->orderByDesc('updated_at')->first(); $secondary = null; if ($pRow) { $secondId = (int) ($pRow->secondparent_id ?? 0); if ($secondId > 0) { $u2 = DB::table('users') ->select('id', 'firstname', 'lastname', 'email', 'cellphone') ->where('id', $secondId) ->first(); if ($u2) { $secondary = [ 'id' => (int) $u2->id, 'firstname' => (string) $u2->firstname, 'lastname' => (string) $u2->lastname, 'email' => (string) $u2->email, 'cellphone' => (string) $u2->cellphone, ]; } } if (!$secondary) { $fallbackEmail = (string) ($pRow->secondparent_email ?? ''); $fallbackPhone = (string) ($pRow->secondparent_phone ?? ''); $fallbackFirst = (string) ($pRow->secondparent_firstname ?? ''); $fallbackLast = (string) ($pRow->secondparent_lastname ?? ''); if ($fallbackEmail || $fallbackPhone || $fallbackFirst || $fallbackLast) { $secondary = [ 'id' => $secondId ?: null, 'firstname' => $fallbackFirst, 'lastname' => $fallbackLast, 'email' => $fallbackEmail, 'cellphone' => $fallbackPhone, ]; } } } return [ 'success' => true, 'data' => [ 'primary' => $primary, 'secondary' => $secondary, ], ]; } catch (Throwable $e) { Log::error('parentsInfo() failed: ' . $e->getMessage()); return [ 'success' => false, 'message' => 'Server error', 'status' => 500, ]; } } public function saveNotificationNote(array $data): array { $sid = (int) $data['student_id']; $code = (string) $data['code']; $note = trim((string) $data['note']); $ymdRaw = (string) ($data['incident_date'] ?? ''); $ymd = $ymdRaw !== '' ? substr($ymdRaw, 0, 10) : ''; try { $day = $ymd !== '' ? $ymd : date('Y-m-d'); [$start, $end] = $this->dayBounds($day); $row = $this->attendanceTrackingModel->query() ->where('student_id', $sid) ->where('semester', $this->semester) ->where('school_year', $this->schoolYear) ->where('date', '>=', $start) ->where('date', '<', $end) ->where('reason', 'like', '%' . $code . '%') ->orderByDesc('date') ->first(); if (!$row) { $row = $this->attendanceTrackingModel->query() ->where('student_id', $sid) ->where('semester', $this->semester) ->where('school_year', $this->schoolYear) ->where('date', '>=', $start) ->where('date', '<', $end) ->orderByDesc('date') ->first(); } if ($row && !empty($row->id)) { DB::table($this->attendanceTrackingModel->getTable()) ->where('id', (int) $row->id) ->update([ 'notif_counter' => DB::raw('COALESCE(notif_counter,0)+1'), 'note' => $note, 'is_notified' => 1, 'updated_at' => now(), ]); } else { $this->attendanceTrackingModel->query()->create([ 'student_id' => $sid, 'date' => $start, 'is_reported' => 0, 'reason' => $code, 'is_notified' => 1, 'notif_counter' => 1, 'semester' => $this->semester, 'school_year' => $this->schoolYear, 'note' => $note, 'created_at' => now(), 'updated_at' => now(), ]); } return [ 'success' => true, 'message' => 'Note saved.', ]; } catch (Throwable $e) { return [ 'success' => false, 'message' => 'Failed to save note: ' . $e->getMessage(), 'status' => 500, ]; } } protected function notificationAlreadySent( int $studentId, string $code, string $ymd, string $channel = 'email', ?string $to = null ): bool { try { if (method_exists($this->notificationModel, 'hasSent')) { return (bool) $this->notificationModel->hasSent($studentId, $code, $ymd, $channel, $to); } $query = $this->notificationModel->query() ->where('student_id', $studentId) ->where('code', $code) ->where('incident_date', $ymd) ->where('channel', $channel); if (!empty($to)) { $query->where('to_address', $to); } return $query->exists(); } catch (Throwable $e) { Log::debug('notificationAlreadySent(): ' . $e->getMessage()); return false; } } protected function logNotification( int $studentId, string $code, string $ymd, string $channel, ?string $to, string $subject, string $status, string $response = '' ): void { try { $where = [ 'student_id' => $studentId, 'code' => $code, 'incident_date' => $ymd, 'channel' => $channel, ]; if (!empty($to)) { $where['to_address'] = $to; } $existing = $this->notificationModel->query() ->where($where) ->orderByDesc('id') ->first(); if ($existing && !empty($existing->id)) { $existing->update([ 'subject' => $subject, 'status' => $status, 'response' => $response, 'semester' => $this->semester, 'school_year' => $this->schoolYear, 'updated_at' => now(), ]); } else { $this->notificationModel->query()->create([ 'student_id' => $studentId, 'code' => $code, 'incident_date' => $ymd, 'channel' => $channel, 'to_address' => $to, 'subject' => $subject, 'status' => $status, 'response' => $response, 'semester' => $this->semester, 'school_year' => $this->schoolYear, ]); } } catch (Throwable $e) { Log::debug('logNotification(): ' . $e->getMessage()); } } protected function getPrimaryParentForStudent(int $studentId): ?array { $row = DB::table('students as s') ->selectRaw('u.id AS user_id, u.email, u.cellphone, CONCAT(u.firstname, " ", u.lastname) AS parent_name') ->leftJoin('users as u', 'u.id', '=', 's.parent_id') ->where('s.id', $studentId) ->first(); if (!$row || empty($row->user_id)) { return null; } return [ 'user_id' => (int) $row->user_id, 'email' => (string) ($row->email ?? ''), 'parent_name' => (string) ($row->parent_name ?? ''), 'phone' => $row->cellphone ?? null, ]; } protected function getSecondaryParentForStudent(int $studentId): ?array { try { $stu = DB::table('students') ->select('id', 'parent_id', 'school_year', 'semester') ->where('id', $studentId) ->first(); if (!$stu) { return null; } $primaryId = (int) ($stu->parent_id ?? 0); $schoolYear = (string) ($stu->school_year ?? ''); $pb = DB::table('parents')->where('firstparent_id', $primaryId); if ($schoolYear !== '') { $pb->where('school_year', $schoolYear); } $pRow = $pb->orderByDesc('updated_at')->first(); if (!$pRow) { return null; } $secondId = (int) ($pRow->secondparent_id ?? 0); if ($secondId > 0) { $u2 = DB::table('users') ->select('id', 'firstname', 'lastname', 'email', 'cellphone') ->where('id', $secondId) ->first(); if ($u2 && !empty($u2->email)) { return [ 'user_id' => (int) $u2->id, 'email' => (string) $u2->email, 'parent_name' => trim(($u2->firstname ?? '') . ' ' . ($u2->lastname ?? '')), 'phone' => (string) ($u2->cellphone ?? ''), ]; } } $fallbackEmail = (string) ($pRow->secondparent_email ?? ''); $fallbackPhone = (string) ($pRow->secondparent_phone ?? ''); $fallbackFirst = (string) ($pRow->secondparent_firstname ?? ''); $fallbackLast = (string) ($pRow->secondparent_lastname ?? ''); if ($fallbackEmail || $fallbackPhone || $fallbackFirst || $fallbackLast) { return [ 'user_id' => $secondId ?: null, 'email' => $fallbackEmail, 'parent_name' => trim($fallbackFirst . ' ' . $fallbackLast) ?: null, 'phone' => $fallbackPhone, ]; } } catch (Throwable $e) { Log::error('getSecondaryParentForStudent() failed: ' . $e->getMessage()); } return null; } protected function computeViolations( array $students, array $attendanceData, ?string $schoolYear = null, ?string $semester = null, ?array $weekRowsFallback = null ): array { $this->debugCompute = ['active_weeks' => 0, 'by_student' => 0]; $schoolYear = $schoolYear ?: $this->schoolYear; $semester = ($semester !== null && $semester !== '') ? (string) $semester : null; $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 && !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->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; } protected function renderWithEmailLayout(string $subject, string $bodyHtml): string { return view('emails._wrap_layout', [ 'title' => $subject, 'body_html' => $bodyHtml, ])->render(); } protected 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; } protected function attendanceReportedColumns(): array { if ($this->attendanceReportedColumns !== null) { return $this->attendanceReportedColumns; } $table = $this->attendanceDataModel->getTable(); $this->attendanceReportedColumns = [ 'is_reported' => \Schema::hasColumn($table, 'is_reported'), 'reported' => \Schema::hasColumn($table, 'reported'), ]; return $this->attendanceReportedColumns; } protected function applyUnreportedAttendanceFilter(Builder $qb): void { $cols = $this->attendanceReportedColumns(); 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')) { $table = $this->attendanceDataModel->getTable(); $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') )"); } } protected 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', ]; } protected function schoolYearForDate(string $ymd): string { $ts = strtotime($ymd); if ($ts === false) { return (string) $this->schoolYear; } $y = (int) date('Y', $ts); $m = (int) date('n', $ts); $startY = ($m >= 8) ? $y : $y - 1; return sprintf('%d-%d', $startY, $startY + 1); } protected 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)]; } protected function buildTemplateContext(array $v, ?array $parent = null): array { $studentName = (string) ($v['name'] ?? ''); $incident = (string) ($v['last_date'] ?? date('Y-m-d')); $p = $parent ?: ($this->getPrimaryParentForStudent((int) ($v['id'] ?? 0)) ?? []); return [ '{{parent_name}}' => (string) ($p['parent_name'] ?? 'Parent/Guardian'), '{{student_name}}' => $studentName, '{{incident_date}}' => $incident, '{{school_phone}}' => '978-364-0219', '{{voicemail_phone}}' => (string) ($p['phone'] ?? '—'), '{{class_time}}' => '10:00 AM', ]; } protected function renderTemplate(string $code, string $variant, array $context): ?array { $row = null; if (method_exists($this->attendanceEmailTemplateModel, 'getTemplate')) { $row = $this->attendanceEmailTemplateModel->getTemplate($code, $variant) ?: $this->attendanceEmailTemplateModel->getTemplate($code, 'default'); } else { $row = $this->attendanceEmailTemplateModel->query() ->where('code', $code) ->where('variant', $variant) ->where('is_active', 1) ->first(); if (!$row) { $row = $this->attendanceEmailTemplateModel->query() ->where('code', $code) ->where('variant', 'default') ->where('is_active', 1) ->first(); } $row = $row?->toArray(); } if (!$row) { return null; } $subject = strtr($row['subject'], $context); $body = strtr($row['body_html'], $context); return [$subject, $body]; } protected function getViolationDatesForStudent(int $studentId, string $code, ?string $incidentDate = null): array { $code = strtoupper(trim($code)); $statuses = ['absent', 'late']; if (Str::startsWith($code, 'ABS')) { $statuses = ['absent']; } elseif (Str::startsWith($code, 'LATE')) { $statuses = ['late']; } elseif (Str::startsWith($code, 'MIX')) { $statuses = ['absent', 'late']; } if (method_exists($this->attendanceDataModel, 'getRecentUnreportedDates')) { return $this->attendanceDataModel->getRecentUnreportedDates( $studentId, $statuses, $incidentDate, 35, $this->semester, $this->schoolYear ); } return $this->attendanceDataModel->query() ->where('student_id', $studentId) ->whereIn(DB::raw('LOWER(TRIM(status))'), $statuses) ->when($incidentDate, fn ($q) => $q->where('date', '<=', $incidentDate)) ->orderByDesc('date') ->limit(35) ->pluck('date') ->map(fn ($d) => substr((string) $d, 0, 10)) ->unique() ->values() ->all(); } protected function getActiveWeeksFromAttendanceData( string $startYmd, string $endYmd, ?string $schoolYear = null, ?string $semester = null, ?array $weekRowsFallback = null ): array { $schoolYear = $schoolYear ?: $this->schoolYear; $semester = ($semester !== null && $semester !== '') ? (string) $semester : null; $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, 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; } protected 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; } protected 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; } protected 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'); } protected function activeWeekWindowStartYmd( string $endYmd, int $windowWeeks, string $schoolYear, ?string $semester ): string { $windowWeeks = max(1, $windowWeeks); [$syStart, $syEnd] = $this->deriveSchoolYearBounds($schoolYear ?: $this->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); } protected 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; } protected 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; } protected 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; } }