Files
2026-06-11 11:46:12 -04:00

164 lines
5.9 KiB
PHP

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