403 lines
14 KiB
PHP
403 lines
14 KiB
PHP
<?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 5–7.
|
||
*
|
||
* 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;
|
||
}
|
||
}
|