Files
2026-05-16 13:44:12 -04:00

270 lines
10 KiB
PHP
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services;
use App\Interfaces\ScoreCalculatorInterface;
use App\Models\SemesterScoreModel;
use App\Models\ConfigurationModel;
use App\Models\FinalExamModel;
use App\Models\MidtermExamModel;
use App\Models\ParticipationModel;
use RuntimeException;
class SemesterScoreService
{
private SemesterScoreModel $semesterScoreModel;
protected ConfigurationModel $configModel;
protected string $semester;
protected string $schoolYear;
/** Who is performing the update (nullable if unknown) */
private ?int $actorId = null;
/** @var array<string, ScoreCalculatorInterface> */
private array $scoreCalculators = [];
public function __construct(
SemesterScoreModel $semesterScoreModel,
ConfigurationModel $configModel,
ScoreCalculatorInterface ...$scoreCalculators
) {
$this->semesterScoreModel = $semesterScoreModel;
$this->configModel = $configModel;
$this->semester = (string) $this->configModel->getConfig('semester');
$this->schoolYear = (string) $this->configModel->getConfig('school_year');
// Default actor from session (can be overridden later)
$this->actorId = (int) (session()->get('user_id') ?? 0) ?: null;
foreach ($scoreCalculators as $calculator) {
$this->scoreCalculators[get_class($calculator)] = $calculator;
}
}
/**
* Update scores for multiple students.
* - If $semester/$schoolYear not provided, they default from service config.
* - If $updatedBy is provided, it overrides the default actor for this batch.
* - Each student can carry its own 'updated_by'; that per-student value wins.
*/
public function updateScoresForStudents(
array $students,
?string $semester = null,
?string $schoolYear = null
): void {
// Defaults from service config if omitted
$semester = $semester ?? $this->semester;
$schoolYear = $schoolYear ?? $this->schoolYear;
foreach ($students as $student) {
$this->updateStudentScores($student, $semester, $schoolYear);
}
}
/**
* Update (compute + persist) the scores for a single student.
*/
public function updateStudentScores(array $student, string $semester, string $schoolYear): void
{
// Validate required student data
foreach (['student_id', 'school_id', 'class_section_id'] as $field) {
if (!isset($student[$field])) {
throw new RuntimeException("Missing required student field: {$field}");
}
}
// Per-student actor resolution:
// 1) student['updated_by'] if present,
// 2) batch/service actorId if set,
// 3) session user as last resort,
// 4) null if none
$updatedBy = isset($student['updated_by'])
? (int) $student['updated_by']
: ($this->actorId ?? ((int) (session()->get('user_id') ?? 0) ?: null));
// Compute all score components
$scoreData = $this->calculateAllScores(
(int) $student['student_id'],
$semester,
$schoolYear,
(int) ($student['class_section_id'] ?? 0) ?: null
);
// Persist (upsert) the scores
$this->saveStudentScores(
(int) $student['student_id'],
(string) $student['school_id'],
(int) $student['class_section_id'],
$updatedBy, // nullable OK
$semester,
$schoolYear,
$scoreData
);
}
/**
* Calculates all components needed for the semester score.
* Returns an associative array of score columns => values (flat).
*/
private function calculateAllScores(int $studentId, string $semester, string $schoolYear, ?int $classSectionId = null): array
{
$scoreData = [
'updated_at' => utc_now(),
];
// Calculate individual components via injected calculators
foreach ($this->scoreCalculators as $calculator) {
try {
$scoreData = array_merge(
$scoreData,
$calculator->calculate($studentId, $semester, $schoolYear, $classSectionId)
);
} catch (\Throwable $e) {
log_message(
'error',
'SemesterScoreService calculator failed for student '
. $studentId . ' (' . get_class($calculator) . '): ' . $e->getMessage()
);
}
}
// Participation fallback if calculator didnt provide it
if (!isset($scoreData['participation_score'])) {
$participationModel = new ParticipationModel();
$participationScore = $participationModel->getParticipationScore($studentId, $semester, $schoolYear, $classSectionId);
$scoreData['participation_score'] = $participationScore !== null ? (float) $participationScore : null;
}
// Composite scores
$scoreData['ptap_score'] = $this->calculatePTAPScore($scoreData);
$midfinal = $this->calculateSemesterScore($scoreData, $semester, $studentId, $schoolYear, $classSectionId);
return array_merge($scoreData, [
'semester_score' => $midfinal['semester_score'],
'midterm_exam_score' => $midfinal['midterm_score'],
'final_exam_score' => $midfinal['final_score'],
'participation_score' => $scoreData['participation_score'], // explicit keep
]);
}
/**
* PTAP weighting rules (tests are represented by quiz averages):
* - No tests and no projects: 50% homework, 50% participation.
* - No projects: 40% homework, 40% participation, 20% tests.
* - No tests: 34% homework, 33% participation, 33% projects.
* - All available: 30% homework, 30% participation, 30% projects, 10% tests.
*/
private function calculatePTAPScore(array $scores): ?float
{
$avgHomework = $scores['homework_avg'] ?? null;
$avgQuiz = $scores['quiz_avg'] ?? null;
$avgProject = $scores['project_avg'] ?? null;
$avgParticipation = $scores['participation_score'] ?? null;
$hasTests = $avgQuiz !== null;
$hasProjects = $avgProject !== null;
if (!$hasTests && !$hasProjects) {
if ($avgHomework === null || $avgParticipation === null) {
return null;
}
return round(($avgHomework * 0.5) + ($avgParticipation * 0.5), 1);
}
if (!$hasProjects) {
if ($avgHomework === null || $avgParticipation === null || $avgQuiz === null) {
return null;
}
return round(($avgHomework * 0.4) + ($avgParticipation * 0.4) + ($avgQuiz * 0.2), 1);
}
if (!$hasTests) {
if ($avgHomework === null || $avgParticipation === null || $avgProject === null) {
return null;
}
return round(($avgHomework * 0.34) + ($avgParticipation * 0.33) + ($avgProject * 0.33), 1);
}
if ($avgHomework === null || $avgParticipation === null || $avgProject === null || $avgQuiz === null) {
return null;
}
return round(
($avgHomework * 0.3) + ($avgParticipation * 0.3) + ($avgProject * 0.3) + ($avgQuiz * 0.1),
1
);
}
/**
* Calculates semester score using:
* - Fall: 0.2 * attendance + 0.2 * ptap + 0.6 * midterm
* - Spring: 0.2 * attendance + 0.2 * ptap + 0.6 * final
* Returns null when a required component is missing.
*/
private function calculateSemesterScore(array $scores, string $semester, int $studentId, string $schoolYear, ?int $classSectionId): array
{
$ptap = $scores['ptap_score'] ?? null;
$attendance = $scores['attendance_score'] ?? null;
$out = [
'midterm_score' => null,
'final_score' => null,
'semester_score' => null,
];
if (strcasecmp($semester, 'Fall') === 0) {
$mid = $scores['midterm_exam_score'] ?? (new MidtermExamModel())->getMidtermExamScore($studentId, $semester, $schoolYear, $classSectionId);
$out['midterm_score'] = $mid !== null ? round((float) $mid, 1) : null;
if ($ptap !== null && $attendance !== null && $out['midterm_score'] !== null) {
$out['semester_score'] = round(($attendance * 0.2) + ($ptap * 0.2) + ($out['midterm_score'] * 0.6), 1);
}
return $out;
}
if (strcasecmp($semester, 'Spring') === 0) {
$fin = $scores['final_exam_score'] ?? (new FinalExamModel())->getFinalExamScore($studentId, $semester, $schoolYear, $classSectionId);
$out['final_score'] = $fin !== null ? round((float) $fin, 1) : null;
if ($ptap !== null && $attendance !== null && $out['final_score'] !== null) {
$out['semester_score'] = round(($attendance * 0.2) + ($ptap * 0.2) + ($out['final_score'] * 0.6), 1);
}
return $out;
}
return $out;
}
/**
* Persist (upsert) semester scores for a student.
* Ensure SemesterScoreModel::$allowedFields includes ALL keys we pass here.
*/
private function saveStudentScores(
int $studentId,
string $schoolId,
int $classSectionId,
?int $updatedBy,
string $semester,
string $schoolYear,
array $scoreData
): void {
$base = [
'student_id' => $studentId,
'school_id' => $schoolId,
'class_section_id' => $classSectionId,
'semester' => $semester,
'school_year' => $schoolYear,
'updated_at' => utc_now(),
];
if ($updatedBy !== null) {
$base['updated_by'] = $updatedBy;
}
// Merge in the computed score fields (e.g., homework_avg, quiz_avg, project_avg, attendance_score, etc.)
$payload = array_merge($base, $scoreData);
// Upsert using your model (make sure allowedFields has all keys in $payload)
$this->semesterScoreModel->upsert($payload);
}
}