feat: expand client jobcard and reporting workflows
This commit is contained in:
@@ -7,8 +7,8 @@ final class JobcardReference
|
||||
{
|
||||
public static function generate(int $sequence, ?int $year = null): string
|
||||
{
|
||||
if ($sequence < 1) {
|
||||
throw new \InvalidArgumentException('Sequence must be positive.');
|
||||
if ($sequence < 1 || $sequence > 999999) {
|
||||
throw new \InvalidArgumentException('Sequence must be between 1 and 999999.');
|
||||
}
|
||||
$year ??= (int) date('Y');
|
||||
if ($year < 2000 || $year > 9999) {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user