feat: expand client jobcard and reporting workflows

This commit is contained in:
Marco0300
2026-09-01 19:34:41 +02:00
parent 323afaf832
commit 703c3ca67d
10 changed files with 608 additions and 18 deletions
+124
View File
@@ -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;
}
}
+2 -2
View File
@@ -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) {
+134
View File
@@ -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;
}
}
+79
View File
@@ -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) . '"';
}
}