524 lines
18 KiB
PHP
524 lines
18 KiB
PHP
<?php
|
||
|
||
namespace App\Models;
|
||
|
||
use CodeIgniter\Model;
|
||
use CodeIgniter\I18n\Time;
|
||
|
||
class AttendanceTrackingModel extends Model
|
||
{
|
||
protected $table = 'attendance_tracking';
|
||
protected $primaryKey = 'id';
|
||
protected $useAutoIncrement = true;
|
||
protected $returnType = 'array';
|
||
protected $useSoftDeletes = false;
|
||
|
||
protected $allowedFields = [
|
||
'student_id',
|
||
'date',
|
||
'is_reported',
|
||
'reason',
|
||
'is_notified',
|
||
'notif_counter',
|
||
'semester',
|
||
'school_year',
|
||
'note',
|
||
];
|
||
|
||
protected $useTimestamps = true;
|
||
protected $createdField = 'created_at';
|
||
protected $updatedField = 'updated_at';
|
||
protected $dateFormat = 'datetime';
|
||
|
||
/* ----------------------- Helpers ----------------------- */
|
||
|
||
/** Normalize Y-m-d (or Y-m-d H:i:s) into a full 'Y-m-d H:i:s' at midnight if time missing. */
|
||
private function normalizeDateTime(string $date): string
|
||
{
|
||
// If already has time, trust it; else append midnight
|
||
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $date)) {
|
||
return $date;
|
||
}
|
||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||
return $date . ' 00:00:00';
|
||
}
|
||
// Best-effort parse
|
||
try {
|
||
$t = Time::parse($date, 'UTC');
|
||
} catch (\Throwable $e) {
|
||
$t = Time::today('UTC');
|
||
}
|
||
return $t->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;
|
||
}
|
||
}
|