Files
alrahma_sunday_school_api/app/Services/Grading/GradingBelowSixtyService.php
T
2026-06-09 01:25:14 -04:00

1469 lines
55 KiB
PHP

<?php
namespace App\Services\Grading;
use App\Models\BelowSixtyDecision;
use App\Models\CurrentFlag;
use App\Models\ParentMeetingSchedule;
use App\Models\ScoreComment;
use App\Models\StudentDecision;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use RuntimeException;
class GradingBelowSixtyService
{
public function listRows(string $schoolYear, string $semester): array
{
$semesterKey = strtolower(trim($semester));
$rows = DB::table('semester_scores as ss')
->select([
's.id as student_id',
's.school_id',
's.firstname',
's.lastname',
'cs.class_section_name',
'ss.homework_avg',
'ss.project_avg',
'ss.participation_score',
DB::raw('COALESCE(ss.test_avg, ss.quiz_avg) as test_avg'),
'ss.ptap_score',
'ss.attendance_score',
'ss.midterm_exam_score',
'ss.final_exam_score',
'ss.semester_score',
])
->join('students as s', 's.id', '=', 'ss.student_id')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id')
->where('s.is_active', 1)
->where('ss.school_year', $schoolYear)
->whereRaw('LOWER(TRIM(ss.semester)) = ?', [$semesterKey])
->whereNotNull('ss.semester_score')
->where('ss.semester_score', '<', 60)
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()
->map(fn ($row) => (array) $row)
->all();
if (empty($rows)) {
return [];
}
$studentIds = array_values(array_unique(array_map(
static fn ($r) => (int) ($r['student_id'] ?? 0),
$rows
)));
$studentIds = array_values(array_filter($studentIds, static fn ($id) => $id > 0));
$commentMap = [];
if (! empty($studentIds)) {
$commentRows = ScoreComment::query()
->select('student_id', 'comment', 'created_at')
->where('score_type', 'general')
->where('school_year', $schoolYear)
->whereRaw('LOWER(TRIM(semester)) = ?', [$semesterKey])
->whereIn('student_id', $studentIds)
->orderBy('created_at', 'DESC')
->get();
foreach ($commentRows as $row) {
$sid = (int) $row->student_id;
if ($sid > 0 && ! isset($commentMap[$sid])) {
$commentMap[$sid] = (string) ($row->comment ?? '');
}
}
}
$statusMap = [];
if (! empty($studentIds)) {
$flagRows = CurrentFlag::query()
->select('student_id', 'flag_state')
->where('flag', 'grade')
->where('school_year', $schoolYear)
->whereRaw('LOWER(TRIM(semester)) = ?', [$semesterKey])
->whereIn('student_id', $studentIds)
->get();
foreach ($flagRows as $row) {
$sid = (int) $row->student_id;
if ($sid > 0) {
$statusMap[$sid] = (string) ($row->flag_state ?? '');
}
}
}
foreach ($rows as &$row) {
$sid = (int) ($row['student_id'] ?? 0);
$row['comment'] = $commentMap[$sid] ?? '';
$flagState = strtolower(trim((string) ($statusMap[$sid] ?? '')));
$row['status'] = ($flagState === 'closed' || $flagState === 'canceled') ? 'Closed' : 'Open';
}
unset($row);
return $rows;
}
public function emailContext(int $studentId, string $schoolYear, string $semester): array
{
$row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
if (empty($row)) {
throw new RuntimeException('Student record not found for the selected term.');
}
$studentName = trim((string) ($row['firstname'] ?? '').' '.(string) ($row['lastname'] ?? ''));
$subject = $this->buildBelowSixtySubject($studentName, $semester, $schoolYear);
$parentName = $this->fetchBelowSixtyParentName($studentId);
$scores = [
'homework_avg' => $row['homework_avg'] ?? null,
'project_avg' => $row['project_avg'] ?? null,
'participation_score' => $row['participation_score'] ?? null,
'test_avg' => $row['test_avg'] ?? null,
'ptap_score' => $row['ptap_score'] ?? null,
'attendance_score' => $row['attendance_score'] ?? null,
'midterm_exam_score' => $row['midterm_exam_score'] ?? null,
'semester_score' => $row['semester_score'] ?? null,
];
return [
'student_id' => $studentId,
'student_name' => $studentName !== '' ? $studentName : 'Student',
'parent_name' => $parentName,
'class_section_name' => $row['class_section_name'] ?? '',
'semester' => $semester,
'school_year' => $schoolYear,
'scores' => $scores,
'comment' => $row['comment'] ?? '',
'subject' => $subject,
];
}
public function sendEmail(array $payload): void
{
Event::dispatch('below60.email', [$payload]);
}
public function updateStatus(int $studentId, string $semester, string $schoolYear, string $status, string $note, ?int $userId): void
{
$status = ucfirst(strtolower($status));
if (! in_array($status, ['Open', 'Closed'], true)) {
throw new RuntimeException('Invalid status.');
}
$semesterKey = strtolower(trim($semester));
$existing = CurrentFlag::query()
->where('student_id', $studentId)
->where('flag', 'grade')
->where('school_year', $schoolYear)
->whereRaw('LOWER(TRIM(semester)) = ?', [$semesterKey])
->first();
$now = now();
if ($existing) {
$data = [
'flag_state' => $status,
'flag_datetime' => $now,
'updated_at' => $now,
];
if ($status === 'Open') {
$data['updated_by_open'] = $userId;
if ($note !== '') {
$prev = (string) ($existing->open_description ?? '');
$data['open_description'] = trim($prev.PHP_EOL.$note);
}
} else {
$data['updated_by_closed'] = $userId;
if ($note !== '') {
$prev = (string) ($existing->close_description ?? '');
$data['close_description'] = trim($prev.PHP_EOL.$note);
}
}
$existing->update($data);
return;
}
$row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
$studentName = trim((string) ($row['firstname'] ?? '').' '.(string) ($row['lastname'] ?? ''));
$grade = (string) ($row['class_section_name'] ?? '');
$data = [
'student_id' => $studentId,
'student_name' => $studentName !== '' ? $studentName : 'Student',
'grade' => $grade,
'flag' => 'grade',
'flag_datetime' => $now,
'flag_state' => $status,
'semester' => $semester,
'school_year' => $schoolYear,
'updated_at' => $now,
];
if ($status === 'Open') {
$data['updated_by_open'] = $userId;
if ($note !== '') {
$data['open_description'] = $note;
}
} else {
$data['updated_by_closed'] = $userId;
if ($note !== '') {
$data['close_description'] = $note;
}
}
CurrentFlag::query()->create($data);
}
public function meetingContext(int $studentId, string $schoolYear, string $semester): array
{
$context = $this->fetchBelowSixtyMeetingContext($studentId, $schoolYear, $semester);
if (empty($context)) {
throw new RuntimeException('Student record not found for the selected term.');
}
return $context + [
'student_id' => $studentId,
'semester' => $semester,
'school_year' => $schoolYear,
];
}
public function saveMeeting(array $payload, ?int $userId): void
{
$studentId = (int) ($payload['student_id'] ?? 0);
$semester = (string) ($payload['semester'] ?? '');
$schoolYear = (string) ($payload['school_year'] ?? '');
$date = trim((string) ($payload['date'] ?? ''));
$time = trim((string) ($payload['time'] ?? ''));
$notes = trim((string) ($payload['notes'] ?? ''));
if ($studentId <= 0 || $semester === '' || $schoolYear === '' || $date === '') {
throw new RuntimeException('Missing required fields.');
}
$context = $this->fetchBelowSixtyMeetingContext($studentId, $schoolYear, $semester);
if (empty($context)) {
throw new RuntimeException('Student record not found for the selected term.');
}
ParentMeetingSchedule::query()->create([
'student_id' => $studentId,
'parent_user_id' => $context['parent_user_id'] ?? null,
'parent_name' => $context['parent_name'],
'student_name' => $context['student_name'],
'class_section_name' => $context['class_section_name'],
'date' => $date,
'time' => $time !== '' ? $time : null,
'notes' => $notes !== '' ? $notes : null,
'semester' => $semester,
'school_year' => $schoolYear,
'status' => 'scheduled',
'created_by' => $userId,
]);
}
public function listDecisionRows(string $schoolYear): array
{
$scoreRows = DB::table('semester_scores as ss')
->select([
's.id as student_id',
's.school_id',
's.firstname',
's.lastname',
's.age',
'ss.class_section_id',
'cs.class_section_name',
DB::raw('LOWER(TRIM(ss.semester)) as sem_key'),
'ss.semester_score',
])
->join('students as s', 's.id', '=', 'ss.student_id')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id')
->where('s.is_active', 1)
->where('ss.school_year', $schoolYear)
->whereRaw('LOWER(TRIM(ss.semester)) IN (?, ?)', ['fall', 'spring'])
->whereNotNull('ss.semester_score')
->orderBy('cs.class_section_name', 'ASC')
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->orderByDesc('ss.updated_at')
->orderByDesc('ss.id')
->get();
if ($scoreRows->isEmpty()) {
return [];
}
$studentMap = [];
foreach ($scoreRows as $row) {
$studentId = (int) ($row->student_id ?? 0);
if ($studentId <= 0) {
continue;
}
if (! isset($studentMap[$studentId])) {
$studentMap[$studentId] = [
'student_id' => $studentId,
'school_id' => $row->school_id,
'firstname' => $row->firstname,
'lastname' => $row->lastname,
'age' => $row->age,
'class_section_id' => (int) ($row->class_section_id ?? 0),
'class_section_name' => $row->class_section_name,
'fall_score' => null,
'spring_score' => null,
];
}
$semesterKey = strtolower(trim((string) ($row->sem_key ?? '')));
$score = is_numeric($row->semester_score ?? null) ? (float) $row->semester_score : null;
if ($score === null) {
continue;
}
if ($semesterKey === 'fall' && $studentMap[$studentId]['fall_score'] === null) {
$studentMap[$studentId]['fall_score'] = $score;
}
if ($semesterKey === 'spring' && $studentMap[$studentId]['spring_score'] === null) {
$studentMap[$studentId]['spring_score'] = $score;
}
}
$studentIds = array_keys($studentMap);
if (empty($studentIds)) {
return [];
}
$belowDecisionMap = BelowSixtyDecision::query()
->where('school_year', $schoolYear)
->where('semester', 'year')
->whereIn('student_id', $studentIds)
->get()
->keyBy(static fn (BelowSixtyDecision $row) => (int) $row->student_id);
$studentDecisionMap = StudentDecision::query()
->where('school_year', $schoolYear)
->whereIn('student_id', $studentIds)
->get()
->keyBy(static fn (StudentDecision $row) => (int) $row->student_id);
$certificateRows = DB::table('certificate_records')
->select('student_id', 'certificate_number', 'issued_at')
->where('school_year', $schoolYear)
->whereIn('student_id', $studentIds)
->orderByDesc('issued_at')
->get();
$certificateMap = [];
foreach ($certificateRows as $row) {
$studentId = (int) ($row->student_id ?? 0);
if ($studentId > 0 && ! isset($certificateMap[$studentId])) {
$certificateMap[$studentId] = (string) ($row->certificate_number ?? '');
}
}
$rows = [];
foreach ($studentMap as $studentId => $info) {
$fall = $info['fall_score'];
$spring = $info['spring_score'];
if ($fall === null || $spring === null) {
continue;
}
$yearScore = round(($fall + $spring) / 2, 2);
if ($yearScore >= 60) {
continue;
}
$belowDecision = $belowDecisionMap->get($studentId);
$studentDecision = $studentDecisionMap->get($studentId);
if ($studentDecision !== null && is_numeric($studentDecision->year_score)) {
$yearScore = round((float) $studentDecision->year_score, 2);
}
$rows[] = [
'student_id' => $studentId,
'school_id' => $info['school_id'],
'firstname' => $info['firstname'],
'lastname' => $info['lastname'],
'age' => $info['age'],
'class_section_id' => $info['class_section_id'],
'class_section_name' => $info['class_section_name'],
'fall_score' => $fall,
'spring_score' => $spring,
'year_score' => $yearScore,
'decision' => (string) ($belowDecision?->decision ?? ''),
'decision_notes' => (string) ($belowDecision?->notes ?? ''),
'consolidated_decision' => $studentDecision?->decision,
'certificate_number' => $certificateMap[$studentId] ?? '',
];
}
return $rows;
}
public function saveDecision(int $studentId, string $schoolYear, string $decision, string $notes, ?int $userId): array
{
if ($studentId <= 0 || trim($schoolYear) === '') {
throw new RuntimeException('Missing student or school year.');
}
$decision = trim($decision);
$notes = trim($notes);
$belowDecision = BelowSixtyDecision::query()->firstOrNew([
'student_id' => $studentId,
'semester' => 'year',
'school_year' => $schoolYear,
]);
$belowDecision->decision = $decision !== '' ? $decision : null;
$belowDecision->notes = $notes !== '' ? $notes : null;
$belowDecision->decided_by = $userId;
$belowDecision->save();
$scoreRows = DB::table('semester_scores as ss')
->select([
DB::raw('LOWER(TRIM(ss.semester)) as sem_key'),
'ss.semester_score',
'cs.class_section_name',
])
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id')
->where('ss.student_id', $studentId)
->where('ss.school_year', $schoolYear)
->whereRaw('LOWER(TRIM(ss.semester)) IN (?, ?)', ['fall', 'spring'])
->whereNotNull('ss.semester_score')
->orderByDesc('ss.updated_at')
->orderByDesc('ss.id')
->get();
$fallScore = null;
$springScore = null;
$classSectionName = null;
foreach ($scoreRows as $row) {
$semesterKey = strtolower(trim((string) ($row->sem_key ?? '')));
$score = is_numeric($row->semester_score ?? null) ? (float) $row->semester_score : null;
if ($classSectionName === null && trim((string) ($row->class_section_name ?? '')) !== '') {
$classSectionName = (string) $row->class_section_name;
}
if ($score === null) {
continue;
}
if ($semesterKey === 'fall' && $fallScore === null) {
$fallScore = $score;
}
if ($semesterKey === 'spring' && $springScore === null) {
$springScore = $score;
}
}
if ($classSectionName === null) {
$enrollment = DB::table('student_class as sc')
->select('cs.class_section_name')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->where('sc.student_id', $studentId)
->where('sc.school_year', $schoolYear)
->orderByDesc('sc.id')
->first();
$classSectionName = $enrollment?->class_section_name;
}
$yearScore = null;
if ($fallScore !== null && $springScore !== null) {
$yearScore = round(($fallScore + $springScore) / 2, 2);
} elseif ($fallScore !== null) {
$yearScore = round($fallScore, 2);
} elseif ($springScore !== null) {
$yearScore = round($springScore, 2);
}
$studentDecision = StudentDecision::query()->firstOrNew([
'student_id' => $studentId,
'school_year' => $schoolYear,
]);
$studentDecision->class_section_name = $classSectionName;
$studentDecision->year_score = $yearScore;
$studentDecision->decision = $decision !== '' ? $decision : null;
$studentDecision->source = $decision !== '' ? 'manual' : 'pending';
$studentDecision->notes = $notes !== '' ? $notes : null;
$studentDecision->generated_by = $userId;
$studentDecision->save();
return [
'student_id' => $studentId,
'school_year' => $schoolYear,
'decision' => $decision,
'notes' => $notes,
'year_score' => $yearScore,
'class_section_name' => $classSectionName,
];
}
public function studentDecisionDetails(int $studentId, string $schoolYear): array
{
if ($studentId <= 0 || trim($schoolYear) === '') {
throw new RuntimeException('Missing student or school year.');
}
return [
'semesters' => $this->fetchAllSemestersForStudent($studentId, $schoolYear),
];
}
public function decisionEmailContext(int $studentId, string $schoolYear): array
{
$context = $this->getBelowSixtyDecisionEmailContext($studentId, $schoolYear);
if (empty($context['student'])) {
throw new RuntimeException('Student not found.');
}
$decision = trim((string) ($context['decision_row']['decision'] ?? ''));
if ($decision === '') {
throw new RuntimeException('No saved decision found for this student. Save a decision first.');
}
$subject = 'Whole Year Academic Decision — '
.(string) ($context['student_name'] ?? 'Student')
.' ('.$schoolYear.')';
return [
'student_id' => $studentId,
'student_name' => (string) ($context['student_name'] ?? 'Student'),
'class_section_name' => (string) ($context['class_section_name'] ?? ''),
'school_year' => $schoolYear,
'semester' => 'year',
'decision' => $decision,
'subject' => $subject,
'html' => $this->buildDecisionEmailHtml($context),
'year_score' => $context['year_score'] ?? null,
];
}
public function sendDecisionEmail(int $studentId, string $schoolYear, ?string $subject, ?string $html): void
{
$context = $this->getBelowSixtyDecisionEmailContext($studentId, $schoolYear);
if (empty($context['student'])) {
throw new RuntimeException('Student not found.');
}
$decision = trim((string) ($context['decision_row']['decision'] ?? ''));
if ($decision === '') {
throw new RuntimeException('No saved decision found for this student.');
}
$resolvedSubject = trim((string) $subject);
if ($resolvedSubject === '') {
$resolvedSubject = 'Whole Year Academic Decision — '
.(string) ($context['student_name'] ?? 'Student')
.' ('.$schoolYear.')';
}
$payload = [
'student_id' => $studentId,
'student_name' => (string) ($context['student_name'] ?? 'Student'),
'class_section_name' => (string) ($context['class_section_name'] ?? ''),
'semester' => 'year',
'school_year' => $schoolYear,
'decision' => $decision,
'notes' => (string) ($context['decision_row']['notes'] ?? ''),
'subject' => $resolvedSubject,
'scores' => [
'fall_score' => $context['fall_score'] ?? null,
'spring_score' => $context['spring_score'] ?? null,
'year_score' => $context['year_score'] ?? null,
],
'all_semesters' => $context['all_semesters'] ?? [],
];
$resolvedHtml = trim((string) $html);
if ($resolvedHtml !== '') {
$payload['html'] = $resolvedHtml;
} else {
$payload['html'] = $this->buildDecisionEmailHtml($context);
}
$this->sendEmail($payload);
}
public function listAllDecisions(string $schoolYear): array
{
$saved = StudentDecision::query()
->where('school_year', $schoolYear)
->get();
$savedMap = [];
foreach ($saved as $row) {
$studentId = (int) $row->student_id;
if ($studentId > 0) {
$savedMap[$studentId] = $row;
}
}
$allScoreRows = DB::table('semester_scores as ss')
->select([
's.id as student_id',
's.school_id',
's.firstname',
's.lastname',
's.gender',
'ss.class_section_id',
'cs.class_section_name',
DB::raw('LOWER(TRIM(ss.semester)) as sem_key'),
'ss.semester_score',
])
->join('students as s', 's.id', '=', 'ss.student_id')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id')
->where('s.is_active', 1)
->where('ss.school_year', $schoolYear)
->whereRaw('LOWER(TRIM(ss.semester)) IN (?, ?)', ['fall', 'spring'])
->whereNotNull('ss.semester_score')
->orderBy('cs.class_section_name')
->orderBy('s.lastname')
->orderBy('s.firstname')
->get();
$studentMap = [];
foreach ($allScoreRows as $row) {
$studentId = (int) ($row->student_id ?? 0);
if ($studentId <= 0) {
continue;
}
if (! isset($studentMap[$studentId])) {
$studentMap[$studentId] = [
'school_id' => $row->school_id,
'firstname' => $row->firstname,
'lastname' => $row->lastname,
'gender' => $row->gender,
'class_section_id' => (int) ($row->class_section_id ?? 0),
'class_section_name' => $row->class_section_name,
'fall_score' => null,
'spring_score' => null,
];
}
$semesterKey = strtolower(trim((string) ($row->sem_key ?? '')));
$score = is_numeric($row->semester_score ?? null) ? (float) $row->semester_score : null;
if ($semesterKey === 'fall') {
$studentMap[$studentId]['fall_score'] = $score;
} elseif ($semesterKey === 'spring') {
$studentMap[$studentId]['spring_score'] = $score;
}
}
$belowRows = BelowSixtyDecision::query()
->where('school_year', $schoolYear)
->where('semester', 'year')
->get();
$belowMap = [];
foreach ($belowRows as $row) {
$studentId = (int) ($row->student_id ?? 0);
if ($studentId <= 0) {
continue;
}
if (! isset($belowMap[$studentId]) || trim((string) ($belowMap[$studentId]->decision ?? '')) === '') {
$belowMap[$studentId] = $row;
}
}
$rows = [];
foreach ($studentMap as $studentId => $info) {
$fall = $info['fall_score'];
$spring = $info['spring_score'];
if ($fall !== null && $spring !== null) {
$yearScore = round(($fall + $spring) / 2, 2);
} elseif ($fall !== null) {
$yearScore = round((float) $fall, 2);
} elseif ($spring !== null) {
$yearScore = round((float) $spring, 2);
} else {
$yearScore = null;
}
if (isset($savedMap[$studentId])) {
$savedRow = $savedMap[$studentId];
$decision = trim((string) ($savedRow->decision ?? ''));
$source = trim((string) ($savedRow->source ?? 'pending'));
$notes = (string) ($savedRow->notes ?? '');
if (is_numeric($savedRow->year_score ?? null)) {
$yearScore = round((float) $savedRow->year_score, 2);
}
} elseif ($yearScore !== null && $yearScore >= 60) {
$decision = 'Pass';
$source = 'auto';
$notes = '';
} elseif ($yearScore !== null && isset($belowMap[$studentId])) {
$decision = trim((string) ($belowMap[$studentId]->decision ?? ''));
$source = $decision !== '' ? 'manual' : 'pending';
$notes = (string) ($belowMap[$studentId]->notes ?? '');
} else {
$decision = '';
$source = 'pending';
$notes = '';
}
$rows[] = [
'student_id' => $studentId,
'school_id' => $info['school_id'],
'firstname' => $info['firstname'],
'lastname' => $info['lastname'],
'gender' => $info['gender'] ?? '',
'class_section_id' => (int) ($info['class_section_id'] ?? 0),
'class_section_name' => $info['class_section_name'],
'fall_score' => $fall,
'spring_score' => $spring,
'year_score' => $yearScore,
'decision' => $decision,
'source' => $source,
'notes' => $notes,
'saved' => isset($savedMap[$studentId]),
'is_trophy' => false,
];
}
$rowsByClass = [];
foreach ($rows as $index => $row) {
$classSectionId = (int) ($row['class_section_id'] ?? 0);
if ($classSectionId <= 0) {
continue;
}
$rowsByClass[$classSectionId][] = $index;
}
foreach ($rowsByClass as $classIndexes) {
$scores = [];
foreach ($classIndexes as $rowIndex) {
$yearScore = $rows[$rowIndex]['year_score'] ?? null;
if (is_numeric($yearScore)) {
$scores[] = (float) $yearScore;
}
}
$thresholdInfo = $this->calculateTrophyThreshold($scores, 75.0);
$threshold = $thresholdInfo['threshold'];
if ($threshold === null) {
continue;
}
foreach ($classIndexes as $rowIndex) {
$yearScore = $rows[$rowIndex]['year_score'] ?? null;
$rows[$rowIndex]['is_trophy'] = is_numeric($yearScore) && (float) $yearScore >= $threshold;
}
}
return [
'rows' => $rows,
'generated' => ! empty($savedMap),
'school_year' => $schoolYear,
];
}
public function generateAllDecisions(string $schoolYear, ?int $userId): int
{
$allScoreRows = DB::table('semester_scores as ss')
->select([
's.id as student_id',
's.firstname',
's.lastname',
'cs.class_section_name',
DB::raw('LOWER(TRIM(ss.semester)) as sem_key'),
'ss.semester_score',
])
->join('students as s', 's.id', '=', 'ss.student_id')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id')
->where('s.is_active', 1)
->where('ss.school_year', $schoolYear)
->whereRaw('LOWER(TRIM(ss.semester)) IN (?, ?)', ['fall', 'spring'])
->whereNotNull('ss.semester_score')
->get();
if ($allScoreRows->isEmpty()) {
throw new RuntimeException('No semester scores found for this school year.');
}
$studentMap = [];
foreach ($allScoreRows as $row) {
$studentId = (int) ($row->student_id ?? 0);
if ($studentId <= 0) {
continue;
}
if (! isset($studentMap[$studentId])) {
$studentMap[$studentId] = [
'firstname' => $row->firstname,
'lastname' => $row->lastname,
'class_section_name' => $row->class_section_name,
'fall_score' => null,
'spring_score' => null,
];
}
$semesterKey = strtolower(trim((string) ($row->sem_key ?? '')));
$score = is_numeric($row->semester_score ?? null) ? (float) $row->semester_score : null;
if ($semesterKey === 'fall') {
$studentMap[$studentId]['fall_score'] = $score;
} elseif ($semesterKey === 'spring') {
$studentMap[$studentId]['spring_score'] = $score;
}
}
$belowRows = BelowSixtyDecision::query()
->where('school_year', $schoolYear)
->where('semester', 'year')
->get();
$belowMap = [];
foreach ($belowRows as $row) {
$studentId = (int) ($row->student_id ?? 0);
if ($studentId <= 0) {
continue;
}
if (! isset($belowMap[$studentId]) || trim((string) ($belowMap[$studentId]->decision ?? '')) === '') {
$belowMap[$studentId] = $row;
}
}
$existingRows = StudentDecision::query()
->where('school_year', $schoolYear)
->get();
$existingMap = [];
foreach ($existingRows as $row) {
$studentId = (int) ($row->student_id ?? 0);
if ($studentId > 0) {
$existingMap[$studentId] = $row;
}
}
$savedCount = 0;
foreach ($studentMap as $studentId => $info) {
$fall = $info['fall_score'];
$spring = $info['spring_score'];
if ($fall !== null && $spring !== null) {
$yearScore = round(($fall + $spring) / 2, 2);
} elseif ($fall !== null) {
$yearScore = round((float) $fall, 2);
} elseif ($spring !== null) {
$yearScore = round((float) $spring, 2);
} else {
continue;
}
if ($yearScore >= 60) {
$decision = 'Pass';
$source = 'auto';
$notes = null;
} elseif (isset($belowMap[$studentId]) && trim((string) ($belowMap[$studentId]->decision ?? '')) !== '') {
$decision = trim((string) $belowMap[$studentId]->decision);
$source = 'manual';
$notes = trim((string) ($belowMap[$studentId]->notes ?? ''));
$notes = $notes !== '' ? $notes : null;
} else {
$decision = null;
$source = 'pending';
$notes = null;
}
$payload = [
'student_id' => $studentId,
'school_year' => $schoolYear,
'class_section_name' => $info['class_section_name'] ?? null,
'year_score' => $yearScore,
'decision' => $decision,
'source' => $source,
'notes' => $notes,
'generated_by' => $userId,
];
if (isset($existingMap[$studentId])) {
$existingMap[$studentId]->fill($payload)->save();
} else {
StudentDecision::query()->create($payload);
}
$savedCount++;
}
return $savedCount;
}
private function fetchBelowSixtyEmailRow(int $studentId, string $schoolYear, string $semester): array
{
$semesterKey = strtolower(trim($semester));
$row = DB::table('semester_scores as ss')
->select([
's.id as student_id',
's.firstname',
's.lastname',
'cs.class_section_name',
'ss.homework_avg',
'ss.project_avg',
'ss.participation_score',
DB::raw('COALESCE(ss.test_avg, ss.quiz_avg) as test_avg'),
'ss.ptap_score',
'ss.attendance_score',
'ss.midterm_exam_score',
'ss.semester_score',
])
->join('students as s', 's.id', '=', 'ss.student_id')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id')
->where('ss.school_year', $schoolYear)
->where('ss.student_id', $studentId)
->where('s.is_active', 1)
->whereRaw('LOWER(TRIM(ss.semester)) = ?', [$semesterKey])
->first();
if (! $row) {
return [];
}
$commentRow = ScoreComment::query()
->select('comment')
->where('score_type', 'general')
->where('school_year', $schoolYear)
->whereRaw('LOWER(TRIM(semester)) = ?', [$semesterKey])
->where('student_id', $studentId)
->orderBy('created_at', 'DESC')
->first();
$row = (array) $row;
$row['comment'] = (string) ($commentRow?->comment ?? '');
return $row;
}
private function fetchAllSemestersForStudent(int $studentId, string $schoolYear): array
{
$rows = DB::table('semester_scores as ss')
->select([
'ss.semester',
'cs.class_section_name',
'ss.homework_avg',
'ss.project_avg',
'ss.participation_score',
DB::raw('COALESCE(ss.test_avg, ss.quiz_avg) as test_avg'),
'ss.ptap_score',
'ss.attendance_score',
'ss.midterm_exam_score',
'ss.final_exam_score',
'ss.semester_score',
])
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id')
->where('ss.student_id', $studentId)
->where('ss.school_year', $schoolYear)
->orderBy('ss.semester')
->get();
$semesters = [];
foreach ($rows as $row) {
$semester = ucfirst(strtolower(trim((string) ($row->semester ?? ''))));
$semesters[$semester] = [
'semester' => $semester,
'class_section_name' => $row->class_section_name,
'homework_avg' => $row->homework_avg,
'project_avg' => $row->project_avg,
'participation_score' => $row->participation_score,
'test_avg' => $row->test_avg,
'ptap_score' => $row->ptap_score,
'attendance_score' => $row->attendance_score,
'midterm_exam_score' => $row->midterm_exam_score,
'final_exam_score' => $row->final_exam_score,
'semester_score' => $row->semester_score,
'comments' => [],
];
}
if (! empty($semesters)) {
$commentRows = DB::table('score_comments')
->select('semester', 'score_type', 'comment', 'created_at')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->whereNotNull('comment')
->where('comment', '!=', '')
->orderBy('semester')
->orderBy('score_type')
->orderByDesc('created_at')
->get();
foreach ($commentRows as $row) {
$semester = ucfirst(strtolower(trim((string) ($row->semester ?? ''))));
$type = strtolower(trim((string) ($row->score_type ?? 'general')));
if (isset($semesters[$semester]) && ! isset($semesters[$semester]['comments'][$type])) {
$semesters[$semester]['comments'][$type] = (string) ($row->comment ?? '');
}
}
}
return array_values($semesters);
}
private function fetchBelowSixtyParentName(int $studentId): string
{
$parentName = 'Parent/Guardian';
try {
$rows = DB::select(
'SELECT u.firstname, u.lastname
FROM family_students fs
JOIN family_guardians fg ON fg.family_id = fs.family_id
JOIN users u ON u.id = fg.user_id
WHERE fs.student_id = ?
ORDER BY fg.is_primary DESC, u.lastname, u.firstname
LIMIT 1',
[$studentId]
);
if (! empty($rows[0])) {
$candidate = trim((string) ($rows[0]->firstname ?? '').' '.(string) ($rows[0]->lastname ?? ''));
if ($candidate !== '') {
$parentName = $candidate;
}
}
} catch (\Throwable $e) {
}
return $parentName;
}
private function getBelowSixtyDecisionEmailContext(int $studentId, string $schoolYear): array
{
$student = DB::table('students as s')
->select(['s.id', 's.firstname', 's.lastname', 's.is_active'])
->where('s.id', $studentId)
->first();
if ($student === null) {
return [
'student' => null,
'decision_row' => null,
'student_name' => '',
'class_section_name' => '',
'fall_score' => null,
'spring_score' => null,
'year_score' => null,
'all_semesters' => [],
];
}
$studentName = trim((string) ($student->firstname ?? '').' '.(string) ($student->lastname ?? ''));
if ($studentName === '') {
$studentName = 'Student';
}
$decisionRow = BelowSixtyDecision::query()
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('semester', 'year')
->first();
$scoreRows = DB::table('semester_scores as ss')
->select([
DB::raw('LOWER(TRIM(ss.semester)) as sem_key'),
'ss.semester',
'ss.semester_score',
'ss.class_section_id',
'cs.class_section_name',
])
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id')
->where('ss.student_id', $studentId)
->where('ss.school_year', $schoolYear)
->whereRaw('LOWER(TRIM(ss.semester)) IN (?, ?)', ['fall', 'spring'])
->whereNotNull('ss.semester_score')
->orderByDesc('ss.updated_at')
->orderByDesc('ss.id')
->get();
$fallScore = null;
$springScore = null;
$classSectionName = '';
foreach ($scoreRows as $row) {
$semesterKey = strtolower(trim((string) ($row->sem_key ?? '')));
$score = is_numeric($row->semester_score ?? null) ? (float) $row->semester_score : null;
if ($classSectionName === '' && trim((string) ($row->class_section_name ?? '')) !== '') {
$classSectionName = (string) $row->class_section_name;
}
if ($score === null) {
continue;
}
if ($semesterKey === 'fall' && $fallScore === null) {
$fallScore = $score;
}
if ($semesterKey === 'spring' && $springScore === null) {
$springScore = $score;
}
}
if ($classSectionName === '') {
$enrollment = DB::table('student_class as sc')
->select('cs.class_section_name')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->where('sc.student_id', $studentId)
->where('sc.school_year', $schoolYear)
->orderByDesc('sc.id')
->first();
$classSectionName = (string) ($enrollment?->class_section_name ?? '');
}
$yearScore = null;
if ($fallScore !== null && $springScore !== null) {
$yearScore = round(($fallScore + $springScore) / 2, 2);
} elseif ($fallScore !== null) {
$yearScore = round($fallScore, 2);
} elseif ($springScore !== null) {
$yearScore = round($springScore, 2);
}
return [
'student' => (array) $student,
'decision_row' => $decisionRow?->toArray(),
'student_name' => $studentName,
'class_section_name' => $classSectionName,
'school_year' => $schoolYear,
'fall_score' => $fallScore,
'spring_score' => $springScore,
'year_score' => $yearScore,
'all_semesters' => $this->fetchAllSemestersForStudent($studentId, $schoolYear),
];
}
private function buildDecisionEmailHtml(array $context): string
{
$studentName = (string) ($context['student_name'] ?? 'Student');
$schoolYear = (string) ($context['school_year'] ?? '');
$classSectionName = (string) ($context['class_section_name'] ?? '');
$decision = trim((string) ($context['decision_row']['decision'] ?? ''));
$notes = trim((string) ($context['decision_row']['notes'] ?? ''));
$fallScore = $context['fall_score'] ?? null;
$springScore = $context['spring_score'] ?? null;
$yearScore = $context['year_score'] ?? null;
$fallText = $fallScore !== null ? number_format((float) $fallScore, 2) : 'N/A';
$springText = $springScore !== null ? number_format((float) $springScore, 2) : 'N/A';
$yearText = $yearScore !== null ? number_format((float) $yearScore, 2) : 'N/A';
$html = '
<p>Dear Parent/Guardian,</p>
<p>
This message is regarding <strong>'.e($studentName).'</strong>
for the <strong>'.e($schoolYear).'</strong> school year.
</p>
<p>
Class: <strong>'.e($classSectionName !== '' ? $classSectionName : 'N/A').'</strong><br>
Fall Score: <strong>'.e($fallText).'</strong><br>
Spring Score: <strong>'.e($springText).'</strong><br>
Whole-Year Score: <strong>'.e($yearText).'</strong><br>
Decision: <strong>'.e($decision).'</strong>
</p>';
if ($notes !== '') {
$html .= '
<p>
<strong>Decision Notes:</strong><br>
'.nl2br(e($notes)).'
</p>';
}
$html .= $this->buildDecisionEmailDetailsHtml($context['all_semesters'] ?? []);
$html .= '
<p>Please contact the school administration if you have any questions.</p>
<p>Regards,<br>Al Rahma Sunday School</p>';
return $html;
}
private function calculateTrophyThreshold(array $scores, float $percentile = 75.0): array
{
$scores = array_values(array_filter(
$scores,
static fn ($value): bool => is_numeric($value) && $value !== null
));
$scores = array_map('floatval', $scores);
sort($scores);
$count = count($scores);
if ($count === 0) {
return ['threshold' => null, 'winners' => 0, 'method' => 'empty'];
}
$minWinners = 3;
$maxWinners = max($minWinners, (int) floor($count * (1 - $percentile / 100)));
$threshold = $this->empiricalTrophyPercentile($scores, $percentile);
$winners = $this->countScoresAtOrAbove($scores, $threshold);
if ($winners < $minWinners) {
$target = min($minWinners, $count);
$descending = array_reverse($scores);
$threshold = $descending[$target - 1];
$winners = $this->countScoresAtOrAbove($scores, $threshold);
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_reduced'];
}
if ($winners <= $maxWinners) {
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'empirical_percentile'];
}
$result = $this->capTrophyThresholdByRank($scores, $maxWinners);
if ($result['winners'] < $minWinners) {
$target = min($minWinners, $count);
$descending = array_reverse($scores);
$threshold = $descending[$target - 1];
$winners = $this->countScoresAtOrAbove($scores, $threshold);
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_after_cap'];
}
return $result;
}
private function capTrophyThresholdByRank(array $sortedScores, int $max): array
{
$descending = array_reverse($sortedScores);
$threshold = $descending[$max - 1];
$winners = $this->countScoresAtOrAbove($sortedScores, $threshold);
if ($winners <= $max) {
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'capped_25pct'];
}
$uniqueHigherScores = array_values(array_unique(array_filter(
$sortedScores,
static fn ($score): bool => $score > $threshold
)));
sort($uniqueHigherScores);
foreach ($uniqueHigherScores as $candidate) {
$winnerCount = $this->countScoresAtOrAbove($sortedScores, $candidate);
if ($winnerCount <= $max) {
return ['threshold' => $candidate, 'winners' => $winnerCount, 'method' => 'capped_25pct'];
}
}
return ['threshold' => $sortedScores[0], 'winners' => count($sortedScores), 'method' => 'all_equal'];
}
private function empiricalTrophyPercentile(array $sortedScores, float $percentile): float
{
$count = count($sortedScores);
if ($count === 0) {
return 0.0;
}
if ($count === 1) {
return (float) $sortedScores[0];
}
$rank = ($percentile / 100) * ($count - 1);
$lowerIndex = (int) floor($rank);
$upperIndex = (int) ceil($rank);
$weight = $rank - $lowerIndex;
$lower = (float) $sortedScores[$lowerIndex];
$upper = (float) $sortedScores[$upperIndex];
return $lower + (($upper - $lower) * $weight);
}
private function countScoresAtOrAbove(array $scores, float $threshold): int
{
return count(array_filter(
$scores,
static fn ($score): bool => is_numeric($score) && (float) $score >= $threshold
));
}
private function buildDecisionEmailDetailsHtml(array $semesters): string
{
if (empty($semesters)) {
return '
<p><strong>Detailed Scores and Comments</strong></p>
<p>No detailed score data found.</p>';
}
$scoreLabels = [
'homework_avg' => 'Homework Avg',
'project_avg' => 'Project Avg',
'participation_score' => 'Participation',
'test_avg' => 'Test Avg',
'ptap_score' => 'PTAP Score',
'attendance_score' => 'Attendance',
'midterm_exam_score' => 'Midterm Score',
'final_exam_score' => 'Final Exam',
'semester_score' => 'Semester Score',
];
$commentTypeLabels = [
'general' => 'General',
'attendance' => 'Attendance',
'attendance_comment' => 'Attendance',
'midterm' => 'Midterm',
'final' => 'Final Exam',
'ptap' => 'PTAP',
];
$html = '
<hr>
<p><strong>Detailed Scores and Comments</strong></p>';
foreach ($semesters as $semester) {
$semesterName = trim((string) ($semester['semester'] ?? ''));
if ($semesterName === '') {
$semesterName = 'Semester';
}
$classSectionName = trim((string) ($semester['class_section_name'] ?? ''));
$html .= '
<p style="margin-top:16px;margin-bottom:6px;">
<strong>'.e($semesterName).' Semester</strong>';
if ($classSectionName !== '') {
$html .= ' — '.e($classSectionName);
}
$html .= '
</p>
<table border="1" cellpadding="6" cellspacing="0" style="border-collapse:collapse;width:100%;margin-bottom:10px;">
<thead>
<tr>
<th align="left" style="background:#f2f2f2;">Item</th>
<th align="right" style="background:#f2f2f2;">Score</th>
</tr>
</thead>
<tbody>';
$hasScoreRow = false;
foreach ($scoreLabels as $key => $label) {
if (! array_key_exists($key, $semester)) {
continue;
}
$value = $semester[$key];
if ($value === null || $value === '') {
continue;
}
$scoreText = is_numeric($value) ? number_format((float) $value, 2) : (string) $value;
$fontWeight = $key === 'semester_score' ? 'font-weight:bold;' : '';
$html .= '
<tr>
<td>'.e($label).'</td>
<td align="right" style="'.$fontWeight.'">'.e($scoreText).'</td>
</tr>';
$hasScoreRow = true;
}
if (! $hasScoreRow) {
$html .= '
<tr>
<td colspan="2">No scores recorded.</td>
</tr>';
}
$html .= '
</tbody>
</table>';
$comments = is_array($semester['comments'] ?? null) ? $semester['comments'] : [];
if (! empty($comments)) {
$deduped = [];
$seen = [];
foreach ($comments as $type => $text) {
$text = trim((string) $text);
if ($text === '') {
continue;
}
$label = $commentTypeLabels[$type] ?? (string) $type;
$hash = $label.'|'.$text;
if (isset($seen[$hash])) {
continue;
}
$seen[$hash] = true;
$deduped[] = ['label' => $label, 'text' => $text];
}
if (! empty($deduped)) {
$html .= '<p style="margin:8px 0 4px;"><strong>Comments</strong></p>';
foreach ($deduped as $comment) {
$html .= '
<p style="margin:4px 0 8px;padding:8px;background:#f8f9fa;border-left:4px solid #999;">
<strong>'.e($comment['label']).':</strong><br>
'.nl2br(e($comment['text'])).'
</p>';
}
}
}
}
return $html;
}
private function buildBelowSixtySubject(string $studentName, string $semester, string $schoolYear): string
{
$subject = 'Student Performance Alert';
if ($studentName !== '') {
$subject .= ' — '.$studentName;
}
if ($semester !== '' || $schoolYear !== '') {
$subject .= ' ('.trim($semester.' '.$schoolYear).')';
}
return $subject;
}
private function fetchBelowSixtyMeetingContext(int $studentId, string $schoolYear, string $semester): array
{
$row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
if (empty($row)) {
return [];
}
$parentName = 'Parent/Guardian';
$parentUserId = null;
try {
$pRows = DB::select(
'SELECT u.id AS user_id, u.firstname, u.lastname
FROM family_students fs
JOIN family_guardians fg ON fg.family_id = fs.family_id
JOIN users u ON u.id = fg.user_id
WHERE fs.student_id = ?
ORDER BY fg.is_primary DESC, u.lastname, u.firstname
LIMIT 1',
[$studentId]
);
if (! empty($pRows[0])) {
$parentUserId = (int) ($pRows[0]->user_id ?? 0) ?: null;
$parentName = trim((string) ($pRows[0]->firstname ?? '').' '.(string) ($pRows[0]->lastname ?? ''));
if ($parentName === '') {
$parentName = 'Parent/Guardian';
}
}
} catch (\Throwable $e) {
}
if ($parentUserId === null) {
try {
$srow = DB::selectOne(
'SELECT s.parent_id, u.firstname, u.lastname
FROM students s
LEFT JOIN users u ON u.id = s.parent_id
WHERE s.id = ?
LIMIT 1',
[$studentId]
);
if (! empty($srow)) {
$parentUserId = (int) ($srow->parent_id ?? 0) ?: null;
$fallbackName = trim((string) ($srow->firstname ?? '').' '.(string) ($srow->lastname ?? ''));
if ($fallbackName !== '') {
$parentName = $fallbackName;
}
}
} catch (\Throwable $e) {
}
}
$studentName = trim((string) ($row['firstname'] ?? '').' '.(string) ($row['lastname'] ?? ''));
return [
'student_name' => $studentName !== '' ? $studentName : 'Student',
'parent_name' => $parentName,
'parent_user_id' => $parentUserId,
'class_section_name' => (string) ($row['class_section_name'] ?? ''),
];
}
}