toDateTimeString(); // Y-m-d H:i:s } /** Apply semester/schoolYear filters if provided. */ private function applyTermFilters($qb, ?string $semester, ?string $schoolYear) { if ($semester !== null && $semester !== '') { $qb->where('semester', $semester); } if ($schoolYear !== null && $schoolYear !== '') { $qb->where('school_year', $schoolYear); } return $qb; } /** Derive (semester, school_year) from a Y-m-d date (Fall=Aug–Dec; Spring=Jan–Jul). */ private function deriveTermFromDate(string $ymd): array { try { $d = Time::createFromFormat('Y-m-d', substr($ymd, 0, 10), 'UTC') ?? Time::today('UTC'); } catch (\Throwable $e) { $d = Time::today('UTC'); } $m = (int)$d->format('n'); $y = (int)$d->format('Y'); $semester = ($m >= 8 && $m <= 12) ? 'Fall' : 'Spring'; $startY = ($m >= 8) ? $y : $y - 1; $endY = $startY + 1; return [$semester, sprintf('%d-%d', $startY, $endY)]; } /* ----------------------- CRUD-ish APIs ----------------------- */ /** * Records a student absence (stores time at 00:00:00 if only a day is given). * NOTE: $semester/$schoolYear are optional; will be derived from $date if omitted. */ public function recordAbsence( int $studentId, string $date, ?string $semester = null, ?string $schoolYear = null, bool $isExcused = false, ?string $reason = null ) { $dt = $this->normalizeDateTime($date); if ($semester === null || $schoolYear === null) { [$sem, $yr] = $this->deriveTermFromDate($dt); $semester = $semester ?? $sem; $schoolYear = $schoolYear ?? $yr; } return $this->insert([ 'student_id' => $studentId, 'date' => $dt, 'is_reported' => $isExcused ? 1 : 0, 'reason' => $reason, 'is_notified' => 0, 'notif_counter' => 0, 'semester' => $semester, 'school_year' => $schoolYear, ], true); } /** * All absences for a student; start/end accept Y-m-d or Y-m-d H:i:s. * For day-only ranges, we compare on DATE(date). */ public function getStudentAbsences( int $studentId, ?string $startDate = null, ?string $endDate = null, ?string $semester = null, ?string $schoolYear = null ): array { $qb = $this->builder()->where('student_id', $studentId); if ($startDate && preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) { $qb->where('DATE(`date`) >=', $startDate); } elseif ($startDate) { $qb->where('date >=', $this->normalizeDateTime($startDate)); } if ($endDate && preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) { $qb->where('DATE(`date`) <=', $endDate); } elseif ($endDate) { $qb->where('date <=', $this->normalizeDateTime($endDate)); } $this->applyTermFilters($qb, $semester, $schoolYear); return $qb->orderBy('date', 'DESC')->get()->getResultArray(); } /** * Unexcused + unnotified absences (optionally for a specific day). */ public function getUnnotifiedUnexcusedAbsences( ?string $date = null, ?string $semester = null, ?string $schoolYear = null ): array { $qb = $this->builder() ->where('is_reported', 0) ->where('is_notified', 0); if ($date && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { $qb->where('DATE(`date`)', $date); } elseif ($date) { $qb->where('date', $this->normalizeDateTime($date)); } $this->applyTermFilters($qb, $semester, $schoolYear); return $qb->orderBy('date', 'DESC')->get()->getResultArray(); } /** * Has at least N absences in the last N days (rough heuristic); * for weekly checks, prefer checkConsecutiveAbsences(). */ public function hasConsecutiveAbsences( int $studentId, int $days, ?string $semester = null, ?string $schoolYear = null ): bool { $start = date('Y-m-d', strtotime("-{$days} days")); $qb = $this->builder() ->select('COUNT(*) AS cnt') ->where('student_id', $studentId) ->where('DATE(`date`) >=', $start); $this->applyTermFilters($qb, $semester, $schoolYear); $row = $qb->get()->getFirstRow('array'); return (int)($row['cnt'] ?? 0) >= $days; } /** * Return TRUE if last $threshold absences are exactly weekly (7-day spacing). */ public function checkConsecutiveAbsences( int $studentId, int $threshold, ?string $semester = null, ?string $schoolYear = null ): bool { $qb = $this->builder() ->select('date, is_reported, is_notified') ->where('student_id', $studentId) ->where('is_reported', 0) ->where('is_notified', 0); $this->applyTermFilters($qb, $semester, $schoolYear); $absences = $qb->orderBy('date', 'DESC')->limit($threshold)->get()->getResultArray(); if (count($absences) < $threshold) return false; for ($i = 0; $i < $threshold - 1; $i++) { $d1 = new \DateTime($absences[$i]['date']); $d2 = new \DateTime($absences[$i + 1]['date']); $diffDays = (int)$d2->diff($d1)->format('%a'); if ($diffDays !== 7) { return false; } } return true; } /** * TRUE if at least $absencesThreshold absences in the last $weeksStreak weeks. */ public function checkAbsencesInStreak( int $studentId, int $absencesThreshold, int $weeksStreak, ?string $semester = null, ?string $schoolYear = null ): bool { $qb = $this->builder() ->select('date, is_reported, is_notified') ->where('student_id', $studentId) ->where('is_reported', 0) ->where('is_notified', 0); $this->applyTermFilters($qb, $semester, $schoolYear); $records = $qb->orderBy('date', 'DESC')->limit($weeksStreak)->get()->getResultArray(); if (count($records) < $weeksStreak) return false; $count = 0; foreach ($records as $r) { if ((int)$r['is_reported'] === 0 && (int)$r['is_notified'] === 0) { $count++; } } return $count >= $absencesThreshold; } /** * Summarize a student's status this term. */ public function getStudentAttendanceStats( int $studentId, ?string $semester = null, ?string $schoolYear = null ): array { // total_absences $qb1 = $this->builder()->where('student_id', $studentId); $this->applyTermFilters($qb1, $semester, $schoolYear); $total = (int)($qb1->select('COUNT(*) AS cnt')->get()->getFirstRow('array')['cnt'] ?? 0); // unexcused_absences $qb2 = $this->builder()->where('student_id', $studentId)->where('is_reported', 0); $this->applyTermFilters($qb2, $semester, $schoolYear); $unexcused = (int)($qb2->select('COUNT(*) AS cnt')->get()->getFirstRow('array')['cnt'] ?? 0); // last_absence $qb3 = $this->builder()->where('student_id', $studentId); $this->applyTermFilters($qb3, $semester, $schoolYear); $last = $qb3->orderBy('date', 'DESC')->get()->getFirstRow('array'); return [ 'total_absences' => $total, 'unexcused_absences' => $unexcused, 'last_absence' => $last, ]; } /** * Fetch records by semester/schoolYear (with optional limit). */ public function getAttendanceBySemester( ?string $semester = null, ?string $schoolYear = null, int $limit = 0 ): array { $qb = $this->builder(); $this->applyTermFilters($qb, $semester, $schoolYear); if ($limit > 0) $qb->limit($limit); return $qb->orderBy('date', 'DESC')->get()->getResultArray(); } /** * Summary counts for a term. */ public function getSemesterSummary(?string $semester = null, ?string $schoolYear = null): array { // total $qb1 = $this->builder(); $this->applyTermFilters($qb1, $semester, $schoolYear); $total = (int)($qb1->select('COUNT(*) AS cnt')->get()->getFirstRow('array')['cnt'] ?? 0); // unexcused $qb2 = $this->builder()->where('is_reported', 0); $this->applyTermFilters($qb2, $semester, $schoolYear); $unexcused = (int)($qb2->select('COUNT(*) AS cnt')->get()->getFirstRow('array')['cnt'] ?? 0); // distinct students $qb3 = $this->builder(); $this->applyTermFilters($qb3, $semester, $schoolYear); $students = (int)($qb3->select('COUNT(DISTINCT student_id) AS cnt')->get()->getFirstRow('array')['cnt'] ?? 0); return [ 'total_absences' => $total, 'unexcused_absences' => $unexcused, 'students_with_absences' => $students, ]; } /** * Update/create violation rows (matching by student + DATE(date) + term). * Expects input rows like: ['id'=>..., 'violation'=>..., 'absences'=>[['date'=>'Y-m-d'], ...]] */ public function updateAttendanceViolations(array $studentsWithViolations, string $semester, string $schoolYear): array { $results = ['added' => 0, 'updated' => 0, 'skipped' => 0, 'notified' => 0]; foreach ($studentsWithViolations as $student) { if (empty($student['absences'])) { $results['skipped']++; continue; } foreach ($student['absences'] as $absence) { $ymd = substr((string)($absence['date'] ?? date('Y-m-d')), 0, 10); $dt = $this->normalizeDateTime($ymd); $data = [ 'student_id' => (int)$student['id'], 'date' => $dt, 'is_reported' => 0, 'reason' => (string)($student['violation'] ?? ''), 'is_notified' => 0, 'notif_counter' => 0, 'semester' => $semester, 'school_year' => $schoolYear, ]; try { // Match by student + DATE(date) + term $existing = $this->builder() ->where('student_id', $data['student_id']) ->where('DATE(`date`)', $ymd) ->where('semester', $semester) ->where('school_year', $schoolYear) ->get()->getFirstRow('array'); if ($existing) { if (($existing['reason'] ?? '') !== $data['reason']) { $this->update((int)$existing['id'], $data); $results['updated']++; if (!empty($existing['is_notified'])) { $this->resetNotification((int)$existing['id']); $results['notified']++; } } else { $results['skipped']++; } } else { $newId = $this->insert($data, true); $results['added']++; // Only match a standalone "4" (final threshold) if (preg_match('/\b4\b/', $data['reason'])) { $this->markAsNotified((int)$newId); $results['notified']++; } } } catch (\Throwable $e) { log_message('error', sprintf( 'Failed to update attendance violation for student %d on %s: %s', $data['student_id'], $ymd, $e->getMessage() )); $results['skipped']++; } } } return $results; } /* ----------------------- Notification ops ----------------------- */ public function markAsNotified(int $id): bool { // atomic increment + set flag; touch updated_at by doing update() $now = utc_now(); $this->builder() ->where('id', $id) ->set('notif_counter', 'COALESCE(notif_counter,0)+1', false) ->update(); return $this->update($id, [ 'is_notified' => 1, 'updated_at' => $now, ]); } public function resetNotification(int $id): bool { return $this->update($id, ['is_notified' => 0, 'notif_counter' => 0]); } public function markRuleAsNotified( int $studentId, string $code, string $ymd, ?string $semester = null, ?string $schoolYear = null ): bool { $semester = $semester ?? (new \App\Models\ConfigurationModel())->getConfig('semester'); $schoolYear = $schoolYear ?? (new \App\Models\ConfigurationModel())->getConfig('school_year'); $day = substr($ymd, 0, 10); $start = $day . ' 00:00:00'; $end = date('Y-m-d', strtotime($day . ' +1 day')) . ' 00:00:00'; $now = utc_now(); // 1) Try match by code + day (preferred) $qb = $this->where('student_id', $studentId) ->where('semester', $semester) ->where('school_year', $schoolYear) ->where('date >=', $start) ->where('date <', $end) ->like('reason', $code, 'both') ->orderBy('date', 'DESC'); $row = $qb->first(); // 2) Fallback: match by day only (reason may hold a human-friendly title) if (!$row) { $row = $this->where('student_id', $studentId) ->where('semester', $semester) ->where('school_year', $schoolYear) ->where('date >=', $start) ->where('date <', $end) ->orderBy('date', 'DESC') ->first(); } if ($row && !empty($row['id'])) { $count = (int)($row['notif_counter'] ?? 0) + 1; return (bool) $this->update((int)$row['id'], [ 'is_notified' => 1, 'notif_counter' => $count, 'is_reported' => 0, 'updated_at' => $now, ]); } // No existing row -> insert one so future passes see it as notified return (bool) $this->insert([ 'student_id' => $studentId, 'date' => $start, 'is_reported' => 0, 'reason' => $code, // store the CODE, not a prose title 'is_notified' => 1, 'notif_counter' => 1, 'semester' => $semester, 'school_year' => $schoolYear, 'created_at' => $now, 'updated_at' => $now, ]); } /** * Mark the latest matching tracking row as notified and increment counter. * $code is the rule code you stored in "reason" (e.g. ABS_2, LATE_3, MIX_L2A1). * Optionally bound by $lastDate if you want to avoid touching future rows. */ public function markNotified(int $studentId, string $schoolYear, ?string $semester, string $code, ?string $lastDate = null): int { $qb = $this->builder(); $qb->where('student_id', $studentId) ->where('school_year', $schoolYear); if (!empty($semester)) { $qb->where('semester', $semester); } if (!empty($lastDate)) { $qb->where('date <=', $lastDate); // keep it safe to the last violation date you emailed about } $qb->like('reason', $code, 'both') ->orderBy('date', 'DESC') ->limit(1); $row = $qb->get()->getRowArray(); if (!$row) { return 0; } $id = (int)$row['id']; $counter = (int)($row['notif_counter'] ?? 0) + 1; return $this->update($id, [ 'is_notified' => 1, 'notif_counter'=> $counter, 'updated_at' => utc_now(), ]) ? 1 : 0; } }