Files
alrahma_sunday_school/app/Services/SemesterScoreService.php
T
root 0ac3a8375e
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 31s
Tests / PHPUnit (push) Failing after 55s
Fix semester context, attendance rosters, and billing workflows
- load global semester helpers consistently and use date-based semester defaults
- fix grading and daily attendance duplicate student/section rows
- keep attendance violations scoped to the current semester by default
- update invoice, refund, discount, payment, and financial aid flows
- add configuration cleanup migrations for duplicate calendar/semester keys
- refresh parent registration/report-card and print request handling
- update related models, services, views, cron notes, and test coverage
2026-08-16 17:41:11 -04:00

272 lines
10 KiB
PHP
Raw 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;
require_once APPPATH . 'Helpers/global_config_helper.php';
$this->semester = (string) \getSemester();
$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);
}
}