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

206 lines
7.7 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\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 ?? '');
$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;
}
}