*/ 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, 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 */ 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 */ 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; } }