find($studentId); if (!$student) { return null; } $current = $currentSchoolYear ?: $this->resolveCurrentSchoolYear(); if ($current === null) { return null; } $next = $this->nextSchoolYear($current); if ($next === null) { return null; } return $this->evaluateOne($student, $current, $next, $userId); } /** * Evaluate eligibility for a class section worth of students at once. * * @return array{ evaluated:int, eligible:int, repeated:int, on_hold:int, conditional:int, graduated:int, errors:array } */ public function evaluateClassSection( int $classSectionId, ?string $currentSchoolYear = null, ?int $userId = null ): array { $current = $currentSchoolYear ?: $this->resolveCurrentSchoolYear(); if ($current === null) { return $this->emptySummary(); } $next = $this->nextSchoolYear($current); if ($next === null) { return $this->emptySummary(); } $studentIds = DB::table('student_class') ->where('class_section_id', $classSectionId) ->where('school_year', $current) ->pluck('student_id') ->map(fn ($id) => (int) $id) ->all(); $summary = $this->emptySummary(); foreach ($studentIds as $studentId) { $student = Student::query()->find($studentId); if (!$student) { continue; } try { $record = $this->evaluateOne($student, $current, $next, $userId); if ($record) { $summary['evaluated']++; $key = $this->statusBucket($record->promotion_status); if ($key !== null) { $summary[$key]++; } } } catch (\Throwable $e) { Log::error('Promotion eligibility evaluation failed', [ 'student_id' => $studentId, 'exception' => $e, ]); $summary['errors'][] = sprintf('Student %d: %s', $studentId, $e->getMessage()); } } return $summary; } /** * Evaluate eligibility for an entire school year (all students with * class assignments). Convenience wrapper around `evaluateOne`. */ public function evaluateSchoolYear(?string $currentSchoolYear = null, ?int $userId = null): array { $current = $currentSchoolYear ?: $this->resolveCurrentSchoolYear(); if ($current === null) { return $this->emptySummary(); } $next = $this->nextSchoolYear($current); if ($next === null) { return $this->emptySummary(); } $studentIds = DB::table('student_class') ->where('school_year', $current) ->distinct() ->pluck('student_id') ->map(fn ($id) => (int) $id) ->all(); $summary = $this->emptySummary(); foreach ($studentIds as $studentId) { $student = Student::query()->find($studentId); if (!$student) { continue; } try { $record = $this->evaluateOne($student, $current, $next, $userId); if ($record) { $summary['evaluated']++; $key = $this->statusBucket($record->promotion_status); if ($key !== null) { $summary[$key]++; } } } catch (\Throwable $e) { Log::error('Promotion eligibility evaluation failed', [ 'student_id' => $studentId, 'exception' => $e, ]); $summary['errors'][] = sprintf('Student %d: %s', $studentId, $e->getMessage()); } } return $summary; } /** * Core per-student logic implementing section 7's decision tree. */ public function evaluateOne( Student $student, string $currentSchoolYear, string $nextSchoolYear, ?int $userId = null ): StudentPromotionRecord { $studentId = (int) $student->getKey(); $sectionId = (int) DB::table('student_class') ->where('student_id', $studentId) ->where('school_year', $currentSchoolYear) ->orderByDesc('updated_at') ->orderByDesc('id') ->value('class_section_id'); $progressionInfo = $this->progression->resolveByCurrentClassSectionId($sectionId); if ($progressionInfo === null && $sectionId > 0) { // Even without a mapping we still want to record the eligibility decision. $progressionInfo = [ 'current_level_id' => null, 'current_level_name' => ClassSection::getClassSectionNameBySectionId($sectionId), 'next_level_id' => null, 'next_level_name' => null, 'is_terminal' => false, ]; } $progressionInfo ??= [ 'current_level_id' => null, 'current_level_name' => $student->registration_grade, 'next_level_id' => null, 'next_level_name' => null, 'is_terminal' => false, ]; $scoreInfo = $this->loadAcademicResult($studentId, $currentSchoolYear); $passed = $scoreInfo['passed']; $finalAvg = $scoreInfo['final_average']; return DB::transaction(function () use ( $student, $studentId, $currentSchoolYear, $nextSchoolYear, $progressionInfo, $passed, $finalAvg, $userId, $scoreInfo ) { $existing = StudentPromotionRecord::query() ->where('student_id', $studentId) ->where('next_school_year', $nextSchoolYear) ->first(); $isNew = $existing === null; $record = $existing ?: new StudentPromotionRecord(); $record->fill([ 'student_id' => $studentId, 'parent_id' => (int) ($student->parent_id ?? 0) ?: null, 'current_school_year' => $currentSchoolYear, 'next_school_year' => $nextSchoolYear, 'current_level_id' => $progressionInfo['current_level_id'], 'current_level_name' => $progressionInfo['current_level_name'], 'promoted_level_id' => $progressionInfo['next_level_id'], 'promoted_level_name' => $progressionInfo['next_level_name'], 'final_average' => $finalAvg, 'eligibility_notes' => $scoreInfo['notes'], 'updated_by' => $userId, ]); if ($isNew) { $record->promotion_status = StudentPromotionRecord::STATUS_NOT_REVIEWED; $record->enrollment_required = true; $record->enrollment_status = StudentPromotionRecord::ENROLLMENT_NOT_STARTED; } $previousStatus = (string) ($record->promotion_status ?? StudentPromotionRecord::STATUS_NOT_REVIEWED); $record->passed_current_level = $passed; $newStatus = $this->resolveStatusFromEvaluation( $passed, $progressionInfo['is_terminal'] ?? false, $scoreInfo['has_data'] ); // Preserve admin overrides for terminal/holds. if (in_array($previousStatus, [ StudentPromotionRecord::STATUS_WITHDRAWN, StudentPromotionRecord::STATUS_GRADUATED, StudentPromotionRecord::STATUS_PROMOTED_AND_ENROLLED, StudentPromotionRecord::STATUS_NOT_ENROLLED, ], true)) { $newStatus = $previousStatus; } $record->promotion_status = $newStatus; $record->save(); if ($isNew) { $this->audit->logRecordCreated($record, $userId); } $this->audit->logEligibilityEvaluation( $record, (bool) $passed, $userId, $scoreInfo['notes'] ); if ($previousStatus !== $newStatus) { $this->audit->logStatusChange($record, $previousStatus, $newStatus, $userId, 'Auto eligibility evaluation'); } return $record; }); } /** * Best effort lookup of the student's final academic result for the * given school year. Treats missing fall + spring scores as "no * data" → on hold (plan section 4: On Hold for missing result). * * @return array{ passed:?bool, final_average:?float, notes:?string, has_data:bool } */ private function loadAcademicResult(int $studentId, string $schoolYear): array { $rows = DB::table('semester_scores') ->select('semester', 'semester_score') ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->get(); $fall = null; $spring = null; foreach ($rows as $row) { $semester = strtolower((string) ($row->semester ?? '')); $score = isset($row->semester_score) ? (float) $row->semester_score : null; if ($semester === 'fall') { $fall = $score; } elseif ($semester === 'spring') { $spring = $score; } } $threshold = $this->passThreshold(); if ($fall === null && $spring === null) { return [ 'passed' => null, 'final_average' => null, 'notes' => 'Missing fall and spring scores', 'has_data' => false, ]; } if ($fall === null || $spring === null) { $existing = $fall ?? $spring; return [ 'passed' => null, 'final_average' => $existing, 'notes' => 'Awaiting both fall and spring scores', 'has_data' => false, ]; } $avg = round(($fall + $spring) / 2.0, 2); return [ 'passed' => $avg >= $threshold, 'final_average' => $avg, 'notes' => sprintf('Final average %.2f (threshold %.2f)', $avg, $threshold), 'has_data' => true, ]; } private function resolveStatusFromEvaluation(?bool $passed, bool $terminal, bool $hasData): string { if (!$hasData) { return StudentPromotionRecord::STATUS_ON_HOLD; } if ($passed === false) { return StudentPromotionRecord::STATUS_REPEATED; } if ($terminal) { return StudentPromotionRecord::STATUS_GRADUATED; } return StudentPromotionRecord::STATUS_AWAITING_PARENT; } private function statusBucket(string $status): ?string { return match ($status) { StudentPromotionRecord::STATUS_AWAITING_PARENT, StudentPromotionRecord::STATUS_ELIGIBLE => 'eligible', StudentPromotionRecord::STATUS_REPEATED => 'repeated', StudentPromotionRecord::STATUS_ON_HOLD => 'on_hold', StudentPromotionRecord::STATUS_CONDITIONAL => 'conditional', StudentPromotionRecord::STATUS_GRADUATED => 'graduated', default => null, }; } private function emptySummary(): array { return [ 'evaluated' => 0, 'eligible' => 0, 'repeated' => 0, 'on_hold' => 0, 'conditional' => 0, 'graduated' => 0, 'errors' => [], ]; } private function resolveCurrentSchoolYear(): ?string { $year = (string) (Configuration::getConfigValueByKey('school_year') ?? ''); return $year !== '' ? $year : null; } private function nextSchoolYear(string $current): ?string { if (!preg_match('/^(\d{4})-(\d{4})$/', $current, $m)) { return null; } return ((int) $m[1] + 1) . '-' . ((int) $m[2] + 1); } private function passThreshold(): float { $configured = Configuration::getConfigValueByKey('promotion_pass_threshold'); if ($configured !== null && is_numeric($configured)) { return (float) $configured; } return self::DEFAULT_PASS_THRESHOLD; } }