*/ private array $source_priority = [ self::SOURCE_ORDERS => 1, self::SOURCE_XLVASK => 2, self::SOURCE_SELFSERVE => 3, ]; private DateTimeZone $timezone; public function __construct(?DateTimeZone $timezone = null) { $this->timezone = $timezone ?? new DateTimeZone('Europe/Copenhagen'); } /** * @param array|int|string $department_ids * @return array{ * department_ids:array, * date:string, * date_to:string, * total:int, * by_source:array{orders:int,xlvask:int,selfserve:int}, * has_missing_opening_hours:bool, * missing_department_ids:array * } * @throws Exception */ public function getSummary(string $date, array|int|string $department_ids, ?string $date_to = null): array { $normalized_department_ids = $this->normalizeDepartmentIds($department_ids); $resolved_date_to = $date_to ?? $date; if ($normalized_department_ids === []) { return $this->emptySummary($date, $resolved_date_to, []); } return $this->summarizeCandidates( $this->fetchCandidates($date, $resolved_date_to, $normalized_department_ids), $this->fetchOpeningHoursByDepartmentId($normalized_department_ids), $normalized_department_ids, $date, $resolved_date_to ); } /** * @param array|int|string $department_ids * @return array{ * department_ids:array, * date:string, * date_to:string, * points:array * }>, * has_missing_opening_hours:bool, * missing_department_ids:array * } * @throws Exception */ public function getTrend(string $date, string $date_to, array|int|string $department_ids): array { $normalized_department_ids = $this->normalizeDepartmentIds($department_ids); if ($normalized_department_ids === []) { return [ 'department_ids' => [], 'date' => $date, 'date_to' => $date_to, 'points' => [], 'has_missing_opening_hours' => false, 'missing_department_ids' => [], ]; } return $this->buildTrendFromCandidates( $this->fetchCandidates($date, $date_to, $normalized_department_ids), $this->fetchOpeningHoursByDepartmentId($normalized_department_ids), $normalized_department_ids, $date, $date_to ); } /** * @param array{ * total?:int, * by_source?:array{orders?:int,xlvask?:int,selfserve?:int}, * has_missing_opening_hours?:bool, * missing_department_ids?:array * } $summary * @return array */ public function toOverviewMetric(array $summary): array { return [ 'state' => 'ready', 'value' => (int)($summary['total'] ?? 0), 'out_of' => null, 'message' => null, 'by_source' => [ self::SOURCE_ORDERS => (int)($summary['by_source'][self::SOURCE_ORDERS] ?? 0), self::SOURCE_XLVASK => (int)($summary['by_source'][self::SOURCE_XLVASK] ?? 0), self::SOURCE_SELFSERVE => (int)($summary['by_source'][self::SOURCE_SELFSERVE] ?? 0), ], 'has_missing_opening_hours' => (bool)($summary['has_missing_opening_hours'] ?? false), 'missing_department_ids' => array_values(array_map('intval', $summary['missing_department_ids'] ?? [])), ]; } /** * @param array> $candidates * @param array> $opening_hours_by_department_id * @param array|int|string $department_ids * @return array{ * department_ids:array, * date:string, * date_to:string, * total:int, * by_source:array{orders:int,xlvask:int,selfserve:int}, * has_missing_opening_hours:bool, * missing_department_ids:array * } * @throws Exception */ public function summarizeCandidates( array $candidates, array $opening_hours_by_department_id, array|int|string $department_ids, string $date, string $date_to ): array { $normalized_department_ids = $this->normalizeDepartmentIds($department_ids); $summary = $this->emptySummary($date, $date_to, $normalized_department_ids); $missing_diagnostics = $this->buildMissingOpeningHoursDiagnostics($normalized_department_ids, $opening_hours_by_department_id, $date, $date_to); $missing_lookup = $this->missingDepartmentLookupByDay($missing_diagnostics['by_day']); foreach ($this->deduplicateCandidates($candidates) as $candidate) { $classification = $this->classifyCandidateAgainstOpeningHours( $candidate, $opening_hours_by_department_id, $missing_lookup, $date, $date_to ); if (($classification['counted'] ?? false) !== true) { continue; } $source = (string)$candidate['source']; $summary['total']++; $summary['by_source'][$source] = (int)($summary['by_source'][$source] ?? 0) + 1; } $summary['has_missing_opening_hours'] = (bool)$missing_diagnostics['has_missing_opening_hours']; $summary['missing_department_ids'] = $missing_diagnostics['missing_department_ids']; return $summary; } /** * @param array> $candidates * @param array> $opening_hours_by_department_id * @param array|int|string $department_ids * @return array{ * department_ids:array, * date:string, * date_to:string, * points:array * }>, * has_missing_opening_hours:bool, * missing_department_ids:array * } * @throws Exception */ public function buildTrendFromCandidates( array $candidates, array $opening_hours_by_department_id, array|int|string $department_ids, string $date, string $date_to ): array { $normalized_department_ids = $this->normalizeDepartmentIds($department_ids); $missing_diagnostics = $this->buildMissingOpeningHoursDiagnostics($normalized_department_ids, $opening_hours_by_department_id, $date, $date_to); $missing_lookup = $this->missingDepartmentLookupByDay($missing_diagnostics['by_day']); $points = []; foreach ($this->dateRange($date, $date_to) as $current_date) { $point_date = $current_date->format('Y-m-d'); $points[$point_date] = [ 'date' => $point_date, 'total' => 0, 'by_source' => $this->emptyBySource(), 'has_missing_opening_hours' => isset($missing_lookup[$point_date]), 'missing_department_ids' => array_values(array_map( 'intval', array_keys($missing_lookup[$point_date] ?? []) )), ]; } foreach ($this->deduplicateCandidates($candidates) as $candidate) { $classification = $this->classifyCandidateAgainstOpeningHours( $candidate, $opening_hours_by_department_id, $missing_lookup, $date, $date_to ); if (($classification['counted'] ?? false) !== true) { continue; } $point_date = (string)$classification['candidate_date']; $source = (string)$candidate['source']; if (!isset($points[$point_date])) { $points[$point_date] = [ 'date' => $point_date, 'total' => 0, 'by_source' => $this->emptyBySource(), 'has_missing_opening_hours' => false, 'missing_department_ids' => [], ]; } $points[$point_date]['total']++; $points[$point_date]['by_source'][$source] = (int)($points[$point_date]['by_source'][$source] ?? 0) + 1; } return [ 'department_ids' => $normalized_department_ids, 'date' => $date, 'date_to' => $date_to, 'points' => array_values($points), 'has_missing_opening_hours' => (bool)$missing_diagnostics['has_missing_opening_hours'], 'missing_department_ids' => $missing_diagnostics['missing_department_ids'], ]; } /** * @param array> $candidates * @return array> */ public function deduplicateCandidates(array $candidates): array { $deduplicated = []; foreach ($candidates as $candidate) { $dedupe_key = trim((string)($candidate['dedupe_key'] ?? '')); if ($dedupe_key === '') { continue; } if (!isset($deduplicated[$dedupe_key])) { $deduplicated[$dedupe_key] = $candidate; continue; } if ($this->shouldReplaceDeduplicatedCandidate($deduplicated[$dedupe_key], $candidate)) { $deduplicated[$dedupe_key] = $candidate; } } uasort($deduplicated, function (array $left, array $right): int { $left_start = trim((string)($left['start_at'] ?? '')); $right_start = trim((string)($right['start_at'] ?? '')); return strcmp($left_start, $right_start); }); return array_values($deduplicated); } /** * @param array $candidate * @param array> $opening_hours_by_department_id * @param array>|null $missing_lookup_by_day * @return array{ * counted: bool, * reason: string, * candidate_date: ?string, * department_id: int * } */ public function classifyCandidateAgainstOpeningHours( array $candidate, array $opening_hours_by_department_id, ?array $missing_lookup_by_day, string $date, string $date_to ): array { $department_id = (int)($candidate['department_id'] ?? 0); $timestamp = $this->parseTimestamp($candidate['start_at'] ?? null); if ($timestamp === null || $department_id < 1) { return [ 'counted' => false, 'reason' => 'invalid_candidate', 'candidate_date' => null, 'department_id' => $department_id, ]; } $candidate_date = $timestamp->format('Y-m-d'); if ($candidate_date < $date || $candidate_date > $date_to) { return [ 'counted' => false, 'reason' => 'outside_selected_range', 'candidate_date' => $candidate_date, 'department_id' => $department_id, ]; } if (isset($missing_lookup_by_day[$candidate_date][$department_id])) { return [ 'counted' => false, 'reason' => 'missing_opening_hours', 'candidate_date' => $candidate_date, 'department_id' => $department_id, ]; } $outside_opening_hours = $this->isOutsideOpeningHours( $timestamp, $opening_hours_by_department_id[$department_id] ?? null ); if ($outside_opening_hours !== true) { return [ 'counted' => false, 'reason' => 'inside_opening_hours', 'candidate_date' => $candidate_date, 'department_id' => $department_id, ]; } return [ 'counted' => true, 'reason' => 'outside_opening_hours', 'candidate_date' => $candidate_date, 'department_id' => $department_id, ]; } /** * @param array|int|string $department_ids * @param array> $opening_hours_by_department_id * @return array{ * by_day:array>, * has_missing_opening_hours:bool, * missing_department_ids:array * } * @throws Exception */ public function buildMissingOpeningHoursDiagnostics( array|int|string $department_ids, array $opening_hours_by_department_id, string $date, string $date_to ): array { $normalized_department_ids = $this->normalizeDepartmentIds($department_ids); $missing_by_day = []; $missing_department_ids = []; foreach ($this->dateRange($date, $date_to) as $current_date) { $weekday = strtolower($current_date->format('l')); $point_date = $current_date->format('Y-m-d'); foreach ($normalized_department_ids as $department_id) { $opening_hours = $opening_hours_by_department_id[$department_id] ?? null; if ($this->hasOpeningHoursForWeekday($opening_hours, $weekday)) { continue; } $missing_by_day[$point_date][] = (int)$department_id; $missing_department_ids[$department_id] = (int)$department_id; } } foreach ($missing_by_day as $point_date => $department_list) { $unique_ids = array_values(array_unique(array_map('intval', $department_list))); sort($unique_ids); $missing_by_day[$point_date] = $unique_ids; } $missing_department_ids = array_values($missing_department_ids); sort($missing_department_ids); return [ 'by_day' => $missing_by_day, 'has_missing_opening_hours' => $missing_department_ids !== [], 'missing_department_ids' => $missing_department_ids, ]; } /** * @param array|int|string $department_ids * @return array */ private function normalizeDepartmentIds(array|int|string $department_ids): array { $queue = is_array($department_ids) ? $department_ids : [$department_ids]; $normalized = []; while ($queue !== []) { $value = array_shift($queue); if (is_array($value)) { foreach ($value as $nested) { $queue[] = $nested; } continue; } if (is_string($value) && str_contains($value, ',')) { foreach (explode(',', $value) as $segment) { $queue[] = trim($segment); } continue; } $department_id = (int)$value; if ($department_id > 0) { $normalized[$department_id] = $department_id; } } return array_values($normalized); } /** * @param array $department_ids * @return array> * @throws Exception */ private function fetchCandidates(string $date, string $date_to, array $department_ids): array { $order_candidates = $this->fetchOrderCandidatesInRange($date, $date_to, $department_ids); $selfserve_candidates = $this->fetchSelfserveCandidatesInRange($date, $date_to, $department_ids); $xlvask_candidates = $this->fetchXlvaskCandidatesInRange($date, $date_to, $department_ids); $order_ids_for_selfserve = array_values(array_unique(array_filter(array_merge( array_map(static fn(array $candidate): int => (int)($candidate['entity_id'] ?? 0), $order_candidates), array_map(static fn(array $candidate): int => (int)($candidate['linked_order_id'] ?? 0), $xlvask_candidates) )))); $wash_ids_for_xlvask = array_values(array_unique(array_filter(array_map( static fn(array $candidate): string => trim((string)($candidate['wash_id'] ?? '')), $order_candidates )))); return array_merge( $order_candidates, $selfserve_candidates, $xlvask_candidates, $this->fetchSelfserveCandidatesByOrderIds($order_ids_for_selfserve), $this->fetchXlvaskCandidatesByWashIds($wash_ids_for_xlvask, $department_ids) ); } /** * @param array $department_ids * @return array> * @throws Exception */ private function fetchOrderCandidatesInRange(string $date, string $date_to, array $department_ids): array { global $db; if ($department_ids === []) { return []; } [$date_start, $date_end] = $this->resolveSqlDateRange($date, $date_to); $department_ids_sql = implode(',', array_map('intval', $department_ids)); $escaped_start = $db->escape_string($date_start); $escaped_end = $db->escape_string($date_end); $sql = "SELECT DISTINCT o.id, o.department_id, o.created_at, o.wash_id FROM orders o JOIN order_items oi ON oi.order_id = o.id JOIN products p ON p.id = oi.product_id WHERE o.department_id IN ($department_ids_sql) AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end' AND o.deleted_at IS NULL AND oi.deleted_at IS NULL AND p.is_wash = 1"; $result = $db->query($sql); if (!is_object($result) || $result->num_rows === 0) { return []; } $candidates = []; while ($row = $result->fetch_assoc()) { $order_id = (int)($row['id'] ?? 0); $department_id = (int)($row['department_id'] ?? 0); $start_at = trim((string)($row['created_at'] ?? '')); if ($order_id < 1 || $department_id < 1 || $start_at === '') { continue; } $candidates[] = [ 'source' => self::SOURCE_ORDERS, 'dedupe_key' => 'order:' . $order_id, 'department_id' => $department_id, 'start_at' => $start_at, 'entity_id' => $order_id, 'wash_id' => trim((string)($row['wash_id'] ?? '')), ]; } return $candidates; } /** * @param array $department_ids * @return array> * @throws Exception */ private function fetchSelfserveCandidatesInRange(string $date, string $date_to, array $department_ids): array { global $db; if ($department_ids === []) { return []; } [$date_start, $date_end] = $this->resolveSqlDateRange($date, $date_to); $department_ids_sql = implode(',', array_map('intval', $department_ids)); $escaped_start = $db->escape_string($date_start); $escaped_end = $db->escape_string($date_end); $sql = "SELECT id, department_id, order_id, wash_started_at, machine_start_triggered_at FROM selfserve_wash_sessions WHERE department_id IN ($department_ids_sql) AND deleted_at IS NULL AND COALESCE(wash_started_at, machine_start_triggered_at) IS NOT NULL AND COALESCE(wash_started_at, machine_start_triggered_at) BETWEEN '$escaped_start' AND '$escaped_end'"; $result = $db->query($sql); if (!is_object($result) || $result->num_rows === 0) { return []; } return $this->mapSelfserveRowsToCandidates($db->fetch_all($result)); } /** * @param array $order_ids * @return array> */ private function fetchSelfserveCandidatesByOrderIds(array $order_ids): array { global $db; $normalized_order_ids = array_values(array_unique(array_filter(array_map('intval', $order_ids)))); if ($normalized_order_ids === []) { return []; } $order_ids_sql = implode(',', $normalized_order_ids); $sql = "SELECT id, department_id, order_id, wash_started_at, machine_start_triggered_at FROM selfserve_wash_sessions WHERE order_id IN ($order_ids_sql) AND deleted_at IS NULL AND COALESCE(wash_started_at, machine_start_triggered_at) IS NOT NULL"; $result = $db->query($sql); if (!is_object($result) || $result->num_rows === 0) { return []; } return $this->mapSelfserveRowsToCandidates($db->fetch_all($result)); } /** * @param array> $rows * @return array> */ private function mapSelfserveRowsToCandidates(array $rows): array { $candidates = []; foreach ($rows as $row) { $session_id = (int)($row['id'] ?? 0); $department_id = (int)($row['department_id'] ?? 0); $order_id = (int)($row['order_id'] ?? 0); $start_at = trim((string)($row['wash_started_at'] ?? '')); if ($start_at === '') { $start_at = trim((string)($row['machine_start_triggered_at'] ?? '')); } if ($session_id < 1 || $department_id < 1 || $start_at === '') { continue; } $candidates[] = [ 'source' => self::SOURCE_SELFSERVE, 'dedupe_key' => $order_id > 0 ? 'order:' . $order_id : 'selfserve:' . $session_id, 'department_id' => $department_id, 'start_at' => $start_at, 'entity_id' => $session_id, 'linked_order_id' => $order_id > 0 ? $order_id : null, ]; } return $candidates; } /** * @param array $department_ids * @return array> * @throws Exception */ private function fetchXlvaskCandidatesInRange(string $date, string $date_to, array $department_ids): array { global $db; if ($department_ids === []) { return []; } [$iso_start, $iso_end] = $this->resolveXlvaskDateRange($date, $date_to); $escaped_start = $db->escape_string($iso_start); $escaped_end = $db->escape_string($iso_end); $sql = "SELECT x.WashId, x.CustomerId, x.Customer, x.Hall, x.StartTime, x.FinishStatus, o.id AS order_id, o.department_id AS order_department_id FROM xlvask_usage_logs x LEFT JOIN orders o ON o.wash_id = x.WashId AND o.deleted_at IS NULL WHERE x.StartTime BETWEEN '$escaped_start' AND '$escaped_end'"; $result = $db->query($sql); if (!is_object($result) || $result->num_rows === 0) { return []; } return $this->mapXlvaskRowsToCandidates($db->fetch_all($result), $department_ids); } /** * @param array $wash_ids * @param array $department_ids * @return array> * @throws Exception */ private function fetchXlvaskCandidatesByWashIds(array $wash_ids, array $department_ids): array { global $db; $normalized_wash_ids = array_values(array_filter(array_map( static fn(mixed $value): string => trim((string)$value), $wash_ids ))); if ($normalized_wash_ids === []) { return []; } $escaped_wash_ids = array_map(static fn(string $wash_id): string => "'" . $db->escape_string($wash_id) . "'", $normalized_wash_ids); $wash_ids_sql = implode(',', $escaped_wash_ids); $sql = "SELECT x.WashId, x.CustomerId, x.Customer, x.Hall, x.StartTime, x.FinishStatus, o.id AS order_id, o.department_id AS order_department_id FROM xlvask_usage_logs x LEFT JOIN orders o ON o.wash_id = x.WashId AND o.deleted_at IS NULL WHERE x.WashId IN ($wash_ids_sql)"; $result = $db->query($sql); if (!is_object($result) || $result->num_rows === 0) { return []; } return $this->mapXlvaskRowsToCandidates($db->fetch_all($result), $department_ids); } /** * @param array> $rows * @param array $department_ids * @return array> * @throws Exception */ private function mapXlvaskRowsToCandidates(array $rows, array $department_ids): array { $selected_departments = $this->fetchDepartmentsByIds($department_ids); $department_name_lookup = []; foreach ($selected_departments as $department) { $department_id = (int)($department['id'] ?? 0); $department_name = (string)($department['name'] ?? ''); if ($department_id < 1 || trim($department_name) === '') { continue; } $department_name_lookup[$this->normalizeDepartmentName($department_name)] = $department_id; } $allowed_department_lookup = []; foreach ($department_ids as $department_id) { $allowed_department_lookup[(int)$department_id] = true; } $candidates = []; foreach ($rows as $row) { $wash_id = trim((string)($row['WashId'] ?? '')); $start_at = $this->normalizeTimestamp($row['StartTime'] ?? null); $order_id = (int)($row['order_id'] ?? 0); $department_id = (int)($row['order_department_id'] ?? 0); if ($wash_id === '' || $start_at === null || !$this->isBillableCompletedXlvaskRow($row)) { continue; } if ($department_id < 1) { $department_id = $this->resolveDepartmentIdFromHall((string)($row['Hall'] ?? ''), $department_name_lookup); } if ($department_id < 1 || !isset($allowed_department_lookup[$department_id])) { continue; } $candidates[] = [ 'source' => self::SOURCE_XLVASK, 'dedupe_key' => $order_id > 0 ? 'order:' . $order_id : 'xlvask:' . $wash_id, 'department_id' => $department_id, 'start_at' => $start_at, 'entity_id' => $wash_id, 'linked_order_id' => $order_id > 0 ? $order_id : null, ]; } return $candidates; } /** * @param array $row */ private function isBillableCompletedXlvaskRow(array $row): bool { $finish_status = trim((string)($row['FinishStatus'] ?? '')); $customer = (string)($row['Customer'] ?? ''); $customer_id = trim((string)($row['CustomerId'] ?? '')); return $finish_status === '1' && $customer_id !== '' && $customer_id !== '0' && !in_array($customer, xlvask_usage_log::$default_customers, true); } /** * @param array $department_ids * @return array> */ private function fetchOpeningHoursByDepartmentId(array $department_ids): array { global $db; if ($department_ids === []) { return []; } $department_ids_sql = implode(',', array_map('intval', $department_ids)); $sql = "SELECT * FROM department_time_bookings_opening_hours WHERE department IN ($department_ids_sql)"; $result = $db->query($sql); if (!is_object($result) || $result->num_rows === 0) { return []; } $rows = []; while ($row = $result->fetch_assoc()) { $rows[(int)($row['department'] ?? 0)] = $row; } return $rows; } /** * @param array $department_ids * @return array */ private function fetchDepartmentsByIds(array $department_ids): array { global $db; if ($department_ids === []) { return []; } $department_ids_sql = implode(',', array_map('intval', $department_ids)); $sql = "SELECT id, name FROM departments WHERE id IN ($department_ids_sql)"; $result = $db->query($sql); if (!is_object($result) || $result->num_rows === 0) { return []; } return array_map(static function (array $row): array { return [ 'id' => (int)($row['id'] ?? 0), 'name' => (string)($row['name'] ?? ''), ]; }, $db->fetch_all($result)); } /** * @param array|null $opening_hours */ private function hasOpeningHoursForWeekday(?array $opening_hours, string $weekday): bool { if (!is_array($opening_hours)) { return false; } $opening_start = $opening_hours[$weekday . '_start'] ?? null; $opening_end = $opening_hours[$weekday . '_end'] ?? null; return is_string($opening_start) && trim($opening_start) !== '' && is_string($opening_end) && trim($opening_end) !== ''; } /** * @param array|null $opening_hours */ private function isOutsideOpeningHours(DateTimeInterface $timestamp, ?array $opening_hours): ?bool { $weekday = strtolower($timestamp->format('l')); if (!$this->hasOpeningHoursForWeekday($opening_hours, $weekday)) { return null; } $opening_start = trim((string)$opening_hours[$weekday . '_start']); $opening_end = trim((string)$opening_hours[$weekday . '_end']); $wash_time = $timestamp->format('H:i'); $opening_start_time = date('H:i', strtotime($opening_start)); $opening_end_time = date('H:i', strtotime($opening_end)); return !($wash_time >= $opening_start_time && $wash_time <= $opening_end_time); } private function shouldReplaceDeduplicatedCandidate(array $existing, array $candidate): bool { $existing_priority = $this->source_priority[(string)($existing['source'] ?? '')] ?? 0; $candidate_priority = $this->source_priority[(string)($candidate['source'] ?? '')] ?? 0; if ($candidate_priority !== $existing_priority) { return $candidate_priority > $existing_priority; } $existing_timestamp = $this->parseTimestamp($existing['start_at'] ?? null); $candidate_timestamp = $this->parseTimestamp($candidate['start_at'] ?? null); if ($existing_timestamp === null && $candidate_timestamp !== null) { return true; } if ($existing_timestamp !== null && $candidate_timestamp !== null) { return $candidate_timestamp < $existing_timestamp; } return false; } /** * @param array> $by_day * @return array> */ private function missingDepartmentLookupByDay(array $by_day): array { $lookup = []; foreach ($by_day as $point_date => $department_ids) { foreach ($department_ids as $department_id) { $lookup[$point_date][(int)$department_id] = true; } } return $lookup; } /** * @return array{orders:int,xlvask:int,selfserve:int} */ private function emptyBySource(): array { return [ self::SOURCE_ORDERS => 0, self::SOURCE_XLVASK => 0, self::SOURCE_SELFSERVE => 0, ]; } /** * @param array $department_ids * @return array{ * department_ids:array, * date:string, * date_to:string, * total:int, * by_source:array{orders:int,xlvask:int,selfserve:int}, * has_missing_opening_hours:bool, * missing_department_ids:array * } */ private function emptySummary(string $date, string $date_to, array $department_ids): array { return [ 'department_ids' => array_values(array_map('intval', $department_ids)), 'date' => $date, 'date_to' => $date_to, 'total' => 0, 'by_source' => $this->emptyBySource(), 'has_missing_opening_hours' => false, 'missing_department_ids' => [], ]; } /** * @return array{0:string,1:string} * @throws Exception */ private function resolveSqlDateRange(string $date, string $date_to): array { $start = $this->parseDate($date)->setTime(0, 0, 0); $end = $this->parseDate($date_to)->setTime(23, 59, 59); return [ $start->format('Y-m-d H:i:s'), $end->format('Y-m-d H:i:s'), ]; } /** * @return array{0:string,1:string} * @throws Exception */ private function resolveXlvaskDateRange(string $date, string $date_to): array { $start = $this->parseDate($date)->setTime(0, 0, 0, 0); $end = $this->parseDate($date_to)->setTime(23, 59, 59, 999000); return [ $start->format('Y-m-d\TH:i:s.v'), $end->format('Y-m-d\TH:i:s.v'), ]; } /** * @return DatePeriod * @throws Exception */ private function dateRange(string $date, string $date_to): DatePeriod { $start = $this->parseDate($date)->setTime(0, 0, 0); $end = $this->parseDate($date_to)->setTime(0, 0, 0)->add(new DateInterval('P1D')); return new DatePeriod($start, new DateInterval('P1D'), $end); } /** * @throws Exception */ private function parseDate(string $date): DateTimeImmutable { $parsed = DateTimeImmutable::createFromFormat('Y-m-d', $date, $this->timezone); if ($parsed === false) { throw new Exception('Invalid date: ' . $date); } return $parsed; } private function parseTimestamp(mixed $value): ?DateTimeImmutable { $normalized = $this->normalizeTimestamp($value); if ($normalized === null) { return null; } try { return new DateTimeImmutable($normalized, $this->timezone); } catch (Exception) { return null; } } private function normalizeTimestamp(mixed $value): ?string { if (!is_string($value) && !is_numeric($value)) { return null; } $candidate = trim((string)$value); if ($candidate === '') { return null; } try { return (new DateTimeImmutable($candidate, $this->timezone))->setTimezone($this->timezone)->format('Y-m-d H:i:s'); } catch (Exception) { return null; } } private function normalizeDepartmentName(string $name): string { $normalized = mb_strtolower(trim($name), 'UTF-8'); $normalized = str_replace( ['æ', 'ø', 'å', 'ä', 'ö', 'ü'], ['ae', 'oe', 'aa', 'ae', 'oe', 'ue'], $normalized ); $normalized = preg_replace('/[^a-z0-9]+/u', '', $normalized) ?? ''; return $normalized; } /** * Hall names are typically stored as DepartmentName_Lane. */ private function resolveDepartmentIdFromHall(string $hall, array $department_name_lookup): int { $hall = trim($hall); if ($hall === '') { return 0; } $department_name = explode('_', $hall)[0] ?? ''; $normalized_name = $this->normalizeDepartmentName($department_name); return (int)($department_name_lookup[$normalized_name] ?? 0); } }