Files
alrahma_sunday_school/app/Models/AttendanceTrackingModel.php
root 0ac3a8375e
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 31s
Tests / PHPUnit (push) Failing after 55s
Fix semester context, attendance rosters, and billing workflows
- load global semester helpers consistently and use date-based semester defaults
- fix grading and daily attendance duplicate student/section rows
- keep attendance violations scoped to the current semester by default
- update invoice, refund, discount, payment, and financial aid flows
- add configuration cleanup migrations for duplicate calendar/semester keys
- refresh parent registration/report-card and print request handling
- update related models, services, views, cron notes, and test coverage
2026-08-16 17:41:11 -04:00

531 lines
18 KiB
PHP
Raw Permalink 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 CodeIgniter\Model;
use App\Models\Concerns\SchoolYearAutoFillTrait;
use CodeIgniter\I18n\Time;
class AttendanceTrackingModel extends Model
{
use SchoolYearAutoFillTrait;
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 $validationRules = [
'school_year' => 'required|string|max_length[9]',
];
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=AugDec; Spring=JanJul). */
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 ?? getSemester();
$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;
}
}