Files

135 lines
4.7 KiB
PHP
Raw Permalink Normal View History

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