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,202 @@
<?php
namespace App\Services\Promotions;
use App\Models\ClassSection;
use App\Models\LevelProgression;
use App\Models\SchoolClass;
use Illuminate\Support\Collection;
/**
* Resolves current → next level using the configurable
* `level_progressions` table (plan section 10).
*
* Falls back to `class_id + 1` only if no mapping is found, matching
* the legacy behaviour in {@see \App\Services\School\AccountEventService}.
*/
class LevelProgressionService
{
/**
* Default mapping shipped with the plan doc; used to seed the table
* if no rows exist when the service is first asked for a list.
*/
public const DEFAULT_MAP = [
['current' => 'KG1', 'next' => 'KG2', 'order' => 10, 'terminal' => false],
['current' => 'KG2', 'next' => 'Grade 1', 'order' => 20, 'terminal' => false],
['current' => 'Grade 1','next' => 'Grade 2', 'order' => 30, 'terminal' => false],
['current' => 'Grade 2','next' => 'Grade 3', 'order' => 40, 'terminal' => false],
['current' => 'Grade 3','next' => 'Grade 4', 'order' => 50, 'terminal' => false],
['current' => 'Grade 4','next' => 'Grade 5', 'order' => 60, 'terminal' => false],
['current' => 'Grade 5','next' => 'Grade 6', 'order' => 70, 'terminal' => false],
['current' => 'Grade 6','next' => 'Grade 7', 'order' => 80, 'terminal' => false],
['current' => 'Grade 7','next' => 'Grade 8', 'order' => 90, 'terminal' => false],
['current' => 'Grade 8','next' => 'Grade 9', 'order' => 100, 'terminal' => false],
['current' => 'Grade 9','next' => 'Youth', 'order' => 110, 'terminal' => false],
['current' => 'Youth', 'next' => null, 'order' => 120, 'terminal' => true],
];
/**
* Returns all configured level progressions, seeding defaults if the
* table is empty.
*
* @return Collection<int,LevelProgression>
*/
public function list(bool $activeOnly = true): Collection
{
$query = LevelProgression::query()->ordered();
if ($activeOnly) {
$query->active();
}
$rows = $query->get();
if ($rows->isEmpty() && $activeOnly) {
$this->seedDefaults();
$rows = LevelProgression::query()->ordered()->active()->get();
}
return $rows;
}
/**
* Resolve next-level information for a student based on their current
* `class_id` (i.e. classes.id). Returns null if no progression rule
* matches and no fallback can be derived.
*
* @return array{
* current_level_id:?int,
* current_level_name:?string,
* next_level_id:?int,
* next_level_name:?string,
* is_terminal:bool
* }|null
*/
public function resolveByCurrentClassId(?int $currentClassId): ?array
{
if ($currentClassId === null || $currentClassId <= 0) {
return null;
}
$current = SchoolClass::query()->find($currentClassId);
if (!$current) {
return null;
}
$currentName = (string) ($current->class_name ?? '');
$progression = LevelProgression::findByCurrentLevelId($currentClassId)
?: ($currentName !== '' ? LevelProgression::findByCurrentLevelName($currentName) : null);
if ($progression) {
return [
'current_level_id' => $currentClassId,
'current_level_name' => $currentName !== '' ? $currentName : $progression->current_level_name,
'next_level_id' => $progression->next_level_id !== null ? (int) $progression->next_level_id : null,
'next_level_name' => $progression->next_level_name,
'is_terminal' => (bool) $progression->is_terminal,
];
}
// Legacy fallback: numeric next class id (matches AccountEventService::preparePromotionQueue).
$nextId = $currentClassId + 1;
$next = SchoolClass::query()->find($nextId);
return [
'current_level_id' => $currentClassId,
'current_level_name' => $currentName !== '' ? $currentName : null,
'next_level_id' => $next ? (int) $next->id : null,
'next_level_name' => $next ? (string) $next->class_name : null,
'is_terminal' => false,
];
}
/**
* Resolve next-level info from a `classSection` row id (the
* "from_class_section_id" used elsewhere).
*/
public function resolveByCurrentClassSectionId(?int $classSectionId): ?array
{
if ($classSectionId === null || $classSectionId <= 0) {
return null;
}
$classId = ClassSection::getClassId($classSectionId);
if (!$classId) {
return null;
}
return $this->resolveByCurrentClassId((int) $classId);
}
/**
* Seed the table with the default mapping from {@see DEFAULT_MAP}.
* Idempotent relies on the unique key on `current_level_name`.
*/
public function seedDefaults(): int
{
$created = 0;
foreach (self::DEFAULT_MAP as $row) {
$existing = LevelProgression::query()
->whereRaw('LOWER(current_level_name) = ?', [strtolower($row['current'])])
->first();
if ($existing) {
continue;
}
$currentClassId = $this->resolveClassIdByName($row['current']);
$nextClassId = isset($row['next']) && $row['next'] !== null
? $this->resolveClassIdByName($row['next'])
: null;
LevelProgression::query()->create([
'current_level_id' => $currentClassId,
'current_level_name' => $row['current'],
'next_level_id' => $nextClassId,
'next_level_name' => $row['next'] ?? null,
'order_index' => (int) ($row['order'] ?? 0),
'is_terminal' => (bool) ($row['terminal'] ?? false),
'is_active' => true,
]);
$created++;
}
return $created;
}
public function upsertMapping(array $payload): LevelProgression
{
$currentName = (string) ($payload['current_level_name'] ?? '');
if ($currentName === '') {
throw new \InvalidArgumentException('current_level_name is required');
}
$existing = LevelProgression::findByCurrentLevelName($currentName);
$data = [
'current_level_id' => isset($payload['current_level_id']) ? (int) $payload['current_level_id'] : ($existing->current_level_id ?? null),
'current_level_name' => $currentName,
'next_level_id' => isset($payload['next_level_id']) ? (int) $payload['next_level_id'] : null,
'next_level_name' => $payload['next_level_name'] ?? null,
'order_index' => isset($payload['order_index']) ? (int) $payload['order_index'] : ($existing->order_index ?? 0),
'is_terminal' => (bool) ($payload['is_terminal'] ?? ($existing->is_terminal ?? false)),
'is_active' => array_key_exists('is_active', $payload) ? (bool) $payload['is_active'] : ($existing->is_active ?? true),
'notes' => $payload['notes'] ?? ($existing->notes ?? null),
];
if ($existing) {
$existing->fill($data);
$existing->save();
return $existing;
}
return LevelProgression::query()->create($data);
}
private function resolveClassIdByName(?string $name): ?int
{
if ($name === null || $name === '') {
return null;
}
$row = SchoolClass::query()
->whereRaw('LOWER(class_name) = ?', [strtolower(trim($name))])
->orderByDesc('id')
->first();
return $row ? (int) $row->id : null;
}
}
@@ -0,0 +1,153 @@
<?php
namespace App\Services\Promotions;
use App\Models\PromotionAuditLog;
use App\Models\StudentPromotionRecord;
/**
* Centralised writer for the promotion audit trail (plan section 18).
*/
class PromotionAuditService
{
public function logRecordCreated(StudentPromotionRecord $record, ?int $userId, ?string $notes = null): PromotionAuditLog
{
return $this->log($record, PromotionAuditLog::ACTION_RECORD_CREATED, [
'user_id' => $userId,
'new_value' => $record->promotion_status,
'notes' => $notes,
]);
}
public function logStatusChange(
StudentPromotionRecord $record,
?string $oldStatus,
string $newStatus,
?int $userId,
?string $notes = null
): PromotionAuditLog {
return $this->log($record, PromotionAuditLog::ACTION_STATUS_CHANGED, [
'user_id' => $userId,
'field' => 'promotion_status',
'old_value' => $oldStatus,
'new_value' => $newStatus,
'notes' => $notes,
]);
}
public function logEligibilityEvaluation(
StudentPromotionRecord $record,
bool $passed,
?int $userId,
?string $notes = null
): PromotionAuditLog {
return $this->log($record, PromotionAuditLog::ACTION_ELIGIBILITY_EVALUATED, [
'user_id' => $userId,
'field' => 'passed_current_level',
'new_value' => $passed ? '1' : '0',
'notes' => $notes,
]);
}
public function logParentNotified(StudentPromotionRecord $record, ?int $userId, ?string $notes = null): PromotionAuditLog
{
return $this->log($record, PromotionAuditLog::ACTION_PARENT_NOTIFIED, [
'user_id' => $userId,
'notes' => $notes,
]);
}
public function logEnrollmentStepCompleted(
StudentPromotionRecord $record,
string $field,
?int $userId,
?string $notes = null
): PromotionAuditLog {
return $this->log($record, PromotionAuditLog::ACTION_ENROLLMENT_STEP_COMPLETED, [
'user_id' => $userId,
'field' => $field,
'new_value' => '1',
'notes' => $notes,
]);
}
public function logEnrollmentStarted(StudentPromotionRecord $record, ?int $userId): PromotionAuditLog
{
return $this->log($record, PromotionAuditLog::ACTION_ENROLLMENT_STARTED, [
'user_id' => $userId,
]);
}
public function logEnrollmentCompleted(StudentPromotionRecord $record, ?int $userId): PromotionAuditLog
{
return $this->log($record, PromotionAuditLog::ACTION_ENROLLMENT_COMPLETED, [
'user_id' => $userId,
]);
}
public function logPromotionFinalized(StudentPromotionRecord $record, ?int $userId): PromotionAuditLog
{
return $this->log($record, PromotionAuditLog::ACTION_PROMOTION_FINALIZED, [
'user_id' => $userId,
'new_value' => $record->promotion_status,
]);
}
public function logDeadlineSet(
StudentPromotionRecord $record,
?string $oldDeadline,
?string $newDeadline,
?int $userId
): PromotionAuditLog {
return $this->log($record, PromotionAuditLog::ACTION_DEADLINE_SET, [
'user_id' => $userId,
'field' => 'enrollment_deadline',
'old_value' => $oldDeadline,
'new_value' => $newDeadline,
]);
}
public function logReminderSent(
StudentPromotionRecord $record,
string $reminderType,
?int $userId
): PromotionAuditLog {
return $this->log($record, PromotionAuditLog::ACTION_REMINDER_SENT, [
'user_id' => $userId,
'field' => 'reminder_type',
'new_value' => $reminderType,
]);
}
public function logDeadlineExpired(StudentPromotionRecord $record): PromotionAuditLog
{
return $this->log($record, PromotionAuditLog::ACTION_DEADLINE_EXPIRED, []);
}
public function logManualOverride(
StudentPromotionRecord $record,
string $field,
?string $oldValue,
?string $newValue,
?int $userId,
?string $notes = null
): PromotionAuditLog {
return $this->log($record, PromotionAuditLog::ACTION_MANUAL_OVERRIDE, [
'user_id' => $userId,
'field' => $field,
'old_value' => $oldValue,
'new_value' => $newValue,
'notes' => $notes,
]);
}
private function log(StudentPromotionRecord $record, string $action, array $extra): PromotionAuditLog
{
return PromotionAuditLog::query()->create(array_merge([
'promotion_id' => (int) $record->getKey(),
'student_id' => (int) $record->student_id,
'action_type' => $action,
'performed_at' => now(),
], $extra));
}
}
@@ -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;
}
}
@@ -0,0 +1,391 @@
<?php
namespace App\Services\Promotions;
use App\Models\Configuration;
use App\Models\Enrollment;
use App\Models\Student;
use App\Models\StudentPromotionRecord;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* Implements the parent-side enrollment workflow described in plan
* sections 6, 8, 9 and 12. Tracks the parent's progress through the
* checklist and finalises the promotion + creates the next-year
* enrollment record once everything is complete.
*/
class PromotionEnrollmentService
{
public const ALLOWED_STEPS = [
'info_confirmed',
'documents_uploaded',
'agreement_accepted',
'payment_completed',
];
public function __construct(
private PromotionStatusService $statusService,
private PromotionAuditService $audit
) {
}
/**
* Lists actionable promotion records for a parent (the parent
* portal landing page in plan section 12).
*/
public function overviewForParent(int $parentId, ?string $nextSchoolYear = null): array
{
$year = $nextSchoolYear ?: $this->guessNextSchoolYear();
$query = StudentPromotionRecord::query()->forParent($parentId);
if ($year !== null) {
$query->forNextYear($year);
}
$records = $query
->orderByRaw('CASE WHEN promotion_status IN (?, ?, ?) THEN 0 ELSE 1 END', [
StudentPromotionRecord::STATUS_AWAITING_PARENT,
StudentPromotionRecord::STATUS_ELIGIBLE,
StudentPromotionRecord::STATUS_ENROLLMENT_STARTED,
])
->orderBy('promotion_id', 'desc')
->get();
$studentIds = $records->pluck('student_id')->unique()->all();
/** @var EloquentCollection<int,Student> $studentsCollection */
$studentsCollection = !empty($studentIds)
? Student::query()->whereIn('id', $studentIds)->get()
: new EloquentCollection();
$studentsById = $studentsCollection->keyBy('id');
$payload = $records->map(function (StudentPromotionRecord $record) use ($studentsById) {
return $this->presentRecord($record, $studentsById->get($record->student_id));
})->all();
return [
'records' => $payload,
'next_school_year' => $year,
'message' => $this->parentBannerMessage($records),
];
}
/**
* Mark the start of the enrollment process for a single promotion
* record.
*/
public function startEnrollment(StudentPromotionRecord $record, int $parentId, ?int $userId = null): StudentPromotionRecord
{
$this->assertParentOwns($record, $parentId);
$this->assertActionable($record);
return DB::transaction(function () use ($record, $parentId, $userId) {
if (!$record->enrollment_started_at) {
$record->enrollment_started_at = now();
}
$record->enrollment_status = StudentPromotionRecord::ENROLLMENT_IN_PROGRESS;
$record->parent_id = $record->parent_id ?: $parentId;
$record->updated_by = $userId;
$record->save();
if ($record->promotion_status !== StudentPromotionRecord::STATUS_ENROLLMENT_STARTED) {
$this->statusService->transition(
$record,
StudentPromotionRecord::STATUS_ENROLLMENT_STARTED,
$userId,
'Parent started enrollment'
);
}
$this->audit->logEnrollmentStarted($record, $userId);
return $record->refresh();
});
}
/**
* Update one or more checklist steps. Returns the (possibly
* finalised) record.
*/
public function updateSteps(
StudentPromotionRecord $record,
int $parentId,
array $steps,
?int $userId = null
): StudentPromotionRecord {
$this->assertParentOwns($record, $parentId);
$this->assertActionable($record);
$sanitized = [];
foreach ($steps as $field => $value) {
if (!in_array($field, self::ALLOWED_STEPS, true)) {
continue;
}
$sanitized[$field] = (bool) $value;
}
if (empty($sanitized)) {
return $record;
}
return DB::transaction(function () use ($record, $parentId, $sanitized, $userId) {
$changed = [];
foreach ($sanitized as $field => $value) {
if ((bool) $record->{$field} !== $value) {
$record->{$field} = $value;
$changed[] = $field;
}
}
if (empty($changed)) {
return $record;
}
if (!$record->enrollment_started_at) {
$record->enrollment_started_at = now();
}
$record->enrollment_status = StudentPromotionRecord::ENROLLMENT_IN_PROGRESS;
$record->parent_id = $record->parent_id ?: $parentId;
$record->updated_by = $userId;
$record->save();
foreach ($changed as $field) {
$this->audit->logEnrollmentStepCompleted($record, $field, $userId);
}
if ($record->promotion_status !== StudentPromotionRecord::STATUS_ENROLLMENT_STARTED) {
if (in_array($record->promotion_status, [
StudentPromotionRecord::STATUS_ELIGIBLE,
StudentPromotionRecord::STATUS_AWAITING_PARENT,
], true)) {
$this->statusService->transition(
$record,
StudentPromotionRecord::STATUS_ENROLLMENT_STARTED,
$userId,
'Enrollment progress recorded'
);
}
}
// Auto-finalise once all checklist items are complete.
if ($record->enrollmentChecklistComplete()) {
$record = $this->finalize($record, $userId);
}
return $record;
});
}
/**
* Submit the enrollment, asserting the checklist is complete.
*/
public function submitEnrollment(
StudentPromotionRecord $record,
int $parentId,
?int $userId = null
): StudentPromotionRecord {
$this->assertParentOwns($record, $parentId);
$this->assertActionable($record);
if (!$record->enrollmentChecklistComplete()) {
throw new RuntimeException('Enrollment checklist is incomplete.');
}
return $this->finalize($record, $userId);
}
/**
* Mark expired records (past deadline, still incomplete) as
* "not_enrolled_for_next_year" (plan section 15).
*
* @return array{ expired:int, ids:array<int,int> }
*/
public function markExpiredDeadlines(?\DateTimeInterface $now = null, ?int $userId = null): array
{
$now = $now ?: now();
$records = StudentPromotionRecord::query()
->whereIn('promotion_status', [
StudentPromotionRecord::STATUS_ELIGIBLE,
StudentPromotionRecord::STATUS_AWAITING_PARENT,
StudentPromotionRecord::STATUS_ENROLLMENT_STARTED,
])
->whereNotNull('enrollment_deadline')
->whereDate('enrollment_deadline', '<', $now)
->get();
$ids = [];
foreach ($records as $record) {
DB::transaction(function () use ($record, $userId, &$ids) {
$record->enrollment_status = StudentPromotionRecord::ENROLLMENT_EXPIRED;
$record->save();
$this->audit->logDeadlineExpired($record);
$this->statusService->forceStatus(
$record,
StudentPromotionRecord::STATUS_NOT_ENROLLED,
$userId,
'Deadline expired without enrollment'
);
$ids[] = (int) $record->getKey();
});
}
return [
'expired' => count($ids),
'ids' => $ids,
];
}
/**
* Finalise promotion + create the next-year enrollment record
* (plan sections 5, 6 step 6 and section 9).
*/
private function finalize(StudentPromotionRecord $record, ?int $userId): StudentPromotionRecord
{
if ($record->promotion_status === StudentPromotionRecord::STATUS_PROMOTED_AND_ENROLLED) {
return $record;
}
return DB::transaction(function () use ($record, $userId) {
$record->enrollment_status = StudentPromotionRecord::ENROLLMENT_COMPLETED;
$record->enrollment_completed_at = $record->enrollment_completed_at ?: now();
$record->promotion_finalized_at = now();
$record->updated_by = $userId;
$enrollmentId = $this->ensureNextYearEnrollment($record);
if ($enrollmentId !== null) {
$record->enrollment_id = $enrollmentId;
}
$record->save();
$this->audit->logEnrollmentCompleted($record, $userId);
$this->statusService->transition(
$record,
StudentPromotionRecord::STATUS_PROMOTED_AND_ENROLLED,
$userId,
'Enrollment checklist complete'
);
$this->audit->logPromotionFinalized($record->refresh(), $userId);
return $record->refresh();
});
}
/**
* Create or update an `enrollments` row for the next school year so
* the student is officially placed in the new year (plan section 9
* record-update rule).
*/
private function ensureNextYearEnrollment(StudentPromotionRecord $record): ?int
{
$existing = Enrollment::query()
->where('student_id', $record->student_id)
->where('school_year', $record->next_school_year)
->first();
$payload = [
'student_id' => $record->student_id,
'parent_id' => $record->parent_id,
'school_year' => $record->next_school_year,
'enrollment_status' => 'enrolled',
'admission_status' => 'accepted',
'is_withdrawn' => false,
'enrollment_date' => now()->toDateString(),
];
if ($existing) {
$existing->fill($payload);
$existing->save();
return (int) $existing->getKey();
}
$created = Enrollment::query()->create($payload);
return $created ? (int) $created->getKey() : null;
}
private function presentRecord(StudentPromotionRecord $record, ?Student $student): array
{
return [
'promotion_id' => (int) $record->getKey(),
'student_id' => (int) $record->student_id,
'student_name' => $student
? trim((string) $student->firstname . ' ' . (string) $student->lastname)
: null,
'current_level' => $record->current_level_name,
'promoted_level' => $record->promoted_level_name,
'current_school_year' => $record->current_school_year,
'next_school_year' => $record->next_school_year,
'promotion_status' => $record->promotion_status,
'enrollment_status' => $record->enrollment_status,
'enrollment_deadline' => $record->enrollment_deadline?->toDateString(),
'enrollment_started_at' => $record->enrollment_started_at?->toDateTimeString(),
'enrollment_completed_at' => $record->enrollment_completed_at?->toDateTimeString(),
'parent_action_required' => in_array(
$record->promotion_status,
StudentPromotionRecord::parentActionableStatuses(),
true
),
'checklist' => [
'info_confirmed' => (bool) $record->info_confirmed,
'documents_uploaded' => (bool) $record->documents_uploaded,
'agreement_accepted' => (bool) $record->agreement_accepted,
'payment_completed' => (bool) $record->payment_completed,
],
'final_average' => $record->final_average !== null ? (float) $record->final_average : null,
'passed_current_level' => $record->passed_current_level,
];
}
private function parentBannerMessage(EloquentCollection $records): ?string
{
$actionable = $records->first(function (StudentPromotionRecord $r) {
return in_array(
$r->promotion_status,
StudentPromotionRecord::parentActionableStatuses(),
true
);
});
if (!$actionable) {
return null;
}
$level = $actionable->promoted_level_name ?: 'the next level';
$deadline = $actionable->enrollment_deadline?->toDateString();
$deadlineText = $deadline ? 'by ' . $deadline : 'as soon as possible';
return sprintf(
'Your child is eligible for promotion to %s. Please complete enrollment for the new school year %s.',
$level,
$deadlineText
);
}
private function assertParentOwns(StudentPromotionRecord $record, int $parentId): void
{
if ($parentId <= 0) {
throw new RuntimeException('Authenticated parent is required.');
}
if ((int) $record->parent_id !== $parentId) {
$studentParent = (int) (Student::query()->where('id', $record->student_id)->value('parent_id') ?? 0);
if ($studentParent !== $parentId) {
throw new RuntimeException('You are not authorised to act on this promotion record.');
}
}
}
private function assertActionable(StudentPromotionRecord $record): void
{
$actionable = StudentPromotionRecord::parentActionableStatuses();
if (!in_array($record->promotion_status, $actionable, true)) {
throw new RuntimeException(sprintf(
'Promotion record is not actionable in status "%s".',
$record->promotion_status
));
}
}
private function guessNextSchoolYear(): ?string
{
$current = (string) (Configuration::getConfigValueByKey('school_year') ?? '');
if (!preg_match('/^(\d{4})-(\d{4})$/', $current, $m)) {
return null;
}
return ((int) $m[1] + 1) . '-' . ((int) $m[2] + 1);
}
}
@@ -0,0 +1,223 @@
<?php
namespace App\Services\Promotions;
use App\Models\Student;
use App\Models\StudentPromotionRecord;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
/**
* Admin-side reads against `student_promotion_records` (plan sections 13
* and 16). Always returns hydrated student / parent context so the
* admin UI can render rows without N+1 queries.
*/
class PromotionQueryService
{
/**
* Filterable list of promotion records for admin views.
*
* Filters supported:
* - status: string|array
* - next_school_year: string
* - current_school_year: string
* - parent_id: int
* - student_id: int
* - search: string (matches student first/last name)
* - parent_action_required: bool (subset of parentActionableStatuses)
*
* Returns array<int, array<string, mixed>> when paginate=false,
* otherwise a Laravel paginator instance.
*
* @param array{
* status?: string|array<int,string>,
* next_school_year?: string|null,
* current_school_year?: string|null,
* parent_id?: int|null,
* student_id?: int|null,
* search?: string|null,
* parent_action_required?: bool,
* per_page?: int|null,
* } $filters
*
* @return array{
* data: array<int,array<string,mixed>>,
* total: int,
* page: int,
* per_page: int,
* total_pages: int,
* counts_by_status: array<string,int>
* }
*/
public function list(array $filters): array
{
$query = StudentPromotionRecord::query();
$this->applyFilters($query, $filters);
$perPage = isset($filters['per_page']) ? max(1, min(200, (int) $filters['per_page'])) : 50;
$page = isset($filters['page']) ? max(1, (int) $filters['page']) : 1;
/** @var LengthAwarePaginator $paginator */
$paginator = $query->orderByDesc('promotion_id')->paginate($perPage, ['*'], 'page', $page);
/** @var EloquentCollection<int,StudentPromotionRecord> $records */
$records = $paginator->getCollection();
$studentIds = $records->pluck('student_id')->unique()->values()->all();
$parentIds = $records->pluck('parent_id')->filter()->unique()->values()->all();
$studentsById = !empty($studentIds)
? Student::query()->whereIn('id', $studentIds)->get()->keyBy('id')
: new EloquentCollection();
$parentsById = !empty($parentIds)
? User::query()->whereIn('id', $parentIds)->get()->keyBy('id')
: new EloquentCollection();
$rows = $records->map(function (StudentPromotionRecord $record) use ($studentsById, $parentsById) {
return $this->presentAdminRow(
$record,
$studentsById->get($record->student_id),
$record->parent_id ? $parentsById->get($record->parent_id) : null
);
})->all();
return [
'data' => array_values($rows),
'total' => (int) $paginator->total(),
'page' => (int) $paginator->currentPage(),
'per_page' => (int) $paginator->perPage(),
'total_pages' => (int) $paginator->lastPage(),
'counts_by_status' => $this->countsByStatus($filters),
];
}
/**
* Returns the per-status count of promotion records that match the
* given filter set (ignoring `status` and pagination filters). Plan
* section 16's reports rely on these counts.
*
* @return array<string,int>
*/
public function countsByStatus(array $filters): array
{
$query = StudentPromotionRecord::query();
// Apply non-status filters only.
$filtersSansStatus = $filters;
unset($filtersSansStatus['status'], $filtersSansStatus['parent_action_required']);
$this->applyFilters($query, $filtersSansStatus);
$rows = $query
->select('promotion_status', DB::raw('count(*) as total'))
->groupBy('promotion_status')
->get();
$counts = array_fill_keys(StudentPromotionRecord::ALL_STATUSES, 0);
foreach ($rows as $row) {
$counts[(string) $row->promotion_status] = (int) $row->total;
}
return $counts;
}
/**
* Hydrates a single record with student + parent context for the
* admin detail view.
*/
public function detail(StudentPromotionRecord $record): array
{
$student = Student::query()->find($record->student_id);
$parent = $record->parent_id ? User::query()->find($record->parent_id) : null;
return $this->presentAdminRow($record, $student, $parent, true);
}
/**
* @param Builder<StudentPromotionRecord> $query
*/
private function applyFilters(Builder $query, array $filters): void
{
if (!empty($filters['status'])) {
$statuses = is_array($filters['status']) ? $filters['status'] : [$filters['status']];
$statuses = array_values(array_filter($statuses, static fn ($s) => is_string($s) && $s !== ''));
if (!empty($statuses)) {
$query->whereIn('promotion_status', $statuses);
}
}
if (!empty($filters['parent_action_required'])) {
$query->whereIn('promotion_status', StudentPromotionRecord::parentActionableStatuses());
}
if (!empty($filters['next_school_year'])) {
$query->where('next_school_year', $filters['next_school_year']);
}
if (!empty($filters['current_school_year'])) {
$query->where('current_school_year', $filters['current_school_year']);
}
if (!empty($filters['parent_id'])) {
$query->where('parent_id', (int) $filters['parent_id']);
}
if (!empty($filters['student_id'])) {
$query->where('student_id', (int) $filters['student_id']);
}
if (!empty($filters['search'])) {
$search = '%' . strtolower((string) $filters['search']) . '%';
$query->whereIn('student_id', function ($sub) use ($search) {
$sub->select('id')
->from('students')
->where(function ($w) use ($search) {
$w->whereRaw('LOWER(firstname) LIKE ?', [$search])
->orWhereRaw('LOWER(lastname) LIKE ?', [$search]);
});
});
}
}
private function presentAdminRow(
StudentPromotionRecord $record,
?Student $student,
?User $parent,
bool $detail = false
): array {
$row = [
'promotion_id' => (int) $record->getKey(),
'student_id' => (int) $record->student_id,
'student_name' => $student
? trim((string) $student->firstname . ' ' . (string) $student->lastname)
: null,
'school_id' => $student->school_id ?? null,
'parent_id' => $record->parent_id ? (int) $record->parent_id : null,
'parent_name' => $parent
? trim((string) $parent->firstname . ' ' . (string) $parent->lastname)
: null,
'parent_email' => $parent->email ?? null,
'current_school_year' => $record->current_school_year,
'next_school_year' => $record->next_school_year,
'current_level' => $record->current_level_name,
'promoted_level' => $record->promoted_level_name,
'promotion_status' => $record->promotion_status,
'enrollment_status' => $record->enrollment_status,
'enrollment_deadline' => $record->enrollment_deadline?->toDateString(),
'parent_notified_at' => $record->parent_notified_at?->toDateTimeString(),
'enrollment_started_at' => $record->enrollment_started_at?->toDateTimeString(),
'enrollment_completed_at' => $record->enrollment_completed_at?->toDateTimeString(),
'promotion_finalized_at' => $record->promotion_finalized_at?->toDateTimeString(),
'final_average' => $record->final_average !== null ? (float) $record->final_average : null,
'passed_current_level' => $record->passed_current_level,
'updated_by' => $record->updated_by ? (int) $record->updated_by : null,
'updated_at' => $record->updated_at?->toDateTimeString(),
];
if ($detail) {
$row['checklist'] = [
'info_confirmed' => (bool) $record->info_confirmed,
'documents_uploaded' => (bool) $record->documents_uploaded,
'agreement_accepted' => (bool) $record->agreement_accepted,
'payment_completed' => (bool) $record->payment_completed,
];
$row['eligibility_notes'] = $record->eligibility_notes;
$row['enrollment_id'] = $record->enrollment_id ? (int) $record->enrollment_id : null;
}
return $row;
}
}
@@ -0,0 +1,207 @@
<?php
namespace App\Services\Promotions;
use App\Models\PromotionReminderLog;
use App\Models\Student;
use App\Models\StudentPromotionRecord;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Sends reminders to parents and persists a log entry per dispatch
* (plan section 14).
*
* The actual delivery is delegated to whichever notification dispatcher
* is registered. A nullable dispatcher is supported so the service can
* be used from CLI/tests without external side effects.
*/
class PromotionReminderService
{
/** @var (callable(int $userId, string $title, string $body, array $channels):void)|null */
private $dispatcher;
public function __construct(
private PromotionAuditService $audit,
?callable $dispatcher = null
) {
$this->dispatcher = $dispatcher;
}
/**
* Records a single reminder dispatch.
*/
public function send(
StudentPromotionRecord $record,
string $reminderType,
?int $userId = null,
?string $subject = null,
?string $message = null,
array $channels = ['in_app', 'email']
): PromotionReminderLog {
if (!in_array($reminderType, PromotionReminderLog::ALLOWED_TYPES, true)) {
$reminderType = PromotionReminderLog::TYPE_MANUAL;
}
$studentName = $this->studentName($record);
$deadline = $record->enrollment_deadline?->toDateString();
$level = $record->promoted_level_name ?: 'the next level';
$defaultSubject = 'Reminder: complete enrollment for the next school year';
$defaultMessage = sprintf(
'%s is eligible for promotion to %s.%s Please complete enrollment as soon as possible.',
$studentName ?: 'Your child',
$level,
$deadline ? ' Deadline: ' . $deadline . '.' : ''
);
$finalSubject = $subject ?: $defaultSubject;
$finalMessage = $message ?: $defaultMessage;
return DB::transaction(function () use (
$record,
$reminderType,
$finalSubject,
$finalMessage,
$channels,
$userId
) {
$log = PromotionReminderLog::query()->create([
'promotion_id' => (int) $record->getKey(),
'student_id' => (int) $record->student_id,
'parent_id' => (int) $record->parent_id ?: null,
'reminder_type' => $reminderType,
'channel' => implode(',', array_values(array_unique(array_filter($channels)))),
'subject' => $finalSubject,
'message' => $finalMessage,
'sent_at' => now(),
'sent_by' => $userId,
]);
if ($record->parent_id) {
$this->dispatch((int) $record->parent_id, $finalSubject, $finalMessage, $channels);
}
if (!$record->parent_notified_at) {
$record->parent_notified_at = now();
$record->save();
$this->audit->logParentNotified($record, $userId, $reminderType);
}
$this->audit->logReminderSent($record, $reminderType, $userId);
return $log;
});
}
/**
* Plan section 14 schedule-driven reminders. Sends the next due
* reminder type for every actionable record whose deadline is set.
*
* @return array{ processed:int, sent:int, skipped:int }
*/
public function dispatchScheduledReminders(?\DateTimeInterface $now = null, ?int $userId = null): array
{
$now = CarbonImmutable::instance($now ?: now());
$records = StudentPromotionRecord::query()
->whereIn('promotion_status', StudentPromotionRecord::parentActionableStatuses())
->whereNotNull('enrollment_deadline')
->get();
$processed = 0;
$sent = 0;
$skipped = 0;
foreach ($records as $record) {
$processed++;
$type = $this->resolveReminderType($record, $now);
if ($type === null) {
$skipped++;
continue;
}
$alreadySent = PromotionReminderLog::query()
->where('promotion_id', $record->getKey())
->where('reminder_type', $type)
->exists();
if ($alreadySent) {
$skipped++;
continue;
}
try {
$this->send($record, $type, $userId);
$sent++;
} catch (\Throwable $e) {
Log::error('Promotion reminder dispatch failed', [
'promotion_id' => $record->getKey(),
'exception' => $e,
]);
$skipped++;
}
}
return [
'processed' => $processed,
'sent' => $sent,
'skipped' => $skipped,
];
}
/**
* Returns the reminder type that should be sent next based on the
* deadline / current time.
*/
private function resolveReminderType(StudentPromotionRecord $record, CarbonImmutable $now): ?string
{
$deadline = $record->enrollment_deadline ? CarbonImmutable::instance($record->enrollment_deadline) : null;
if ($deadline === null) {
return null;
}
$createdAt = $record->created_at ? CarbonImmutable::instance($record->created_at) : $now;
if ($now->greaterThan($deadline)) {
return PromotionReminderLog::TYPE_EXPIRATION;
}
$totalSeconds = max(1, $deadline->getTimestamp() - $createdAt->getTimestamp());
$elapsedSeconds = max(0, $now->getTimestamp() - $createdAt->getTimestamp());
$remainingSeconds = max(0, $deadline->getTimestamp() - $now->getTimestamp());
$remainingDays = (int) floor($remainingSeconds / 86400);
if ($elapsedSeconds === 0) {
return PromotionReminderLog::TYPE_FIRST;
}
if ($remainingDays <= 3) {
return PromotionReminderLog::TYPE_FINAL;
}
if ($elapsedSeconds * 2 >= $totalSeconds) {
return PromotionReminderLog::TYPE_HALFWAY;
}
return PromotionReminderLog::TYPE_FIRST;
}
private function studentName(StudentPromotionRecord $record): ?string
{
$student = Student::query()->find($record->student_id);
if (!$student) {
return null;
}
$name = trim((string) ($student->firstname ?? '') . ' ' . (string) ($student->lastname ?? ''));
return $name !== '' ? $name : null;
}
private function dispatch(int $userId, string $subject, string $message, array $channels): void
{
if (!$this->dispatcher) {
return;
}
try {
($this->dispatcher)($userId, $subject, $message, $channels);
} catch (\Throwable $e) {
Log::error('Promotion reminder dispatcher threw', [
'user_id' => $userId,
'exception' => $e,
]);
}
}
}
@@ -0,0 +1,164 @@
<?php
namespace App\Services\Promotions;
use App\Models\StudentPromotionRecord;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use RuntimeException;
/**
* State machine for promotion records (plan sections 3, 4, 7).
*
* All status transitions go through this service so the audit trail
* stays in sync (plan section 18).
*/
class PromotionStatusService
{
public function __construct(private PromotionAuditService $audit)
{
}
/**
* Map of allowed transitions from current → set of next statuses.
* Edge cases (manual overrides) are handled by `forceStatus`.
*/
private const ALLOWED_TRANSITIONS = [
StudentPromotionRecord::STATUS_NOT_REVIEWED => [
StudentPromotionRecord::STATUS_ELIGIBLE,
StudentPromotionRecord::STATUS_AWAITING_PARENT,
StudentPromotionRecord::STATUS_CONDITIONAL,
StudentPromotionRecord::STATUS_REPEATED,
StudentPromotionRecord::STATUS_ON_HOLD,
StudentPromotionRecord::STATUS_GRADUATED,
StudentPromotionRecord::STATUS_WITHDRAWN,
],
StudentPromotionRecord::STATUS_ELIGIBLE => [
StudentPromotionRecord::STATUS_AWAITING_PARENT,
StudentPromotionRecord::STATUS_ENROLLMENT_STARTED,
StudentPromotionRecord::STATUS_CONDITIONAL,
StudentPromotionRecord::STATUS_ON_HOLD,
StudentPromotionRecord::STATUS_NOT_ENROLLED,
StudentPromotionRecord::STATUS_WITHDRAWN,
],
StudentPromotionRecord::STATUS_AWAITING_PARENT => [
StudentPromotionRecord::STATUS_ENROLLMENT_STARTED,
StudentPromotionRecord::STATUS_PROMOTED_AND_ENROLLED,
StudentPromotionRecord::STATUS_NOT_ENROLLED,
StudentPromotionRecord::STATUS_ON_HOLD,
StudentPromotionRecord::STATUS_WITHDRAWN,
],
StudentPromotionRecord::STATUS_ENROLLMENT_STARTED => [
StudentPromotionRecord::STATUS_PROMOTED_AND_ENROLLED,
StudentPromotionRecord::STATUS_AWAITING_PARENT,
StudentPromotionRecord::STATUS_NOT_ENROLLED,
StudentPromotionRecord::STATUS_ON_HOLD,
StudentPromotionRecord::STATUS_WITHDRAWN,
],
StudentPromotionRecord::STATUS_CONDITIONAL => [
StudentPromotionRecord::STATUS_ELIGIBLE,
StudentPromotionRecord::STATUS_AWAITING_PARENT,
StudentPromotionRecord::STATUS_REPEATED,
StudentPromotionRecord::STATUS_ON_HOLD,
StudentPromotionRecord::STATUS_NOT_REVIEWED,
],
StudentPromotionRecord::STATUS_ON_HOLD => [
StudentPromotionRecord::STATUS_NOT_REVIEWED,
StudentPromotionRecord::STATUS_ELIGIBLE,
StudentPromotionRecord::STATUS_AWAITING_PARENT,
StudentPromotionRecord::STATUS_REPEATED,
StudentPromotionRecord::STATUS_CONDITIONAL,
StudentPromotionRecord::STATUS_WITHDRAWN,
StudentPromotionRecord::STATUS_NOT_ENROLLED,
],
// Terminal statuses are intentionally not in the map (no auto
// transitions); admins can still override via `forceStatus`.
];
public function canTransition(string $from, string $to): bool
{
if ($from === $to) {
return true;
}
if (!in_array($to, StudentPromotionRecord::ALL_STATUSES, true)) {
return false;
}
$allowed = self::ALLOWED_TRANSITIONS[$from] ?? [];
return in_array($to, $allowed, true);
}
/**
* Apply a status transition with audit logging. Returns the saved
* record. Throws if the new status is invalid or the transition is
* not allowed.
*/
public function transition(
StudentPromotionRecord $record,
string $newStatus,
?int $userId = null,
?string $notes = null
): StudentPromotionRecord {
if (!in_array($newStatus, StudentPromotionRecord::ALL_STATUSES, true)) {
throw new InvalidArgumentException(sprintf('Unknown promotion status "%s".', $newStatus));
}
$oldStatus = (string) $record->promotion_status;
if ($oldStatus === $newStatus) {
return $record;
}
if (!$this->canTransition($oldStatus, $newStatus)) {
throw new RuntimeException(sprintf(
'Transition from "%s" to "%s" is not allowed.',
$oldStatus,
$newStatus
));
}
return DB::transaction(function () use ($record, $oldStatus, $newStatus, $userId, $notes) {
$record->promotion_status = $newStatus;
$record->updated_by = $userId;
$record->save();
$this->audit->logStatusChange($record, $oldStatus, $newStatus, $userId, $notes);
return $record;
});
}
/**
* Force a status change ignoring the transition map (e.g. admin
* override). The audit trail records this as a manual override.
*/
public function forceStatus(
StudentPromotionRecord $record,
string $newStatus,
?int $userId = null,
?string $notes = null
): StudentPromotionRecord {
if (!in_array($newStatus, StudentPromotionRecord::ALL_STATUSES, true)) {
throw new InvalidArgumentException(sprintf('Unknown promotion status "%s".', $newStatus));
}
$oldStatus = (string) $record->promotion_status;
if ($oldStatus === $newStatus) {
return $record;
}
return DB::transaction(function () use ($record, $oldStatus, $newStatus, $userId, $notes) {
$record->promotion_status = $newStatus;
$record->updated_by = $userId;
$record->save();
$this->audit->logManualOverride(
$record,
'promotion_status',
$oldStatus,
$newStatus,
$userId,
$notes ?? 'Manual status override'
);
return $record;
});
}
}