feat: complete jobcard operations and access controls

This commit is contained in:
Marco0300
2026-09-01 20:07:20 +02:00
parent 703c3ca67d
commit 168c15c6ae
12 changed files with 1241 additions and 29 deletions
+174
View File
@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace App\Domain\SLA;
final class SlaAgreement
{
/** @var list<string> */
private const DISPLAY_FIELDS = [
'id',
'client_id',
'enabled',
'agreement_type',
'allocated_hours',
'period_type',
'start_date',
'end_date',
'rollover_enabled',
'notes',
];
/** @return array{client_id: int, enabled: bool, agreement_type: string|null, allocated_hours: float, period_type: string, start_date: string|null, end_date: string|null, rollover_enabled: bool, notes: string|null} */
public function normalize(array $record): array
{
return [
'client_id' => $this->integer($record['client_id'] ?? null),
'enabled' => $this->boolean($record['enabled'] ?? true, true),
'agreement_type' => $this->text($record['agreement_type'] ?? null),
'allocated_hours' => $this->number($record['allocated_hours'] ?? 0),
'period_type' => strtolower($this->text($record['period_type'] ?? null) ?? 'monthly'),
'start_date' => $this->text($record['start_date'] ?? null),
'end_date' => $this->text($record['end_date'] ?? null),
'rollover_enabled' => $this->boolean($record['rollover_enabled'] ?? false, false),
'notes' => $this->text($record['notes'] ?? null),
];
}
/** @return array{valid: bool, errors: array<string, string>, client_id: int, enabled: bool, agreement_type: string|null, allocated_hours: float, period_type: string, start_date: string|null, end_date: string|null, rollover_enabled: bool, notes: string|null} */
public function validate(array $record): array
{
$normalized = $this->normalize($record);
$errors = [];
if (!$this->positiveInteger($record['client_id'] ?? null)) {
$errors['client_id'] = 'Client ID must be a positive integer.';
}
if (!$this->validBoolean($record['enabled'] ?? true)) {
$errors['enabled'] = 'Enabled must be a boolean value.';
}
if (!$this->nullableScalar($record['agreement_type'] ?? null)) {
$errors['agreement_type'] = 'Agreement type must be text.';
} elseif ($normalized['agreement_type'] !== null && $this->length($normalized['agreement_type']) > 120) {
$errors['agreement_type'] = 'Agreement type must be 120 characters or fewer.';
}
if (!is_numeric($record['allocated_hours'] ?? 0) || !is_finite((float) ($record['allocated_hours'] ?? 0))) {
$errors['allocated_hours'] = 'Allocated hours must be a finite number.';
} elseif ($normalized['allocated_hours'] < 0) {
$errors['allocated_hours'] = 'Allocated hours must not be negative.';
}
if (!$this->nullableScalar($record['period_type'] ?? null)
|| !in_array($normalized['period_type'], ['monthly', 'annual', 'custom'], true)) {
$errors['period_type'] = 'Period type must be monthly, annual, or custom.';
}
if (!$this->nullableScalar($record['start_date'] ?? null)) {
$errors['start_date'] = 'Start date must be a valid date in YYYY-MM-DD format.';
} elseif ($normalized['start_date'] !== null && !$this->validDate($normalized['start_date'])) {
$errors['start_date'] = 'Start date must be a valid date in YYYY-MM-DD format.';
}
if (!$this->nullableScalar($record['end_date'] ?? null)) {
$errors['end_date'] = 'End date must be a valid date in YYYY-MM-DD format.';
} elseif ($normalized['end_date'] !== null && !$this->validDate($normalized['end_date'])) {
$errors['end_date'] = 'End date must be a valid date in YYYY-MM-DD format.';
}
if (!isset($errors['start_date'])
&& !isset($errors['end_date'])
&& $normalized['start_date'] !== null
&& $normalized['end_date'] !== null
&& $normalized['start_date'] > $normalized['end_date']) {
$errors['end_date'] = 'End date must not be before start date.';
}
if (!$this->validBoolean($record['rollover_enabled'] ?? false)) {
$errors['rollover_enabled'] = 'Rollover enabled must be a boolean value.';
} elseif ($normalized['rollover_enabled']) {
$errors['rollover_enabled'] = 'Rollover cannot be enabled until rollover rules are configured.';
}
if (!$this->nullableScalar($record['notes'] ?? null)) {
$errors['notes'] = 'Notes must be text.';
}
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 integer(mixed $value): int
{
$text = is_int($value) || is_string($value) ? trim((string) $value) : '';
return preg_match('/^\d+$/', $text) === 1 ? (int) $text : 0;
}
private function positiveInteger(mixed $value): bool
{
if (is_int($value)) return $value > 0;
if (!is_string($value)) return false;
$value = trim($value);
return preg_match('/^\d+$/', $value) === 1 && (int) $value > 0;
}
private function number(mixed $value): float
{
return is_numeric($value) && is_finite((float) $value) ? (float) $value : 0.0;
}
private function boolean(mixed $value, bool $default): bool
{
if (is_bool($value)) return $value;
if ($value === 1 || $value === 0) return $value === 1;
if (is_string($value)) {
$value = strtolower(trim($value));
if (in_array($value, ['1', 'true', 'yes', 'on'], true)) return true;
if (in_array($value, ['0', 'false', 'no', 'off', ''], true)) return false;
}
return $default;
}
private function validBoolean(mixed $value): bool
{
if (is_bool($value) || $value === 0 || $value === 1) return true;
if (!is_string($value)) return false;
return in_array(strtolower(trim($value)), ['1', '0', 'true', 'false', 'yes', 'no', 'on', 'off', ''], true);
}
private function nullableScalar(mixed $value): bool
{
return $value === null || is_scalar($value);
}
private function validDate(string $value): bool
{
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
$errors = \DateTimeImmutable::getLastErrors();
return $date !== false
&& ($errors === false || ($errors['warning_count'] === 0 && $errors['error_count'] === 0))
&& $date->format('Y-m-d') === $value;
}
private function length(string $value): int
{
return function_exists('mb_strlen') ? mb_strlen($value) : strlen($value);
}
private function text(mixed $value): ?string
{
if ($value === null) return null;
$text = trim(is_scalar($value) ? (string) $value : '');
return $text === '' ? null : $text;
}
}