Files
api/services/nginx/app/classes/department_outside_hours_statistics_service.php
T
Jeppe B 2a6a86c9c3 Resolve backend Qodana critical and high findings (#314)
Resolve recommended-profile Critical and High findings, retain narrow analyzer exceptions, and update the edge-broker WebSocket dependency to a non-vulnerable release.
2026-07-17 05:44:16 +02:00

1058 lines
36 KiB
PHP

<?php
namespace classes;
use DateInterval;
use DatePeriod;
use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use Exception;
use helpers\xlvask_usage_log;
class department_outside_hours_statistics_service
{
public const SOURCE_ORDERS = 'orders';
public const SOURCE_XLVASK = 'xlvask';
public const SOURCE_SELFSERVE = 'selfserve';
/**
* Higher number means higher precedence when multiple records map to the same physical wash.
* @var array<string,int>
*/
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>|int|string $department_ids
* @return array{
* department_ids:array<int>,
* 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<int>
* }
* @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>|int|string $department_ids
* @return array{
* department_ids:array<int>,
* date:string,
* date_to:string,
* points:array<int,array{
* date:string,
* total:int,
* by_source:array{orders:int,xlvask:int,selfserve:int},
* has_missing_opening_hours:bool,
* missing_department_ids:array<int>
* }>,
* has_missing_opening_hours:bool,
* missing_department_ids:array<int>
* }
* @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<int|string>
* } $summary
* @return array<string,mixed>
*/
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<int,array<string,mixed>> $candidates
* @param array<int,array<string,mixed>> $opening_hours_by_department_id
* @param array<int|string>|int|string $department_ids
* @return array{
* department_ids:array<int>,
* 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<int>
* }
* @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<int,array<string,mixed>> $candidates
* @param array<int,array<string,mixed>> $opening_hours_by_department_id
* @param array<int|string>|int|string $department_ids
* @return array{
* department_ids:array<int>,
* date:string,
* date_to:string,
* points:array<int,array{
* date:string,
* total:int,
* by_source:array{orders:int,xlvask:int,selfserve:int},
* has_missing_opening_hours:bool,
* missing_department_ids:array<int>
* }>,
* has_missing_opening_hours:bool,
* missing_department_ids:array<int>
* }
* @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<int,array<string,mixed>> $candidates
* @return array<int,array<string,mixed>>
*/
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<string,mixed> $candidate
* @param array<int,array<string,mixed>> $opening_hours_by_department_id
* @param array<string,array<int,bool>>|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>|int|string $department_ids
* @param array<int,array<string,mixed>> $opening_hours_by_department_id
* @return array{
* by_day:array<string,array<int>>,
* has_missing_opening_hours:bool,
* missing_department_ids:array<int>
* }
* @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>|int|string $department_ids
* @return array<int>
*/
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<int> $department_ids
* @return array<int,array<string,mixed>>
* @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<int> $department_ids
* @return array<int,array<string,mixed>>
* @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<int> $department_ids
* @return array<int,array<string,mixed>>
* @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<int> $order_ids
* @return array<int,array<string,mixed>>
*/
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<int,array<string,mixed>> $rows
* @return array<int,array<string,mixed>>
*/
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<int> $department_ids
* @return array<int,array<string,mixed>>
* @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<int|string> $wash_ids
* @param array<int> $department_ids
* @return array<int,array<string,mixed>>
* @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<int,array<string,mixed>> $rows
* @param array<int> $department_ids
* @return array<int,array<string,mixed>>
* @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<string,mixed> $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<int> $department_ids
* @return array<int,array<string,mixed>>
*/
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<int> $department_ids
* @return array<int,array{id:int,name:string}>
*/
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<string,mixed>|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<string,mixed>|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<string,array<int>> $by_day
* @return array<string,array<int,bool>>
*/
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<int> $department_ids
* @return array{
* department_ids:array<int>,
* 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<int>
* }
*/
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<DateTimeImmutable>
* @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);
}
}