270 lines
10 KiB
PHP
Executable File
270 lines
10 KiB
PHP
Executable File
<?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 didn’t 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);
|
||
}
|
||
}
|