165 lines
5.9 KiB
PHP
165 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;
|
|
});
|
|
}
|
|
}
|