175 lines
7.2 KiB
PHP
175 lines
7.2 KiB
PHP
<?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;
|
||
|
|
}
|
||
|
|
}
|