feat: bootstrap JOBcard CRM foundation
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Normalize the fields accepted by a client contact form.
|
||||
*
|
||||
* Empty optional values are represented as null and the primary flag is a bool.
|
||||
*/
|
||||
function normalize_client_contact(array $input): array
|
||||
{
|
||||
$email = trim((string)($input['email'] ?? ''));
|
||||
$phone = trim((string)($input['phone'] ?? ''));
|
||||
|
||||
return [
|
||||
'name' => trim((string)($input['name'] ?? '')),
|
||||
'email' => $email === '' ? null : strtolower($email),
|
||||
'phone' => $phone === '' ? null : $phone,
|
||||
'is_primary' => normalize_client_contact_primary($input['is_primary'] ?? $input['primary'] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize a client contact in one reusable operation.
|
||||
*/
|
||||
function validate_client_contact(array $input): array
|
||||
{
|
||||
$contact = normalize_client_contact($input);
|
||||
$errors = [];
|
||||
|
||||
if ($contact['name'] === '') {
|
||||
$errors['name'] = 'Contact name is required.';
|
||||
} elseif (mb_strlen($contact['name']) > 120) {
|
||||
$errors['name'] = 'Contact name must be 120 characters or fewer.';
|
||||
}
|
||||
|
||||
if ($contact['email'] !== null && filter_var($contact['email'], FILTER_VALIDATE_EMAIL) === false) {
|
||||
$errors['email'] = 'Contact email must be a valid email address.';
|
||||
} elseif ($contact['email'] !== null && mb_strlen($contact['email']) > 190) {
|
||||
$errors['email'] = 'Contact email must be 190 characters or fewer.';
|
||||
}
|
||||
|
||||
if ($contact['phone'] !== null && mb_strlen($contact['phone']) > 60) {
|
||||
$errors['phone'] = 'Contact phone must be 60 characters or fewer.';
|
||||
}
|
||||
|
||||
if (!is_bool($contact['is_primary'])) {
|
||||
$errors['is_primary'] = 'Primary contact flag must be boolean.';
|
||||
$contact['is_primary'] = false;
|
||||
}
|
||||
|
||||
return [...$contact, 'errors' => $errors];
|
||||
}
|
||||
|
||||
function normalize_client_contact_primary(mixed $value): bool|int|string
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (is_int($value) && ($value === 0 || $value === 1)) {
|
||||
return $value === 1;
|
||||
}
|
||||
if (is_string($value)) {
|
||||
$normalized = strtolower(trim($value));
|
||||
if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) {
|
||||
return true;
|
||||
}
|
||||
if (in_array($normalized, ['', '0', 'false', 'no', 'off'], true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Return whether a client name already occurs in a list, ignoring case and outer whitespace.
|
||||
* Existing entries may be strings or rows containing a `name` field.
|
||||
*/
|
||||
function client_name_is_duplicate(string $name, array $existingClients): bool
|
||||
{
|
||||
$candidate = strtolower(trim($name));
|
||||
foreach ($existingClients as $existing) {
|
||||
$existingName = is_array($existing) ? ($existing['name'] ?? '') : $existing;
|
||||
if (is_string($existingName) && strtolower(trim($existingName)) === $candidate) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
function validate_client(array $input): array
|
||||
{
|
||||
$name = trim((string)($input['name'] ?? ''));
|
||||
$status = (string)($input['status'] ?? 'active');
|
||||
$errors = [];
|
||||
if ($name === '') $errors['name'] = 'Client name is required.';
|
||||
if (mb_strlen($name) > 190) $errors['name'] = 'Client name must be 190 characters or fewer.';
|
||||
if (!in_array($status, ['active', 'inactive'], true)) $errors['status'] = 'Invalid client status.';
|
||||
return ['name' => $name, 'status' => $status, 'errors' => $errors];
|
||||
}
|
||||
@@ -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]) : [];
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/ReportDataMapper.php';
|
||||
|
||||
final class HoursPerClientReport
|
||||
{
|
||||
/** @param list<array<string, mixed>> $entries
|
||||
* @return list<array{client_id:int, client_name:string, hours:float}>
|
||||
*/
|
||||
public function aggregate(array $entries): array
|
||||
{
|
||||
$totals = [];
|
||||
foreach ($entries as $entry) {
|
||||
$id = (int)($entry['client_id'] ?? 0);
|
||||
$key = (string)$id;
|
||||
if (!isset($totals[$key])) {
|
||||
$totals[$key] = [
|
||||
'client_id' => $id,
|
||||
'client_name' => (string)($entry['client_name'] ?? ''),
|
||||
'hours' => 0.0,
|
||||
];
|
||||
}
|
||||
$totals[$key]['hours'] += max(0.0, (float)($entry['hours'] ?? 0));
|
||||
}
|
||||
$rows = array_values($totals);
|
||||
foreach ($rows as &$row) {
|
||||
$row['hours'] = round($row['hours'], 2);
|
||||
}
|
||||
unset($row);
|
||||
usort($rows, static fn (array $a, array $b): int => strcmp($a['client_name'], $b['client_name']) ?: $a['client_id'] <=> $b['client_id']);
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Maps raw domain rows into an explicitly allow-listed client view and a
|
||||
* separately retained internal view. This class has no framework or storage
|
||||
* dependency and is safe to use before an export format is selected.
|
||||
*/
|
||||
final class ReportDataMapper
|
||||
{
|
||||
/** @var list<string> */
|
||||
private const CLIENT_FIELDS = [
|
||||
'id',
|
||||
'name',
|
||||
'registration_number',
|
||||
'status',
|
||||
'support_email',
|
||||
'support_phone',
|
||||
'preferred_contact_method',
|
||||
'physical_address',
|
||||
'postal_address',
|
||||
'general_notes',
|
||||
'client_id',
|
||||
'client_name',
|
||||
'reference_no',
|
||||
'priority',
|
||||
'work_requested',
|
||||
'completed_at',
|
||||
'closed_at',
|
||||
'allocated_hours',
|
||||
'used_hours',
|
||||
'remaining_hours',
|
||||
'usage_percentage',
|
||||
'status_label',
|
||||
'period_type',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'hours',
|
||||
];
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function clientFacing(array $record): array
|
||||
{
|
||||
$safe = [];
|
||||
foreach (self::CLIENT_FIELDS as $field) {
|
||||
if (array_key_exists($field, $record)) {
|
||||
$safe[$field] = $record[$field];
|
||||
}
|
||||
}
|
||||
return $safe;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function internal(array $record): array
|
||||
{
|
||||
return array_diff_key($record, $this->clientFacing($record));
|
||||
}
|
||||
|
||||
/** @return array{client: array<string, mixed>, internal: array<string, mixed>} */
|
||||
public function map(array $record): array
|
||||
{
|
||||
return ['client' => $this->clientFacing($record), 'internal' => $this->internal($record)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class SlaReport
|
||||
{
|
||||
/** @param list<array<string, mixed>> $agreements
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function rows(array $agreements): array
|
||||
{
|
||||
$rows = [];
|
||||
foreach ($agreements as $agreement) {
|
||||
$allocated = max(0.0, (float)($agreement['allocated_hours'] ?? 0));
|
||||
$used = 0.0;
|
||||
foreach ((array)($agreement['hours'] ?? []) as $hours) {
|
||||
$used += max(0.0, (float)$hours);
|
||||
}
|
||||
$used = round($used, 2);
|
||||
$remaining = round(max(0.0, $allocated - $used), 2);
|
||||
$percentage = $allocated > 0
|
||||
? round(($used / $allocated) * 100, 2)
|
||||
: ($used > 0 ? 100.0 : 0.0);
|
||||
$status = $used > $allocated
|
||||
? 'exceeded'
|
||||
: ($percentage >= 90 ? 'critical' : ($percentage >= 75 ? 'warning' : 'within_limit'));
|
||||
$rows[] = [
|
||||
'client_id' => (int)($agreement['client_id'] ?? 0),
|
||||
'client_name' => (string)($agreement['client_name'] ?? ''),
|
||||
'allocated_hours' => $allocated,
|
||||
'used_hours' => $used,
|
||||
'remaining_hours' => $remaining,
|
||||
'usage_percentage' => $percentage,
|
||||
'status' => $status,
|
||||
];
|
||||
}
|
||||
usort($rows, static fn (array $a, array $b): int => strcmp($a['client_name'], $b['client_name']) ?: $a['client_id'] <=> $b['client_id']);
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
function calculate_sla_usage(float $allocatedHours, array $hours): array
|
||||
{
|
||||
$used = round(array_sum(array_map(static fn ($value): float => max(0.0, (float)$value), $hours)), 2);
|
||||
$remaining = round(max(0.0, $allocatedHours - $used), 2);
|
||||
$percentage = $allocatedHours > 0 ? round(($used / $allocatedHours) * 100, 2) : ($used > 0 ? 100.0 : 0.0);
|
||||
$status = $used > $allocatedHours ? 'exceeded' : ($percentage >= 90 ? 'critical' : ($percentage >= 75 ? 'warning' : 'within_limit'));
|
||||
return ['used' => $used, 'remaining' => $remaining, 'percentage' => $percentage, 'status' => $status];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\SLA;
|
||||
|
||||
final class SlaThresholdClassifier
|
||||
{
|
||||
public const WITHIN_LIMIT = 'within_limit';
|
||||
public const WARNING = 'warning';
|
||||
public const CRITICAL = 'critical';
|
||||
public const EXCEEDED = 'exceeded';
|
||||
|
||||
public function classify(float $usedHours, float $allocatedHours): string
|
||||
{
|
||||
if ($allocatedHours < 0) throw new \InvalidArgumentException('Allocated hours must not be negative.');
|
||||
$usedHours = max(0.0, $usedHours);
|
||||
if ($allocatedHours === 0.0) return $usedHours > 0.0 ? self::EXCEEDED : self::WITHIN_LIMIT;
|
||||
if ($usedHours > $allocatedHours) return self::EXCEEDED;
|
||||
$percentage = ($usedHours / $allocatedHours) * 100;
|
||||
return $percentage >= 90.0 ? self::CRITICAL : ($percentage >= 75.0 ? self::WARNING : self::WITHIN_LIMIT);
|
||||
}
|
||||
|
||||
public function percentage(float $usedHours, float $allocatedHours): float
|
||||
{
|
||||
if ($allocatedHours < 0) throw new \InvalidArgumentException('Allocated hours must not be negative.');
|
||||
$usedHours = max(0.0, $usedHours);
|
||||
return $allocatedHours > 0.0 ? round(($usedHours / $allocatedHours) * 100, 2) : ($usedHours > 0.0 ? 100.0 : 0.0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user