*/ 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); } }