1469 lines
55 KiB
PHP
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'] ?? ''),
|
|
];
|
|
}
|
|
}
|