1235 lines
40 KiB
PHP
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
|
|
));
|
|
}
|
|
}
|