From 0cca597fdc5f24b1f3a0fcd54eb79afa030cf712 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 6 Jul 2026 23:49:28 +0200 Subject: [PATCH] Fix XLVask usage import dates Fix XLVask usage-log import metadata and period-scoped Selvvask automation. --- .../app/classes/xlvask_automation_service.php | 7 +-- .../xlvask/helpers/xlvask_usage_log.php | 9 +++- .../nginx/app/objects/xlvask_usage_logs_o.php | 50 +++++++++++++++++-- .../nginx/app/routes/moduleXLVaskRoute.php | 6 ++- .../Unit/XLVask/XLVaskUsageLogHelperTest.php | 32 ++++++++++++ .../XLVask/XLVaskUsageRouteContractTest.php | 19 +++++++ 6 files changed, 113 insertions(+), 10 deletions(-) diff --git a/services/nginx/app/classes/xlvask_automation_service.php b/services/nginx/app/classes/xlvask_automation_service.php index cee9dcb9..ccdca1d9 100644 --- a/services/nginx/app/classes/xlvask_automation_service.php +++ b/services/nginx/app/classes/xlvask_automation_service.php @@ -1246,19 +1246,20 @@ class xlvask_automation_service { global $db; (new xlvask_usage_logs_o())->structure(); + $startTimeExpression = "STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')"; $where = [ 'FinishStatus = 1', '(ignored_at IS NULL OR ignored_at = "")', ]; if ($dateFrom !== null && strtotime($dateFrom) !== false) { - $where[] = "StartTime >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'"; + $where[] = "{$startTimeExpression} >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'"; } else { - $where[] = "StartTime >= '" . $db->escape_string(date('Y-m-d H:i:s', strtotime('-7 days'))) . "'"; + $where[] = "{$startTimeExpression} >= '" . $db->escape_string(date('Y-m-d H:i:s', strtotime('-7 days'))) . "'"; } if ($dateTo !== null && strtotime($dateTo) !== false) { - $where[] = "StartTime <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'"; + $where[] = "{$startTimeExpression} <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'"; } $limit = max(1, min(500, $limit)); diff --git a/services/nginx/app/modules/xlvask/helpers/xlvask_usage_log.php b/services/nginx/app/modules/xlvask/helpers/xlvask_usage_log.php index f827a17c..108bb486 100644 --- a/services/nginx/app/modules/xlvask/helpers/xlvask_usage_log.php +++ b/services/nginx/app/modules/xlvask/helpers/xlvask_usage_log.php @@ -379,6 +379,10 @@ class xlvask_usage_log extends xlvask_helper private function unsetNullifiableProperties(): void { + $nullable_review_metadata = [ + 'ignored_at', + 'ignored_reason', + ]; // Unset properties that are null or empty strings $properties = [ 'WashId', 'CustomerId', 'Customer', 'VatNumber', 'Location', @@ -391,7 +395,10 @@ class xlvask_usage_log extends xlvask_helper if ($this->isEmptyOrDefault($this->{$property})) { $tmp_value = $this->{$property}; if ($tmp_value === $this->default_string || $tmp_value === $this->default_string_nullable) { - $this->{$property} = ''; // Set to null if it matches the default string + $this->{$property} = ( + $tmp_value === $this->default_string_nullable + && in_array($property, $nullable_review_metadata, true) + ) ? null : ''; } elseif ($tmp_value === $this->default_int || $tmp_value === $this->default_int_nullable) { if ($tmp_value === $this->default_int_nullable) { $this->{$property} = null; // Set to null if it matches the default int nullable diff --git a/services/nginx/app/objects/xlvask_usage_logs_o.php b/services/nginx/app/objects/xlvask_usage_logs_o.php index ac30d7d9..5c6db532 100644 --- a/services/nginx/app/objects/xlvask_usage_logs_o.php +++ b/services/nginx/app/objects/xlvask_usage_logs_o.php @@ -82,7 +82,7 @@ class xlvask_usage_logs_o extends db $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', '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); } @@ -195,18 +195,20 @@ class xlvask_usage_logs_o extends db /** * Import the usage logs from XL Vask - * @param string $dateTimeModifier A date time modifier to use for the import, defaults to '-7 days' + * @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 $dateTimeModifier = '-7 days'): void + public function importUsageLogs(?string $dateFrom = null, ?string $dateTo = null): void { if (!empty($this->id)) { throw new Exception('To prevent issues, having a selected object is not allowed.'); } $usage_logs = $this->getUsageLogsFromXLVask( - date('Y-m-d\TH:i:s.000', strtotime($dateTimeModifier)) // Example: '2025-05-01T00:00:00.000' + self::formatImportDateFrom($dateFrom) // Example: '2025-05-01T00:00:00.000' ); + $usage_logs = self::filterUsageLogsUntil($usage_logs, $dateTo); /** @var string[] $known_usage_logIds The XL Vask usage logIds currently known */ $known_usage_logIds = array_map(function ($log) { return $log['WashId']; @@ -236,6 +238,46 @@ class xlvask_usage_logs_o extends db unset($new_usage_logs); } + 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') diff --git a/services/nginx/app/routes/moduleXLVaskRoute.php b/services/nginx/app/routes/moduleXLVaskRoute.php index 724177f4..8caabf23 100644 --- a/services/nginx/app/routes/moduleXLVaskRoute.php +++ b/services/nginx/app/routes/moduleXLVaskRoute.php @@ -255,11 +255,13 @@ class moduleXLVaskRoute $this->get('/modules/xlvask/tasks/import-usage', function () { global $response; self::requirePermission('modules_xlvask_import_usage'); + $dateFrom = $this->isParametersSet(['dateFrom']) ? trim((string)$this->getParameter('dateFrom')) : null; + $dateTo = $this->isParametersSet(['dateTo']) ? trim((string)$this->getParameter('dateTo')) : null; // Create the xlvask_usage_logs_o object $xlvask_usage_logs_o = new \objects\xlvask_usage_logs_o(); // Import usage logs - $xlvask_usage_logs_o->importUsageLogs(); - (new xlvask_automation_service())->runPending(null, null, [], 100, null); + $xlvask_usage_logs_o->importUsageLogs($dateFrom, $dateTo); + (new xlvask_automation_service())->runPending($dateFrom, $dateTo, [], 100, null); // Response $response->success( 'Usage logs imported', diff --git a/services/nginx/app/tests/Unit/XLVask/XLVaskUsageLogHelperTest.php b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageLogHelperTest.php index 69e642b8..7aba76f7 100644 --- a/services/nginx/app/tests/Unit/XLVask/XLVaskUsageLogHelperTest.php +++ b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageLogHelperTest.php @@ -3,6 +3,20 @@ use helpers\xlvask_usage_log; use objects\xlvask_usage_logs_o; +it('serializes empty ignore metadata as SQL null values for new usage logs', function (): void { + $log = new xlvask_usage_log(); + $data = $log->toArray(); + + expect($data) + ->toHaveKey('ignored_at') + ->toHaveKey('ignored_by') + ->toHaveKey('ignored_reason') + ->and($data['ignored_at'])->toBeNull() + ->and($data['ignored_by'])->toBeNull() + ->and($data['ignored_reason'])->toBeNull() + ->and($data['Updated'])->toBe(''); +}); + it('accepts persisted ignore metadata from xlvask usage log rows', function (): void { $log = new xlvask_usage_log(); @@ -57,3 +71,21 @@ it('calculates XL Vask amount summaries without hydrating order item previews', 'primary_product_name' => 'Stor bil', ]); }); + +it('formats date-only XL Vask usage import start dates for the upstream API', function (): void { + $method = new ReflectionMethod(xlvask_usage_logs_o::class, 'formatImportDateFrom'); + + expect($method->invoke(null, '2026-03-01'))->toBe('2026-03-01T00:00:00.000'); +}); + +it('filters fetched XL Vask usage logs inclusively to the requested import end date', function (): void { + $keep = new xlvask_usage_log(['StartTime' => '2026-03-31T23:59:59.000']); + $drop = new xlvask_usage_log(['StartTime' => '2026-04-01T00:00:00.000']); + $method = new ReflectionMethod(xlvask_usage_logs_o::class, 'filterUsageLogsUntil'); + + $result = $method->invoke(null, [$keep, $drop], '2026-03-31'); + + expect($result) + ->toHaveCount(1) + ->and($result[0])->toBe($keep); +}); diff --git a/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php index 3c442a81..406c1c43 100644 --- a/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php +++ b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php @@ -43,3 +43,22 @@ it('returns cached amount summaries on XL Vask usage order rows without widening ->and($route)->toContain("\$tmp_res['order']['xlvask_primary_product_name'] = \$amount_summary['primary_product_name']") ->and($route)->toContain("\$tmp_res['order']['xlvask_amount_cached'] = \$amount_summary['cached']"); }); + +it('scopes manual XL Vask usage import and automation to optional period dates', function (): void { + $route = file_get_contents(WD . '/routes/moduleXLVaskRoute.php'); + $automation = file_get_contents(WD . '/classes/xlvask_automation_service.php'); + + expect($route) + ->not->toBeFalse() + ->and($automation)->not->toBeFalse(); + + $route = (string)$route; + $automation = (string)$automation; + + expect($route) + ->toContain("getParameter('dateFrom')") + ->toContain("getParameter('dateTo')") + ->toContain('$xlvask_usage_logs_o->importUsageLogs($dateFrom, $dateTo)') + ->toContain('runPending($dateFrom, $dateTo, [], 100, null)') + ->and($automation)->toContain("STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')"); +});