reconstruction of the project
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AttendanceTracking;
|
||||
|
||||
use App\Models\AttendanceData;
|
||||
use App\Models\AttendanceTracking;
|
||||
use App\Models\Student;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Throwable;
|
||||
|
||||
class AttendanceCaseQueryService
|
||||
{
|
||||
public function __construct(
|
||||
protected Student $studentModel,
|
||||
protected AttendanceData $attendanceDataModel,
|
||||
protected AttendanceTracking $attendanceTrackingModel,
|
||||
protected ViolationRuleEngineService $violationRuleEngine,
|
||||
protected AttendanceParentLookupService $parentLookupService
|
||||
) {
|
||||
}
|
||||
|
||||
public function getStudentCase(
|
||||
int $studentId,
|
||||
?string $codeParam = null,
|
||||
?string $incidentDate = null,
|
||||
bool $includeNotified = false,
|
||||
bool $pendingOnly = false,
|
||||
?string $schoolYearFilter = null,
|
||||
?string $semesterFilter = null,
|
||||
?string $defaultSchoolYear = null,
|
||||
?string $defaultSemester = null
|
||||
): array {
|
||||
$student = $this->studentModel->query()->find($studentId);
|
||||
if (!$student) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Student not found',
|
||||
'status' => 404,
|
||||
];
|
||||
}
|
||||
|
||||
$student = $student->toArray();
|
||||
$student['name'] = trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? ''));
|
||||
|
||||
$codeParam = strtoupper((string) ($codeParam ?? ''));
|
||||
$incidentDate = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $incidentDate) ? (string) $incidentDate : '';
|
||||
|
||||
$statusFilter = ['absent', 'late'];
|
||||
if (str_starts_with($codeParam, 'ABS')) {
|
||||
$statusFilter = ['absent'];
|
||||
} elseif (str_starts_with($codeParam, 'LATE')) {
|
||||
$statusFilter = ['late'];
|
||||
} elseif (str_starts_with($codeParam, 'MIX')) {
|
||||
$statusFilter = ['absent', 'late'];
|
||||
}
|
||||
|
||||
$schoolYear = $schoolYearFilter
|
||||
?? ($incidentDate !== ''
|
||||
? $this->violationRuleEngine->schoolYearForDate($incidentDate, $defaultSchoolYear)
|
||||
: (string) $defaultSchoolYear);
|
||||
|
||||
$semester = $semesterFilter;
|
||||
if ($semester === null && $codeParam === '' && $incidentDate === '') {
|
||||
$semester = (string) $defaultSemester;
|
||||
}
|
||||
|
||||
[$syStart, $syEnd] = $this->violationRuleEngine->deriveSchoolYearBounds($schoolYear ?: date('Y'));
|
||||
$today = Carbon::today();
|
||||
$start = Carbon::parse($syStart)->startOfDay();
|
||||
$rangeEnd = Carbon::parse(min($syEnd, $today->format('Y-m-d')))->startOfDay();
|
||||
|
||||
$windowWeeks = $this->violationRuleEngine->windowWeeksForViolationCode($codeParam);
|
||||
$endYmd = $incidentDate !== '' ? $incidentDate : date('Y-m-d');
|
||||
$startYmd = $this->violationRuleEngine->activeWeekWindowStartYmd($endYmd, $windowWeeks, $schoolYear, $semester);
|
||||
|
||||
$studentIdValues = [(int) $studentId];
|
||||
$studentCodeRaw = trim((string) ($student['student_code'] ?? $student['school_id'] ?? ''));
|
||||
$studentCodeValues = [];
|
||||
|
||||
if ($studentCodeRaw !== '') {
|
||||
if (is_numeric($studentCodeRaw)) {
|
||||
$studentCodeNum = (int) $studentCodeRaw;
|
||||
if ($studentCodeNum > 0 && $studentCodeNum !== (int) $studentId) {
|
||||
$studentIdValues[] = $studentCodeNum;
|
||||
}
|
||||
} else {
|
||||
$studentCodeValues[] = $studentCodeRaw;
|
||||
}
|
||||
}
|
||||
|
||||
$studentIdValues = array_values(array_unique(array_filter($studentIdValues, fn ($v) => $v > 0)));
|
||||
$studentCodeValues = array_values(array_unique(array_filter($studentCodeValues, fn ($v) => $v !== '')));
|
||||
|
||||
$qb = $this->attendanceDataModel->query()
|
||||
->select([
|
||||
'id',
|
||||
'student_id',
|
||||
'class_id',
|
||||
'class_section_id',
|
||||
'date',
|
||||
'status',
|
||||
'reason',
|
||||
'school_year',
|
||||
'semester',
|
||||
'is_reported',
|
||||
'is_notified',
|
||||
])
|
||||
->where(function ($q) use ($studentIdValues, $studentCodeValues) {
|
||||
$q->whereIn('student_id', $studentIdValues);
|
||||
if (!empty($studentCodeValues)) {
|
||||
$q->orWhereIn('student_id', $studentCodeValues);
|
||||
}
|
||||
})
|
||||
->where('date', '>=', max($start->format('Y-m-d'), $startYmd))
|
||||
->where('date', '<', $this->violationRuleEngine->dayBounds($endYmd)[1]);
|
||||
|
||||
if ($statusFilter === ['absent']) {
|
||||
$qb->whereRaw("(LOWER(TRIM(status)) LIKE 'abs%' OR LOWER(TRIM(status)) = 'a')");
|
||||
} elseif ($statusFilter === ['late']) {
|
||||
$qb->whereRaw("(LOWER(TRIM(status)) LIKE 'late%' OR LOWER(TRIM(status)) = 'l')");
|
||||
} else {
|
||||
$qb->whereRaw("(LOWER(TRIM(status)) LIKE 'abs%' OR LOWER(TRIM(status)) LIKE 'late%' OR LOWER(TRIM(status)) IN ('a','l'))");
|
||||
}
|
||||
|
||||
if ($pendingOnly) {
|
||||
$this->violationRuleEngine->applyUnreportedAttendanceFilter($qb, $this->attendanceDataModel->getTable());
|
||||
|
||||
if (!$includeNotified) {
|
||||
$qb->whereRaw("(LOWER(TRIM(COALESCE(is_notified, ''))) NOT IN ('yes','1'))");
|
||||
}
|
||||
}
|
||||
|
||||
if ($schoolYearFilter !== null) {
|
||||
$qb->where('school_year', $schoolYearFilter);
|
||||
}
|
||||
|
||||
if ($semesterFilter !== null) {
|
||||
$qb->where('semester', $semesterFilter);
|
||||
}
|
||||
|
||||
$attendanceRows = $qb->orderByDesc('date')->get()->toArray();
|
||||
|
||||
$data = [
|
||||
'student' => $student,
|
||||
'attendance' => $attendanceRows,
|
||||
'violation_code' => $codeParam !== '' ? $codeParam : null,
|
||||
'violation_date' => $incidentDate,
|
||||
'violation_notified' => null,
|
||||
'show_violation_summary' => true,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
];
|
||||
|
||||
if ($data['violation_date'] === '' && !empty($data['attendance'][0]['date'])) {
|
||||
$data['violation_date'] = substr((string) $data['attendance'][0]['date'], 0, 10);
|
||||
}
|
||||
|
||||
if (!empty($data['attendance'][0]['is_notified'])) {
|
||||
$data['violation_notified'] = $data['attendance'][0]['is_notified'];
|
||||
}
|
||||
|
||||
if ($data['violation_date'] !== '') {
|
||||
foreach ($attendanceRows as $row) {
|
||||
$ymd = substr((string) ($row['date'] ?? ''), 0, 10);
|
||||
if ($ymd === $data['violation_date']) {
|
||||
$data['show_violation_summary'] = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $data,
|
||||
];
|
||||
}
|
||||
|
||||
public function getNotifiedViolations(
|
||||
?string $schoolYearParam = null,
|
||||
?string $semesterParam = null,
|
||||
?string $defaultSchoolYear = null,
|
||||
?string $defaultSemester = null
|
||||
): array {
|
||||
$schoolYear = filled($schoolYearParam) ? (string) $schoolYearParam : (string) $defaultSchoolYear;
|
||||
$semester = filled($semesterParam) ? (string) $semesterParam : (string) $defaultSemester;
|
||||
|
||||
$trackingData = $this->attendanceTrackingModel->query()
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', $semester)
|
||||
->where(function ($q) {
|
||||
$q->where('is_notified', 1)
|
||||
->orWhere('is_notified', '1')
|
||||
->orWhereRaw("LOWER(TRIM(COALESCE(is_notified, ''))) = 'yes'");
|
||||
})
|
||||
->orderByDesc('date')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
if (empty($trackingData)) {
|
||||
$latest = $this->attendanceTrackingModel->query()
|
||||
->select(['school_year', 'semester'])
|
||||
->where(function ($q) {
|
||||
$q->where('is_notified', 1)
|
||||
->orWhere('is_notified', '1')
|
||||
->orWhereRaw("LOWER(TRIM(COALESCE(is_notified, ''))) = 'yes'");
|
||||
})
|
||||
->orderByDesc('date')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if ($latest) {
|
||||
$schoolYear = (string) ($latest->school_year ?? $schoolYear);
|
||||
$semester = (string) ($latest->semester ?? $semester);
|
||||
|
||||
$trackingData = $this->attendanceTrackingModel->query()
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', $semester)
|
||||
->where(function ($q) {
|
||||
$q->where('is_notified', 1)
|
||||
->orWhere('is_notified', '1')
|
||||
->orWhereRaw("LOWER(TRIM(COALESCE(is_notified, ''))) = 'yes'");
|
||||
})
|
||||
->orderByDesc('date')
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($trackingData)) {
|
||||
return [
|
||||
'students' => [],
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'show_actions' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$studentIds = array_unique(array_map(fn ($r) => (int) ($r['student_id'] ?? 0), $trackingData));
|
||||
|
||||
$students = $this->studentModel->query()
|
||||
->whereIn('id', $studentIds)
|
||||
->where('is_active', 1)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$activeIdSet = array_flip(array_map(
|
||||
static fn ($row) => (int) ($row['id'] ?? 0),
|
||||
$students
|
||||
));
|
||||
|
||||
$trackingData = array_values(array_filter(
|
||||
$trackingData,
|
||||
static fn ($row) => isset($activeIdSet[(int) ($row['student_id'] ?? 0)])
|
||||
));
|
||||
|
||||
$studentIds = array_keys($activeIdSet);
|
||||
|
||||
if (empty($trackingData)) {
|
||||
return [
|
||||
'students' => [],
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'show_actions' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$studentMap = [];
|
||||
foreach ($students as $s) {
|
||||
$studentMap[$s['id']] = $s;
|
||||
}
|
||||
|
||||
$classByStudent = [];
|
||||
try {
|
||||
$rows = DB::table('student_class as sc')
|
||||
->select([
|
||||
'sc.student_id',
|
||||
'sc.class_section_id',
|
||||
'cs.class_section_name',
|
||||
'cs.class_id',
|
||||
'c.class_name',
|
||||
])
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
|
||||
->leftJoin('classes as c', 'c.id', '=', 'cs.class_id')
|
||||
->whereIn('sc.student_id', $studentIds)
|
||||
->where('sc.school_year', $schoolYear)
|
||||
->orderByDesc('sc.id')
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$sid = (int) ($row->student_id ?? 0);
|
||||
if ($sid > 0 && !isset($classByStudent[$sid])) {
|
||||
$classByStudent[$sid] = [
|
||||
'class_section_id' => $row->class_section_id ?? null,
|
||||
'class_section_name' => (string) ($row->class_section_name ?? ''),
|
||||
'class_id' => $row->class_id ?? null,
|
||||
'class_name' => (string) ($row->class_name ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
$mergedData = [];
|
||||
foreach ($trackingData as $row) {
|
||||
$sid = (int) ($row['student_id'] ?? 0);
|
||||
if (!isset($studentMap[$sid])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$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'] ?? '');
|
||||
} elseif (!empty($merged['registration_grade'])) {
|
||||
$merged['grade'] = (string) $merged['registration_grade'];
|
||||
}
|
||||
|
||||
try {
|
||||
$p = $this->parentLookupService->getPrimaryParentForStudent($sid);
|
||||
if ($p) {
|
||||
$merged['parent_name'] = $p['parent_name'] ?? '';
|
||||
$merged['parent_email'] = $p['email'] ?? '';
|
||||
$merged['parent_phone'] = $p['phone'] ?? '';
|
||||
}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
$mergedData[] = $merged;
|
||||
}
|
||||
|
||||
return [
|
||||
'students' => $mergedData,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AttendanceTracking;
|
||||
|
||||
use App\Models\AttendanceData;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Student;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AttendanceCommunicationSupportService
|
||||
{
|
||||
protected string $semester;
|
||||
protected string $schoolYear;
|
||||
|
||||
public function __construct(
|
||||
protected Student $studentModel,
|
||||
protected AttendanceData $attendanceDataModel,
|
||||
protected Configuration $configModel,
|
||||
protected AttendanceParentLookupService $parentLookupService,
|
||||
protected AttendanceEmailComposerService $emailComposerService
|
||||
) {
|
||||
$this->semester = (string) ($this->configModel->getConfig('semester') ?? '');
|
||||
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||
}
|
||||
|
||||
public function compose(
|
||||
int $studentId,
|
||||
string $code = 'ABS_1',
|
||||
string $variant = 'default',
|
||||
?string $incidentDate = null
|
||||
): array {
|
||||
if ($studentId <= 0) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Missing student_id.',
|
||||
'status' => 422,
|
||||
];
|
||||
}
|
||||
|
||||
$student = $this->studentModel->query()->find($studentId)?->toArray() ?? [];
|
||||
$parent = $this->parentLookupService->getPrimaryParentForStudent($studentId);
|
||||
$secondary = $this->parentLookupService->getSecondaryParentForStudent($studentId, $this->schoolYear);
|
||||
|
||||
$result = $this->emailComposerService->composePreview(
|
||||
$studentId,
|
||||
$student,
|
||||
$parent,
|
||||
$secondary,
|
||||
$code,
|
||||
$variant,
|
||||
$incidentDate
|
||||
);
|
||||
|
||||
if (($result['success'] ?? false) && isset($result['data'])) {
|
||||
$result['data']['semester'] = $this->semester;
|
||||
$result['data']['school_year'] = $this->schoolYear;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function parentsInfo(int $studentId): array
|
||||
{
|
||||
return $this->parentLookupService->parentsInfo($studentId, $this->schoolYear);
|
||||
}
|
||||
|
||||
public function getViolationDatesForStudent(int $studentId, string $code, ?string $incidentDate = null): array
|
||||
{
|
||||
$code = strtoupper(trim($code));
|
||||
$statuses = ['absent', 'late'];
|
||||
|
||||
if (str_starts_with($code, 'ABS')) {
|
||||
$statuses = ['absent'];
|
||||
} elseif (str_starts_with($code, 'LATE')) {
|
||||
$statuses = ['late'];
|
||||
} elseif (str_starts_with($code, 'MIX')) {
|
||||
$statuses = ['absent', 'late'];
|
||||
}
|
||||
|
||||
if (method_exists($this->attendanceDataModel, 'getRecentUnreportedDates')) {
|
||||
return $this->attendanceDataModel->getRecentUnreportedDates(
|
||||
$studentId,
|
||||
$statuses,
|
||||
$incidentDate,
|
||||
35,
|
||||
$this->semester,
|
||||
$this->schoolYear
|
||||
);
|
||||
}
|
||||
|
||||
return $this->attendanceDataModel->query()
|
||||
->where('student_id', $studentId)
|
||||
->whereIn(DB::raw('LOWER(TRIM(status))'), $statuses)
|
||||
->when($incidentDate, fn ($q) => $q->where('date', '<=', $incidentDate))
|
||||
->orderByDesc('date')
|
||||
->limit(35)
|
||||
->pluck('date')
|
||||
->map(fn ($d) => substr((string) $d, 0, 10))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AttendanceTracking;
|
||||
|
||||
use App\Models\AttendanceEmailTemplate;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AttendanceEmailComposerService
|
||||
{
|
||||
public function __construct(
|
||||
protected AttendanceEmailTemplate $attendanceEmailTemplateModel
|
||||
) {
|
||||
}
|
||||
|
||||
public function buildTemplateContext(array $violation, ?array $parent = null): array
|
||||
{
|
||||
$studentName = (string) ($violation['name'] ?? '');
|
||||
$incident = (string) ($violation['last_date'] ?? date('Y-m-d'));
|
||||
|
||||
return [
|
||||
'{{parent_name}}' => (string) ($parent['parent_name'] ?? 'Parent/Guardian'),
|
||||
'{{student_name}}' => $studentName,
|
||||
'{{incident_date}}' => $incident,
|
||||
'{{school_phone}}' => '978-364-0219',
|
||||
'{{voicemail_phone}}' => (string) ($parent['phone'] ?? '—'),
|
||||
'{{class_time}}' => '10:00 AM',
|
||||
];
|
||||
}
|
||||
|
||||
public function renderTemplate(string $code, string $variant, array $context): ?array
|
||||
{
|
||||
$row = null;
|
||||
|
||||
if (method_exists($this->attendanceEmailTemplateModel, 'getTemplate')) {
|
||||
$row = $this->attendanceEmailTemplateModel->getTemplate($code, $variant)
|
||||
?: $this->attendanceEmailTemplateModel->getTemplate($code, 'default');
|
||||
} else {
|
||||
$row = $this->attendanceEmailTemplateModel->query()
|
||||
->where('code', $code)
|
||||
->where('variant', $variant)
|
||||
->where('is_active', 1)
|
||||
->first();
|
||||
|
||||
if (!$row) {
|
||||
$row = $this->attendanceEmailTemplateModel->query()
|
||||
->where('code', $code)
|
||||
->where('variant', 'default')
|
||||
->where('is_active', 1)
|
||||
->first();
|
||||
}
|
||||
|
||||
$row = $row?->toArray();
|
||||
}
|
||||
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$subject = strtr($row['subject'], $context);
|
||||
$body = strtr($row['body_html'], $context);
|
||||
|
||||
return [$subject, $body];
|
||||
}
|
||||
|
||||
public function renderWithEmailLayout(string $subject, string $bodyHtml): string
|
||||
{
|
||||
return view('emails._wrap_layout', [
|
||||
'title' => $subject,
|
||||
'body_html' => $bodyHtml,
|
||||
])->render();
|
||||
}
|
||||
|
||||
public function pickVariant(string $code, ?string $globalVariantOverride = null): string
|
||||
{
|
||||
if (!empty($globalVariantOverride)) {
|
||||
return $globalVariantOverride;
|
||||
}
|
||||
|
||||
return match ($code) {
|
||||
'ABS_2', 'ABS_2_IN3W' => 'no_answer',
|
||||
default => 'default',
|
||||
};
|
||||
}
|
||||
|
||||
public function buildFallbackAutoEmail(string $code, array $violation, string $parentName = ''): array
|
||||
{
|
||||
$subject = match ($code) {
|
||||
'ABS_1' => 'Attendance Notice: Unreported Absence',
|
||||
'LATE_2' => 'Attendance Notice: Repeated Lateness',
|
||||
default => 'Attendance Notice',
|
||||
};
|
||||
|
||||
$studentName = (string) ($violation['name'] ?? '');
|
||||
$date = (string) ($violation['last_date'] ?? date('Y-m-d'));
|
||||
|
||||
$body = "<p>Dear " . ($parentName ?: 'Parent/Guardian') . "</p>"
|
||||
. "<p>We'd like to inform you about your student's recent attendance:</p>"
|
||||
. "<ul>"
|
||||
. "<li><strong>Student:</strong> {$studentName}</li>"
|
||||
. "<li><strong>Issue:</strong> " . (string) ($violation['violation'] ?? '') . "</li>"
|
||||
. "<li><strong>As of:</strong> {$date}</li>"
|
||||
. "</ul>"
|
||||
. "<p>If you have any questions, please contact the school office.</p>";
|
||||
|
||||
return [$subject, $body];
|
||||
}
|
||||
|
||||
public function normalizeBodyToHtml(string $bodyInput): string
|
||||
{
|
||||
if ($bodyInput !== '' && preg_match('/<[^>]+>/', $bodyInput)) {
|
||||
return $bodyInput;
|
||||
}
|
||||
|
||||
if ($bodyInput !== '' && stripos($bodyInput, '<') !== false) {
|
||||
$decoded = html_entity_decode($bodyInput, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
if (preg_match('/<[^>]+>/', $decoded)) {
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
return nl2br(e($bodyInput), false);
|
||||
}
|
||||
|
||||
public function plainTextToHtml(string $text): string
|
||||
{
|
||||
$text = preg_replace("/\r\n?/", "\n", trim($text));
|
||||
|
||||
if ($text === '') {
|
||||
return '<p></p>';
|
||||
}
|
||||
|
||||
$escaped = htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
|
||||
$escaped = preg_replace_callback(
|
||||
'#\bhttps?://[^\s<]+#i',
|
||||
static function ($m) {
|
||||
$url = $m[0];
|
||||
$urlAttr = htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
return '<a href="' . $urlAttr . '" target="_blank" rel="noopener noreferrer">' . $url . '</a>';
|
||||
},
|
||||
$escaped
|
||||
);
|
||||
|
||||
$paragraphs = preg_split("/\n{2,}/", $escaped);
|
||||
|
||||
$paragraphs = array_map(static function ($p) {
|
||||
$p = preg_replace("/\n/", "<br>", $p);
|
||||
return '<p>' . $p . '</p>';
|
||||
}, $paragraphs);
|
||||
|
||||
return implode("\n", $paragraphs);
|
||||
}
|
||||
|
||||
public function renderAutoEmailFor(array $violation, ?array $parent = null): ?array
|
||||
{
|
||||
$code = (string) ($violation['violation_code'] ?? $violation['code'] ?? '');
|
||||
$ctx = $this->buildTemplateContext($violation, $parent);
|
||||
$tpl = $this->renderTemplate($code, 'default', $ctx);
|
||||
|
||||
if (!$tpl) {
|
||||
return $this->buildFallbackAutoEmail($code, $violation, (string) ($parent['parent_name'] ?? ''));
|
||||
}
|
||||
|
||||
return $tpl;
|
||||
}
|
||||
|
||||
public function composePreview(
|
||||
int $studentId,
|
||||
array $student,
|
||||
?array $primaryParent,
|
||||
?array $secondaryParent,
|
||||
string $code,
|
||||
string $variant,
|
||||
?string $incidentDate = null
|
||||
): array {
|
||||
$lastDate = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $incidentDate)
|
||||
? $incidentDate
|
||||
: date('Y-m-d');
|
||||
|
||||
$violation = [
|
||||
'id' => $studentId,
|
||||
'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')),
|
||||
'last_date' => $lastDate,
|
||||
'violation' => $code,
|
||||
'violation_code' => $code,
|
||||
];
|
||||
|
||||
$ctx = $this->buildTemplateContext($violation, $primaryParent);
|
||||
$rendered = $this->renderTemplate($code, $variant, $ctx);
|
||||
|
||||
if (!$rendered) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => "Template not found for {$code} ({$variant}).",
|
||||
'status' => 404,
|
||||
];
|
||||
}
|
||||
|
||||
[$subject, $body] = $rendered;
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'student_id' => $studentId,
|
||||
'student_name' => $violation['name'],
|
||||
'parent_email' => (string) ($primaryParent['email'] ?? ''),
|
||||
'parent_name' => (string) ($primaryParent['parent_name'] ?? ''),
|
||||
'secondary_email' => (string) ($secondaryParent['email'] ?? ''),
|
||||
'secondary_name' => (string) ($secondaryParent['parent_name'] ?? ''),
|
||||
'code' => $code,
|
||||
'variant' => $variant,
|
||||
'subject' => $subject,
|
||||
'body_html' => $body,
|
||||
'incident_date' => $lastDate,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AttendanceTracking;
|
||||
|
||||
interface AttendanceMailerService
|
||||
{
|
||||
public function send(string $to, string $subject, string $html): bool;
|
||||
|
||||
public function queueAttendanceEvent(array $payload): void;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AttendanceTracking;
|
||||
|
||||
use App\Models\ParentNotification;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class AttendanceNotificationLogService
|
||||
{
|
||||
public function __construct(
|
||||
protected ParentNotification $notificationModel
|
||||
) {
|
||||
}
|
||||
|
||||
public function notificationAlreadySent(
|
||||
int $studentId,
|
||||
string $code,
|
||||
string $ymd,
|
||||
string $channel = 'email',
|
||||
?string $to = null
|
||||
): bool {
|
||||
try {
|
||||
if (method_exists($this->notificationModel, 'hasSent')) {
|
||||
return (bool) $this->notificationModel->hasSent($studentId, $code, $ymd, $channel, $to);
|
||||
}
|
||||
|
||||
$query = $this->notificationModel->query()
|
||||
->where('student_id', $studentId)
|
||||
->where('code', $code)
|
||||
->where('incident_date', $ymd)
|
||||
->where('channel', $channel);
|
||||
|
||||
if (!empty($to)) {
|
||||
$query->where('to_address', $to);
|
||||
}
|
||||
|
||||
return $query->exists();
|
||||
} catch (Throwable $e) {
|
||||
Log::debug('notificationAlreadySent(): ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function logNotification(
|
||||
int $studentId,
|
||||
string $code,
|
||||
string $ymd,
|
||||
string $channel,
|
||||
?string $to,
|
||||
string $subject,
|
||||
string $status,
|
||||
string $response = '',
|
||||
?string $semester = null,
|
||||
?string $schoolYear = null
|
||||
): void {
|
||||
try {
|
||||
$where = [
|
||||
'student_id' => $studentId,
|
||||
'code' => $code,
|
||||
'incident_date' => $ymd,
|
||||
'channel' => $channel,
|
||||
];
|
||||
|
||||
if (!empty($to)) {
|
||||
$where['to_address'] = $to;
|
||||
}
|
||||
|
||||
$existing = $this->notificationModel->query()
|
||||
->where($where)
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if ($existing && !empty($existing->id)) {
|
||||
$existing->update([
|
||||
'subject' => $subject,
|
||||
'status' => $status,
|
||||
'response' => $response,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
} else {
|
||||
$this->notificationModel->query()->create([
|
||||
'student_id' => $studentId,
|
||||
'code' => $code,
|
||||
'incident_date' => $ymd,
|
||||
'channel' => $channel,
|
||||
'to_address' => $to,
|
||||
'subject' => $subject,
|
||||
'status' => $status,
|
||||
'response' => $response,
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
]);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Log::debug('logNotification(): ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AttendanceTracking;
|
||||
|
||||
use App\Models\AttendanceData;
|
||||
use App\Models\AttendanceTracking;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Student;
|
||||
use App\Models\StudentClass;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class AttendanceNotificationWorkflowService
|
||||
{
|
||||
protected string $semester;
|
||||
protected string $schoolYear;
|
||||
|
||||
public function __construct(
|
||||
protected Student $studentModel,
|
||||
protected StudentClass $studentClassModel,
|
||||
protected AttendanceData $attendanceDataModel,
|
||||
protected AttendanceTracking $attendanceTrackingModel,
|
||||
protected Configuration $configModel,
|
||||
protected AttendanceMailerService $attendanceMailerService,
|
||||
protected ViolationRuleEngineService $violationRuleEngine,
|
||||
protected AttendanceParentLookupService $parentLookupService,
|
||||
protected AttendanceEmailComposerService $emailComposerService,
|
||||
protected AttendanceNotificationLogService $notificationLogService
|
||||
) {
|
||||
$this->semester = (string) ($this->configModel->getConfig('semester') ?? '');
|
||||
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||
}
|
||||
|
||||
public function record(array $data): array
|
||||
{
|
||||
$sid = (int) $data['student_id'];
|
||||
$ymd = substr((string) $data['date'], 0, 10);
|
||||
$semester = $data['semester'] ?? $this->semester;
|
||||
$schoolYear = $data['school_year'] ?? $this->schoolYear;
|
||||
$subject = $data['subject_type'] ?? 'Absent';
|
||||
|
||||
$student = $this->studentModel->query()->find($sid)?->toArray() ?? [];
|
||||
$parentEmail = $data['parent_email'] ?? '';
|
||||
$parentName = $data['parent_name'] ?? '';
|
||||
|
||||
if (!$parentEmail) {
|
||||
$parent = $this->parentLookupService->getPrimaryParentForStudent($sid) ?? [];
|
||||
$parentEmail = $parent['email'] ?? '';
|
||||
$parentName = $parent['parent_name'] ?? '';
|
||||
}
|
||||
|
||||
if (!$parentEmail) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'No parent email found for this student.',
|
||||
'status' => 422,
|
||||
];
|
||||
}
|
||||
|
||||
[$start, $end] = $this->violationRuleEngine->dayBounds($ymd);
|
||||
|
||||
$row = $this->attendanceTrackingModel->query()
|
||||
->where('student_id', $sid)
|
||||
->where('semester', $semester)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('date', '>=', $start)
|
||||
->where('date', '<', $end)
|
||||
->orderByDesc('date')
|
||||
->first();
|
||||
|
||||
$row = $row?->toArray();
|
||||
|
||||
$consequence = null;
|
||||
$reasonTxt = (string) ($row['reason'] ?? '');
|
||||
if (preg_match('/\bABS_4\b|dismissal/i', $reasonTxt)) {
|
||||
$consequence = 'dismissal';
|
||||
} elseif (preg_match('/\bABS_3\b|final_warning/i', $reasonTxt)) {
|
||||
$consequence = 'final_warning';
|
||||
} elseif (preg_match('/\bABS_2\b|follow_up/i', $reasonTxt)) {
|
||||
$consequence = 'follow_up';
|
||||
}
|
||||
|
||||
$class = method_exists($this->studentClassModel, 'getClassSectionsByStudentId')
|
||||
? $this->studentClassModel->getClassSectionsByStudentId($sid, $this->schoolYear)
|
||||
: [];
|
||||
|
||||
$payload = [
|
||||
'student_id' => $sid,
|
||||
'student' => $student,
|
||||
'parent' => ['email' => $parentEmail, 'name' => $parentName],
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
'class' => $class,
|
||||
'now' => now()->toDateTimeString(),
|
||||
'date' => $ymd,
|
||||
'subject' => $subject,
|
||||
'consequence' => $consequence ?: 'follow_up',
|
||||
];
|
||||
|
||||
try {
|
||||
$this->attendanceMailerService->queueAttendanceEvent($payload);
|
||||
|
||||
if ($row && !empty($row['id']) && method_exists($this->attendanceTrackingModel, 'markAsNotified')) {
|
||||
$this->attendanceTrackingModel->markAsNotified((int) $row['id']);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Notification queued.',
|
||||
'data' => $payload,
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Attendance record queue failed: ' . $e->getMessage());
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
'status' => 500,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
public function sendAutoEmails(array $violations, ?string $variantOverride = null): array
|
||||
{
|
||||
$toSend = array_values(array_filter($violations, fn ($v) => ($v['action'] ?? '') === 'auto_email'));
|
||||
|
||||
$sent = 0;
|
||||
$skipped = 0;
|
||||
$errors = 0;
|
||||
$details = [];
|
||||
|
||||
foreach ($toSend as $v) {
|
||||
$sid = (int) ($v['id'] ?? 0);
|
||||
$code = (string) ($v['violation_code'] ?? $v['code'] ?? '');
|
||||
$date = (string) ($v['last_date'] ?? '');
|
||||
$to = trim((string) ($v['parent_email'] ?? ''));
|
||||
$pname = (string) ($v['parent_name'] ?? '');
|
||||
|
||||
if ($sid <= 0 || !$code || !$date || !$to) {
|
||||
$errors++;
|
||||
$details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'missing data'];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->notificationLogService->notificationAlreadySent($sid, $code, $date, 'email', $to)) {
|
||||
$skipped++;
|
||||
$details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'duplicate'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$parent = $this->parentLookupService->getPrimaryParentForStudent($sid);
|
||||
$context = $this->emailComposerService->buildTemplateContext($v, $parent);
|
||||
$variant = $this->emailComposerService->pickVariant($code, $variantOverride);
|
||||
$tpl = $this->emailComposerService->renderTemplate($code, $variant, $context);
|
||||
|
||||
if (!$tpl) {
|
||||
[$subject, $body] = $this->emailComposerService->buildFallbackAutoEmail($code, $v, $pname);
|
||||
} else {
|
||||
[$subject, $body] = $tpl;
|
||||
}
|
||||
|
||||
$wrapped = $this->emailComposerService->renderWithEmailLayout($subject, $body);
|
||||
$anyOk = false;
|
||||
|
||||
try {
|
||||
$ok = $this->attendanceMailerService->send($to, $subject, $wrapped);
|
||||
|
||||
if ($ok) {
|
||||
$anyOk = true;
|
||||
$details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'sent'];
|
||||
$this->notificationLogService->logNotification(
|
||||
$sid,
|
||||
$code,
|
||||
$date,
|
||||
'email',
|
||||
$to,
|
||||
$subject,
|
||||
'sent',
|
||||
'OK',
|
||||
$this->semester,
|
||||
$this->schoolYear
|
||||
);
|
||||
} else {
|
||||
$errors++;
|
||||
$this->notificationLogService->logNotification(
|
||||
$sid,
|
||||
$code,
|
||||
$date,
|
||||
'email',
|
||||
$to,
|
||||
$subject,
|
||||
'failed',
|
||||
'send returned false',
|
||||
$this->semester,
|
||||
$this->schoolYear
|
||||
);
|
||||
$details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'failed'];
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$errors++;
|
||||
$this->notificationLogService->logNotification(
|
||||
$sid,
|
||||
$code,
|
||||
$date,
|
||||
'email',
|
||||
$to,
|
||||
$subject,
|
||||
'failed',
|
||||
$e->getMessage(),
|
||||
$this->semester,
|
||||
$this->schoolYear
|
||||
);
|
||||
$details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'error: ' . $e->getMessage()];
|
||||
}
|
||||
|
||||
try {
|
||||
$sec = $this->parentLookupService->getSecondaryParentForStudent($sid, $this->schoolYear);
|
||||
$to2 = trim((string) ($sec['email'] ?? ''));
|
||||
|
||||
if ($to2 !== '' && strcasecmp($to2, $to) !== 0) {
|
||||
if ($this->notificationLogService->notificationAlreadySent($sid, $code, $date, 'email', $to2)) {
|
||||
$skipped++;
|
||||
$details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'duplicate_cc'];
|
||||
} else {
|
||||
$ok2 = $this->attendanceMailerService->send($to2, $subject, $wrapped);
|
||||
if ($ok2) {
|
||||
$anyOk = true;
|
||||
$this->notificationLogService->logNotification(
|
||||
$sid,
|
||||
$code,
|
||||
$date,
|
||||
'email',
|
||||
$to2,
|
||||
$subject,
|
||||
'sent',
|
||||
'OK',
|
||||
$this->semester,
|
||||
$this->schoolYear
|
||||
);
|
||||
$details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'cc_sent'];
|
||||
} else {
|
||||
$errors++;
|
||||
$this->notificationLogService->logNotification(
|
||||
$sid,
|
||||
$code,
|
||||
$date,
|
||||
'email',
|
||||
$to2,
|
||||
$subject,
|
||||
'failed',
|
||||
'send returned false',
|
||||
$this->semester,
|
||||
$this->schoolYear
|
||||
);
|
||||
$details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'cc_failed'];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$errors++;
|
||||
$details[] = ['student_id' => $sid, 'code' => $code, 'date' => $date, 'result' => 'cc_error: ' . $e->getMessage()];
|
||||
}
|
||||
|
||||
if ($anyOk) {
|
||||
$sent++;
|
||||
|
||||
if (method_exists($this->attendanceTrackingModel, 'markRuleAsNotified')) {
|
||||
$this->attendanceTrackingModel->markRuleAsNotified(
|
||||
$sid,
|
||||
$code,
|
||||
$date,
|
||||
$this->semester,
|
||||
$this->schoolYear
|
||||
);
|
||||
}
|
||||
|
||||
$allDates = [];
|
||||
$absDates = array_map(fn ($d) => substr((string) $d, 0, 10), (array) ($v['absences'] ?? []));
|
||||
$lateDates = array_map(fn ($d) => substr((string) $d, 0, 10), (array) ($v['lates'] ?? []));
|
||||
|
||||
if (!empty($absDates)) {
|
||||
$allDates = array_merge($allDates, $absDates);
|
||||
}
|
||||
if (!empty($lateDates)) {
|
||||
$allDates = array_merge($allDates, $lateDates);
|
||||
}
|
||||
if (empty($allDates)) {
|
||||
$allDates[] = substr($date, 0, 10);
|
||||
}
|
||||
|
||||
if (method_exists($this->attendanceDataModel, 'markReportedAndNotified')) {
|
||||
$this->attendanceDataModel->markReportedAndNotified(
|
||||
$sid,
|
||||
$allDates,
|
||||
$this->semester,
|
||||
$this->schoolYear
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'sent' => $sent,
|
||||
'skipped' => $skipped,
|
||||
'errors' => $errors,
|
||||
'details' => $details,
|
||||
];
|
||||
}
|
||||
|
||||
public function sendEmailManual(array $data, array $violationDates = []): array
|
||||
{
|
||||
$sid = (int) $data['student_id'];
|
||||
$to = trim((string) $data['to']);
|
||||
$subject = (string) $data['subject'];
|
||||
$bodyInput = (string) $data['body_html'];
|
||||
$code = (string) $data['code'];
|
||||
$variant = (string) ($data['variant'] ?? 'default');
|
||||
|
||||
$ymdRaw = (string) ($data['incident_date'] ?? '');
|
||||
$ymd = $ymdRaw !== '' ? substr($ymdRaw, 0, 10) : '';
|
||||
|
||||
$safeHtmlBody = $this->emailComposerService->normalizeBodyToHtml($bodyInput);
|
||||
$wrapped = $this->emailComposerService->renderWithEmailLayout($subject, $safeHtmlBody);
|
||||
|
||||
$semester = $this->semester ?: (string) ($this->configModel->getConfig('semester') ?? '');
|
||||
$schoolYear = $this->schoolYear ?: (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||
|
||||
try {
|
||||
$anyOk = false;
|
||||
|
||||
$ok = $this->attendanceMailerService->send($to, $subject, $wrapped);
|
||||
|
||||
$this->notificationLogService->logNotification(
|
||||
$sid,
|
||||
$code,
|
||||
$ymd,
|
||||
'email',
|
||||
$to,
|
||||
$subject,
|
||||
$ok ? 'sent' : 'failed',
|
||||
$ok ? 'OK' : 'send returned false',
|
||||
$semester,
|
||||
$schoolYear
|
||||
);
|
||||
|
||||
if ($ok) {
|
||||
$anyOk = true;
|
||||
}
|
||||
|
||||
$sec = $this->parentLookupService->getSecondaryParentForStudent($sid, $schoolYear);
|
||||
$to2 = trim((string) ($sec['email'] ?? ''));
|
||||
|
||||
if ($to2 !== '' && strcasecmp($to2, $to) !== 0) {
|
||||
if (!$this->notificationLogService->notificationAlreadySent($sid, $code, $ymd, 'email', $to2)) {
|
||||
$ok2 = $this->attendanceMailerService->send($to2, $subject, $wrapped);
|
||||
|
||||
$this->notificationLogService->logNotification(
|
||||
$sid,
|
||||
$code,
|
||||
$ymd,
|
||||
'email',
|
||||
$to2,
|
||||
$subject,
|
||||
$ok2 ? 'sent' : 'failed',
|
||||
$ok2 ? 'OK' : 'send returned false',
|
||||
$semester,
|
||||
$schoolYear
|
||||
);
|
||||
|
||||
if ($ok2) {
|
||||
$anyOk = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$anyOk) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Failed to send email.',
|
||||
'status' => 500,
|
||||
];
|
||||
}
|
||||
|
||||
if (method_exists($this->attendanceTrackingModel, 'markRuleAsNotified')) {
|
||||
$this->attendanceTrackingModel->markRuleAsNotified(
|
||||
$sid,
|
||||
$code,
|
||||
$ymd !== '' ? $ymd : date('Y-m-d'),
|
||||
$semester,
|
||||
$schoolYear
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($violationDates) && method_exists($this->attendanceDataModel, 'markReportedAndNotified')) {
|
||||
$this->attendanceDataModel->markReportedAndNotified(
|
||||
$sid,
|
||||
$violationDates,
|
||||
$semester,
|
||||
$schoolYear
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Email sent successfully.',
|
||||
'data' => [
|
||||
'student_id' => $sid,
|
||||
'to' => $to,
|
||||
'secondary_to' => $to2 ?? null,
|
||||
'subject' => $subject,
|
||||
'variant' => $variant,
|
||||
'incident_date' => $ymd,
|
||||
],
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
$this->notificationLogService->logNotification(
|
||||
$sid,
|
||||
$code,
|
||||
$ymd,
|
||||
'email',
|
||||
$to,
|
||||
$subject,
|
||||
'failed',
|
||||
$e->getMessage(),
|
||||
$semester,
|
||||
$schoolYear
|
||||
);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Error: ' . $e->getMessage(),
|
||||
'status' => 500,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
public function saveNotificationNote(array $data): array
|
||||
{
|
||||
$sid = (int) $data['student_id'];
|
||||
$code = (string) $data['code'];
|
||||
$note = trim((string) $data['note']);
|
||||
$ymdRaw = (string) ($data['incident_date'] ?? '');
|
||||
$ymd = $ymdRaw !== '' ? substr($ymdRaw, 0, 10) : '';
|
||||
|
||||
try {
|
||||
$day = $ymd !== '' ? $ymd : date('Y-m-d');
|
||||
[$start, $end] = $this->violationRuleEngine->dayBounds($day);
|
||||
|
||||
$row = $this->attendanceTrackingModel->query()
|
||||
->where('student_id', $sid)
|
||||
->where('semester', $this->semester)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->where('date', '>=', $start)
|
||||
->where('date', '<', $end)
|
||||
->where('reason', 'like', '%' . $code . '%')
|
||||
->orderByDesc('date')
|
||||
->first();
|
||||
|
||||
if (!$row) {
|
||||
$row = $this->attendanceTrackingModel->query()
|
||||
->where('student_id', $sid)
|
||||
->where('semester', $this->semester)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->where('date', '>=', $start)
|
||||
->where('date', '<', $end)
|
||||
->orderByDesc('date')
|
||||
->first();
|
||||
}
|
||||
|
||||
if ($row && !empty($row->id)) {
|
||||
DB::table($this->attendanceTrackingModel->getTable())
|
||||
->where('id', (int) $row->id)
|
||||
->update([
|
||||
'notif_counter' => DB::raw('COALESCE(notif_counter,0)+1'),
|
||||
'note' => $note,
|
||||
'is_notified' => 1,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
} else {
|
||||
$this->attendanceTrackingModel->query()->create([
|
||||
'student_id' => $sid,
|
||||
'date' => $start,
|
||||
'is_reported' => 0,
|
||||
'reason' => $code,
|
||||
'is_notified' => 1,
|
||||
'notif_counter' => 1,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'note' => $note,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Note saved.',
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Failed to save note: ' . $e->getMessage(),
|
||||
'status' => 500,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AttendanceTracking;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class AttendanceParentLookupService
|
||||
{
|
||||
public function getPrimaryParentForStudent(int $studentId): ?array
|
||||
{
|
||||
$row = DB::table('students as s')
|
||||
->selectRaw('u.id AS user_id, u.email, u.cellphone, CONCAT(u.firstname, " ", u.lastname) AS parent_name')
|
||||
->leftJoin('users as u', 'u.id', '=', 's.parent_id')
|
||||
->where('s.id', $studentId)
|
||||
->first();
|
||||
|
||||
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,
|
||||
];
|
||||
}
|
||||
|
||||
public function getSecondaryParentForStudent(int $studentId, ?string $fallbackSchoolYear = null): ?array
|
||||
{
|
||||
try {
|
||||
$stu = DB::table('students')
|
||||
->select('id', 'parent_id', 'school_year', 'semester')
|
||||
->where('id', $studentId)
|
||||
->first();
|
||||
|
||||
if (!$stu) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$primaryId = (int) ($stu->parent_id ?? 0);
|
||||
$schoolYear = (string) ($stu->school_year ?? $fallbackSchoolYear ?? '');
|
||||
|
||||
$pb = DB::table('parents')->where('firstparent_id', $primaryId);
|
||||
if ($schoolYear !== '') {
|
||||
$pb->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
$pRow = $pb->orderByDesc('updated_at')->first();
|
||||
if (!$pRow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$secondId = (int) ($pRow->secondparent_id ?? 0);
|
||||
if ($secondId > 0) {
|
||||
$u2 = DB::table('users')
|
||||
->select('id', 'firstname', 'lastname', 'email', 'cellphone')
|
||||
->where('id', $secondId)
|
||||
->first();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public function parentsInfo(int $studentId, ?string $currentSchoolYear = null): array
|
||||
{
|
||||
if ($studentId <= 0) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Missing student_id',
|
||||
'status' => 422,
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$stu = DB::table('students')
|
||||
->select('id', 'parent_id', 'school_year', 'semester')
|
||||
->where('id', $studentId)
|
||||
->first();
|
||||
|
||||
if (!$stu) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Student not found',
|
||||
'status' => 404,
|
||||
];
|
||||
}
|
||||
|
||||
$primaryId = (int) $stu->parent_id;
|
||||
$schoolYear = $stu->school_year ?: $currentSchoolYear;
|
||||
|
||||
$u1 = DB::table('users')
|
||||
->select('id', 'firstname', 'lastname', 'email', 'cellphone')
|
||||
->where('id', $primaryId)
|
||||
->first();
|
||||
|
||||
$primary = $u1 ? [
|
||||
'id' => (int) $u1->id,
|
||||
'firstname' => (string) $u1->firstname,
|
||||
'lastname' => (string) $u1->lastname,
|
||||
'email' => (string) $u1->email,
|
||||
'cellphone' => (string) $u1->cellphone,
|
||||
] : null;
|
||||
|
||||
$secondaryRaw = $this->getSecondaryParentForStudent($studentId, $schoolYear);
|
||||
|
||||
$secondary = $secondaryRaw ? [
|
||||
'id' => $secondaryRaw['user_id'] ?? null,
|
||||
'firstname' => null,
|
||||
'lastname' => null,
|
||||
'email' => $secondaryRaw['email'] ?? '',
|
||||
'cellphone' => $secondaryRaw['phone'] ?? '',
|
||||
'name' => $secondaryRaw['parent_name'] ?? '',
|
||||
] : null;
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'primary' => $primary,
|
||||
'secondary' => $secondary,
|
||||
],
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
Log::error('parentsInfo() failed: ' . $e->getMessage());
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Server error',
|
||||
'status' => 500,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AttendanceTracking;
|
||||
|
||||
use App\Models\AttendanceData;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Throwable;
|
||||
|
||||
class AttendancePendingViolationService
|
||||
{
|
||||
public function __construct(
|
||||
protected AttendanceData $attendanceDataModel,
|
||||
protected ViolationRuleEngineService $violationRuleEngine,
|
||||
protected AttendanceViolationStudentResolverService $studentResolverService
|
||||
) {
|
||||
}
|
||||
|
||||
public function getPendingViolations(
|
||||
string $defaultSchoolYear,
|
||||
?string $schoolYearParam = null,
|
||||
?string $semesterParam = null
|
||||
): array {
|
||||
$schoolYear = filled($schoolYearParam) ? (string) $schoolYearParam : (string) $defaultSchoolYear;
|
||||
$semester = filled($semesterParam) ? (string) $semesterParam : null;
|
||||
|
||||
$debugInfo = [
|
||||
'school_year_param' => $schoolYear,
|
||||
'semester_param' => $semester,
|
||||
'class_students' => 0,
|
||||
'student_ids' => 0,
|
||||
'attendance_rows' => 0,
|
||||
'filtered_rows' => 0,
|
||||
'violations' => 0,
|
||||
'active_weeks' => 0,
|
||||
'abs_last5' => 0,
|
||||
'late_last5' => 0,
|
||||
];
|
||||
|
||||
$resolved = $this->studentResolverService->resolveForSchoolYear($schoolYear, $semester);
|
||||
|
||||
$schoolYear = (string) ($resolved['school_year'] ?? $schoolYear);
|
||||
$semester = $resolved['semester'] ?? $semester;
|
||||
$studentIds = $resolved['student_ids'] ?? [];
|
||||
$studentCodes = $resolved['student_codes'] ?? [];
|
||||
$students = $resolved['students'] ?? [];
|
||||
$studentCodeToId = $resolved['student_code_to_id'] ?? [];
|
||||
$existingIds = $resolved['existing_ids'] ?? [];
|
||||
$resolverDebug = $resolved['debug'] ?? [];
|
||||
|
||||
$debugInfo['class_students'] = $resolverDebug['class_students'] ?? 0;
|
||||
$debugInfo['student_ids'] = $resolverDebug['student_ids'] ?? 0;
|
||||
|
||||
if (empty($studentIds) && empty($studentCodes)) {
|
||||
return [
|
||||
'students' => [],
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'error' => 'No student identifiers found for the current school year.',
|
||||
'debug' => $debugInfo,
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($students)) {
|
||||
return [
|
||||
'students' => [],
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'error' => 'No students found matching attendance records.',
|
||||
'debug' => $debugInfo,
|
||||
];
|
||||
}
|
||||
|
||||
$today = Carbon::today();
|
||||
[$syStart, $syEnd] = $this->violationRuleEngine->deriveSchoolYearBounds($schoolYear);
|
||||
$start = Carbon::parse($syStart)->startOfDay();
|
||||
$rangeEnd = Carbon::parse(min($syEnd, $today->format('Y-m-d')))->startOfDay();
|
||||
$endEx = $rangeEnd->copy()->addDay();
|
||||
|
||||
$qb = $this->attendanceDataModel->query()
|
||||
->select([
|
||||
'id',
|
||||
'student_id',
|
||||
'class_id',
|
||||
'class_section_id',
|
||||
'date',
|
||||
'status',
|
||||
'reason',
|
||||
'school_year',
|
||||
'semester',
|
||||
'is_reported',
|
||||
'is_notified',
|
||||
])
|
||||
->when(!empty($schoolYear), fn ($q) => $q->where('school_year', $schoolYear))
|
||||
->when(!empty($semester), fn ($q) => $q->where('semester', $semester))
|
||||
->where(function ($q) use ($studentIds, $studentCodes) {
|
||||
if (!empty($studentIds)) {
|
||||
$q->whereIn('student_id', $studentIds);
|
||||
}
|
||||
if (!empty($studentCodes)) {
|
||||
if (!empty($studentIds)) {
|
||||
$q->orWhereIn('student_id', $studentCodes);
|
||||
} else {
|
||||
$q->whereIn('student_id', $studentCodes);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->violationRuleEngine->applyUnreportedAttendanceFilter($qb, $this->attendanceDataModel->getTable());
|
||||
|
||||
$qb->whereRaw("(LOWER(TRIM(COALESCE(is_notified, ''))) NOT IN ('yes','1'))")
|
||||
->whereRaw("LOWER(TRIM(status)) IN ('absent','late')")
|
||||
->where('date', '>=', $start->format('Y-m-d'))
|
||||
->where('date', '<', $endEx->format('Y-m-d'));
|
||||
|
||||
$termRows = $qb->orderByDesc('date')->get()->toArray();
|
||||
|
||||
if (empty($termRows)) {
|
||||
$latestAttendanceTerm = DB::table($this->attendanceDataModel->getTable())
|
||||
->select('school_year', 'semester', 'date')
|
||||
->when(!empty($studentIds), fn ($q) => $q->whereIn('student_id', $studentIds))
|
||||
->orderByDesc('date')
|
||||
->first();
|
||||
|
||||
if ($latestAttendanceTerm) {
|
||||
$schoolYear = !empty($latestAttendanceTerm->school_year)
|
||||
? (string) $latestAttendanceTerm->school_year
|
||||
: $schoolYear;
|
||||
|
||||
$semester = !empty($latestAttendanceTerm->semester)
|
||||
? (string) $latestAttendanceTerm->semester
|
||||
: $semester;
|
||||
|
||||
[$syStart, $syEnd] = $this->violationRuleEngine->deriveSchoolYearBounds($schoolYear ?: $defaultSchoolYear);
|
||||
$start = Carbon::parse($syStart)->startOfDay();
|
||||
$rangeEnd = Carbon::parse(min($syEnd, $today->format('Y-m-d')))->startOfDay();
|
||||
$endEx = $rangeEnd->copy()->addDay();
|
||||
|
||||
$qb = $this->attendanceDataModel->query()
|
||||
->select([
|
||||
'id',
|
||||
'student_id',
|
||||
'class_id',
|
||||
'class_section_id',
|
||||
'date',
|
||||
'status',
|
||||
'reason',
|
||||
'school_year',
|
||||
'semester',
|
||||
'is_reported',
|
||||
'is_notified',
|
||||
])
|
||||
->when(!empty($schoolYear), fn ($q) => $q->where('school_year', $schoolYear))
|
||||
->when(!empty($semester), fn ($q) => $q->where('semester', $semester))
|
||||
->where(function ($q) use ($studentIds, $studentCodes) {
|
||||
if (!empty($studentIds)) {
|
||||
$q->whereIn('student_id', $studentIds);
|
||||
}
|
||||
if (!empty($studentCodes)) {
|
||||
if (!empty($studentIds)) {
|
||||
$q->orWhereIn('student_id', $studentCodes);
|
||||
} else {
|
||||
$q->whereIn('student_id', $studentCodes);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->violationRuleEngine->applyUnreportedAttendanceFilter($qb, $this->attendanceDataModel->getTable());
|
||||
|
||||
$qb->whereRaw("(LOWER(TRIM(COALESCE(is_notified, ''))) NOT IN ('yes','1'))")
|
||||
->whereRaw("LOWER(TRIM(status)) IN ('absent','late')")
|
||||
->where('date', '>=', $start->format('Y-m-d'))
|
||||
->where('date', '<', $endEx->format('Y-m-d'));
|
||||
|
||||
$termRows = $qb->orderByDesc('date')->get()->toArray();
|
||||
}
|
||||
|
||||
if (empty($termRows)) {
|
||||
return [
|
||||
'students' => [],
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'debug' => $debugInfo,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$attendanceRows = [];
|
||||
foreach ($termRows as $row) {
|
||||
$dStr = $row['date'] ?? null;
|
||||
if (!$dStr) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$d = Carbon::parse($dStr);
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($d->lt($start) || !$d->lt($endEx)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sid = $this->studentResolverService->ensureAttendanceStudentExists(
|
||||
$students,
|
||||
$studentCodeToId,
|
||||
$existingIds,
|
||||
$row['student_id'] ?? null
|
||||
);
|
||||
|
||||
if ($sid === null || $sid <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row['student_id'] = $sid;
|
||||
$row['status'] = strtolower(trim((string) ($row['status'] ?? '')));
|
||||
$row['date'] = $d->format('Y-m-d');
|
||||
|
||||
$attendanceRows[] = $row;
|
||||
}
|
||||
|
||||
if (empty($attendanceRows)) {
|
||||
return [
|
||||
'students' => [],
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'debug' => $debugInfo,
|
||||
];
|
||||
}
|
||||
|
||||
$debugInfo['attendance_rows'] = count($termRows);
|
||||
$debugInfo['filtered_rows'] = count($attendanceRows);
|
||||
|
||||
$violations = $this->violationRuleEngine->computeViolations(
|
||||
$students,
|
||||
$attendanceRows,
|
||||
$schoolYear,
|
||||
null,
|
||||
$attendanceRows
|
||||
);
|
||||
|
||||
$ruleDebug = $this->violationRuleEngine->getDebugCompute();
|
||||
$debugInfo['violations'] = count($violations);
|
||||
$debugInfo['active_weeks'] = $ruleDebug['active_weeks'] ?? 0;
|
||||
$debugInfo['by_student'] = $ruleDebug['by_student'] ?? 0;
|
||||
$debugInfo['abs_last5'] = $ruleDebug['abs_last5'] ?? 0;
|
||||
$debugInfo['late_last5'] = $ruleDebug['late_last5'] ?? 0;
|
||||
|
||||
return [
|
||||
'students' => $violations,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'debug' => $debugInfo,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AttendanceTracking;
|
||||
|
||||
use App\Models\AttendanceData;
|
||||
use App\Models\AttendanceTracking;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Student;
|
||||
use App\Models\StudentClass;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class AttendanceTrackingService
|
||||
{
|
||||
protected string $semester;
|
||||
protected string $schoolYear;
|
||||
|
||||
public function __construct(
|
||||
protected Student $studentModel,
|
||||
protected StudentClass $studentClassModel,
|
||||
protected AttendanceData $attendanceDataModel,
|
||||
protected AttendanceTracking $attendanceTrackingModel,
|
||||
protected Configuration $configModel,
|
||||
protected AttendanceMailerService $attendanceMailerService,
|
||||
protected ViolationRuleEngineService $violationRuleEngine,
|
||||
protected AttendanceParentLookupService $parentLookupService,
|
||||
protected AttendanceEmailComposerService $emailComposerService,
|
||||
protected AttendanceNotificationLogService $notificationLogService,
|
||||
protected AttendanceViolationStudentResolverService $studentResolverService,
|
||||
protected AttendanceCaseQueryService $attendanceCaseQueryService,
|
||||
protected AttendanceNotificationWorkflowService $attendanceNotificationWorkflowService,
|
||||
protected AttendanceCommunicationSupportService $attendanceCommunicationSupportService,
|
||||
protected AttendancePendingViolationService $attendancePendingViolationService
|
||||
) {
|
||||
$this->semester = (string) ($this->configModel->getConfig('semester') ?? '');
|
||||
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||
}
|
||||
|
||||
public function getPendingViolations(?string $schoolYearParam = null, ?string $semesterParam = null): array
|
||||
{
|
||||
return $this->attendancePendingViolationService->getPendingViolations(
|
||||
$this->schoolYear,
|
||||
$schoolYearParam,
|
||||
$semesterParam
|
||||
);
|
||||
}
|
||||
|
||||
public function getStudentCase(
|
||||
int $studentId,
|
||||
?string $codeParam = null,
|
||||
?string $incidentDate = null,
|
||||
bool $includeNotified = false,
|
||||
bool $pendingOnly = false,
|
||||
?string $schoolYearFilter = null,
|
||||
?string $semesterFilter = null
|
||||
): array {
|
||||
return $this->attendanceCaseQueryService->getStudentCase(
|
||||
$studentId,
|
||||
$codeParam,
|
||||
$incidentDate,
|
||||
$includeNotified,
|
||||
$pendingOnly,
|
||||
$schoolYearFilter,
|
||||
$semesterFilter,
|
||||
$this->schoolYear,
|
||||
$this->semester
|
||||
);
|
||||
}
|
||||
|
||||
public function getNotifiedViolations(?string $schoolYearParam = null, ?string $semesterParam = null): array
|
||||
{
|
||||
return $this->attendanceCaseQueryService->getNotifiedViolations(
|
||||
$schoolYearParam,
|
||||
$semesterParam,
|
||||
$this->schoolYear,
|
||||
$this->semester
|
||||
);
|
||||
}
|
||||
|
||||
public function compose(
|
||||
int $studentId,
|
||||
string $code = 'ABS_1',
|
||||
string $variant = 'default',
|
||||
?string $incidentDate = null
|
||||
): array {
|
||||
return $this->attendanceCommunicationSupportService->compose(
|
||||
$studentId,
|
||||
$code,
|
||||
$variant,
|
||||
$incidentDate
|
||||
);
|
||||
}
|
||||
|
||||
public function parentsInfo(int $studentId): array
|
||||
{
|
||||
return $this->attendanceCommunicationSupportService->parentsInfo($studentId);
|
||||
}
|
||||
|
||||
public function record(array $data): array
|
||||
{
|
||||
return $this->attendanceNotificationWorkflowService->record($data);
|
||||
}
|
||||
|
||||
public function sendAutoEmails(?string $variantOverride = null): array
|
||||
{
|
||||
$classStudentsQuery = $this->studentClassModel->query();
|
||||
if (method_exists($this->studentClassModel, 'scopeActive')) {
|
||||
$classStudentsQuery->active();
|
||||
}
|
||||
|
||||
$classStudents = $classStudentsQuery
|
||||
->where('school_year', $this->schoolYear)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
if (empty($classStudents)) {
|
||||
return [
|
||||
'success' => true,
|
||||
'sent' => 0,
|
||||
'skipped' => 0,
|
||||
'errors' => 0,
|
||||
'details' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$studentIds = array_column($classStudents, 'student_id');
|
||||
|
||||
$students = $this->studentModel->query()
|
||||
->whereIn('id', $studentIds)
|
||||
->where('is_active', 1)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$attendanceQuery = $this->attendanceDataModel->query()
|
||||
->whereIn('student_id', $studentIds)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->where('date', '>=', Carbon::today()->subWeeks(5)->format('Y-m-d'));
|
||||
|
||||
$this->violationRuleEngine->applyUnreportedAttendanceFilter(
|
||||
$attendanceQuery,
|
||||
$this->attendanceDataModel->getTable()
|
||||
);
|
||||
|
||||
$attendanceData = $attendanceQuery
|
||||
->whereRaw("LOWER(TRIM(status)) IN ('absent','late')")
|
||||
->orderByDesc('date')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$violations = $this->violationRuleEngine->computeViolations(
|
||||
$students,
|
||||
$attendanceData,
|
||||
$this->schoolYear,
|
||||
null,
|
||||
$attendanceData
|
||||
);
|
||||
|
||||
return $this->attendanceNotificationWorkflowService->sendAutoEmails($violations, $variantOverride);
|
||||
}
|
||||
|
||||
public function sendEmailManual(array $data): array
|
||||
{
|
||||
$sid = (int) $data['student_id'];
|
||||
$code = (string) $data['code'];
|
||||
$ymdRaw = (string) ($data['incident_date'] ?? '');
|
||||
$ymd = $ymdRaw !== '' ? substr($ymdRaw, 0, 10) : '';
|
||||
|
||||
$violationDates = $this->attendanceCommunicationSupportService->getViolationDatesForStudent(
|
||||
$sid,
|
||||
$code,
|
||||
$ymd
|
||||
);
|
||||
|
||||
return $this->attendanceNotificationWorkflowService->sendEmailManual($data, $violationDates);
|
||||
}
|
||||
|
||||
public function saveNotificationNote(array $data): array
|
||||
{
|
||||
return $this->attendanceNotificationWorkflowService->saveNotificationNote($data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AttendanceTracking;
|
||||
|
||||
use App\Models\AttendanceData;
|
||||
use App\Models\Student;
|
||||
use App\Models\StudentClass;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AttendanceViolationStudentResolverService
|
||||
{
|
||||
public function __construct(
|
||||
protected Student $studentModel,
|
||||
protected StudentClass $studentClassModel,
|
||||
protected AttendanceData $attendanceDataModel
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolveForSchoolYear(string $schoolYear, ?string $semester = null): array
|
||||
{
|
||||
$debug = [
|
||||
'class_students' => 0,
|
||||
'student_ids' => 0,
|
||||
];
|
||||
|
||||
$classStudentsQuery = $this->studentClassModel->query();
|
||||
if (method_exists($this->studentClassModel, 'scopeActive')) {
|
||||
$classStudentsQuery->active();
|
||||
}
|
||||
|
||||
$classStudents = $classStudentsQuery
|
||||
->where('school_year', $schoolYear)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$debug['class_students'] = count($classStudents);
|
||||
|
||||
if (empty($classStudents)) {
|
||||
$latestTerm = DB::table('student_class')
|
||||
->select('school_year', 'semester')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if ($latestTerm && !empty($latestTerm->school_year)) {
|
||||
$schoolYear = (string) $latestTerm->school_year;
|
||||
if (!empty($latestTerm->semester)) {
|
||||
$semester = (string) $latestTerm->semester;
|
||||
}
|
||||
|
||||
$classStudentsQuery = $this->studentClassModel->query();
|
||||
if (method_exists($this->studentClassModel, 'scopeActive')) {
|
||||
$classStudentsQuery->active();
|
||||
}
|
||||
|
||||
$classStudents = $classStudentsQuery
|
||||
->where('school_year', $schoolYear)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$debug['class_students'] = count($classStudents);
|
||||
}
|
||||
}
|
||||
|
||||
[$studentIds, $studentCodes] = $this->extractStudentIdentifiers($classStudents);
|
||||
|
||||
if (empty($studentIds) && empty($studentCodes)) {
|
||||
[$studentIds, $studentCodes] = $this->deriveIdentifiersFromAttendanceData($schoolYear);
|
||||
}
|
||||
|
||||
[$studentIds, $studentCodes] = $this->filterInactiveIdentifiers($studentIds, $studentCodes);
|
||||
|
||||
$debug['student_ids'] = count($studentIds) + count($studentCodes);
|
||||
|
||||
if (empty($studentIds) && empty($studentCodes)) {
|
||||
return [
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'student_ids' => [],
|
||||
'student_codes' => [],
|
||||
'students' => [],
|
||||
'student_code_to_id'=> [],
|
||||
'existing_ids' => [],
|
||||
'debug' => $debug,
|
||||
];
|
||||
}
|
||||
|
||||
$students = $this->loadActiveStudents($studentIds, $studentCodes);
|
||||
$students = $this->addPlaceholderStudentsForMissingCodes($students, $studentCodes);
|
||||
$students = $this->ensureEntriesForAllIdentifiers($students, $studentIds, $studentCodes);
|
||||
|
||||
[$studentCodeToId, $existingIds] = $this->buildLookupMaps($students);
|
||||
|
||||
return [
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'student_ids' => $studentIds,
|
||||
'student_codes' => $studentCodes,
|
||||
'students' => $students,
|
||||
'student_code_to_id' => $studentCodeToId,
|
||||
'existing_ids' => $existingIds,
|
||||
'debug' => $debug,
|
||||
];
|
||||
}
|
||||
|
||||
public function ensureAttendanceStudentExists(
|
||||
array &$students,
|
||||
array &$studentCodeToId,
|
||||
array &$existingIds,
|
||||
mixed $rawStudentId
|
||||
): ?int {
|
||||
if (is_numeric($rawStudentId)) {
|
||||
$sid = (int) $rawStudentId;
|
||||
if ($sid <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isset($existingIds[$sid])) {
|
||||
$students[] = [
|
||||
'id' => $sid,
|
||||
'school_id' => (string) $sid,
|
||||
'firstname' => (string) $sid,
|
||||
'lastname' => '',
|
||||
'parent_id' => 0,
|
||||
];
|
||||
$existingIds[$sid] = true;
|
||||
}
|
||||
|
||||
return $sid;
|
||||
}
|
||||
|
||||
if (is_string($rawStudentId)) {
|
||||
$code = trim($rawStudentId);
|
||||
if ($code === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sid = $studentCodeToId[$code] ?? null;
|
||||
if ($sid === null) {
|
||||
$sid = abs(crc32($code)) ?: random_int(3000000, 3999999);
|
||||
$studentCodeToId[$code] = $sid;
|
||||
|
||||
if (!isset($existingIds[$sid])) {
|
||||
$students[] = [
|
||||
'id' => $sid,
|
||||
'school_id' => $code,
|
||||
'firstname' => $code,
|
||||
'lastname' => '',
|
||||
'parent_id' => 0,
|
||||
];
|
||||
$existingIds[$sid] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $sid;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function extractStudentIdentifiers(array $classStudents): array
|
||||
{
|
||||
$studentIds = [];
|
||||
$studentCodes = [];
|
||||
|
||||
foreach ($classStudents as $row) {
|
||||
$raw = $row['student_id'] ?? null;
|
||||
|
||||
if (is_numeric($raw)) {
|
||||
$studentIds[] = (int) $raw;
|
||||
} elseif (is_string($raw) && $raw !== '') {
|
||||
$studentCodes[] = trim($raw);
|
||||
}
|
||||
}
|
||||
|
||||
$studentIds = array_values(array_unique(array_filter($studentIds, fn ($v) => $v > 0)));
|
||||
$studentCodes = array_values(array_unique(array_filter($studentCodes, fn ($v) => $v !== '')));
|
||||
|
||||
return [$studentIds, $studentCodes];
|
||||
}
|
||||
|
||||
protected function deriveIdentifiersFromAttendanceData(string $schoolYear): array
|
||||
{
|
||||
$studentIds = [];
|
||||
$studentCodes = [];
|
||||
|
||||
$attendanceStudents = DB::table($this->attendanceDataModel->getTable())
|
||||
->selectRaw('DISTINCT student_id')
|
||||
->when(!empty($schoolYear), fn ($q) => $q->where('school_year', $schoolYear))
|
||||
->orderBy('student_id')
|
||||
->get();
|
||||
|
||||
foreach ($attendanceStudents as $r) {
|
||||
$raw = $r->student_id ?? null;
|
||||
|
||||
if (is_numeric($raw)) {
|
||||
$studentIds[] = (int) $raw;
|
||||
} elseif (is_string($raw) && $raw !== '') {
|
||||
$studentCodes[] = trim($raw);
|
||||
}
|
||||
}
|
||||
|
||||
$studentIds = array_values(array_unique(array_filter($studentIds, fn ($v) => $v > 0)));
|
||||
$studentCodes = array_values(array_unique(array_filter($studentCodes, fn ($v) => $v !== '')));
|
||||
|
||||
return [$studentIds, $studentCodes];
|
||||
}
|
||||
|
||||
protected function filterInactiveIdentifiers(array $studentIds, array $studentCodes): array
|
||||
{
|
||||
if (!empty($studentIds)) {
|
||||
$inactiveRows = $this->studentModel->query()
|
||||
->select('id')
|
||||
->whereIn('id', $studentIds)
|
||||
->where('is_active', 0)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$inactiveIdSet = array_flip(array_map(
|
||||
static fn ($r) => (int) ($r['id'] ?? 0),
|
||||
$inactiveRows
|
||||
));
|
||||
|
||||
$studentIds = array_values(array_filter(
|
||||
$studentIds,
|
||||
static fn ($id) => !isset($inactiveIdSet[$id])
|
||||
));
|
||||
}
|
||||
|
||||
if (!empty($studentCodes)) {
|
||||
$inactiveCodeRows = $this->studentModel->query()
|
||||
->select('school_id')
|
||||
->whereIn('school_id', $studentCodes)
|
||||
->where('is_active', 0)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$inactiveCodeSet = array_flip(array_map(
|
||||
static fn ($r) => (string) ($r['school_id'] ?? ''),
|
||||
$inactiveCodeRows
|
||||
));
|
||||
|
||||
$studentCodes = array_values(array_filter(
|
||||
$studentCodes,
|
||||
static fn ($code) => $code !== '' && !isset($inactiveCodeSet[$code])
|
||||
));
|
||||
}
|
||||
|
||||
return [$studentIds, $studentCodes];
|
||||
}
|
||||
|
||||
protected function loadActiveStudents(array $studentIds, array $studentCodes): array
|
||||
{
|
||||
$students = [];
|
||||
|
||||
if (!empty($studentIds)) {
|
||||
$students = $this->studentModel->query()
|
||||
->whereIn('id', $studentIds)
|
||||
->where('is_active', 1)
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
if (!empty($studentCodes)) {
|
||||
$byCode = $this->studentModel->query()
|
||||
->whereIn('school_id', $studentCodes)
|
||||
->where('is_active', 1)
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$seen = array_flip(array_map(fn ($s) => (int) ($s['id'] ?? 0), $students));
|
||||
|
||||
foreach ($byCode as $s) {
|
||||
$sid = (int) ($s['id'] ?? 0);
|
||||
if ($sid > 0 && !isset($seen[$sid])) {
|
||||
$students[] = $s;
|
||||
$seen[$sid] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $students;
|
||||
}
|
||||
|
||||
protected function addPlaceholderStudentsForMissingCodes(array $students, array $studentCodes): array
|
||||
{
|
||||
$knownCodes = [];
|
||||
|
||||
foreach ($students as $s) {
|
||||
$code = trim((string) ($s['school_id'] ?? ''));
|
||||
if ($code !== '') {
|
||||
$knownCodes[$code] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($studentCodes as $code) {
|
||||
if (!isset($knownCodes[$code])) {
|
||||
$virtualId = abs(crc32($code)) ?: random_int(1000000, 1999999);
|
||||
$students[] = [
|
||||
'id' => $virtualId,
|
||||
'school_id' => $code,
|
||||
'firstname' => $code,
|
||||
'lastname' => '',
|
||||
'parent_id' => 0,
|
||||
'class_name' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $students;
|
||||
}
|
||||
|
||||
protected function ensureEntriesForAllIdentifiers(array $students, array $studentIds, array $studentCodes): array
|
||||
{
|
||||
[$studentCodeToId, $existingIds] = $this->buildLookupMaps($students);
|
||||
|
||||
foreach (array_merge($studentIds, $studentCodes) as $sidOrCode) {
|
||||
if (is_numeric($sidOrCode)) {
|
||||
$sid = (int) $sidOrCode;
|
||||
|
||||
if ($sid > 0 && !isset($existingIds[$sid])) {
|
||||
$students[] = [
|
||||
'id' => $sid,
|
||||
'school_id' => (string) $sid,
|
||||
'firstname' => (string) $sid,
|
||||
'lastname' => '',
|
||||
'parent_id' => 0,
|
||||
'class_name' => '',
|
||||
];
|
||||
$existingIds[$sid] = true;
|
||||
}
|
||||
} else {
|
||||
$code = (string) $sidOrCode;
|
||||
|
||||
if ($code !== '' && !isset($studentCodeToId[$code])) {
|
||||
$virtualId = abs(crc32($code)) ?: random_int(2000000, 2999999);
|
||||
$students[] = [
|
||||
'id' => $virtualId,
|
||||
'school_id' => $code,
|
||||
'firstname' => $code,
|
||||
'lastname' => '',
|
||||
'parent_id' => 0,
|
||||
'class_name' => '',
|
||||
];
|
||||
|
||||
$studentCodeToId[$code] = $virtualId;
|
||||
$existingIds[$virtualId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $students;
|
||||
}
|
||||
|
||||
protected function buildLookupMaps(array $students): array
|
||||
{
|
||||
$studentCodeToId = [];
|
||||
foreach ($students as $stu) {
|
||||
$code = trim((string) ($stu['student_code'] ?? $stu['school_id'] ?? ''));
|
||||
if ($code !== '') {
|
||||
$studentCodeToId[$code] = (int) ($stu['id'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
$existingIds = array_flip(array_map(
|
||||
fn ($s) => (int) ($s['id'] ?? 0),
|
||||
$students
|
||||
));
|
||||
|
||||
return [$studentCodeToId, $existingIds];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AttendanceTracking;
|
||||
|
||||
use App\Models\AttendanceTracking;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
class ViolationRuleEngineService
|
||||
{
|
||||
protected array $debugCompute = [];
|
||||
protected ?array $attendanceReportedColumns = null;
|
||||
|
||||
public function __construct(
|
||||
protected AttendanceTracking $attendanceTrackingModel,
|
||||
protected AttendanceParentLookupService $parentLookupService
|
||||
) {
|
||||
}
|
||||
|
||||
public function getDebugCompute(): array
|
||||
{
|
||||
return $this->debugCompute;
|
||||
}
|
||||
|
||||
public function computeViolations(
|
||||
array $students,
|
||||
array $attendanceData,
|
||||
string $schoolYear,
|
||||
?string $semester = null,
|
||||
?array $weekRowsFallback = null
|
||||
): array {
|
||||
$this->debugCompute = ['active_weeks' => 0, 'by_student' => 0];
|
||||
|
||||
$studentCodeToId = [];
|
||||
foreach ($students as $stu) {
|
||||
$code = trim((string) ($stu['student_code'] ?? $stu['school_id'] ?? ''));
|
||||
if ($code !== '') {
|
||||
$studentCodeToId[$code] = (int) ($stu['id'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
$byStudent = [];
|
||||
foreach ($attendanceData as $r) {
|
||||
$rawSid = $r['student_id'] ?? null;
|
||||
$sid = is_numeric($rawSid)
|
||||
? (int) $rawSid
|
||||
: ($studentCodeToId[trim((string) $rawSid)] ?? 0);
|
||||
|
||||
if ($sid <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ymd = substr((string) ($r['date'] ?? ''), 0, 10);
|
||||
$stat = strtolower(trim((string) ($r['status'] ?? '')));
|
||||
|
||||
if (!in_array($stat, ['absent', 'late'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$byStudent[$sid][$stat][] = $ymd;
|
||||
}
|
||||
|
||||
$this->debugCompute['by_student'] = count($byStudent);
|
||||
|
||||
[$syStart, $syEnd] = $this->deriveSchoolYearBounds($schoolYear);
|
||||
|
||||
$weekRowsSource = null;
|
||||
if (!empty($weekRowsFallback)) {
|
||||
foreach ($weekRowsFallback as $r) {
|
||||
$st = strtolower(trim((string) ($r['status'] ?? '')));
|
||||
if ($st !== '' && !in_array($st, ['absent', 'late'], true)) {
|
||||
$weekRowsSource = $weekRowsFallback;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$activeWeekKeys = $this->getActiveWeeksFromAttendanceData($syStart, $syEnd, $schoolYear, $semester, $weekRowsSource);
|
||||
$this->debugCompute['active_weeks'] = count($activeWeekKeys);
|
||||
|
||||
if (empty($activeWeekKeys)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$weekIndex = array_flip($activeWeekKeys);
|
||||
$currentWeekIdx = count($activeWeekKeys) - 1;
|
||||
$windowStartIdx = max(0, $currentWeekIdx - 4);
|
||||
$requiredWeeksCount = $currentWeekIdx - $windowStartIdx + 1;
|
||||
|
||||
$keepLastWeeks = function (array $dates) use ($weekIndex, $windowStartIdx, $currentWeekIdx): array {
|
||||
$out = [];
|
||||
foreach ($dates as $d) {
|
||||
$key = date('o-\WW', strtotime($d));
|
||||
if (isset($weekIndex[$key])) {
|
||||
$idx = $weekIndex[$key];
|
||||
$ymd = substr((string) $d, 0, 10);
|
||||
if ($idx >= $windowStartIdx && $idx <= $currentWeekIdx) {
|
||||
$out[] = $ymd;
|
||||
}
|
||||
}
|
||||
}
|
||||
$out = array_values(array_unique($out));
|
||||
rsort($out);
|
||||
return $out;
|
||||
};
|
||||
|
||||
$classByStudent = [];
|
||||
try {
|
||||
$studentIdsForClass = array_values(array_unique(array_map(
|
||||
static fn($s) => (int) ($s['id'] ?? 0),
|
||||
$students
|
||||
)));
|
||||
|
||||
if (!empty($studentIdsForClass)) {
|
||||
$rows = DB::table('student_class as sc')
|
||||
->select([
|
||||
'sc.student_id',
|
||||
'sc.class_section_id',
|
||||
'cs.class_section_name',
|
||||
'cs.class_id',
|
||||
'c.class_name',
|
||||
])
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
|
||||
->leftJoin('classes as c', 'c.id', '=', 'cs.class_id')
|
||||
->whereIn('sc.student_id', $studentIdsForClass)
|
||||
->where('sc.school_year', $schoolYear)
|
||||
->orderByDesc('sc.id')
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$sid = (int) ($row->student_id ?? 0);
|
||||
if ($sid <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($classByStudent[$sid])) {
|
||||
$classByStudent[$sid] = [
|
||||
'class_section_id' => $row->class_section_id ?? null,
|
||||
'class_section_name' => (string) ($row->class_section_name ?? ''),
|
||||
'class_id' => $row->class_id ?? null,
|
||||
'class_name' => (string) ($row->class_name ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Log::debug('computeViolations(): class prefetch failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$out = [];
|
||||
$absCountLast5 = 0;
|
||||
$lateCountLast5 = 0;
|
||||
|
||||
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);
|
||||
$absCountLast5 += count($absDates);
|
||||
$lateCountLast5 += count($lateDates);
|
||||
|
||||
$absenceViolation = null;
|
||||
if (!empty($absDates)) {
|
||||
$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_in5w4 = $this->hasNInWActiveWeeks($absWeekIdx, 4, 5);
|
||||
|
||||
if ($A4row || $A_in5w4) {
|
||||
$absenceViolation = [
|
||||
'type' => 'absence',
|
||||
'code' => 'ABS_4',
|
||||
'severity' => 4,
|
||||
'action' => 'expel_notify',
|
||||
'color' => '#ff4d4f',
|
||||
'title' => '4 Absences (in a row or within last 5 active weeks)',
|
||||
'description' => 'Notify team to inform parent about expulsion and send email.',
|
||||
'last_date' => $lastAbsDate,
|
||||
];
|
||||
} elseif ($A3row || $A_in4w3) {
|
||||
$absenceViolation = [
|
||||
'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,
|
||||
];
|
||||
} elseif ($A2row || $A_in3w2) {
|
||||
$absenceViolation = [
|
||||
'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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$lateViolation = null;
|
||||
if (!empty($lateDates)) {
|
||||
$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);
|
||||
|
||||
$lateCoversAllWindowWeeks = false;
|
||||
if ($requiredWeeksCount >= 4) {
|
||||
$lateWeeksSet = array_flip($lateWeekIdx);
|
||||
$lateCoversAllWindowWeeks = true;
|
||||
for ($i = $windowStartIdx; $i <= $currentWeekIdx; $i++) {
|
||||
if (!isset($lateWeeksSet[$i])) {
|
||||
$lateCoversAllWindowWeeks = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($L4row || $lateCoversAllWindowWeeks) {
|
||||
$lateViolation = [
|
||||
'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,
|
||||
];
|
||||
} else {
|
||||
$twoLatesOneAbsWithin4 = $this->twoLatesOneAbsInWWeeks($lateWeekIdx, $absWeekIdx, 4);
|
||||
|
||||
if ($L3row || $L_in4w3 || $twoLatesOneAbsWithin4) {
|
||||
$lateViolation = [
|
||||
'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,
|
||||
];
|
||||
} elseif ($L2row || $L_in3w2) {
|
||||
$lateViolation = [
|
||||
'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,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$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->parentLookupService->getPrimaryParentForStudent($sid);
|
||||
|
||||
[$start, $end] = $this->dayBounds($chosen['last_date']);
|
||||
|
||||
$trackingQ = $this->attendanceTrackingModel->query()
|
||||
->where('student_id', $sid)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('date', '>=', $start)
|
||||
->where('date', '<', $end)
|
||||
->where('reason', 'like', '%' . $chosen['code'] . '%');
|
||||
|
||||
if ($semester !== null) {
|
||||
$trackingQ->where('semester', $semester);
|
||||
}
|
||||
|
||||
$tracking = $trackingQ->orderByDesc('date')->first();
|
||||
$tracking = $tracking?->toArray() ?? [];
|
||||
$isNotified = (bool) ($tracking['is_notified'] ?? 0);
|
||||
$notifCnt = (int) ($tracking['notif_counter'] ?? 0);
|
||||
|
||||
$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'] ?? ''),
|
||||
'className' => (string) ($cls['class_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'],
|
||||
'is_notified' => $isNotified,
|
||||
'notif_counter' => $notifCnt,
|
||||
'absences' => $absDates,
|
||||
'lates' => $lateDates,
|
||||
];
|
||||
}
|
||||
|
||||
$this->debugCompute['abs_last5'] = $absCountLast5;
|
||||
$this->debugCompute['late_last5'] = $lateCountLast5;
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function chooseHigherSeverity(?array $a, ?array $b): ?array
|
||||
{
|
||||
if ($a && $b) {
|
||||
if ($a['severity'] === $b['severity']) {
|
||||
$prio = ['expel_notify' => 3, 'team_notify' => 2, 'auto_email' => 1];
|
||||
return ($prio[$a['action']] ?? 0) >= ($prio[$b['action']] ?? 0) ? $a : $b;
|
||||
}
|
||||
return $a['severity'] > $b['severity'] ? $a : $b;
|
||||
}
|
||||
|
||||
return $a ?: $b;
|
||||
}
|
||||
|
||||
public function attendanceReportedColumns(string $table = 'attendance_data'): array
|
||||
{
|
||||
if ($this->attendanceReportedColumns !== null) {
|
||||
return $this->attendanceReportedColumns;
|
||||
}
|
||||
|
||||
$this->attendanceReportedColumns = [
|
||||
'is_reported' => Schema::hasColumn($table, 'is_reported'),
|
||||
'reported' => Schema::hasColumn($table, 'reported'),
|
||||
];
|
||||
|
||||
return $this->attendanceReportedColumns;
|
||||
}
|
||||
|
||||
public function applyUnreportedAttendanceFilter($qb, string $table = 'attendance_data'): void
|
||||
{
|
||||
$cols = $this->attendanceReportedColumns($table);
|
||||
|
||||
if (!empty($cols['is_reported'])) {
|
||||
$qb->whereRaw("(LOWER(TRIM(COALESCE(is_reported, ''))) NOT IN ('yes','1','true','y','on'))");
|
||||
}
|
||||
if (!empty($cols['reported'])) {
|
||||
$qb->whereRaw("(LOWER(TRIM(COALESCE(reported, ''))) NOT IN ('yes','1','true','y','on'))");
|
||||
}
|
||||
|
||||
$qb->whereRaw("(LOWER(COALESCE(reason, '')) NOT LIKE '%parent-reported%')")
|
||||
->whereRaw("(LOWER(COALESCE(reason, '')) NOT LIKE '%parent reported%')");
|
||||
|
||||
if (Schema::hasTable('parent_attendance_reports')) {
|
||||
$qb->whereRaw("NOT EXISTS (
|
||||
SELECT 1 FROM parent_attendance_reports par
|
||||
WHERE par.student_id = {$table}.student_id
|
||||
AND DATE(par.report_date) = DATE({$table}.date)
|
||||
AND par.type IN ('absent','late')
|
||||
)");
|
||||
}
|
||||
}
|
||||
|
||||
public function deriveSchoolYearBounds(string $schoolYear): array
|
||||
{
|
||||
if (!preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $m)) {
|
||||
$y = (int) date('Y');
|
||||
$startY = (int) (date('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)];
|
||||
}
|
||||
|
||||
public function schoolYearForDate(string $ymd, ?string $fallbackSchoolYear = null): string
|
||||
{
|
||||
$ts = strtotime($ymd);
|
||||
if ($ts === false) {
|
||||
return (string) $fallbackSchoolYear;
|
||||
}
|
||||
|
||||
$y = (int) date('Y', $ts);
|
||||
$m = (int) date('n', $ts);
|
||||
$startY = ($m >= 8) ? $y : $y - 1;
|
||||
|
||||
return sprintf('%d-%d', $startY, $startY + 1);
|
||||
}
|
||||
|
||||
public 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',
|
||||
];
|
||||
}
|
||||
|
||||
public function getActiveWeeksFromAttendanceData(
|
||||
string $startYmd,
|
||||
string $endYmd,
|
||||
?string $schoolYear = null,
|
||||
?string $semester = null,
|
||||
?array $weekRowsFallback = null
|
||||
): array {
|
||||
$weeks = [];
|
||||
|
||||
if (!empty($weekRowsFallback)) {
|
||||
foreach ($weekRowsFallback as $r) {
|
||||
$d = $r['date'] ?? null;
|
||||
if (!$d) {
|
||||
continue;
|
||||
}
|
||||
$ts = strtotime($d);
|
||||
if ($ts === false) {
|
||||
continue;
|
||||
}
|
||||
$ymd = date('Y-m-d', $ts);
|
||||
if ($ymd < $startYmd || $ymd > $endYmd) {
|
||||
continue;
|
||||
}
|
||||
$wk = date('o-\WW', $ts);
|
||||
$weeks[$wk] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($weeks)) {
|
||||
$rows = DB::table('attendance_data')
|
||||
->selectRaw('DATE(date) AS ymd')
|
||||
->where('school_year', $schoolYear)
|
||||
->whereRaw('DATE(date) >= ?', [$startYmd])
|
||||
->whereRaw('DATE(date) <= ?', [$endYmd])
|
||||
->where(function ($q) {
|
||||
$q->whereNull('reason')
|
||||
->orWhere(function ($q2) {
|
||||
$q2->where('reason', 'not like', '%break%')
|
||||
->where('reason', 'not like', '%cancel%');
|
||||
});
|
||||
})
|
||||
->when($semester !== null && $semester !== '', fn($q) => $q->where('semester', $semester))
|
||||
->groupBy(DB::raw('DATE(date)'))
|
||||
->orderBy('ymd')
|
||||
->get();
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$wk = date('o-\WW', strtotime($r->ymd));
|
||||
$weeks[$wk] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$keys = array_keys($weeks);
|
||||
sort($keys, SORT_NATURAL);
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
public function datesToWeekIndices(array $dates, array $weekIndex): array
|
||||
{
|
||||
$set = [];
|
||||
foreach ($dates as $ymd) {
|
||||
$wk = date('o-\WW', strtotime($ymd));
|
||||
if (isset($weekIndex[$wk])) {
|
||||
$set[$weekIndex[$wk]] = true;
|
||||
}
|
||||
}
|
||||
$idx = array_keys($set);
|
||||
sort($idx);
|
||||
return $idx;
|
||||
}
|
||||
|
||||
public function windowWeeksForViolationCode(string $code): int
|
||||
{
|
||||
$code = strtoupper(trim($code));
|
||||
if ($code === '') {
|
||||
return 5;
|
||||
}
|
||||
if (Str::startsWith($code, 'ABS_2') || Str::startsWith($code, 'LATE_2')) {
|
||||
return 3;
|
||||
}
|
||||
if (Str::startsWith($code, 'ABS_3') || Str::startsWith($code, 'LATE_3') || Str::startsWith($code, 'MIX')) {
|
||||
return 4;
|
||||
}
|
||||
if (Str::startsWith($code, 'LATE_4')) {
|
||||
return 4;
|
||||
}
|
||||
if (Str::startsWith($code, 'ABS_4')) {
|
||||
return 5;
|
||||
}
|
||||
return 5;
|
||||
}
|
||||
|
||||
public function isoWeekStartYmd(string $weekKey): string
|
||||
{
|
||||
if (preg_match('/^(\d{4})-W(\d{2})$/', $weekKey, $m)) {
|
||||
return date('Y-m-d', strtotime($m[1] . '-W' . $m[2] . '-1'));
|
||||
}
|
||||
return date('Y-m-d');
|
||||
}
|
||||
|
||||
public function activeWeekWindowStartYmd(
|
||||
string $endYmd,
|
||||
int $windowWeeks,
|
||||
string $schoolYear,
|
||||
?string $semester
|
||||
): string {
|
||||
$windowWeeks = max(1, $windowWeeks);
|
||||
[$syStart, $syEnd] = $this->deriveSchoolYearBounds($schoolYear);
|
||||
$endBound = $endYmd;
|
||||
|
||||
if ($syEnd !== '' && $endBound > $syEnd) {
|
||||
$endBound = $syEnd;
|
||||
}
|
||||
|
||||
$activeWeekKeys = $this->getActiveWeeksFromAttendanceData($syStart, $endBound, $schoolYear, $semester, null);
|
||||
|
||||
if (empty($activeWeekKeys)) {
|
||||
$days = ($windowWeeks * 7) - 1;
|
||||
return date('Y-m-d', strtotime($endBound . ' -' . $days . ' days'));
|
||||
}
|
||||
|
||||
$activeWeekKeys = array_values($activeWeekKeys);
|
||||
$weekIndex = array_flip($activeWeekKeys);
|
||||
$endWeekKey = date('o-\WW', strtotime($endBound));
|
||||
|
||||
if (!isset($weekIndex[$endWeekKey])) {
|
||||
$endWeekKey = null;
|
||||
for ($i = count($activeWeekKeys) - 1; $i >= 0; $i--) {
|
||||
$wkStart = $this->isoWeekStartYmd($activeWeekKeys[$i]);
|
||||
if ($wkStart <= $endBound) {
|
||||
$endWeekKey = $activeWeekKeys[$i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($endWeekKey === null) {
|
||||
$endWeekKey = end($activeWeekKeys);
|
||||
}
|
||||
}
|
||||
|
||||
$currentIdx = $weekIndex[$endWeekKey] ?? (count($activeWeekKeys) - 1);
|
||||
$windowStartIdx = max(0, $currentIdx - ($windowWeeks - 1));
|
||||
$startWeekKey = $activeWeekKeys[$windowStartIdx] ?? $activeWeekKeys[0];
|
||||
|
||||
return $this->isoWeekStartYmd($startWeekKey);
|
||||
}
|
||||
|
||||
public function hasNConsecutiveItems(array $datesDesc, int $n, int $daysApart): bool
|
||||
{
|
||||
$N = count($datesDesc);
|
||||
if ($N < $n) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$dates = $datesDesc;
|
||||
sort($dates);
|
||||
|
||||
$run = 1;
|
||||
for ($i = 1; $i < $N; $i++) {
|
||||
$prev = Carbon::parse($dates[$i - 1]);
|
||||
$curr = Carbon::parse($dates[$i]);
|
||||
$diff = $prev->diffInDays($curr);
|
||||
|
||||
if ($diff === $daysApart) {
|
||||
$run++;
|
||||
if ($run >= $n) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
$run = 1;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function hasNInWActiveWeeks(array $weekIdx, int $n, int $w): bool
|
||||
{
|
||||
$N = count($weekIdx);
|
||||
if ($N < $n) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$l = 0;
|
||||
for ($r = 0; $r < $N; $r++) {
|
||||
while ($weekIdx[$r] - $weekIdx[$l] > ($w - 1)) {
|
||||
$l++;
|
||||
}
|
||||
if (($r - $l + 1) >= $n) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function twoLatesOneAbsInWWeeks(array $lateIdx, array $absIdx, int $w): bool
|
||||
{
|
||||
if (count($lateIdx) < 2 || count($absIdx) < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$L = $lateIdx;
|
||||
$A = $absIdx;
|
||||
$iL1 = 0;
|
||||
$iL2 = 1;
|
||||
$iA = 0;
|
||||
|
||||
while ($iL2 < count($L)) {
|
||||
$windowStart = $L[$iL1];
|
||||
$windowEnd = $windowStart + ($w - 1);
|
||||
|
||||
if ($L[$iL2] > $windowEnd) {
|
||||
$iL1++;
|
||||
$iL2 = $iL1 + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
while ($iA < count($A) && $A[$iA] < $windowStart) {
|
||||
$iA++;
|
||||
}
|
||||
|
||||
if ($iA < count($A) && $A[$iA] <= $windowEnd) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$iL2++;
|
||||
if ($iL2 >= count($L)) {
|
||||
$iL1++;
|
||||
$iL2 = $iL1 + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user