setTable('xlvask_usage_logs'); } /** * Add a new XL Vask customer * @param array $data The properties * @returns void * @throws Exception If the object was not created successfully */ public function add(array $data): void { $tmp_id = self::add_object($data); $this->id = $tmp_id; self::getObjectProperties(); self::objectChanged(); } public function getObjectProperties(): void { $this->WashId = new object_property($this->table, $this->id, 'WashId', 'string', false); $this->CustomerId = new object_property($this->table, $this->id, 'CustomerId', 'string', false); $this->Customer = new object_property($this->table, $this->id, 'Customer', 'string', false); $this->VatNumber = new object_property($this->table, $this->id, 'VatNumber', 'string', false); $this->Location = new object_property($this->table, $this->id, 'Location', 'string', false); $this->Hall = new object_property($this->table, $this->id, 'Hall', 'string', false); $this->HallId = new object_property($this->table, $this->id, 'HallId', 'string', false); $this->StartTime = new object_property($this->table, $this->id, 'StartTime', 'string', false); $this->FinishTime = new object_property($this->table, $this->id, 'FinishTime', 'string', false); $this->RegistrationNumber = new object_property($this->table, $this->id, 'RegistrationNumber', 'string', false); $this->VehicleType = new object_property($this->table, $this->id, 'VehicleType', 'string', false); $this->IdentificationType = new object_property($this->table, $this->id, 'IdentificationType', 'string', false); $this->IdentificationId = new object_property($this->table, $this->id, 'IdentificationId', 'string', false); $this->Info = new object_property($this->table, $this->id, 'Info', 'string', false); $this->Updated = new object_property($this->table, $this->id, 'Updated', 'string', false); $this->Prepaid = new object_property($this->table, $this->id, 'Prepaid', 'string', false); $this->FinishStatus = new object_property($this->table, $this->id, 'FinishStatus', 'string', false); $this->CustomerGuid = new object_property($this->table, $this->id, 'CustomerGuid', 'string', false); $this->VehicleId = new object_property($this->table, $this->id, 'VehicleId', 'string', false); $this->WashItems = new object_property($this->table, $this->id, 'WashItems', 'string', false); $this->ignored_at = new object_property($this->table, $this->id, 'ignored_at', 'datetime', false); $this->ignored_by = new object_property($this->table, $this->id, 'ignored_by', 'int', false); $this->ignored_reason = new object_property($this->table, $this->id, 'ignored_reason', 'string', false); } public function objectChanged(): void { //TODO: Add cache invalidation } public function getCachedAmountSummaryFromRow(array $row): array { $cached_amount = self::normalizeMoneyValue($row['cached_total_net_amount'] ?? null); $cached_at = trim((string)($row['cached_amount_at'] ?? '')); if ($cached_amount !== null && $cached_at !== '') { return [ 'total_net_amount' => $cached_amount, 'primary_product_name' => (string)($row['cached_primary_product_name'] ?? ''), 'cached' => true, ]; } $summary = self::calculateAmountSummaryFromWashItems($row['WashItems'] ?? []); $id = (int)($row['id'] ?? 0); if ($id > 0) { self::cacheAmountSummary($id, $summary); } return [ ...$summary, 'cached' => false, ]; } /** Read-only amount projection for GET routes. */ public function getAmountSummaryReadOnly(array $row): array { $cachedAmount = self::normalizeMoneyValue($row['cached_total_net_amount'] ?? null); $cachedAt = trim((string)($row['cached_amount_at'] ?? '')); if ($cachedAmount !== null && $cachedAt !== '') { return [ 'total_net_amount' => $cachedAmount, 'primary_product_name' => (string)($row['cached_primary_product_name'] ?? ''), 'cached' => true, ]; } return [...self::calculateAmountSummaryFromWashItems($row['WashItems'] ?? []), 'cached' => false]; } public static function calculateAmountSummaryFromWashItems(array|string|null $washItems): array { if (is_string($washItems)) { $decoded = json_decode($washItems, true); $washItems = is_array($decoded) ? $decoded : []; } $total = 0.0; $primaryProductName = ''; foreach (is_array($washItems) ? $washItems : [] as $item) { if (!is_array($item)) { continue; } if ($primaryProductName === '' && isset($item['OriginalProductName'])) { $primaryProductName = trim((string)$item['OriginalProductName']); } $priceIncVat = self::normalizeMoneyValue($item['PriceIncVat'] ?? null); $vat = self::normalizeMoneyValue($item['Vat'] ?? 0.0) ?? 0.0; if ($priceIncVat === null) { continue; } $total += $priceIncVat - $vat; } return [ 'total_net_amount' => round($total, 2), 'primary_product_name' => $primaryProductName, ]; } private static function cacheAmountSummary(int $id, array $summary): void { global $db; if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) { return; } $amount = number_format((float)($summary['total_net_amount'] ?? 0.0), 2, '.', ''); $primaryProductName = $db->escape_string((string)($summary['primary_product_name'] ?? '')); $db->query( "UPDATE xlvask_usage_logs SET cached_total_net_amount = {$amount}, cached_primary_product_name = " . ($primaryProductName === '' ? 'NULL' : "'{$primaryProductName}'") . ", cached_amount_at = NOW() WHERE id = {$id}" ); } private static function normalizeMoneyValue(mixed $value): ?float { if ($value === null || $value === '') { return null; } if (is_int($value) || is_float($value)) { return (float)$value; } $normalized = preg_replace('/[^\d,.\-]/', '', (string)$value); if ($normalized === null || $normalized === '') { return null; } if (str_contains($normalized, ',') && !str_contains($normalized, '.')) { $normalized = str_replace(',', '.', $normalized); } else { $normalized = str_replace(',', '', $normalized); } return is_numeric($normalized) ? (float)$normalized : null; } /** * Import the usage logs from XL Vask * @param string|null $dateFrom Optional import start date or date-time modifier. Defaults to '-7 days'. * @param string|null $dateTo Optional inclusive import end date. * @throws Exception If the objects were not successfully added. * @returns void */ public function importUsageLogs(?string $dateFrom = null, ?string $dateTo = null): void { $this->importUsageLogsWithSummary($dateFrom, $dateTo); } /** * Revision-aware and idempotent XL Vask import. * * @return array{fetched:int,new:int,updated:int,unchanged:int,invalid:int,errors:array>} */ public function importUsageLogsWithSummary(?string $dateFrom = null, ?string $dateTo = null, array $allowedHallIds = []): array { global $db; if (!empty($this->id)) { throw new Exception('To prevent issues, having a selected object is not allowed.'); } xlvask_usage_logs_schema_bootstrap::ensureTables(); $usage_logs = $this->getUsageLogsFromXLVask( self::formatImportDateFrom($dateFrom) // Example: '2025-05-01T00:00:00.000' ); $usage_logs = self::filterUsageLogsUntil($usage_logs, $dateTo); $upstreamFetched = count($usage_logs); $allowedHallIds = array_values(array_unique(array_filter(array_map( static fn(mixed $id): string => trim((string)$id), $allowedHallIds ), static fn(string $id): bool => $id !== ''))); if ($allowedHallIds !== []) { $usage_logs = array_values(array_filter( $usage_logs, static fn(xlvask_usage_log $log): bool => in_array(trim((string)$log->HallId), $allowedHallIds, true) )); } $washIds = array_values(array_filter(array_map( static fn(xlvask_usage_log $log): string => trim((string)$log->WashId), $usage_logs ))); $knownRows = $washIds === [] ? [] : self::getFieldsWhere( ['WashId' => $washIds], ['id', 'WashId', 'source_hash', 'source_revision', 'expected_version'] ); $knownByWashId = []; foreach ($knownRows as $knownRow) { $knownByWashId[(string)$knownRow['WashId']] = $knownRow; } $summary = [ 'fetched' => count($usage_logs), 'upstream_fetched' => $upstreamFetched, 'new' => 0, 'updated' => 0, 'unchanged' => 0, 'invalid' => 0, 'errors' => [], ]; foreach ($usage_logs as $log) { $washId = trim((string)$log->WashId); if ($washId === '' || !$log->isValid()) { $summary['invalid']++; $summary['errors'][] = [ 'wash_id' => $washId, 'error' => 'XL Vask usage log failed structural validation.', ]; if ($washId !== '' && isset($knownByWashId[$washId])) { $this->markImportState((int)$knownByWashId[$washId]['id'], 'invalid', 'XL Vask-kildedata kunne ikke valideres.'); } continue; } $payload = self::normalizeSourcePayload($log->toArray()); $sourceHash = self::sourceHashForAutomation($payload); $sourceRevision = trim((string)($payload['Updated'] ?? '')) ?: $sourceHash; $known = $knownByWashId[$washId] ?? null; if ($known === null) { $this->add($payload); $this->updateSourceMetadata((int)$this->id, $sourceHash, $sourceRevision, 'new', true); $knownByWashId[$washId] = ['id' => (int)$this->id, 'WashId' => $washId, 'source_hash' => $sourceHash]; $summary['new']++; continue; } $existingHash = trim((string)($known['source_hash'] ?? '')); if ($existingHash !== '' && hash_equals($existingHash, $sourceHash)) { $this->updateSourceMetadata((int)$known['id'], $sourceHash, $sourceRevision, 'unchanged', false); $summary['unchanged']++; continue; } $before = $known; $connection = $db->conn(); $connection->begin_transaction(); try { $this->updateExistingSourceRow((int)$known['id'], $payload, $sourceHash, $sourceRevision); $this->supersedeSuggestionsForSourceRevision((int)$known['id']); $this->recordSourceRevision((int)$known['id'], $washId, $before, $payload, $sourceHash, $sourceRevision); $connection->commit(); } catch (\Throwable $throwable) { $connection->rollback(); throw $throwable; } $knownByWashId[$washId]['source_hash'] = $sourceHash; $summary['updated']++; } return $summary; } public static function sourceHashForAutomation(array $payload): string { $payload = self::normalizeSourcePayload($payload); $encoded = json_encode( self::sortSourceValue($payload), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION ); if ($encoded === false) { throw new Exception('Could not build XL Vask source hash.'); } return hash('sha256', $encoded); } private static function normalizeSourcePayload(array $payload): array { $allowed = [ 'WashId', 'CustomerId', 'Customer', 'VatNumber', 'Location', 'Hall', 'HallId', 'StartTime', 'FinishTime', 'RegistrationNumber', 'VehicleType', 'IdentificationType', 'IdentificationId', 'Info', 'Updated', 'Prepaid', 'FinishStatus', 'CustomerGuid', 'VehicleId', 'WashItems', ]; $payload = array_intersect_key($payload, array_flip($allowed)); if (isset($payload['WashItems']) && is_string($payload['WashItems'])) { $decoded = json_decode($payload['WashItems'], true); if (is_array($decoded)) { $payload['WashItems'] = $decoded; } } if (isset($payload['WashItems']) && is_array($payload['WashItems'])) { $payload['WashItems'] = array_values($payload['WashItems']); usort($payload['WashItems'], static function (mixed $left, mixed $right): int { $leftJson = json_encode(self::sortSourceValue($left), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION) ?: ''; $rightJson = json_encode(self::sortSourceValue($right), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION) ?: ''; return strcmp($leftJson, $rightJson); }); } return $payload; } private static function sortSourceValue(mixed $value): mixed { if (!is_array($value)) { return $value; } $sorted = array_map(static fn(mixed $item): mixed => self::sortSourceValue($item), $value); $isList = $sorted === [] || array_keys($sorted) === range(0, count($sorted) - 1); if (!$isList) { ksort($sorted, SORT_STRING); } return $sorted; } private function updateExistingSourceRow(int $id, array $payload, string $sourceHash, string $sourceRevision): void { global $db; $assignments = []; foreach ($payload as $column => $value) { $assignments[] = '`' . str_replace('`', '', $column) . '` = ' . $this->sqlValue($value); } $assignments[] = "source_hash = '" . $db->escape_string($sourceHash) . "'"; $assignments[] = "source_revision = '" . $db->escape_string($sourceRevision) . "'"; $assignments[] = 'source_observed_at = NOW()'; $assignments[] = 'source_stable_since = NOW()'; $assignments[] = 'source_observation_count = 1'; $assignments[] = "import_state = 'updated'"; $assignments[] = "resolution_state = 'needs_review'"; $assignments[] = "certainty = 'none'"; $assignments[] = "planned_action = 'recheck'"; $assignments[] = "state_reason = 'XL Vask-kildedata blev opdateret.'"; $assignments[] = 'expected_version = expected_version + 1'; $assignments[] = 'cached_total_net_amount = NULL'; $assignments[] = 'cached_primary_product_name = NULL'; $assignments[] = 'cached_amount_at = NULL'; if ($db->query('UPDATE xlvask_usage_logs SET ' . implode(', ', $assignments) . " WHERE id = {$id}") === false || $db->conn()->affected_rows !== 1) { throw new Exception('Could not atomically update the XL Vask source row.'); } } private function supersedeSuggestionsForSourceRevision(int $usageLogId): void { global $db; if ($db->query( "UPDATE xlvask_automation_suggestions SET status = 'superseded', updated_at = NOW() WHERE usage_log_id = {$usageLogId} AND status <> 'superseded'" ) === false) { throw new Exception('Could not invalidate stale XL Vask automation suggestions.'); } } private function updateSourceMetadata(int $id, string $sourceHash, string $sourceRevision, string $state, bool $new): void { global $db; $sourceHash = $db->escape_string($sourceHash); $sourceRevision = $db->escape_string($sourceRevision); $state = $db->escape_string($state); $stable = $new ? 'NOW()' : "COALESCE(source_stable_since, NOW())"; $observations = $new ? '1' : 'GREATEST(1, source_observation_count) + 1'; $db->query( "UPDATE xlvask_usage_logs SET source_hash = '{$sourceHash}', source_revision = '{$sourceRevision}', source_observed_at = NOW(), source_stable_since = {$stable}, source_observation_count = {$observations}, import_state = '{$state}' WHERE id = {$id}" ); } private function markImportState(int $id, string $state, string $reason): void { global $db; $state = $db->escape_string($state); $reason = $db->escape_string($reason); $connection = $db->conn(); $connection->begin_transaction(); try { if ($db->query( "UPDATE xlvask_usage_logs SET import_state = '{$state}', resolution_state = 'failed', certainty = 'none', planned_action = 'none', state_reason = '{$reason}', source_stable_since = NULL, source_observation_count = 0, source_observed_at = NOW(), expected_version = expected_version + 1 WHERE id = {$id}" ) === false || $db->conn()->affected_rows !== 1) { throw new Exception('Could not atomically mark invalid XL Vask source data.'); } $this->supersedeSuggestionsForSourceRevision($id); $connection->commit(); } catch (\Throwable $throwable) { $connection->rollback(); throw $throwable; } } private function recordSourceRevision( int $id, string $washId, array $before, array $after, string $sourceHash, string $sourceRevision ): void { global $db; $beforeJson = $db->escape_string(json_encode([ 'source_hash' => (string)($before['source_hash'] ?? ''), 'source_revision' => (string)($before['source_revision'] ?? ''), ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'); $afterJson = $db->escape_string(json_encode([ 'source_hash' => $sourceHash, 'source_revision' => $sourceRevision, 'observed_fields' => array_values(array_keys($after)), ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'); $washId = $db->escape_string($washId); $sourceHash = $db->escape_string($sourceHash); $sourceRevision = $db->escape_string($sourceRevision); if ($db->query( "INSERT INTO xlvask_automation_audit (usage_log_id, wash_id, event_type, policy_version, input_hash, source_revision, before_json, after_json) VALUES ({$id}, '{$washId}', 'source_updated', 'xlvask-autopilot-v1', '{$sourceHash}', '{$sourceRevision}', '{$beforeJson}', '{$afterJson}')" ) === false || $db->conn()->affected_rows !== 1) { throw new Exception('Could not record the XL Vask source revision audit event.'); } } private function sqlValue(mixed $value): string { global $db; if ($value === null) { return 'NULL'; } if (is_bool($value)) { return $value ? '1' : '0'; } if (is_int($value) || is_float($value)) { return (string)$value; } if (is_array($value)) { $value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]'; } return "'" . $db->escape_string((string)$value) . "'"; } private static function formatImportDateFrom(?string $dateFrom): string { $dateFrom = trim((string)($dateFrom ?? '')); $timestamp = strtotime($dateFrom === '' ? '-7 days' : $dateFrom); if ($timestamp === false) { throw new Exception('Invalid XL Vask usage import dateFrom'); } return date('Y-m-d\TH:i:s.000', $timestamp); } /** * @param xlvask_usage_log[] $usageLogs * @return xlvask_usage_log[] * @throws Exception */ private static function filterUsageLogsUntil(array $usageLogs, ?string $dateTo): array { $dateTo = trim((string)($dateTo ?? '')); if ($dateTo === '') { return $usageLogs; } $dateToTimestamp = strtotime($dateTo); if ($dateToTimestamp === false) { throw new Exception('Invalid XL Vask usage import dateTo'); } $inclusiveEndTimestamp = strtotime(date('Y-m-d 23:59:59', $dateToTimestamp)); if ($inclusiveEndTimestamp === false) { throw new Exception('Invalid XL Vask usage import dateTo'); } return array_values(array_filter($usageLogs, function (xlvask_usage_log $log) use ($inclusiveEndTimestamp) { $startTimestamp = strtotime((string)$log->StartTime); return $startTimestamp !== false && $startTimestamp <= $inclusiveEndTimestamp; })); } /** * This function retrieves the usage logs from XL Vask * @param string $fromDate The date from which to retrieve the usage logs, in ISO 8601 format (e.g., '2025-05-01T00:00:00.000') * @returns xlvask_usage_log[] * @throws Exception */ public function getUsageLogsFromXLVask(string $fromDate): array { // Validate the date format if (!preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}$/', $fromDate)) { throw new Exception('Invalid date format. Expected format: YYYY-MM-DDTHH:MM:SS.SSS (e.g., 2025-05-01T00:00:00.000)'); } // Set the memory limit to 512MB, as the import can be quite large ini_set('memory_limit', '512M'); /** Get the vehicles from XL Vask */ $xlvask = new xlvask(); $vehicle_class = $xlvask->new($xlvask->helpers->xlvask_usage_log); /** @var xlvask_usage_log[] $vehicles */ $vehicles = $vehicle_class->toObjects($xlvask->getUsageLog( $fromDate, // Example: '2025-05-01T00:00:00.000' )); return $vehicles; } }