'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 */ 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; } }