Files
alrahma_sunday_school/app/Services/StudentDecisionService.php
T
root 2b0206e7f2
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Successful in 1m16s
move service logic from grading and administrator controller
2026-08-20 16:59:51 -04:00

1235 lines
40 KiB
PHP

<?php
namespace App\Services;
use App\Models\BelowSixtyDecisionModel;
use App\Models\ConfigurationModel;
use App\Models\StudentClassModel;
use App\Models\StudentDecisionModel;
use App\Models\StudentModel;
use App\Models\UserModel;
use App\Services\NavbarService;
use App\Support\Enrollment\DeliberationDecision;
use CodeIgniter\Events\Events;
class StudentDecisionService
{
protected $db;
protected $configModel;
protected $studentModel;
protected $studentClassModel;
protected $userModel;
protected string $schoolYear = '';
protected string $semester = '';
public function __construct(
\CodeIgniter\Database\BaseConnection $db,
ConfigurationModel $configModel,
StudentModel $studentModel,
StudentClassModel $studentClassModel,
UserModel $userModel,
string $schoolYear = '',
string $semester = ''
) {
$this->db = $db;
$this->configModel = $configModel;
$this->studentModel = $studentModel;
$this->studentClassModel = $studentClassModel;
$this->userModel = $userModel;
$this->schoolYear = $schoolYear;
$this->semester = $semester;
}
public function setTerm(string $schoolYear, string $semester): void
{
$this->schoolYear = $schoolYear;
$this->semester = $semester;
}
public function previewDecisionEmail(array $params = [])
{
$get = $params['get'] ?? [];
$post = $params['post'] ?? [];
$studentId = (int)($get['student_id'] ?? null);
$semester = trim((string)($get['semester'] ?? null));
$schoolYear = trim((string)($get['school_year'] ?? null));
if ($studentId <= 0 || $semester === '' || $schoolYear === '') {
return ['kind' => 'json', 'status' => 400, 'data' => ['error' => 'Missing student or term.']];
}
$row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
if (empty($row)) {
return ['kind' => 'json', 'status' => 404, 'data' => ['error' => 'Student not found.']];
}
$decisionModel = new BelowSixtyDecisionModel();
$decisionRow = $decisionModel
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
$decision = (string)($decisionRow['decision'] ?? '');
$notes = (string)($decisionRow['notes'] ?? '');
$studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
$parentName = $this->fetchBelowSixtyParentName($studentId);
$subject = 'Academic Decision';
if ($studentName !== '') $subject .= ' — ' . $studentName;
if ($semester !== '' || $schoolYear !== '') {
$subject .= ' (' . trim($semester . ' ' . $schoolYear) . ')';
}
$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,
];
// Fetch all semesters' scores + comments for the email
$allSemesters = $this->fetchAllSemestersForStudent($studentId, $schoolYear);
$html = view('emails/below_sixty_decision', [
'title' => $subject,
'parent_name' => $parentName,
'student_name' => $studentName !== '' ? $studentName : 'your student',
'class_section_name' => $row['class_section_name'] ?? '',
'semester' => $semester,
'school_year' => $schoolYear,
'decision' => $decision,
'notes' => $notes,
'scores' => $scores,
'all_semesters' => array_values($allSemesters),
], ['saveData' => true]);
return ['kind' => 'json', 'status' => 200, 'data' => [
'subject' => $subject,
'html' => $html,
'student_id' => $studentId,
]];
}
public function editDecisionEmail(array $params = [])
{
$get = $params['get'] ?? [];
$post = $params['post'] ?? [];
$studentId = (int)($get['student_id'] ?? null);
$semester = trim((string)($get['semester'] ?? null));
$schoolYear = trim((string)($get['school_year'] ?? null));
if ($studentId <= 0 || $semester === '' || $schoolYear === '') {
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing student or term.'];
}
$row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
if (empty($row)) {
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Student record not found for the selected term.'];
}
$decisionModel = new BelowSixtyDecisionModel();
$decisionRow = $decisionModel
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
$decision = (string)($decisionRow['decision'] ?? '');
$notes = (string)($decisionRow['notes'] ?? '');
$studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
$parentName = $this->fetchBelowSixtyParentName($studentId);
$subject = 'Academic Decision';
if ($studentName !== '') $subject .= ' — ' . $studentName;
if ($semester !== '' || $schoolYear !== '') {
$subject .= ' (' . trim($semester . ' ' . $schoolYear) . ')';
}
$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,
];
$html = view('emails/below_sixty_decision', [
'title' => $subject,
'parent_name' => $parentName,
'student_name' => $studentName !== '' ? $studentName : 'your student',
'class_section_name' => $row['class_section_name'] ?? '',
'semester' => $semester,
'school_year' => $schoolYear,
'decision' => $decision,
'notes' => $notes,
'scores' => $scores,
], ['saveData' => true]);
return ['kind' => 'view', 'view' => 'grading/below_sixty_decision_email_editor', 'data' => [
'studentId' => $studentId,
'studentName' => $studentName,
'semester' => $semester,
'schoolYear' => $schoolYear,
'subject' => $subject,
'html' => $html,
'decision' => $decision,
]];
}
public function sendDecisionEmail(array $params = [])
{
$get = $params['get'] ?? [];
$post = $params['post'] ?? [];
$studentId = (int)($post['student_id'] ?? null);
$semester = trim((string)($post['semester'] ?? null));
$schoolYear = trim((string)($post['school_year'] ?? null));
$subjectInput= trim((string)($post['subject'] ?? null));
$htmlInput = (string)(($post['html'] ?? null) ?? '');
if ($studentId <= 0 || $semester === '' || $schoolYear === '') {
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing student or term.'];
}
$row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
if (empty($row)) {
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Student record not found for the selected term.'];
}
$decisionModel = new BelowSixtyDecisionModel();
$decisionRow = $decisionModel
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
$studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
$subject = $subjectInput !== '' ? $subjectInput : ('Academic Decision — ' . $studentName . ' (' . trim($semester . ' ' . $schoolYear) . ')');
$allSemesters = $this->fetchAllSemestersForStudent($studentId, $schoolYear);
$payload = [
'student_id' => $studentId,
'student_name' => $studentName,
'class_section_name' => $row['class_section_name'] ?? '',
'semester' => $semester,
'school_year' => $schoolYear,
'decision' => (string)($decisionRow['decision'] ?? ''),
'notes' => (string)($decisionRow['notes'] ?? ''),
'subject' => $subject,
'all_semesters' => $allSemesters,
'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,
],
];
if (trim($htmlInput) !== '') {
$payload['html'] = $htmlInput;
}
\CodeIgniter\Events\Events::trigger('below60.decision_email', $payload);
$query = http_build_query(['semester' => $semester, 'school_year' => $schoolYear]);
return ['kind' => 'flash', 'redirect' => base_url('grading/below-60/decisions') . ($query ? '?' . $query : ''), 'type' => 'status', 'message' => 'Decision email sent to parent(s).'];
}
public function allDecisionsPage(array $params = [])
{
$get = $params['get'] ?? [];
$post = $params['post'] ?? [];
$configuredYear = (string)$this->schoolYear;
$schoolYear = trim((string)(($get['school_year'] ?? null) ?? ''));
if ($schoolYear === '') {
$schoolYear = $configuredYear;
}
$schoolYears = $this->getSchoolYearsForScores($schoolYear);
// Load saved YEAR decisions for this school year.
// New structure:
// one row per student per school_year
// uses year_score, not semester_score
// does not use semester = 'year'
$decModel = new StudentDecisionModel();
$saved = $decModel
->where('school_year', $schoolYear)
->findAll();
$savedMap = [];
foreach ($saved as $s) {
$sid = (int)($s['student_id'] ?? 0);
if ($sid > 0) {
$savedMap[$sid] = $s;
}
}
// Fetch Fall and Spring semester scores per student.
// These raw semester scores are only used to calculate the final year_score.
$allScoreRows = $this->db->table('semester_scores ss')
->select([
's.id AS student_id',
's.school_id',
's.firstname',
's.lastname',
's.gender',
's.dob',
's.is_active',
'ss.class_section_id',
'cs.class_section_name',
'c.class_name',
'e.enrollment_status',
'e.is_withdrawn',
'LOWER(TRIM(ss.semester)) AS sem_key',
'ss.semester_score',
])
->join('students s', 's.id = ss.student_id', 'inner')
->join('enrollments e', 'e.student_id = s.id AND e.school_year = ' . $this->db->escape($schoolYear), 'left')
->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
->join('classes c', 'c.id = cs.class_id', 'left')
->groupStart()
->where('s.is_active', 1)
->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false)
->orWhere('e.is_withdrawn', 1)
->groupEnd()
->where('ss.school_year', $schoolYear)
->whereIn('LOWER(TRIM(ss.semester))', ['fall', 'spring'])
->where('ss.semester_score IS NOT NULL', null, false)
->orderBy('cs.class_section_name', 'ASC')
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()
->getResultArray();
// Group Fall/Spring scores by student.
$studentMap = [];
foreach ($allScoreRows as $sr) {
$sid = (int)($sr['student_id'] ?? 0);
if ($sid <= 0) {
continue;
}
if (!isset($studentMap[$sid])) {
$studentMap[$sid] = [
'school_id' => $sr['school_id'] ?? '',
'firstname' => $sr['firstname'] ?? '',
'lastname' => $sr['lastname'] ?? '',
'gender' => $sr['gender'] ?? '',
'dob' => $sr['dob'] ?? '',
'is_active' => (int)($sr['is_active'] ?? 1),
'enrollment_status' => $sr['enrollment_status'] ?? '',
'is_withdrawn' => (int)($sr['is_withdrawn'] ?? 0),
'class_section_id' => (int)($sr['class_section_id'] ?? 0),
'class_name' => $sr['class_name'] ?? '',
'class_section_name' => $sr['class_section_name'] ?? '',
'fall_score' => null,
'spring_score' => null,
];
}
$semKey = strtolower(trim((string)($sr['sem_key'] ?? '')));
$val = is_numeric($sr['semester_score']) ? (float)$sr['semester_score'] : null;
if ($semKey === 'fall') {
$studentMap[$sid]['fall_score'] = $val;
} elseif ($semKey === 'spring') {
$studentMap[$sid]['spring_score'] = $val;
}
}
// Pull below-60 manual decisions for this school year.
// Used only when the calculated year_score is below 60.
$belowDecModel = new BelowSixtyDecisionModel();
$belowRows = $belowDecModel
->where('school_year', $schoolYear)
->findAll();
$belowMap = [];
foreach ($belowRows as $b) {
$sid = (int)($b['student_id'] ?? 0);
if ($sid <= 0) {
continue;
}
// Keep the first non-empty decision found for this student.
if (!isset($belowMap[$sid]) || trim((string)($belowMap[$sid]['decision'] ?? '')) === '') {
$belowMap[$sid] = $b;
}
}
// Build final display rows.
$rows = [];
foreach ($studentMap as $sid => $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[$sid])) {
$savedRow = $savedMap[$sid];
$decision = trim((string)($savedRow['decision'] ?? ''));
$source = trim((string)($savedRow['source'] ?? 'pending'));
$notes = (string)($savedRow['notes'] ?? '');
if (isset($savedRow['year_score']) && $savedRow['year_score'] !== '' && is_numeric($savedRow['year_score'])) {
$yearScore = round((float)$savedRow['year_score'], 2);
}
} elseif ($yearScore !== null && $yearScore >= 60) {
$decision = 'Pass';
$source = 'auto';
$notes = '';
} elseif ($yearScore !== null && isset($belowMap[$sid])) {
$decision = trim((string)($belowMap[$sid]['decision'] ?? ''));
$source = $decision !== '' ? 'manual' : 'pending';
$notes = (string)($belowMap[$sid]['notes'] ?? '');
} else {
$decision = '';
$source = 'pending';
$notes = '';
}
$currentClassSectionName = trim((string)($info['class_section_name'] ?? ''));
if ($currentClassSectionName === '' && isset($savedMap[$sid])) {
$currentClassSectionName = trim((string)($savedMap[$sid]['class_section_name'] ?? ''));
}
$currentClassName = trim((string)($info['class_name'] ?? ''));
if ($currentClassName === '') {
$currentClassName = $this->classOnlyLabel($currentClassSectionName);
}
$rows[] = [
'student_id' => $sid,
'school_id' => $info['school_id'],
'firstname' => $info['firstname'],
'lastname' => $info['lastname'],
'gender' => $info['gender'] ?? '',
'dob' => $info['dob'] ?? '',
'is_active' => (int)($info['is_active'] ?? 1),
'enrollment_status' => $info['enrollment_status'] ?? '',
'is_withdrawn' => (int)($info['is_withdrawn'] ?? 0),
'class_section_id' => (int)($info['class_section_id'] ?? 0),
'class_name' => $currentClassName,
'class_section_name' => $currentClassSectionName,
'fall_score' => $fall,
'spring_score' => $spring,
'year_score' => $yearScore,
'decision' => $decision,
'next_year_placement' => $this->nextYearPlacementLabel($decision, $currentClassName, $currentClassSectionName, (string)($info['dob'] ?? ''), $schoolYear),
'source' => $source,
'notes' => $notes,
'saved' => isset($savedMap[$sid]),
'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;
}
}
$generated = !empty($saved);
return ['kind' => 'view', 'view' => 'grading/all_decisions', 'data' => [
'rows' => $rows,
'schoolYear' => $schoolYear,
'schoolYears' => $schoolYears,
'generated' => $generated,
]];
}
public function generateAllDecisions(array $params = [])
{
$get = $params['get'] ?? [];
$post = $params['post'] ?? [];
$schoolYear = trim((string)($post['school_year'] ?? null));
if ($schoolYear === '') {
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing school year.'];
}
// Fetch Fall and Spring scores per student.
$allScoreRows = $this->db->table('semester_scores ss')
->select([
's.id AS student_id',
's.firstname',
's.lastname',
's.is_active',
'cs.class_section_name',
'LOWER(TRIM(ss.semester)) AS sem_key',
'ss.semester_score',
])
->join('students s', 's.id = ss.student_id', 'inner')
->join('enrollments e', 'e.student_id = s.id AND e.school_year = ' . $this->db->escape($schoolYear), 'left')
->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
->groupStart()
->where('s.is_active', 1)
->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false)
->orWhere('e.is_withdrawn', 1)
->groupEnd()
->where('ss.school_year', $schoolYear)
->whereIn('LOWER(TRIM(ss.semester))', ['fall', 'spring'])
->where('ss.semester_score IS NOT NULL', null, false)
->get()
->getResultArray();
if (empty($allScoreRows)) {
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'No semester scores found for this school year.'];
}
// Group Fall/Spring scores by student.
$studentMap = [];
foreach ($allScoreRows as $sr) {
$sid = (int)($sr['student_id'] ?? 0);
if ($sid <= 0) {
continue;
}
if (!isset($studentMap[$sid])) {
$studentMap[$sid] = [
'firstname' => $sr['firstname'] ?? '',
'lastname' => $sr['lastname'] ?? '',
'class_section_name' => $sr['class_section_name'] ?? '',
'fall_score' => null,
'spring_score' => null,
];
}
$semKey = strtolower(trim((string)($sr['sem_key'] ?? '')));
$val = is_numeric($sr['semester_score']) ? (float)$sr['semester_score'] : null;
if ($semKey === 'fall') {
$studentMap[$sid]['fall_score'] = $val;
} elseif ($semKey === 'spring') {
$studentMap[$sid]['spring_score'] = $val;
}
}
// Pull below-60 manual decisions for this school year.
$belowDecModel = new BelowSixtyDecisionModel();
$belowRows = $belowDecModel
->where('school_year', $schoolYear)
->findAll();
$belowMap = [];
foreach ($belowRows as $b) {
$sid = (int)($b['student_id'] ?? 0);
if ($sid <= 0) {
continue;
}
if (!isset($belowMap[$sid]) || trim((string)($belowMap[$sid]['decision'] ?? '')) === '') {
$belowMap[$sid] = $b;
}
}
// Load existing year decisions so we update instead of duplicating.
// New structure: one row per student per school_year.
$decModel = new StudentDecisionModel();
$existing = $decModel
->where('school_year', $schoolYear)
->findAll();
$existingMap = [];
foreach ($existing as $e) {
$sid = (int)($e['student_id'] ?? 0);
if ($sid > 0) {
$existingMap[$sid] = $e;
}
}
$userId = (int)(session()->get('user_id') ?? 0) ?: null;
$savedCount = 0;
foreach ($studentMap as $sid => $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[$sid]) && trim((string)($belowMap[$sid]['decision'] ?? '')) !== '') {
$decision = trim((string)$belowMap[$sid]['decision']);
$source = 'manual';
$notes = trim((string)($belowMap[$sid]['notes'] ?? ''));
$notes = $notes !== '' ? $notes : null;
} else {
$decision = null;
$source = 'pending';
$notes = null;
}
// Important fix:
// use year_score, not semester_score.
// do not save semester = 'year'.
$payload = [
'student_id' => $sid,
'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[$sid])) {
$decModel->update((int)$existingMap[$sid]['id'], $payload);
} else {
$decModel->insert($payload);
}
$savedCount++;
}
$query = http_build_query(['school_year' => $schoolYear]);
return ['kind' => 'flash', 'redirect' => base_url('grading/decisions') . '?' . $query, 'type' => 'status', 'message' => "Decisions generated for {$savedCount} students."];
}
private function nextYearPlacementLabel(
?string $decision,
string $currentClassName,
string $currentClassSectionName,
string $dob,
string $schoolYear
): string
{
$normalizedDecision = DeliberationDecision::normalize($decision);
$classLabel = $this->classOnlyLabel($currentClassName !== '' ? $currentClassName : $currentClassSectionName);
if ($this->isKgClass($classLabel)) {
$kgPlacement = $this->kgPlacementByComingSeptember($dob, $schoolYear);
if ($kgPlacement !== '') {
return $kgPlacement;
}
}
if ($normalizedDecision === DeliberationDecision::REPEAT_CLASS) {
return $classLabel;
}
return '';
}
private function classOnlyLabel(string $className): string
{
return trim((string) preg_replace('/-.+$/', '', $className));
}
private function isKgClass(string $className): bool
{
$value = strtoupper(trim($className));
return preg_match('/(^|[^A-Z0-9])KG([^A-Z0-9]|$)/', $value) === 1 || str_contains($value, 'KINDERGARTEN');
}
private function kgPlacementByComingSeptember(string $dob, string $schoolYear): string
{
$dob = trim($dob);
if ($dob === '') {
return '';
}
try {
$birthDate = new \DateTimeImmutable($dob);
$cutoff = $this->comingSeptemberFirstCutoff($schoolYear);
} catch (\Throwable) {
return '';
}
if ($birthDate > $cutoff) {
return '';
}
return $birthDate->diff($cutoff)->y >= 6 ? '1' : 'KG';
}
private function comingSeptemberFirstCutoff(string $schoolYear): \DateTimeImmutable
{
if (preg_match('/^(\d{4})-\d{4}$/', $schoolYear, $matches) === 1) {
return new \DateTimeImmutable(((int) $matches[1] + 1) . '-09-01');
}
$today = new \DateTimeImmutable('today');
$cutoff = new \DateTimeImmutable($today->format('Y') . '-09-01');
return $today <= $cutoff ? $cutoff : $cutoff->modify('+1 year');
}
private function normalizeSemesterInput(?string $semester): ?string
{
if (!is_string($semester)) {
return null;
}
$trimmed = trim($semester);
return $trimmed === '' ? null : $trimmed;
}
private function resolveSemesterSelection(?string $requestedSemester, array $semesterOptions, ?string $fallbackSemester): string
{
if ($requestedSemester !== null) {
foreach ($semesterOptions as $option) {
if (strcasecmp($option, $requestedSemester) === 0) {
return $option;
}
}
return $requestedSemester;
}
if ($fallbackSemester !== null && $fallbackSemester !== '') {
foreach ($semesterOptions as $option) {
if (strcasecmp($option, $fallbackSemester) === 0) {
return $option;
}
}
return $fallbackSemester;
}
return $semesterOptions[0] ?? '';
}
private function getSemestersForSchoolYear(string $schoolYear, ?string $fallbackSemester = null): array
{
$rows = $this->db->table('semester_scores')
->select('DISTINCT semester', false)
->where('semester IS NOT NULL', null, false)
->where('semester != ""', null, false)
->where('school_year', $schoolYear)
->orderBy('semester', 'ASC')
->get()
->getResultArray();
$semesters = [];
foreach ($rows as $row) {
$value = trim((string) ($row['semester'] ?? ''));
if ($value === '') continue;
$semesters[] = $value;
}
if (empty($semesters)) {
$semesters = ['Fall', 'Spring'];
}
if ($fallbackSemester !== null && $fallbackSemester !== '' && !in_array($fallbackSemester, $semesters, true)) {
array_unshift($semesters, $fallbackSemester);
}
return array_values(array_unique($semesters));
}
private function getSchoolYearsForScores(?string $fallback = null): array
{
$schoolYears = [];
try {
$rows = $this->db->table('semester_scores')
->select('DISTINCT school_year', false)
->where('school_year IS NOT NULL', null, false)
->where('school_year != ""', null, false)
->orderBy('school_year', 'DESC')
->get()
->getResultArray();
foreach ($rows as $row) {
$val = (string)($row['school_year'] ?? '');
if ($val !== '') $schoolYears[] = $val;
}
} catch (\Throwable $e) {
}
try {
$rows2 = $this->db->table('student_class')
->select('DISTINCT school_year', false)
->where('school_year IS NOT NULL', null, false)
->where('school_year != ""', null, false)
->orderBy('school_year', 'DESC')
->get()
->getResultArray();
foreach ($rows2 as $row) {
$val = (string)($row['school_year'] ?? '');
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
}
} catch (\Throwable $e) {
}
if ($fallback && !in_array($fallback, $schoolYears, true)) {
array_unshift($schoolYears, $fallback);
}
return array_values(array_unique($schoolYears));
}
private function userHasMenuUrl(string $needle): bool
{
$needle = strtolower(trim($needle));
if ($needle === '') {
return false;
}
$rawRole = session()->get('role');
$roles = is_array($rawRole) ? $rawRole : [$rawRole ?? 'guest'];
$roles = array_values(array_filter(array_map('strval', $roles)));
if (empty($roles)) {
return false;
}
$service = new NavbarService();
$menu = $service->getMenuForRoles($roles);
if (empty($menu)) {
return false;
}
$normalize = static function (string $url) use ($needle): string {
$url = strtolower(trim($url));
if ($url === '') return '';
$url = preg_replace('#^https?://[^/]+/#i', '', $url);
$url = ltrim($url, '/');
return $url;
};
$target = $normalize($needle);
$stack = $menu;
while (!empty($stack)) {
$node = array_shift($stack);
if (!empty($node['url'])) {
$url = $normalize((string)$node['url']);
if ($url !== '' && $url === $target) {
return true;
}
}
if (!empty($node['children']) && is_array($node['children'])) {
foreach ($node['children'] as $child) {
$stack[] = $child;
}
}
}
return false;
}
/**
* Parses a classSection name into a (classId, label) pair compatible with your Attendance view.
* - KG -> (0, 'KG')
* - Youth-> (13, 'Youth')
* - Grade N -> (N, 'Grade N')
* Fallback: tries to extract first number; else returns (99, original).
*/
private function parseClassIdFromSectionName(string $name): array
{
$n = trim($name);
// Common patterns used in your data
if (preg_match('/\bKG\b/i', $n)) {
return [0, 'KG'];
}
if (preg_match('/\bYouth\b/i', $n)) {
return [13, 'Youth'];
}
if (preg_match('/Grade\s*(\d+)/i', $n, $m)) {
$g = (int)$m[1];
return [$g, 'Grade ' . $g];
}
// Fallback: any number present becomes the grade id
if (preg_match('/(\d+)/', $n, $m2)) {
$g = (int)$m2[1];
return [$g, 'Grade ' . $g];
}
// Last fallback
return [99, $n];
}
/**
* Resolve class_section_id for a student in the current term.
*/
private function resolveClassSectionIdForStudent(int $studentId): int
{
$row = $this->db->table('student_class')
->select('class_section_id')
->where('student_id', $studentId)
->where('school_year', $this->schoolYear)
->where('semester', $this->semester)
->get()->getRow();
return $row ? (int)$row->class_section_id : 0;
}
/** Simple slugifier used as array keys for tabs */
private function slugify(string $s): string
{
$s = strtolower($s);
$s = preg_replace('/[^a-z0-9]+/i', '-', $s);
return trim($s, '-');
}
/** Rank: KG first, numbers ascending, Youth last */
private function rankOf(string $name): int
{
$k = strtolower(trim($name));
if ($k === 'kg' || $k === 'kindergarten') return -100;
if ($k === 'youth') return 100000;
if (preg_match('/\d+/', $k, $m)) return (int)$m[0];
return 50000;
}
private function fetchBelowSixtyEmailRow(int $studentId, string $schoolYear, string $semester): array
{
$semesterKey = strtolower(trim($semester));
$row = $this->db->table('semester_scores ss')
->select([
's.id AS student_id',
's.firstname',
's.lastname',
'cs.class_section_name',
'ss.homework_avg',
'ss.project_avg',
'ss.participation_score',
'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 s', 's.id = ss.student_id', 'inner')
->join('enrollments e', 'e.student_id = s.id AND e.school_year = ' . $this->db->escape($schoolYear), 'left')
->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
->where('ss.school_year', $schoolYear)
->where('ss.student_id', $studentId)
->groupStart()
->where('s.is_active', 1)
->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false)
->orWhere('e.is_withdrawn', 1)
->groupEnd()
->where("LOWER(TRIM(ss.semester))", $semesterKey)
->get()
->getRowArray();
if (!$row) return [];
$commentRow = $this->db->table('score_comments')
->select('comment')
->where('score_type', 'general')
->where('school_year', $schoolYear)
->where("LOWER(TRIM(semester))", $semesterKey)
->where('student_id', $studentId)
->orderBy('created_at', 'DESC')
->get()
->getRowArray();
$row['comment'] = (string)($commentRow['comment'] ?? '');
return $row;
}
private function fetchBelowSixtyParentName(int $studentId): string
{
$parentName = 'Parent/Guardian';
try {
$rows = $this->db->query(
"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]
)->getResultArray();
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 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 fetchAllSemestersForStudent(int $studentId, string $schoolYear): array
{
$rows = $this->db->table('semester_scores ss')
->select([
'ss.semester',
'cs.class_section_name',
'ss.homework_avg',
'ss.project_avg',
'ss.participation_score',
'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('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
->where('ss.student_id', $studentId)
->where('ss.school_year', $schoolYear)
->orderBy('ss.semester', 'ASC')
->get()
->getResultArray();
$semesters = [];
foreach ($rows as $sr) {
$sem = ucfirst(strtolower(trim((string)($sr['semester'] ?? ''))));
$semesters[$sem] = [
'semester' => $sem,
'class_section_name' => $sr['class_section_name'] ?? '',
'homework_avg' => $sr['homework_avg'] ?? null,
'project_avg' => $sr['project_avg'] ?? null,
'participation_score' => $sr['participation_score'] ?? null,
'test_avg' => $sr['test_avg'] ?? null,
'ptap_score' => $sr['ptap_score'] ?? null,
'attendance_score' => $sr['attendance_score'] ?? null,
'midterm_exam_score' => $sr['midterm_exam_score'] ?? null,
'final_exam_score' => $sr['final_exam_score'] ?? null,
'semester_score' => $sr['semester_score'] ?? null,
'comments' => [],
];
}
if (!empty($semesters)) {
$commentRows = $this->db->table('score_comments')
->select('semester, score_type, comment, created_at')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('comment IS NOT NULL', null, false)
->where('comment !=', '')
->orderBy('semester', 'ASC')
->orderBy('score_type', 'ASC')
->orderBy('created_at', 'DESC')
->get()
->getResultArray();
foreach ($commentRows as $c) {
$sem = ucfirst(strtolower(trim((string)($c['semester'] ?? ''))));
$type = strtolower(trim((string)($c['score_type'] ?? 'general')));
if (isset($semesters[$sem]) && !isset($semesters[$sem]['comments'][$type])) {
$semesters[$sem]['comments'][$type] = (string)($c['comment'] ?? '');
}
}
}
return array_values($semesters);
}
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;
}
$index = ($percentile / 100.0) * ($count - 1);
$lower = (int) floor($index);
$upper = (int) ceil($index);
if ($lower === $upper) {
return $sortedScores[$lower];
}
return $sortedScores[$lower] + ($index - $lower) * ($sortedScores[$upper] - $sortedScores[$lower]);
}
private function countScoresAtOrAbove(array $scores, float $threshold): int
{
return count(array_filter(
$scores,
static fn ($score): bool => $score >= $threshold
));
}
}