feat: expand client jobcard and reporting workflows
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Client;
|
||||
|
||||
/**
|
||||
* Normalizes and validates client records without a framework or persistence
|
||||
* dependency. The output shape is stable for PDO adapters and controllers.
|
||||
*/
|
||||
final class ClientRecord
|
||||
{
|
||||
/** @var list<string> */
|
||||
private const FIELDS = [
|
||||
'name',
|
||||
'registration_number',
|
||||
'status',
|
||||
'support_email',
|
||||
'support_phone',
|
||||
'preferred_contact_method',
|
||||
'physical_address',
|
||||
'postal_address',
|
||||
'general_notes',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
private const DISPLAY_FIELDS = [
|
||||
'id',
|
||||
'name',
|
||||
'registration_number',
|
||||
'status',
|
||||
'support_email',
|
||||
'support_phone',
|
||||
'preferred_contact_method',
|
||||
'physical_address',
|
||||
'postal_address',
|
||||
'general_notes',
|
||||
];
|
||||
|
||||
/** @return array<string, string|null> */
|
||||
public function normalize(array $record): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->text($record['name'] ?? null) ?? '',
|
||||
'registration_number' => $this->text($record['registration_number'] ?? null),
|
||||
'status' => strtolower($this->text($record['status'] ?? null) ?? 'active'),
|
||||
'support_email' => $this->lowerText($record['support_email'] ?? null),
|
||||
'support_phone' => $this->text($record['support_phone'] ?? null),
|
||||
'preferred_contact_method' => $this->text($record['preferred_contact_method'] ?? null),
|
||||
'physical_address' => $this->text($record['physical_address'] ?? null),
|
||||
'postal_address' => $this->text($record['postal_address'] ?? null),
|
||||
'general_notes' => $this->text($record['general_notes'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{valid: bool, errors: array<string, string>, name: string, registration_number: string|null, status: string, support_email: string|null, support_phone: string|null, preferred_contact_method: string|null, physical_address: string|null, postal_address: string|null, general_notes: string|null} */
|
||||
public function validate(array $record): array
|
||||
{
|
||||
$normalized = $this->normalize($record);
|
||||
$errors = [];
|
||||
|
||||
if ($normalized['name'] === '') {
|
||||
$errors['name'] = 'Client name is required.';
|
||||
} elseif (mb_strlen($normalized['name']) > 190) {
|
||||
$errors['name'] = 'Client name must be 190 characters or fewer.';
|
||||
}
|
||||
if (!in_array($normalized['status'], ['active', 'inactive'], true)) {
|
||||
$errors['status'] = 'Invalid client status.';
|
||||
}
|
||||
if ($normalized['registration_number'] !== null && mb_strlen($normalized['registration_number']) > 120) {
|
||||
$errors['registration_number'] = 'Registration number must be 120 characters or fewer.';
|
||||
}
|
||||
if ($normalized['support_email'] !== null) {
|
||||
if (filter_var($normalized['support_email'], FILTER_VALIDATE_EMAIL) === false) {
|
||||
$errors['support_email'] = 'Support email must be a valid email address.';
|
||||
} elseif (mb_strlen($normalized['support_email']) > 190) {
|
||||
$errors['support_email'] = 'Support email must be 190 characters or fewer.';
|
||||
}
|
||||
}
|
||||
if ($normalized['support_phone'] !== null && !$this->validPhone($normalized['support_phone'])) {
|
||||
$errors['support_phone'] = 'Support phone must contain a valid phone number.';
|
||||
}
|
||||
|
||||
return [...$normalized, 'valid' => $errors === [], 'errors' => $errors];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function display(array $record): array
|
||||
{
|
||||
$safe = [];
|
||||
foreach (self::DISPLAY_FIELDS as $field) {
|
||||
if (array_key_exists($field, $record)) {
|
||||
$safe[$field] = $record[$field];
|
||||
}
|
||||
}
|
||||
return $safe;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function toDisplay(array $record): array
|
||||
{
|
||||
return $this->display($record);
|
||||
}
|
||||
|
||||
private function text(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) return null;
|
||||
$text = trim(is_scalar($value) ? (string) $value : '');
|
||||
return $text === '' ? null : $text;
|
||||
}
|
||||
|
||||
private function lowerText(mixed $value): ?string
|
||||
{
|
||||
$text = $this->text($value);
|
||||
return $text === null ? null : strtolower($text);
|
||||
}
|
||||
|
||||
private function validPhone(string $phone): bool
|
||||
{
|
||||
if (mb_strlen($phone) > 60 || preg_match('/^[0-9+().\-\s]+$/', $phone) !== 1) {
|
||||
return false;
|
||||
}
|
||||
return preg_match('/\d.*\d.*\d.*\d.*\d.*\d.*\d/', $phone) === 1;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
/**
|
||||
* Serializes tabular data as RFC 4180-compatible CSV.
|
||||
*/
|
||||
final class CsvExporter
|
||||
{
|
||||
private const UTF8_BOM = "\xEF\xBB\xBF";
|
||||
|
||||
/**
|
||||
* @param list<mixed> $headers
|
||||
* @param list<list<mixed>> $rows
|
||||
*/
|
||||
public function export(array $headers, array $rows, bool $withBom = false): string
|
||||
{
|
||||
$records = [];
|
||||
$records[] = $this->formatRecord($headers);
|
||||
|
||||
$headerCount = count($headers);
|
||||
$rowNumber = 0;
|
||||
foreach ($rows as $row) {
|
||||
$rowNumber++;
|
||||
if (!is_array($row)) {
|
||||
throw new InvalidArgumentException(sprintf('CSV row %d must be an array.', $rowNumber));
|
||||
}
|
||||
|
||||
$fieldCount = count($row);
|
||||
if ($fieldCount !== $headerCount) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'CSV row %d has %d fields; expected %d fields for %d headers.',
|
||||
$rowNumber,
|
||||
$fieldCount,
|
||||
$headerCount,
|
||||
$headerCount,
|
||||
));
|
||||
}
|
||||
|
||||
$records[] = $this->formatRecord($row);
|
||||
}
|
||||
|
||||
$csv = implode("\r\n", $records) . "\r\n";
|
||||
return $withBom ? self::UTF8_BOM . $csv : $csv;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<mixed> $fields
|
||||
*/
|
||||
private function formatRecord(array $fields): string
|
||||
{
|
||||
return implode(',', array_map(
|
||||
fn (mixed $field): string => $this->escapeField($field),
|
||||
$fields,
|
||||
));
|
||||
}
|
||||
|
||||
private function escapeField(mixed $field): string
|
||||
{
|
||||
if ($field === null) {
|
||||
$value = '';
|
||||
} elseif (is_scalar($field)) {
|
||||
$value = (string)$field;
|
||||
} else {
|
||||
throw new InvalidArgumentException('CSV fields must be scalar values or null.');
|
||||
}
|
||||
|
||||
// Prefix formula-like values so spreadsheet programs treat them as text.
|
||||
if ($value !== '' && preg_match('/^[=+\-@]/', $value) === 1) {
|
||||
$value = "'" . $value;
|
||||
}
|
||||
|
||||
if (strpbrk($value, ",\"\r\n") === false) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return '"' . str_replace('"', '""', $value) . '"';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user