135 lines
4.7 KiB
PHP
135 lines
4.7 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Domain\Jobcard;
|
|
|
|
require_once __DIR__ . '/StatusTransitionValidator.php';
|
|
|
|
final class JobcardWorkflow
|
|
{
|
|
public const INITIAL_STATUS = 'new';
|
|
public const PRIORITIES = ['low', 'normal', 'high', 'critical'];
|
|
|
|
private const MAX_WORK_REQUESTED_LENGTH = 10000;
|
|
|
|
public function __construct(private readonly ?StatusTransitionValidator $transitions = null)
|
|
{
|
|
}
|
|
|
|
/** @return list<string> */
|
|
public function allowedInitialStatuses(): array
|
|
{
|
|
return [self::INITIAL_STATUS];
|
|
}
|
|
|
|
/** @return list<string> */
|
|
public function allowedPriorities(): array
|
|
{
|
|
return self::PRIORITIES;
|
|
}
|
|
|
|
/**
|
|
* @return array{valid: bool, client_id: ?int, work_requested: string, priority: string, status: string, errors: array<string, string>}
|
|
*/
|
|
public function validateCommand(array $command): array
|
|
{
|
|
$errors = [];
|
|
$clientId = $this->positiveInteger($command['client_id'] ?? null);
|
|
$workRequested = is_scalar($command['work_requested'] ?? null)
|
|
? trim((string) $command['work_requested'])
|
|
: '';
|
|
$priority = is_scalar($command['priority'] ?? null)
|
|
? (string) $command['priority']
|
|
: '';
|
|
|
|
if ($clientId === null) {
|
|
$errors['client_id'] = 'Client ID must be a positive integer.';
|
|
}
|
|
if ($workRequested === '') {
|
|
$errors['work_requested'] = 'Work requested is required.';
|
|
} elseif (mb_strlen($workRequested) > self::MAX_WORK_REQUESTED_LENGTH) {
|
|
$errors['work_requested'] = 'Work requested must be 10000 characters or fewer.';
|
|
}
|
|
if (!in_array($priority, self::PRIORITIES, true)) {
|
|
$errors['priority'] = 'Invalid jobcard priority.';
|
|
}
|
|
|
|
return [
|
|
'valid' => $errors === [],
|
|
'client_id' => $clientId,
|
|
'work_requested' => $workRequested,
|
|
'priority' => $priority,
|
|
'status' => self::INITIAL_STATUS,
|
|
'errors' => $errors,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Validate a status change and the timestamps required by terminal statuses.
|
|
*
|
|
* @return array{valid: bool, from: string, to: string, completed_at: ?string, closed_at: ?string, errors: array<string, string>}
|
|
*/
|
|
public function validateTransition(
|
|
string $from,
|
|
string $to,
|
|
?string $completedAt = null,
|
|
?string $closedAt = null,
|
|
): array {
|
|
$errors = [];
|
|
$transitionValidator = $this->transitions ?? new StatusTransitionValidator();
|
|
if (!$transitionValidator->canTransition($from, $to)) {
|
|
$errors['transition'] = 'Jobcard status transition is not allowed.';
|
|
}
|
|
|
|
$completed = $this->parseTimestamp($completedAt);
|
|
$closed = $this->parseTimestamp($closedAt);
|
|
if ($completedAt !== null && $completed === null) {
|
|
$errors['completed_at'] = 'Completion timestamp must be a valid datetime.';
|
|
}
|
|
if ($closedAt !== null && $closed === null) {
|
|
$errors['closed_at'] = 'Closure timestamp must be a valid datetime.';
|
|
}
|
|
if (in_array($to, ['completed', 'closed'], true) && $completedAt === null) {
|
|
$errors['completed_at'] = 'Completion timestamp is required.';
|
|
}
|
|
if ($to === 'closed' && $closedAt === null) {
|
|
$errors['closed_at'] = 'Closure timestamp is required.';
|
|
}
|
|
if ($completed !== null && $closed !== null && $closed < $completed) {
|
|
$errors['timestamps'] = 'Closure timestamp must not precede completion timestamp.';
|
|
}
|
|
|
|
return [
|
|
'valid' => $errors === [],
|
|
'from' => $from,
|
|
'to' => $to,
|
|
'completed_at' => $completedAt,
|
|
'closed_at' => $closedAt,
|
|
'errors' => $errors,
|
|
];
|
|
}
|
|
|
|
private function positiveInteger(mixed $value): ?int
|
|
{
|
|
if (is_bool($value) || (is_int($value) && $value > 0)) {
|
|
return is_int($value) && $value > 0 ? $value : null;
|
|
}
|
|
if (is_string($value) && preg_match('/^[1-9]\d*$/', $value) === 1) {
|
|
$integer = filter_var($value, FILTER_VALIDATE_INT);
|
|
return $integer !== false && $integer > 0 ? $integer : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private function parseTimestamp(?string $value): ?\DateTimeImmutable
|
|
{
|
|
if ($value === null) return null;
|
|
$parsed = \DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', $value);
|
|
$errors = \DateTimeImmutable::getLastErrors();
|
|
if ($parsed === false || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0))) {
|
|
return null;
|
|
}
|
|
return $parsed->format('Y-m-d H:i:s') === $value ? $parsed : null;
|
|
}
|
|
}
|