Files
alrahma_sunday_school_api/app/Services/Promotions/PromotionEligibilityService.php
T
2026-06-11 11:46:12 -04:00

406 lines
14 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\Promotions;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\Student;
use App\Models\StudentPromotionRecord;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Implements the eligibility engine described in plan sections 57.
*
* Builds (or refreshes) one `student_promotion_records` row per
* student per next-school-year and assigns a promotion status based on
* academic results stored in `semester_scores`.
*/
class PromotionEligibilityService
{
public const DEFAULT_PASS_THRESHOLD = 60.0;
public function __construct(
private LevelProgressionService $progression,
private PromotionStatusService $statusService,
private PromotionAuditService $audit
) {}
/**
* Evaluate eligibility for a single student.
*
* @param int|null $userId actor id for audit
*/
public function evaluateStudent(
int $studentId,
?string $currentSchoolYear = null,
?int $userId = null
): ?StudentPromotionRecord {
$student = Student::query()->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<int,string> }
*/
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
{
$decision = DB::table('student_decisions')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->orderByDesc('updated_at')
->orderByDesc('id')
->first();
if (! $decision) {
return [
'passed' => null,
'final_average' => null,
'notes' => 'Missing student_decisions record for promotion eligibility',
'has_data' => false,
];
}
$rawDecision = strtolower(trim((string) ($decision->decision ?? '')));
$score = $decision->year_score !== null ? round((float) $decision->year_score, 2) : null;
if (in_array($rawDecision, ['passed', 'pass', 'promoted', 'promote', 'eligible_to_continue'], true)) {
return [
'passed' => true,
'final_average' => $score,
'notes' => $score !== null
? sprintf('student_decisions marked promoted with score %.2f', $score)
: 'student_decisions marked promoted without a year_score',
'has_data' => true,
];
}
if (in_array($rawDecision, ['failed', 'fail', 'retained', 'repeat', 'repeated_level'], true)) {
return [
'passed' => false,
'final_average' => $score,
'notes' => 'student_decisions marked failed/retained',
'has_data' => true,
];
}
if ($rawDecision === '' || $rawDecision === 'pending' || $rawDecision === 'manual review' || $rawDecision === 'manual_review') {
return [
'passed' => null,
'final_average' => $score,
'notes' => 'student_decisions is pending or requires manual review',
'has_data' => false,
];
}
return [
'passed' => null,
'final_average' => $score,
'notes' => 'student_decisions has unrecognized decision: '.$rawDecision,
'has_data' => false,
];
}
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;
}
}