Files
alrahma_sunday_school_api/app/Models/AttendanceTracking.php
T
2026-06-11 11:46:12 -04:00

582 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Models;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class AttendanceTracking extends BaseModel
{
protected $table = 'attendance_tracking';
// ✅ legacy: useTimestamps = true (created_at/updated_at)
public $timestamps = true;
protected $fillable = [
'student_id',
'date',
'is_reported',
'reason',
'is_notified',
'notif_counter',
'semester',
'school_year',
'note',
];
protected $casts = [
'student_id' => 'integer',
'date' => 'datetime', // legacy dateFormat = datetime
'is_reported' => 'integer',
'is_notified' => 'integer',
'notif_counter' => 'integer',
];
/* ----------------------- 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 static function normalizeDateTime(string $date): string
{
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';
}
try {
return Carbon::parse($date, 'UTC')->format('Y-m-d H:i:s');
} catch (\Throwable $e) {
return Carbon::now('UTC')->startOfDay()->format('Y-m-d H:i:s');
}
}
/** Apply semester/schoolYear filters if provided. */
private static function applyTermFilters($q, ?string $semester, ?string $schoolYear)
{
if ($semester !== null && $semester !== '') {
$q->where('semester', $semester);
}
if ($schoolYear !== null && $schoolYear !== '') {
$q->where('school_year', $schoolYear);
}
return $q;
}
/** Derive (semester, school_year) from a Y-m-d date (Fall=AugDec; Spring=JanJul). */
private static function deriveTermFromDate(string $ymd): array
{
try {
$d = Carbon::createFromFormat('Y-m-d', substr($ymd, 0, 10), 'UTC');
} catch (\Throwable $e) {
$d = Carbon::now('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)];
}
/** Day bounds for DATETIME searches (index-friendly). */
private static function dayRange(string $ymd): array
{
$day = substr($ymd, 0, 10);
$start = Carbon::parse($day, 'UTC')->startOfDay();
$endEx = Carbon::parse($day, 'UTC')->addDay()->startOfDay(); // exclusive
return [$start, $endEx];
}
/* ----------------------- 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.
* Returns inserted id.
*/
public static function recordAbsence(
int $studentId,
string $date,
?string $semester = null,
?string $schoolYear = null,
bool $isExcused = false,
?string $reason = null
): int {
$dt = static::normalizeDateTime($date);
if ($semester === null || $schoolYear === null) {
[$sem, $yr] = static::deriveTermFromDate($dt);
$semester = $semester ?? $sem;
$schoolYear = $schoolYear ?? $yr;
}
$row = static::create([
'student_id' => $studentId,
'date' => $dt,
'is_reported' => $isExcused ? 1 : 0,
'reason' => $reason,
'is_notified' => 0,
'notif_counter' => 0,
'semester' => $semester,
'school_year' => $schoolYear,
]);
return (int) $row->id;
}
/**
* All absences for a student; start/end accept Y-m-d or Y-m-d H:i:s.
* For day-only ranges, we use DATETIME bounds (>= startOfDay, < nextDay) for performance.
*/
public static function getStudentAbsences(
int $studentId,
?string $startDate = null,
?string $endDate = null,
?string $semester = null,
?string $schoolYear = null
) {
$q = static::query()->where('student_id', $studentId);
if ($startDate) {
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
[$start] = static::dayRange($startDate);
$q->where('date', '>=', $start);
} else {
$q->where('date', '>=', static::normalizeDateTime($startDate));
}
}
if ($endDate) {
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
[, $endEx] = static::dayRange($endDate);
$q->where('date', '<', $endEx);
} else {
$q->where('date', '<=', static::normalizeDateTime($endDate));
}
}
static::applyTermFilters($q, $semester, $schoolYear);
return $q->orderByDesc('date')->get();
}
/**
* Unexcused + unnotified absences (optionally for a specific day).
*/
public static function getUnnotifiedUnexcusedAbsences(
?string $date = null,
?string $semester = null,
?string $schoolYear = null
) {
$q = static::query()
->where('is_reported', 0)
->where('is_notified', 0);
if ($date) {
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
[$start, $endEx] = static::dayRange($date);
$q->where('date', '>=', $start)->where('date', '<', $endEx);
} else {
$q->where('date', static::normalizeDateTime($date));
}
}
static::applyTermFilters($q, $semester, $schoolYear);
return $q->orderByDesc('date')->get();
}
/**
* Has at least N absences in the last N days (rough heuristic).
*/
public static function hasConsecutiveAbsences(
int $studentId,
int $days,
?string $semester = null,
?string $schoolYear = null
): bool {
$startDay = Carbon::now('UTC')->subDays($days)->toDateString();
[$start] = static::dayRange($startDay);
$q = static::query()
->where('student_id', $studentId)
->where('date', '>=', $start);
static::applyTermFilters($q, $semester, $schoolYear);
return $q->count() >= $days;
}
/**
* TRUE if last $threshold absences are exactly weekly (7-day spacing).
*/
public static function checkConsecutiveAbsences(
int $studentId,
int $threshold,
?string $semester = null,
?string $schoolYear = null
): bool {
$q = static::query()
->select(['date', 'is_reported', 'is_notified'])
->where('student_id', $studentId)
->where('is_reported', 0)
->where('is_notified', 0);
static::applyTermFilters($q, $semester, $schoolYear);
$absences = $q->orderByDesc('date')->limit($threshold)->get()->toArray();
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.
* (Mirrors your legacy behavior: count of last N fetched records)
*/
public static function checkAbsencesInStreak(
int $studentId,
int $absencesThreshold,
int $weeksStreak,
?string $semester = null,
?string $schoolYear = null
): bool {
$q = static::query()
->select(['date', 'is_reported', 'is_notified'])
->where('student_id', $studentId)
->where('is_reported', 0)
->where('is_notified', 0);
static::applyTermFilters($q, $semester, $schoolYear);
$records = $q->orderByDesc('date')->limit($weeksStreak)->get()->toArray();
if (count($records) < $weeksStreak) {
return false;
}
$count = 0;
foreach ($records as $r) {
if ((int) ($r['is_reported'] ?? 0) === 0 && (int) ($r['is_notified'] ?? 0) === 0) {
$count++;
}
}
return $count >= $absencesThreshold;
}
/**
* Summarize a student's status this term.
*/
public static function getStudentAttendanceStats(
int $studentId,
?string $semester = null,
?string $schoolYear = null
): array {
$q1 = static::query()->where('student_id', $studentId);
static::applyTermFilters($q1, $semester, $schoolYear);
$total = (int) $q1->count();
$q2 = static::query()->where('student_id', $studentId)->where('is_reported', 0);
static::applyTermFilters($q2, $semester, $schoolYear);
$unexcused = (int) $q2->count();
$q3 = static::query()->where('student_id', $studentId);
static::applyTermFilters($q3, $semester, $schoolYear);
$last = $q3->orderByDesc('date')->first();
return [
'total_absences' => $total,
'unexcused_absences' => $unexcused,
'last_absence' => $last ? $last->toArray() : null,
];
}
/**
* Fetch records by semester/schoolYear (with optional limit).
*/
public static function getAttendanceBySemester(
?string $semester = null,
?string $schoolYear = null,
int $limit = 0
) {
$q = static::query();
static::applyTermFilters($q, $semester, $schoolYear);
if ($limit > 0) {
$q->limit($limit);
}
return $q->orderByDesc('date')->get();
}
/**
* Summary counts for a term.
*/
public static function getSemesterSummary(?string $semester = null, ?string $schoolYear = null): array
{
$q1 = static::query();
static::applyTermFilters($q1, $semester, $schoolYear);
$total = (int) $q1->count();
$q2 = static::query()->where('is_reported', 0);
static::applyTermFilters($q2, $semester, $schoolYear);
$unexcused = (int) $q2->count();
$q3 = static::query();
static::applyTermFilters($q3, $semester, $schoolYear);
$students = (int) $q3->distinct('student_id')->count('student_id');
return [
'total_absences' => $total,
'unexcused_absences' => $unexcused,
'students_with_absences' => $students,
];
}
/**
* Update/create violation rows (matching by student + day-range(date) + term).
* Expects input rows like: ['id'=>..., 'violation'=>..., 'absences'=>[['date'=>'Y-m-d'], ...]]
*/
public static 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'] ?? now()->toDateString()), 0, 10);
[$start, $endEx] = static::dayRange($ymd);
$data = [
'student_id' => (int) ($student['id'] ?? 0),
'date' => static::normalizeDateTime($ymd),
'is_reported' => 0,
'reason' => (string) ($student['violation'] ?? ''),
'is_notified' => 0,
'notif_counter' => 0,
'semester' => $semester,
'school_year' => $schoolYear,
];
if ($data['student_id'] <= 0) {
$results['skipped']++;
continue;
}
try {
// Match by student + day range + term
$existing = static::query()
->where('student_id', $data['student_id'])
->where('semester', $semester)
->where('school_year', $schoolYear)
->where('date', '>=', $start)
->where('date', '<', $endEx)
->orderByDesc('date')
->first();
if ($existing) {
if (($existing->reason ?? '') !== $data['reason']) {
$existing->fill($data)->save();
$results['updated']++;
if (! empty($existing->is_notified)) {
static::resetNotification((int) $existing->id);
$results['notified']++;
}
} else {
$results['skipped']++;
}
} else {
$new = static::create($data);
$results['added']++;
// Only match standalone "4"
if (preg_match('/\b4\b/', $data['reason'])) {
static::markAsNotified((int) $new->id);
$results['notified']++;
}
}
} catch (\Throwable $e) {
// keep behavior: skip on error
$results['skipped']++;
}
}
}
return $results;
}
/* ----------------------- Notification ops ----------------------- */
public static function markAsNotified(int $id): bool
{
// atomic increment + set flag
$affected = static::query()
->whereKey($id)
->update([
'notif_counter' => DB::raw('COALESCE(notif_counter,0)+1'),
'is_notified' => 1,
'updated_at' => now(),
]);
return $affected > 0;
}
public static function resetNotification(int $id): bool
{
$affected = static::query()
->whereKey($id)
->update([
'is_notified' => 0,
'notif_counter' => 0,
'updated_at' => now(),
]);
return $affected >= 0;
}
/**
* Mark rule as notified by matching latest row for that day + term.
* $code is the rule code stored in "reason" (ABS_2, etc).
* If no row exists, insert one.
*/
public static function markRuleAsNotified(
int $studentId,
string $code,
string $ymd,
?string $semester = null,
?string $schoolYear = null
): bool {
// If you want to keep the old dependency on ConfigurationModel, you can inject it elsewhere.
// Here we require term to be passed or derived from date.
if ($semester === null || $schoolYear === null) {
[$sem, $yr] = static::deriveTermFromDate($ymd);
$semester = $semester ?? $sem;
$schoolYear = $schoolYear ?? $yr;
}
[$start, $endEx] = static::dayRange($ymd);
// 1) Match by code + day (preferred)
$row = static::query()
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->where('date', '>=', $start)
->where('date', '<', $endEx)
->where('reason', 'like', "%{$code}%")
->orderByDesc('date')
->first();
// 2) Fallback: match by day only
if (! $row) {
$row = static::query()
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->where('date', '>=', $start)
->where('date', '<', $endEx)
->orderByDesc('date')
->first();
}
if ($row) {
$counter = ((int) ($row->notif_counter ?? 0)) + 1;
$affected = static::query()
->whereKey((int) $row->id)
->update([
'is_notified' => 1,
'notif_counter' => $counter,
'is_reported' => 0,
'updated_at' => now(),
]);
return $affected > 0;
}
// Insert a row so future runs see it as notified
$created = static::create([
'student_id' => $studentId,
'date' => static::normalizeDateTime($ymd),
'is_reported' => 0,
'reason' => $code,
'is_notified' => 1,
'notif_counter' => 1,
'semester' => $semester,
'school_year' => $schoolYear,
'note' => null,
]);
return ! empty($created->id);
}
/**
* Mark the latest matching tracking row as notified and increment counter.
* Optionally bound by $lastDate (DATETIME string).
*/
public static function markNotified(
int $studentId,
string $schoolYear,
?string $semester,
string $code,
?string $lastDate = null
): int {
$q = static::query()
->where('student_id', $studentId)
->where('school_year', $schoolYear);
if (! empty($semester)) {
$q->where('semester', $semester);
}
if (! empty($lastDate)) {
$q->where('date', '<=', static::normalizeDateTime($lastDate));
}
$row = $q->where('reason', 'like', "%{$code}%")
->orderByDesc('date')
->first();
if (! $row) {
return 0;
}
$counter = ((int) ($row->notif_counter ?? 0)) + 1;
$affected = static::query()
->whereKey((int) $row->id)
->update([
'is_notified' => 1,
'notif_counter' => $counter,
'updated_at' => now(),
]);
return $affected > 0 ? 1 : 0;
}
}