e0dfc3ec82
API CI/CD / Validate (composer + pint) (push) Successful in 3m15s
API CI/CD / Test (PHPUnit) (push) Failing after 5m4s
API CI/CD / Build frontend assets (push) Successful in 1m3s
API CI/CD / Security audit (push) Failing after 49s
API CI/CD / Deploy to shared hosting (PHP) (push) Has been skipped
210 lines
7.8 KiB
PHP
210 lines
7.8 KiB
PHP
<?php
|
||
|
||
namespace App\Services\Promotions;
|
||
|
||
use App\Models\ClassSection;
|
||
use App\Models\LevelProgression;
|
||
use App\Models\SchoolClass;
|
||
use App\Services\School\AccountEventService;
|
||
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 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 ?? '');
|
||
|
||
if (! LevelProgression::query()->exists()) {
|
||
$this->seedDefaults();
|
||
}
|
||
|
||
$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;
|
||
}
|
||
}
|