update api and add more features

This commit is contained in:
root
2026-06-04 02:24:41 -04:00
parent fa6c9519a0
commit 4e33882ac7
131 changed files with 34596 additions and 340 deletions
@@ -0,0 +1,393 @@
<?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
{
$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;
}
}