[ 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; }); } }