1283 lines
52 KiB
PHP
Executable File
1283 lines
52 KiB
PHP
Executable File
<?php
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Controllers\View\EmailController;
|
|
use App\Models\AttendanceData as AttendanceData;
|
|
use App\Models\AttendanceTracking;
|
|
use App\Models\Calendar;
|
|
use App\Models\ClassSection;
|
|
use App\Models\Configuration;
|
|
use App\Models\ParentNotification;
|
|
use App\Models\StudentClass;
|
|
use App\Models\Student;
|
|
use App\Models\User;
|
|
use App\Models\CiDatabase;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\Event;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class AttendanceTrackingController extends BaseApiController
|
|
{
|
|
protected AttendanceTracking $attendanceTracking;
|
|
protected AttendanceData $attendanceData;
|
|
protected ParentNotification $notification;
|
|
protected Student $student;
|
|
protected StudentClass $studentClass;
|
|
protected Configuration $config;
|
|
protected ClassSection $classSection;
|
|
protected User $user;
|
|
protected Calendar $calendar;
|
|
protected CiDatabase $db;
|
|
|
|
protected string $semester;
|
|
protected string $schoolYear;
|
|
|
|
public function __construct()
|
|
{
|
|
parent::__construct();
|
|
|
|
$this->attendanceTracking = model(AttendanceTracking::class);
|
|
$this->attendanceData = model(AttendanceData::class);
|
|
$this->notification = model(ParentNotification::class);
|
|
$this->student = model(Student::class);
|
|
$this->studentClass = model(StudentClass::class);
|
|
$this->config = model(Configuration::class);
|
|
$this->classSection = model(ClassSection::class);
|
|
$this->user = model(User::class);
|
|
$this->calendar = model(Calendar::class);
|
|
$this->db = CiDatabase::instance();
|
|
|
|
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
|
|
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
|
|
}
|
|
|
|
public function pendingViolations(): JsonResponse
|
|
{
|
|
try {
|
|
$schoolYear = (string) ($this->request->getGet('school_year') ?? $this->schoolYear);
|
|
$semester = (string) ($this->request->getGet('semester') ?? $this->semester);
|
|
|
|
$classStudents = $this->studentClass
|
|
->where('school_year', $schoolYear)
|
|
->findAll();
|
|
|
|
if (empty($classStudents)) {
|
|
return $this->success([
|
|
'students' => [],
|
|
'school_year' => $schoolYear,
|
|
'semester' => $semester,
|
|
], 'No students for the selected school year.');
|
|
}
|
|
|
|
$studentIds = array_values(array_unique(array_map('intval', array_column($classStudents, 'student_id'))));
|
|
if (empty($studentIds)) {
|
|
return $this->success([
|
|
'students' => [],
|
|
'school_year' => $schoolYear,
|
|
'semester' => $semester,
|
|
], 'No student IDs found for the selected school year.');
|
|
}
|
|
|
|
$students = $this->student->whereIn('id', $studentIds)->findAll();
|
|
|
|
$today = new \DateTime('today');
|
|
$startDateStr = (clone $today)->modify('-5 weeks')->format('Y-m-d');
|
|
$endDateStr = $today->format('Y-m-d');
|
|
|
|
$start = new \DateTime($startDateStr . ' 00:00:00');
|
|
$endEx = (new \DateTime($endDateStr . ' 00:00:00'))->modify('+1 day');
|
|
|
|
$qb = $this->db->table('attendance_data');
|
|
$qb->select('id, student_id, class_id, class_section_id, date, status, reason, school_year, semester, is_reported, is_notified')
|
|
->where('school_year', $schoolYear);
|
|
|
|
if (!empty($semester)) {
|
|
$qb->where('semester', $semester);
|
|
}
|
|
|
|
$qb->whereIn('student_id', $studentIds)
|
|
->groupStart()
|
|
->where('is_reported', 'no')
|
|
->orWhere('is_reported', 0)
|
|
->orWhere('is_reported', '0')
|
|
->orWhere('is_reported', null)
|
|
->groupEnd()
|
|
->groupStart()
|
|
->where('is_notified', 'no')
|
|
->orWhere('is_notified', 0)
|
|
->orWhere('is_notified', '0')
|
|
->orWhere('is_notified', null)
|
|
->groupEnd()
|
|
->groupStart()
|
|
->where("UPPER(status) IN ('A','L','ABS','ABSENT','LATE')", null, false)
|
|
->orWhere("UPPER(status) LIKE 'ABS%'", null, false)
|
|
->orWhere("UPPER(status) LIKE 'LATE%'", null, false)
|
|
->groupEnd()
|
|
->where('date >=', $start->format('Y-m-d'))
|
|
->where('date <', $endEx->format('Y-m-d'))
|
|
->orderBy('date', 'DESC');
|
|
|
|
$termRows = $qb->get()->getResultArray();
|
|
|
|
if (empty($termRows)) {
|
|
return $this->success([
|
|
'students' => [],
|
|
'school_year' => $schoolYear,
|
|
'semester' => $semester,
|
|
], 'No pending attendance violations.');
|
|
}
|
|
|
|
$attendanceRows = [];
|
|
foreach ($termRows as $row) {
|
|
$dStr = $row['date'] ?? null;
|
|
if (!$dStr) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$d = new \DateTime($dStr);
|
|
} catch (\Throwable $e) {
|
|
continue;
|
|
}
|
|
|
|
if ($d < $start || $d >= $endEx) {
|
|
continue;
|
|
}
|
|
|
|
$row['status'] = strtolower((string) ($row['status'] ?? ''));
|
|
$row['date'] = substr($d->format('Y-m-d H:i:s'), 0, 10);
|
|
$attendanceRows[] = $row;
|
|
}
|
|
|
|
if (empty($attendanceRows)) {
|
|
return $this->success([
|
|
'students' => [],
|
|
'school_year' => $schoolYear,
|
|
'semester' => $semester,
|
|
], 'No pending attendance violations.');
|
|
}
|
|
|
|
$violations = $this->computeViolations($students, $attendanceRows);
|
|
|
|
return $this->success([
|
|
'students' => $violations,
|
|
'school_year' => $schoolYear,
|
|
'semester' => $semester,
|
|
], 'Pending attendance violations retrieved');
|
|
} catch (\Throwable $e) {
|
|
Log::error('pendingViolations error: ' . $e->getMessage());
|
|
return $this->respondError('Unable to fetch pending violations', Response::HTTP_INTERNAL_SERVER_ERROR);
|
|
}
|
|
}
|
|
|
|
public function notifiedViolations(): JsonResponse
|
|
{
|
|
try {
|
|
$schoolYear = (string) ($this->request->getGet('school_year') ?? $this->schoolYear);
|
|
$semester = (string) ($this->request->getGet('semester') ?? $this->semester);
|
|
|
|
$trackingData = $this->attendanceTracking
|
|
->where('school_year', $schoolYear)
|
|
->where('semester', $semester)
|
|
->where('is_notified', '>', 0)
|
|
->orderBy('date', 'DESC')
|
|
->findAll();
|
|
|
|
if (empty($trackingData)) {
|
|
return $this->success([
|
|
'students' => [],
|
|
'school_year' => $schoolYear,
|
|
'semester' => $semester,
|
|
], 'No notified violations.');
|
|
}
|
|
|
|
$studentIds = array_unique(array_column($trackingData, 'student_id'));
|
|
$students = $this->student->whereIn('id', $studentIds)->findAll();
|
|
$studentMap = [];
|
|
foreach ($students as $stu) {
|
|
$studentMap[$stu['id']] = $stu;
|
|
}
|
|
|
|
$classByStudent = $this->fetchClassInfoByStudent($studentIds, $schoolYear);
|
|
|
|
$mergedData = [];
|
|
foreach ($trackingData as $row) {
|
|
$sid = (int) ($row['student_id'] ?? 0);
|
|
$merged = $row;
|
|
if (isset($studentMap[$sid])) {
|
|
$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'] ?? '');
|
|
}
|
|
$parent = $this->getPrimaryParentForStudent($sid);
|
|
if ($parent) {
|
|
$merged['parent_name'] = $parent['parent_name'] ?? '';
|
|
$merged['parent_email'] = $parent['email'] ?? '';
|
|
$merged['parent_phone'] = $parent['phone'] ?? '';
|
|
}
|
|
$mergedData[] = $merged;
|
|
}
|
|
|
|
return $this->success([
|
|
'students' => $mergedData,
|
|
'school_year' => $schoolYear,
|
|
'semester' => $semester,
|
|
], 'Notified attendance violations retrieved');
|
|
} catch (\Throwable $e) {
|
|
Log::error('notifiedViolations error: ' . $e->getMessage());
|
|
return $this->respondError('Unable to fetch notified violations', Response::HTTP_INTERNAL_SERVER_ERROR);
|
|
}
|
|
}
|
|
|
|
public function recordNotification(): JsonResponse
|
|
{
|
|
$rules = [
|
|
'student_id' => 'required|integer',
|
|
'date' => 'required|valid_date[Y-m-d]',
|
|
'parent_email' => 'permit_empty|valid_email',
|
|
'parent_name' => 'permit_empty|string',
|
|
'subject_type' => 'permit_empty|string',
|
|
'semester' => 'permit_empty|string',
|
|
'school_year' => 'permit_empty|string',
|
|
];
|
|
|
|
$payload = $this->payloadData();
|
|
$errors = $this->validateRequest($payload, $rules);
|
|
if (!empty($errors)) {
|
|
return $this->respondValidationError($errors);
|
|
}
|
|
|
|
try {
|
|
$studentId = (int) $payload['student_id'];
|
|
$ymd = substr((string) $payload['date'], 0, 10);
|
|
$semester = $payload['semester'] ?? $this->semester;
|
|
$schoolYear= $payload['school_year'] ?? $this->schoolYear;
|
|
$subject = $payload['subject_type'] ?? 'Absent';
|
|
|
|
$student = $this->student->find($studentId) ?? [];
|
|
$parentEmail = $payload['parent_email'] ?? '';
|
|
$parentName = $payload['parent_name'] ?? '';
|
|
|
|
if ($parentEmail === '') {
|
|
$primary = $this->getPrimaryParentForStudent($studentId) ?? [];
|
|
$parentEmail = $primary['email'] ?? '';
|
|
$parentName = $parentName ?: ($primary['parent_name'] ?? '');
|
|
}
|
|
|
|
if ($parentEmail === '') {
|
|
return $this->respondError('No parent email found for this student.', Response::HTTP_BAD_REQUEST);
|
|
}
|
|
|
|
$tracking = $this->attendanceTracking;
|
|
[$start, $end] = $this->dayBounds($ymd);
|
|
$row = $tracking->where('student_id', $studentId)
|
|
->where('semester', $semester)
|
|
->where('school_year', $schoolYear)
|
|
->where('date', '>=', $start)
|
|
->where('date', '<', $end)
|
|
->orderBy('date', 'DESC')
|
|
->first();
|
|
|
|
$consequence = $this->determineConsequence($row['reason'] ?? '');
|
|
$class = $this->studentClass->getClassSectionsByStudentId($studentId, $schoolYear);
|
|
|
|
$payloadEvent = [
|
|
'student_id' => $studentId,
|
|
'student' => $student,
|
|
'parent' => ['email' => $parentEmail, 'name' => $parentName],
|
|
'semester' => $semester,
|
|
'school_year' => $schoolYear,
|
|
'class' => $class,
|
|
'now' => utc_now(),
|
|
'date' => $ymd,
|
|
'subject' => $subject,
|
|
];
|
|
|
|
$eventName = match ($consequence) {
|
|
'dismissal' => 'attendance.dismissal',
|
|
'final_warning' => 'attendance.final_warning',
|
|
default => 'attendance.follow_up',
|
|
};
|
|
|
|
Event::dispatch($eventName, [$payloadEvent]);
|
|
|
|
if ($row && !empty($row['id'])) {
|
|
$tracking->markAsNotified((int) $row['id']);
|
|
}
|
|
|
|
$this->attendanceData->markRuleAsNotifiedData($studentId, $ymd, $semester, $schoolYear);
|
|
|
|
return $this->success(['message' => 'Notification queued.']);
|
|
} catch (\Throwable $e) {
|
|
Log::error('recordNotification error: ' . $e->getMessage());
|
|
return $this->respondError('Failed to queue notification.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
|
}
|
|
}
|
|
|
|
public function sendAutoEmails(): JsonResponse
|
|
{
|
|
if (!$this->request->is('post')) {
|
|
return $this->respondError('Method not allowed', Response::HTTP_METHOD_NOT_ALLOWED);
|
|
}
|
|
|
|
try {
|
|
$classStudents = $this->studentClass
|
|
->where('school_year', $this->schoolYear)
|
|
->findAll();
|
|
|
|
if (empty($classStudents)) {
|
|
return $this->success([
|
|
'sent' => 0,
|
|
'skipped' => 0,
|
|
'errors' => 0,
|
|
'details' => [],
|
|
], 'No students in current school year.');
|
|
}
|
|
|
|
$studentIds = array_column($classStudents, 'student_id');
|
|
$students = $this->student->whereIn('id', $studentIds)->findAll();
|
|
|
|
$attendanceData = $this->attendanceData
|
|
->whereIn('student_id', $studentIds)
|
|
->where('school_year', $this->schoolYear)
|
|
->where('semester', $this->semester)
|
|
->groupStart()
|
|
->where('is_reported', 'no')
|
|
->orWhere('is_reported', 0)
|
|
->orWhere('is_reported', '0')
|
|
->orWhere('is_reported', null)
|
|
->groupEnd()
|
|
->where("LOWER(status) IN ('absent','late')", null, false)
|
|
->orderBy('date', 'DESC')
|
|
->findAll();
|
|
|
|
$violations = $this->computeViolations($students, $attendanceData);
|
|
$toSend = array_values(array_filter($violations, fn($v) => ($v['action'] ?? '') === 'auto_email'));
|
|
|
|
$summary = $this->processEmailQueue($toSend, 'default');
|
|
|
|
return $this->success($summary, 'Auto emails processed');
|
|
} catch (\Throwable $e) {
|
|
Log::error('sendAutoEmails error: ' . $e->getMessage());
|
|
return $this->respondError('Failed to send auto emails', Response::HTTP_INTERNAL_SERVER_ERROR);
|
|
}
|
|
}
|
|
|
|
public function composeEmail(): JsonResponse
|
|
{
|
|
try {
|
|
$studentId = (int) ($this->request->get('student_id') ?? 0);
|
|
$code = (string) ($this->request->get('code') ?? 'ABS_1');
|
|
$variant = (string) ($this->request->get('variant') ?? 'default');
|
|
$incident = (string) ($this->request->get('incident_date') ?? '');
|
|
|
|
if ($studentId <= 0) {
|
|
return $this->respondError('Missing student_id', Response::HTTP_BAD_REQUEST);
|
|
}
|
|
|
|
$student = $this->student->find($studentId) ?? [];
|
|
$parent = $this->getPrimaryParentForStudent($studentId);
|
|
$secondary = $this->getSecondaryParentForStudent($studentId);
|
|
|
|
$lastDate = preg_match('/^\d{4}-\d{2}-\d{2}$/', $incident) ? $incident : local_date(utc_now(), 'Y-m-d');
|
|
$absDates = str_starts_with($code, 'ABS_') ? $this->getRecentAbsenceDatesForStudent($studentId, $lastDate, 5) : [];
|
|
|
|
$violation = [
|
|
'id' => $studentId,
|
|
'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')),
|
|
'last_date' => $lastDate,
|
|
'violation' => $code,
|
|
'violation_code' => $code,
|
|
'absences' => $absDates,
|
|
];
|
|
|
|
$ctx = $this->buildTemplateContext($violation, $parent);
|
|
[$subject, $body] = $this->renderTemplate($code, $variant, $ctx)
|
|
?? $this->renderFallbackTemplate($code, $ctx, $violation['name'], $lastDate, $parent['parent_name'] ?? 'Parent/Guardian');
|
|
|
|
return $this->success([
|
|
'student_id' => $studentId,
|
|
'student_name' => $violation['name'],
|
|
'primary_parent' => $parent,
|
|
'secondary_parent'=> $secondary,
|
|
'code' => $code,
|
|
'variant' => $variant,
|
|
'incident_date' => $lastDate,
|
|
'subject' => $subject,
|
|
'body_html' => $body,
|
|
], 'Email template composed');
|
|
} catch (\Throwable $e) {
|
|
Log::error('composeEmail error: ' . $e->getMessage());
|
|
return $this->respondError('Failed to compose email', Response::HTTP_INTERNAL_SERVER_ERROR);
|
|
}
|
|
}
|
|
|
|
public function sendEmailManual(): JsonResponse
|
|
{
|
|
if (!$this->request->is('post')) {
|
|
return $this->respondError('Method not allowed', Response::HTTP_METHOD_NOT_ALLOWED);
|
|
}
|
|
|
|
$rules = [
|
|
'student_id' => 'required|integer',
|
|
'to' => 'required|valid_email',
|
|
'subject' => 'required|string',
|
|
'body_html' => 'required|string',
|
|
'code' => 'required|string',
|
|
'variant' => 'permit_empty|string',
|
|
'incident_date' => 'permit_empty|valid_date[Y-m-d]',
|
|
];
|
|
|
|
$payload = $this->payloadData();
|
|
$errors = $this->validateRequest($payload, $rules);
|
|
if (!empty($errors)) {
|
|
return $this->respondValidationError($errors);
|
|
}
|
|
|
|
try {
|
|
$studentId = (int) $payload['student_id'];
|
|
$to = trim((string) $payload['to']);
|
|
$subject = (string) $payload['subject'];
|
|
$plainBody = (string) $payload['body_html'];
|
|
$code = (string) $payload['code'];
|
|
$variant = (string) ($payload['variant'] ?? 'default');
|
|
$ymd = isset($payload['incident_date']) ? substr((string) $payload['incident_date'], 0, 10) : '';
|
|
|
|
$safeBody = $this->normalizeBodyHtml($plainBody);
|
|
$wrapped = view('emails/_wrap_layout', [
|
|
'title' => $subject,
|
|
'body_html' => $safeBody,
|
|
], ['saveData' => false]);
|
|
|
|
$mailer = new EmailController();
|
|
$anyOk = false;
|
|
|
|
$ok = $mailer->sendEmail($to, $subject, $wrapped);
|
|
$this->logNotification($studentId, $code, $ymd, 'email', $to, $subject, $ok ? 'sent' : 'failed', $ok ? 'OK' : 'sendEmail returned false');
|
|
if ($ok) {
|
|
$anyOk = true;
|
|
}
|
|
|
|
$secondary = $this->getSecondaryParentForStudent($studentId);
|
|
$to2 = trim((string) ($secondary['email'] ?? ''));
|
|
if ($to2 !== '' && strcasecmp($to2, $to) !== 0 && !$this->notificationAlreadySent($studentId, $code, $ymd, 'email', $to2)) {
|
|
$ok2 = $mailer->sendEmail($to2, $subject, $wrapped);
|
|
$this->logNotification($studentId, $code, $ymd, 'email', $to2, $subject, $ok2 ? 'sent' : 'failed', $ok2 ? 'OK' : 'sendEmail returned false');
|
|
if ($ok2) {
|
|
$anyOk = true;
|
|
}
|
|
}
|
|
|
|
if (!$anyOk) {
|
|
return $this->respondError('Failed to send email', Response::HTTP_INTERNAL_SERVER_ERROR);
|
|
}
|
|
|
|
$incidentDate = $ymd !== '' ? $ymd : local_date(utc_now(), 'Y-m-d');
|
|
$this->attendanceTracking->markRuleAsNotified($studentId, $code, $incidentDate, $this->semester, $this->schoolYear);
|
|
if ($ymd !== '') {
|
|
$this->attendanceData->markRuleAsNotifiedData($studentId, $ymd, $this->semester, $this->schoolYear);
|
|
}
|
|
|
|
return $this->success(['message' => 'Email sent']);
|
|
} catch (\Throwable $e) {
|
|
Log::error('sendEmailManual error: ' . $e->getMessage());
|
|
return $this->respondError('Failed to send email', Response::HTTP_INTERNAL_SERVER_ERROR);
|
|
}
|
|
}
|
|
|
|
public function parentsInfo(): JsonResponse
|
|
{
|
|
$studentId = (int) ($this->request->getGet('student_id') ?? 0);
|
|
if ($studentId <= 0) {
|
|
return $this->respondError('Missing student_id', Response::HTTP_BAD_REQUEST);
|
|
}
|
|
|
|
try {
|
|
$primary = $this->getPrimaryParentForStudent($studentId);
|
|
$secondary = $this->getSecondaryParentForStudent($studentId);
|
|
|
|
return $this->success([
|
|
'primary' => $primary,
|
|
'secondary' => $secondary,
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
Log::error('parentsInfo error: ' . $e->getMessage());
|
|
return $this->respondError('Unable to fetch parents info', Response::HTTP_INTERNAL_SERVER_ERROR);
|
|
}
|
|
}
|
|
|
|
public function saveNotificationNote(): JsonResponse
|
|
{
|
|
if (!$this->request->is('post')) {
|
|
return $this->respondError('Method not allowed', Response::HTTP_METHOD_NOT_ALLOWED);
|
|
}
|
|
|
|
$rules = [
|
|
'student_id' => 'required|integer',
|
|
'code' => 'required|string',
|
|
'note' => 'required|string',
|
|
'incident_date' => 'permit_empty|valid_date[Y-m-d]',
|
|
];
|
|
|
|
$payload = $this->payloadData();
|
|
$errors = $this->validateRequest($payload, $rules);
|
|
if (!empty($errors)) {
|
|
return $this->respondValidationError($errors);
|
|
}
|
|
|
|
try {
|
|
$studentId = (int) $payload['student_id'];
|
|
$code = (string) $payload['code'];
|
|
$note = trim((string) $payload['note']);
|
|
$ymd = isset($payload['incident_date']) ? substr((string) $payload['incident_date'], 0, 10) : '';
|
|
$day = $ymd !== '' ? $ymd : local_date(utc_now(), 'Y-m-d');
|
|
[$start, $end] = $this->dayBounds($day);
|
|
|
|
$row = $this->attendanceTracking
|
|
->where('student_id', $studentId)
|
|
->where('semester', $this->semester)
|
|
->where('school_year', $this->schoolYear)
|
|
->where('date', '>=', $start)
|
|
->where('date', '<', $end)
|
|
->where('reason', 'like', "%{$code}%")
|
|
->orderBy('date', 'DESC')
|
|
->first();
|
|
|
|
if ($row && !empty($row['id'])) {
|
|
$this->attendanceTracking->update((int) $row['id'], [
|
|
'note' => $note,
|
|
'updated_at' => utc_now(),
|
|
]);
|
|
} else {
|
|
$this->attendanceTracking->insert([
|
|
'student_id' => $studentId,
|
|
'date' => $start,
|
|
'is_reported' => 0,
|
|
'reason' => $code,
|
|
'is_notified' => 0,
|
|
'notif_counter' => 0,
|
|
'semester' => $this->semester,
|
|
'school_year' => $this->schoolYear,
|
|
'note' => $note,
|
|
'created_at' => utc_now(),
|
|
'updated_at' => utc_now(),
|
|
]);
|
|
}
|
|
|
|
return $this->success(['message' => 'Note saved']);
|
|
} catch (\Throwable $e) {
|
|
Log::error('saveNotificationNote error: ' . $e->getMessage());
|
|
return $this->respondError('Failed to save note', Response::HTTP_INTERNAL_SERVER_ERROR);
|
|
}
|
|
}
|
|
|
|
// ----------------- Helper methods copied/adapted from legacy controller -----------------
|
|
|
|
private function notificationAlreadySent(int $studentId, string $code, string $ymd, string $channel = 'email', ?string $to = null): bool
|
|
{
|
|
try {
|
|
return $this->notification->hasSent($studentId, $code, $ymd, $channel, $to);
|
|
} catch (\Throwable $e) {
|
|
Log::debug('notificationAlreadySent(): ' . $e->getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private 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->notification->where($where)->orderBy('id', 'DESC')->first();
|
|
|
|
if ($existing && !empty($existing['id'])) {
|
|
$this->notification->update((int) $existing['id'], [
|
|
'subject' => $subject,
|
|
'status' => $status,
|
|
'response' => $response,
|
|
'semester' => $this->semester,
|
|
'school_year' => $this->schoolYear,
|
|
'updated_at' => utc_now(),
|
|
]);
|
|
} else {
|
|
$this->notification->insert([
|
|
'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());
|
|
}
|
|
}
|
|
|
|
private function determineConsequence(?string $reason): ?string
|
|
{
|
|
if (!$reason) {
|
|
return null;
|
|
}
|
|
$reason = strtolower($reason);
|
|
return match (true) {
|
|
str_contains($reason, 'abs_4') || str_contains($reason, 'dismissal') => 'dismissal',
|
|
str_contains($reason, 'abs_3') || str_contains($reason, 'final_warning') => 'final_warning',
|
|
str_contains($reason, 'abs_2') || str_contains($reason, 'follow_up') => 'follow_up',
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
private function processEmailQueue(array $violations, string $variant): array
|
|
{
|
|
$sent = 0;
|
|
$skipped = 0;
|
|
$errors = 0;
|
|
$details = [];
|
|
$mailer = new EmailController();
|
|
|
|
foreach ($violations as $v) {
|
|
$studentId = (int) ($v['id'] ?? 0);
|
|
$code = (string) ($v['violation_code'] ?? $v['code'] ?? '');
|
|
$date = (string) ($v['last_date'] ?? '');
|
|
$to = trim((string) ($v['parent_email'] ?? ''));
|
|
|
|
if ($studentId <= 0 || !$code || !$date || !$to) {
|
|
$errors++;
|
|
$details[] = ['student_id' => $studentId, 'code' => $code, 'date' => $date, 'result' => 'missing data'];
|
|
continue;
|
|
}
|
|
|
|
if ($this->notificationAlreadySent($studentId, $code, $date, 'email', $to)) {
|
|
$skipped++;
|
|
$details[] = ['student_id' => $studentId, 'code' => $code, 'date' => $date, 'result' => 'duplicate'];
|
|
continue;
|
|
}
|
|
|
|
$parent = $this->getPrimaryParentForStudent($studentId);
|
|
$context = $this->buildTemplateContext($v, $parent);
|
|
$tpl = $this->renderTemplate($code, $variant, $context);
|
|
|
|
if (!$tpl) {
|
|
[$subject, $body] = $this->renderFallbackTemplate($code, $context, $v['name'] ?? '', $date, $parent['parent_name'] ?? 'Parent/Guardian');
|
|
} else {
|
|
[$subject, $body] = $tpl;
|
|
}
|
|
|
|
$wrapped = view('emails/_wrap_layout', [
|
|
'title' => $subject,
|
|
'body_html' => $body,
|
|
], ['saveData' => false]);
|
|
|
|
try {
|
|
if ($mailer->sendEmail($to, $subject, $wrapped)) {
|
|
$sent++;
|
|
$this->logNotification($studentId, $code, $date, 'email', $to, $subject, 'sent', 'OK');
|
|
$details[] = ['student_id' => $studentId, 'code' => $code, 'date' => $date, 'result' => 'sent'];
|
|
$this->attendanceTracking->markRuleAsNotified($studentId, $code, $date, $this->semester, $this->schoolYear);
|
|
$this->attendanceData->markRuleAsNotifiedData($studentId, $date, $this->semester, $this->schoolYear);
|
|
} else {
|
|
$errors++;
|
|
$this->logNotification($studentId, $code, $date, 'email', $to, $subject, 'failed', 'sendEmail returned false');
|
|
$details[] = ['student_id' => $studentId, 'code' => $code, 'date' => $date, 'result' => 'failed'];
|
|
}
|
|
} catch (\Throwable $e) {
|
|
$errors++;
|
|
$this->logNotification($studentId, $code, $date, 'email', $to, $subject, 'failed', $e->getMessage());
|
|
$details[] = ['student_id' => $studentId, 'code' => $code, 'date' => $date, 'result' => 'error: ' . $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
return compact('sent', 'skipped', 'errors', 'details');
|
|
}
|
|
|
|
private function renderFallbackTemplate(string $code, array $context, string $studentName, string $incident, string $parentName): array
|
|
{
|
|
$subject = match ($code) {
|
|
'ABS_4' => 'Attendance Notice: Excessive Absences',
|
|
'ABS_3' => 'Attendance Notice: Continued Absences',
|
|
'ABS_2' => 'Attendance Notice: Repeated Absences',
|
|
'ABS_1' => 'Attendance Notice: Unreported Absence',
|
|
'LATE_4'=> 'Attendance Notice: Excessive Lateness',
|
|
'LATE_3'=> 'Attendance Notice: Continued Lateness',
|
|
'LATE_2'=> 'Attendance Notice: Repeated Lateness',
|
|
default => 'Attendance Notice',
|
|
};
|
|
|
|
$datesList = $context['{{absence_dates_list_bold}}'] ?? '';
|
|
$body = '<p>Dear ' . esc($parentName) . ',</p>'
|
|
. '<p>We would like to inform you about a recent attendance concern for your student.</p>'
|
|
. '<ul>'
|
|
. '<li><strong>Student:</strong> <strong>' . esc($studentName) . '</strong></li>'
|
|
. '<li><strong>Issue:</strong> ' . esc($code) . '</li>'
|
|
. '<li><strong>As of:</strong> ' . esc($incident) . '</li>'
|
|
. '</ul>'
|
|
. ($datesList !== '' ? ('<p><strong>Absence dates:</strong></p>' . $datesList) : '')
|
|
. '<p>Please contact the school office if you have any questions.</p>';
|
|
|
|
return [$subject, $body];
|
|
}
|
|
|
|
private function normalizeBodyHtml(string $body): string
|
|
{
|
|
if ($body === '') {
|
|
return '<p></p>';
|
|
}
|
|
|
|
if (preg_match('/<[^>]+>/', $body)) {
|
|
return $body;
|
|
}
|
|
|
|
$decoded = html_entity_decode($body, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
|
if (preg_match('/<[^>]+>/', $decoded)) {
|
|
return $decoded;
|
|
}
|
|
|
|
return nl2br(esc($body), false);
|
|
}
|
|
|
|
private function fetchClassInfoByStudent(array $studentIds, string $schoolYear): array
|
|
{
|
|
if (empty($studentIds)) {
|
|
return [];
|
|
}
|
|
|
|
$rows = $this->db->table('student_class sc')
|
|
->select('sc.student_id, sc.class_section_id, cs.class_section_name, cs.class_id, c.class_name')
|
|
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
|
|
->join('classes c', 'c.id = cs.class_id', 'left')
|
|
->whereIn('sc.student_id', $studentIds)
|
|
->where('sc.school_year', $schoolYear)
|
|
->orderBy('sc.id', 'DESC')
|
|
->get()->getResultArray();
|
|
|
|
$map = [];
|
|
foreach ($rows as $row) {
|
|
$sid = (int) ($row['student_id'] ?? 0);
|
|
if ($sid > 0 && !isset($map[$sid])) {
|
|
$map[$sid] = $row;
|
|
}
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
|
|
private 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',
|
|
];
|
|
}
|
|
|
|
// The remainder of this class consists of helper methods copied (with minor adjustments)
|
|
// from the legacy AttendanceTrackingController to compute violations and build contexts.
|
|
// --- BEGIN legacy helpers ---
|
|
|
|
private function computeViolations(array $students, array $attendanceData): array
|
|
{
|
|
$byStudent = [];
|
|
foreach ($attendanceData as $r) {
|
|
$sid = (int) $r['student_id'];
|
|
$ymd = substr((string) ($r['date'] ?? ''), 0, 10);
|
|
$stat = strtolower((string) $r['status']);
|
|
|
|
if (!in_array($stat, ['absent', 'late'], true)) {
|
|
continue;
|
|
}
|
|
|
|
$byStudent[$sid][$stat][] = $ymd;
|
|
}
|
|
|
|
[$syStart, $syEnd] = $this->deriveSchoolYearBounds($this->schoolYear);
|
|
$activeWeekKeys = $this->getActiveWeeksFromAttendanceData($syStart, $syEnd);
|
|
if (empty($activeWeekKeys)) {
|
|
return [];
|
|
}
|
|
|
|
$weekIndex = array_flip($activeWeekKeys);
|
|
$currentWeekIdx = count($activeWeekKeys) - 1;
|
|
$windowStartIdx = max(0, $currentWeekIdx - 3);
|
|
$requiredWeeksCount = $currentWeekIdx - $windowStartIdx + 1;
|
|
|
|
$keepLastWeeks = function (array $dates) use ($weekIndex, $windowStartIdx, $currentWeekIdx) {
|
|
$out = [];
|
|
foreach ($dates as $d) {
|
|
$key = date('o-\WW', strtotime($d));
|
|
if (isset($weekIndex[$key])) {
|
|
$idx = $weekIndex[$key];
|
|
if ($idx >= $windowStartIdx && $idx <= $currentWeekIdx) {
|
|
$out[] = substr((string) $d, 0, 10);
|
|
}
|
|
}
|
|
}
|
|
$out = array_values(array_unique($out));
|
|
rsort($out);
|
|
return $out;
|
|
};
|
|
|
|
$classByStudent = $this->fetchClassInfoByStudent(array_column($students, 'id'), $this->schoolYear);
|
|
|
|
$out = [];
|
|
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);
|
|
|
|
$absenceViolation = $this->buildAbsenceViolation($absDates, $absWeekIdx, $currentWeekIdx);
|
|
$lateViolation = $this->buildLateViolation($lateDates, $lateWeekIdx, $absWeekIdx, $currentWeekIdx, $requiredWeeksCount);
|
|
$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);
|
|
|
|
if ($this->alreadyReported($sid, $chosen['code'], $chosen['last_date'])) {
|
|
continue;
|
|
}
|
|
|
|
if ($this->notificationAlreadySent($sid, $chosen['code'], $chosen['last_date'], 'email', $parent['email'] ?? null)) {
|
|
continue;
|
|
}
|
|
|
|
$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'] ?? ''),
|
|
'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'],
|
|
'absences' => $absDates,
|
|
'lates' => $lateDates,
|
|
];
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
private function buildAbsenceViolation(array $absDates, array $absWeekIdx, int $currentWeekIdx): ?array
|
|
{
|
|
if (empty($absDates)) {
|
|
return null;
|
|
}
|
|
|
|
$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_in4w4 = $this->hasNInWActiveWeeks($absWeekIdx, 4, 4);
|
|
|
|
if ($A4row || $A_in4w4) {
|
|
return [
|
|
'type' => 'absence',
|
|
'code' => 'ABS_4',
|
|
'severity' => 4,
|
|
'action' => 'expel_notify',
|
|
'color' => '#ff4d4f',
|
|
'title' => '4 Absences (in a row or within last 4 active weeks)',
|
|
'description' => 'Notify team to inform parent about expulsion and send email.',
|
|
'last_date' => $lastAbsDate,
|
|
];
|
|
}
|
|
|
|
if ($A3row || $A_in4w3) {
|
|
return [
|
|
'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,
|
|
];
|
|
}
|
|
|
|
if ($A2row || $A_in3w2) {
|
|
return [
|
|
'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,
|
|
];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function buildLateViolation(array $lateDates, array $lateWeekIdx, array $absWeekIdx, int $currentWeekIdx, int $requiredWeeksCount): ?array
|
|
{
|
|
if (empty($lateDates)) {
|
|
return null;
|
|
}
|
|
|
|
$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);
|
|
|
|
$lateWeeksSet = array_flip($lateWeekIdx);
|
|
$lateCoversAllWindowWeeks = false;
|
|
if ($requiredWeeksCount >= 4) {
|
|
$lateCoversAllWindowWeeks = true;
|
|
for ($i = $currentWeekIdx - 3; $i <= $currentWeekIdx; $i++) {
|
|
if ($i < 0) {
|
|
continue;
|
|
}
|
|
if (!isset($lateWeeksSet[$i])) {
|
|
$lateCoversAllWindowWeeks = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($L4row || $lateCoversAllWindowWeeks) {
|
|
return [
|
|
'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,
|
|
];
|
|
}
|
|
|
|
$twoLatesOneAbsWithin4 = $this->twoLatesOneAbsInWWeeks($lateWeekIdx, $absWeekIdx, 4);
|
|
|
|
if ($L3row || $L_in4w3 || $twoLatesOneAbsWithin4) {
|
|
return [
|
|
'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,
|
|
];
|
|
}
|
|
|
|
if ($L2row || $L_in3w2) {
|
|
return [
|
|
'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,
|
|
];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function deriveSchoolYearBounds(string $schoolYear): array
|
|
{
|
|
if (!preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $m)) {
|
|
$y = (int) local_now('Y');
|
|
$startY = (int) (local_now('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)];
|
|
}
|
|
|
|
private function getPrimaryParentForStudent(int $studentId): ?array
|
|
{
|
|
$row = $this->db->table('students s')
|
|
->select('u.id AS user_id, u.email, u.cellphone, CONCAT(u.firstname, " ", u.lastname) AS parent_name', false)
|
|
->join('users u', 'u.id = s.parent_id', 'left')
|
|
->where('s.id', $studentId)
|
|
->get()->getRowArray();
|
|
|
|
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,
|
|
];
|
|
}
|
|
|
|
private function getSecondaryParentForStudent(int $studentId): ?array
|
|
{
|
|
try {
|
|
$stu = $this->db->table('students')
|
|
->select('id, parent_id, school_year, semester')
|
|
->where('id', $studentId)
|
|
->get()->getRowArray();
|
|
|
|
if (!$stu) {
|
|
return null;
|
|
}
|
|
|
|
$primaryId = (int) ($stu['parent_id'] ?? 0);
|
|
$schoolYear = (string) ($stu['school_year'] ?? '');
|
|
|
|
$pb = $this->db->table('parents')->where('firstparent_id', $primaryId);
|
|
if ($schoolYear !== '') {
|
|
$pb->where('school_year', $schoolYear);
|
|
}
|
|
|
|
$pRow = $pb->orderBy('updated_at', 'DESC')->limit(1)->get()->getRowArray();
|
|
if (!$pRow) {
|
|
return null;
|
|
}
|
|
|
|
$secondId = (int) ($pRow['secondparent_id'] ?? 0);
|
|
if ($secondId > 0) {
|
|
$u2 = $this->db->table('users')
|
|
->select('id, firstname, lastname, email, cellphone')
|
|
->where('id', $secondId)
|
|
->get()->getRowArray();
|
|
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;
|
|
}
|
|
|
|
private function alreadyReported(int $studentId, string $code, string $lastDate): bool
|
|
{
|
|
try {
|
|
[$start, $end] = $this->dayBounds($lastDate);
|
|
|
|
$row = $this->attendanceTracking
|
|
->where('student_id', $studentId)
|
|
->where('semester', $this->semester)
|
|
->where('school_year', $this->schoolYear)
|
|
->where('date', '>=', $start)
|
|
->where('date', '<', $end)
|
|
->where('reason', 'like', "%{$code}%")
|
|
->orderBy('date', 'DESC')
|
|
->first();
|
|
|
|
if (!$row) {
|
|
$row = $this->attendanceTracking
|
|
->where('student_id', $studentId)
|
|
->where('semester', $this->semester)
|
|
->where('school_year', $this->schoolYear)
|
|
->where('date', '>=', $start)
|
|
->where('date', '<', $end)
|
|
->orderBy('date', 'DESC')
|
|
->first();
|
|
}
|
|
|
|
return $row ? (bool) ($row['is_notified'] ?? 0) : false;
|
|
} catch (\Throwable $e) {
|
|
Log::debug('alreadyReported() error: ' . $e->getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private function getRecentAbsenceDatesForStudent(int $studentId, string $lastDateYmd, int $weeks = 5): array
|
|
{
|
|
if ($studentId <= 0) {
|
|
return [];
|
|
}
|
|
$day = substr($lastDateYmd, 0, 10);
|
|
$start = date('Y-m-d', strtotime($day . " -{$weeks} weeks")) . ' 00:00:00';
|
|
$end = date('Y-m-d', strtotime($day . ' +1 day')) . ' 00:00:00';
|
|
|
|
$qb = $this->db->table('attendance_data');
|
|
$qb->select('date')
|
|
->where('student_id', $studentId)
|
|
->where('school_year', $this->schoolYear)
|
|
->where('semester', $this->semester)
|
|
->groupStart()
|
|
->where('is_reported', 'no')
|
|
->orWhere('is_reported', 0)
|
|
->orWhere('is_reported', '0')
|
|
->orWhere('is_reported', null)
|
|
->groupEnd()
|
|
->groupStart()
|
|
->where('is_notified', 'no')
|
|
->orWhere('is_notified', 0)
|
|
->orWhere('is_notified', '0')
|
|
->orWhere('is_notified', null)
|
|
->groupEnd()
|
|
->groupStart()
|
|
->where("UPPER(status) IN ('A','ABS','ABSENT')", null, false)
|
|
->orWhere("UPPER(status) LIKE 'ABS%'", null, false)
|
|
->groupEnd()
|
|
->where('date >=', $start)
|
|
->where('date <', $end)
|
|
->orderBy('date', 'DESC');
|
|
|
|
$rows = $qb->get()->getResultArray();
|
|
$ymds = [];
|
|
foreach ($rows as $r) {
|
|
$d = substr((string) ($r['date'] ?? ''), 0, 10);
|
|
if ($d !== '') {
|
|
$ymds[$d] = true;
|
|
}
|
|
}
|
|
$out = array_keys($ymds);
|
|
sort($out);
|
|
return $out;
|
|
}
|
|
|
|
private function renderTemplate(string $code, string $variant, array $context): ?array
|
|
{
|
|
if (!class_exists(\App\Models\AttendanceEmailTemplate::class)) {
|
|
return null;
|
|
}
|
|
|
|
$template = model(\App\Models\AttendanceEmailTemplate::class);
|
|
$row = $template->getTemplate($code, $variant);
|
|
if (!$row) {
|
|
$row = $template->getTemplate($code, 'default');
|
|
}
|
|
if (!$row) {
|
|
return null;
|
|
}
|
|
|
|
$subject = strtr($row['subject'], $context);
|
|
$body = strtr($row['body_html'], $context);
|
|
return [$subject, $body];
|
|
}
|
|
|
|
private function buildTemplateContext(array $v, ?array $parent = null): array
|
|
{
|
|
$studentName = (string) ($v['name'] ?? '');
|
|
$incident = (string) ($v['last_date'] ?? local_date(utc_now(), 'Y-m-d'));
|
|
$p = $parent ?: $this->getPrimaryParentForStudent((int) ($v['id'] ?? 0)) ?? [];
|
|
|
|
$absences = [];
|
|
if (!empty($v['absences']) && is_array($v['absences'])) {
|
|
$absences = array_values(array_unique(array_map(static fn($d) => substr((string) $d, 0, 10), $v['absences'])));
|
|
}
|
|
|
|
$absencesAsc = $absences;
|
|
sort($absencesAsc);
|
|
$absenceCount = count($absencesAsc);
|
|
$absenceDatesCsv = $absenceCount ? implode(', ', $absencesAsc) : '';
|
|
$absenceDatesLines = $absenceCount ? implode('<br>', $absencesAsc) : '';
|
|
$absenceDatesList = $absenceCount ? ('<ul><li>' . implode('</li><li>', $absencesAsc) . '</li></ul>') : '';
|
|
|
|
return [
|
|
'{{parent_name}}' => (string) ($p['parent_name'] ?? 'Parent/Guardian'),
|
|
'{{student_name}}' => $studentName,
|
|
'{{student_name_bold}}' => '<strong>' . esc($studentName) . '</strong>',
|
|
'{{incident_date}}' => $incident,
|
|
'{{school_phone}}' => '978-364-0219',
|
|
'{{voicemail_phone}}' => (string) ($p['phone'] ?? '—'),
|
|
'{{class_time}}' => '10:00 AM',
|
|
'{{absence_count}}' => (string) $absenceCount,
|
|
'{{absence_dates}}' => $absenceDatesCsv,
|
|
'{{absence_dates_lines}}' => $absenceDatesLines,
|
|
'{{absence_dates_list}}' => $absenceDatesList,
|
|
'{{absence_dates_bold}}' => $absenceCount ? implode(', ', array_map(static fn($d) => '<strong>' . esc($d) . '</strong>', $absencesAsc)) : '',
|
|
'{{absence_dates_lines_bold}}' => $absenceCount ? implode('<br>', array_map(static fn($d) => '<strong>' . esc($d) . '</strong>', $absencesAsc)) : '',
|
|
'{{absence_dates_list_bold}}' => $absenceCount ? ('<ul><li>' . implode('</li><li>', array_map(static fn($d) => '<strong>' . esc($d) . '</strong>', $absencesAsc)) . '</li></ul>') : '',
|
|
];
|
|
}
|
|
|
|
// --- END legacy helpers ---
|
|
}
|