feat: bootstrap JOBcard CRM foundation

This commit is contained in:
Marco0300
2026-09-01 18:54:47 +02:00
commit 480494c5ed
31 changed files with 1300 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Domain\Jobcard;
final class JobcardReference
{
public static function generate(int $sequence, ?int $year = null): string
{
if ($sequence < 1) {
throw new \InvalidArgumentException('Sequence must be positive.');
}
$year ??= (int) date('Y');
if ($year < 2000 || $year > 9999) {
throw new \InvalidArgumentException('Year must be four digits.');
}
return sprintf('JC-%04d-%06d', $year, $sequence);
}
public static function isValid(string $reference): bool
{
return preg_match('/^JC-\d{4}-\d{6}$/', $reference) === 1;
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Domain\Jobcard;
final class StatusTransitionValidator
{
public const STATUSES = ['new', 'assigned', 'in_progress', 'awaiting_client', 'awaiting_parts', 'completed', 'closed'];
private const TRANSITIONS = [
'new' => ['assigned'],
'assigned' => ['in_progress'],
'in_progress' => ['awaiting_client', 'awaiting_parts', 'completed'],
'awaiting_client' => ['in_progress', 'completed'],
'awaiting_parts' => ['in_progress', 'completed'],
'completed' => ['closed'],
'closed' => [],
];
public function canTransition(string $from, string $to): bool
{
return in_array($from, self::STATUSES, true)
&& in_array($to, self::STATUSES, true)
&& ($from === $to || in_array($to, self::TRANSITIONS[$from], true));
}
public function allowedFrom(string $from): array
{
return in_array($from, self::STATUSES, true) ? array_merge([$from], self::TRANSITIONS[$from]) : [];
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Domain\Jobcard;
final class TimeAggregator
{
public function total(array $entries): float
{
$total = 0.0;
foreach ($entries as $entry) {
if (is_array($entry) && isset($entry['hours']) && is_numeric($entry['hours'])) {
$total += max(0.0, (float) $entry['hours']);
} elseif (is_numeric($entry)) {
$total += max(0.0, (float) $entry);
}
}
return round($total, 2);
}
public function slaTotal(array $entries): float
{
return $this->total(array_values(array_filter(
$entries,
static fn ($entry): bool => is_array($entry) && (($entry['counts_toward_sla'] ?? true) === true)
)));
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
function calculate_duration_hours(?string $start, ?string $end, ?float $manualHours = null): ?float
{
if ($manualHours !== null) {
return $manualHours >= 0 ? round($manualHours, 2) : null;
}
if ($start === null || $end === null || !preg_match('/^\d{2}:\d{2}$/', $start) || !preg_match('/^\d{2}:\d{2}$/', $end)) {
return null;
}
[$startHour, $startMinute] = array_map('intval', explode(':', $start));
[$endHour, $endMinute] = array_map('intval', explode(':', $end));
if ($startHour > 23 || $endHour > 23 || $startMinute > 59 || $endMinute > 59) return null;
$minutes = ($endHour * 60 + $endMinute) - ($startHour * 60 + $startMinute);
return $minutes > 0 ? round($minutes / 60, 2) : null;
}
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Domain\Jobcard;
require_once __DIR__ . '/TimeCalculator.php';
final class TimeEntryValidator
{
public function validate(array $entry): array
{
$errors = [];
$date = trim((string)($entry['work_date'] ?? ''));
if (!$this->validDate($date)) $errors['work_date'] = 'Work date must be a valid date.';
$manual = null;
if (array_key_exists('hours', $entry) && $entry['hours'] !== null) {
if (!is_numeric($entry['hours'])) {
$errors['hours'] = 'Hours must be numeric.';
} else {
$manual = (float) $entry['hours'];
if ($manual < 0) $errors['hours'] = 'Hours must not be negative.';
}
}
$start = $entry['start_time'] ?? null;
$end = $entry['end_time'] ?? null;
if ($manual === null && (($start === null) xor ($end === null))) {
$errors['time'] = 'Start and end time must be supplied together.';
}
$hours = \calculate_duration_hours($start !== null ? (string)$start : null, $end !== null ? (string)$end : null, $manual);
if ($hours === null && !isset($errors['hours']) && !isset($errors['time'])) {
$errors['time'] = 'A positive duration or manual hours is required.';
}
return ['valid' => $errors === [], 'hours' => $hours, 'errors' => $errors];
}
public function isValid(array $entry): bool
{
return $this->validate($entry)['valid'];
}
private function validDate(string $date): bool
{
$parsed = \DateTimeImmutable::createFromFormat('!Y-m-d', $date);
return $parsed !== false && $parsed->format('Y-m-d') === $date;
}
}