*/ public function allowedInitialStatuses(): array { return [self::INITIAL_STATUS]; } /** @return list */ public function allowedPriorities(): array { return self::PRIORITIES; } /** * @return array{valid: bool, client_id: ?int, work_requested: string, priority: string, status: string, errors: array} */ 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} */ 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; } }