1058 lines
36 KiB
PHP
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);
|
|
}
|
|
}
|