diff --git a/services/nginx/app/classes/invoice_period_flag_service.php b/services/nginx/app/classes/invoice_period_flag_service.php index 783c6c3f..57afb73f 100644 --- a/services/nginx/app/classes/invoice_period_flag_service.php +++ b/services/nginx/app/classes/invoice_period_flag_service.php @@ -630,7 +630,8 @@ class invoice_period_flag_service && !$this->hasAttribute($attributes, $customerNumber, 'restrictTankCleaning') && !$this->hasAttribute($attributes, $customerNumber, 'restrictSpotFree') && !$this->hasAttribute($attributes, $customerNumber, 'restrictInteriorCleaning') - && !$this->hasAttribute($attributes, $customerNumber, 'exemptFromAdministrationFee')) { + && !$this->hasAttribute($attributes, $customerNumber, 'exemptFromAdministrationFee') + && !$this->hasAttribute($attributes, $customerNumber, 'onlyTankCleaning')) { continue; } @@ -638,6 +639,8 @@ class invoice_period_flag_service continue; } + $isTankCleaningProduct = $this->rowIsTankCleaningProduct($row); + if ($this->hasAttribute($attributes, $customerNumber, 'restrictAdditionalServices') && (int)($row['related_item_id'] ?? 0) > 0 && (int)($row['item_price'] ?? 0) > 0) { @@ -652,8 +655,31 @@ class invoice_period_flag_service ); } + if ($this->hasAttribute($attributes, $customerNumber, 'restrictTankCleaning') && $isTankCleaningProduct) { + $flags[] = $this->automaticFlag( + 'customer_rule_restrict_tank_cleaning', + 'order_item', + (int)$row['order_item_id'], + null, + $row, + ['product' => $this->productLabel($row)], + $this->orderItemContext($row) + ); + } + + if ($this->hasAttribute($attributes, $customerNumber, 'onlyTankCleaning') && !$isTankCleaningProduct) { + $flags[] = $this->automaticFlag( + 'customer_rule_only_tank_cleaning', + 'order_item', + (int)$row['order_item_id'], + null, + $row, + ['product' => $this->productLabel($row)], + $this->orderItemContext($row) + ); + } + $restrictedProducts = [ - 'restrictTankCleaning' => ['customer_rule_restrict_tank_cleaning', ['tank cleaning', 'tankcleaning', 'tankrens']], 'restrictSpotFree' => ['customer_rule_restrict_spot_free', ['spot free', 'spotfree']], 'restrictInteriorCleaning' => ['customer_rule_restrict_interior_cleaning', ['interior', 'indvendig']], 'exemptFromAdministrationFee' => ['customer_rule_exempt_from_administration_fees', ['administration fee', 'administrationsgebyr', 'administration']], @@ -937,7 +963,14 @@ class invoice_period_flag_service $reg = strtoupper(trim((string)($row['reg_1'] ?? ''))); $key = (int)$row['customer_number'] . '|' . $reg; $expectedProductId = (int)($vehicleTypeByCustomerReg[$key]['product_id'] ?? 0); - if ($expectedProductId > 0 && $expectedProductId !== (int)$row['product_id']) { + $expectedProductName = (string)($vehicleTypeByCustomerReg[$key]['product_name'] ?? ''); + if ($expectedProductId > 0 + && !$this->primaryVehicleProductsMatch( + (int)$row['product_id'], + $this->productLabel($row), + $expectedProductId, + $expectedProductName + )) { $flags[] = $this->automaticFlag( 'vehicle_subscription_type_mismatch', 'order_item', @@ -946,7 +979,7 @@ class invoice_period_flag_service $row, [ 'product' => $this->productLabel($row), - 'expected_product' => (string)($vehicleTypeByCustomerReg[$key]['product_name'] ?? $expectedProductId), + 'expected_product' => $expectedProductName !== '' ? $expectedProductName : (string)$expectedProductId, ], $this->orderItemContext($row) + ['expected_product_id' => $expectedProductId] ); @@ -960,7 +993,12 @@ class invoice_period_flag_service continue; } $expectedProductId = (int)$history[$reg]['product_id']; - if ($expectedProductId === (int)$row['product_id']) { + if ($this->primaryVehicleProductsMatch( + (int)$row['product_id'], + $this->productLabel($row), + $expectedProductId, + (string)$history[$reg]['product_name'] + )) { continue; } $flags[] = $this->automaticFlag( @@ -1126,6 +1164,7 @@ class invoice_period_flag_service 'customer_rule_restrict_spot_free' => "{$product} violates restricted Spot Free.", 'customer_rule_restrict_interior_cleaning' => "{$product} violates restricted interior wash.", 'customer_rule_exempt_from_administration_fees' => "{$product} is an administration fee for an exempt customer.", + 'customer_rule_only_tank_cleaning' => "{$product} violates the only tank cleaning rule.", 'customer_rule_requires_reference' => "Order is missing a required reference.", 'customer_rule_requires_po_number' => "Order is missing a required PO number.", 'customer_rule_invoice_all_orders_individually' => "Invoice collection contains multiple orders for a customer requiring individual invoices.", @@ -1163,6 +1202,10 @@ class invoice_period_flag_service ['type' => 'order', 'text' => 'Wash certificate'], ['type' => 'text', 'text' => ' is attached without a wash certificate item.'], ], + 'xlvask_missing_order_link' => [ + ['type' => 'xlvask_usage_log', 'text' => 'XL Vask wash'], + ['type' => 'text', 'text' => ' is neither ignored nor linked to an order in the selected period.'], + ], default => [], }; } @@ -1466,6 +1509,50 @@ class invoice_period_flag_service return $history; } + private function primaryVehicleProductsMatch( + int $currentProductId, + string $currentProductName, + int $expectedProductId, + string $expectedProductName + ): bool { + if ($expectedProductId > 0 && $currentProductId === $expectedProductId) { + return true; + } + + $currentVehicleType = $this->normalizePrimaryVehicleProductName($currentProductName); + $expectedVehicleType = $this->normalizePrimaryVehicleProductName($expectedProductName); + return $currentVehicleType !== '' + && $expectedVehicleType !== '' + && $currentVehicleType === $expectedVehicleType; + } + + private function normalizePrimaryVehicleProductName(string $productName): string + { + $normalized = strtolower(strtr($productName, [ + 'Æ' => 'ae', + 'Ø' => 'oe', + 'Å' => 'aa', + 'æ' => 'ae', + 'ø' => 'oe', + 'å' => 'aa', + ])); + $normalized = (string)preg_replace('/[^a-z0-9]+/', ' ', $normalized); + $tokens = array_values(array_filter( + explode(' ', trim($normalized)), + static fn(string $token): bool => $token !== '' + && !in_array($token, [ + 'indvendig', + 'indv', + 'interior', + 'internal', + 'vask', + 'wash', + ], true) + )); + + return implode(' ', $tokens); + } + private function getOrderItemsForPreview(int $orderId): array { global $db; @@ -1600,6 +1687,12 @@ class invoice_period_flag_service return false; } + private function rowIsTankCleaningProduct(array $row): bool + { + return (int)($row['product_category'] ?? 0) === 5 + || $this->rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']); + } + private function isIncludedOrderItem(array $row): bool { $value = $row['item_include_in_invoice'] ?? 1; diff --git a/services/nginx/app/classes/motorapi.php b/services/nginx/app/classes/motorapi.php index 7abca203..6c96f7bf 100644 --- a/services/nginx/app/classes/motorapi.php +++ b/services/nginx/app/classes/motorapi.php @@ -155,7 +155,7 @@ class motorapi implements motorapi_i // Get the cached result from the log/local database/cache $motorapi_lookups = new motorapi_lookups_o(); // Add the cached value to the meta - $response->add_meta('cached', true); + self::addCachedMetaIfPossible($response); $cleaned_result = self::cleanJSON($motorapi_lookups->getCachedResult($licensePlate)->result->value()); $object = json_decode($cleaned_result); if ($object === null) { @@ -165,6 +165,13 @@ class motorapi implements motorapi_i return json_decode($cleaned_result); } + public static function addCachedMetaIfPossible(mixed $response): void + { + if (is_object($response) && method_exists($response, 'add_meta')) { + $response->add_meta('cached', true); + } + } + /** * @inheritDoc * @throws Exception If the module is not enabled, the license plate is invalid, the daily limit is exceeded, or the secret key is invalid @@ -385,4 +392,4 @@ class motorapi implements motorapi_i $motorapi_lookups = new motorapi_lookups_o(); $motorapi_lookups->add($licensePlate, json_encode($response), $endpoint); } -} \ No newline at end of file +} diff --git a/services/nginx/app/classes/openai.php b/services/nginx/app/classes/openai.php index a6f24800..5fdc9696 100644 --- a/services/nginx/app/classes/openai.php +++ b/services/nginx/app/classes/openai.php @@ -34,11 +34,58 @@ class openai implements openai_i */ public function requireModuleEnabled(): void { - if (!(bool)$this->config->enabled->getVariableValue()) { + if (!$this->config->enabled->isTrue()) { throw new Exception('OpenAI module is not enabled.'); } } + /** + * Send a structured JSON text task to the OpenAI Responses API. + * + * @throws Exception + */ + public function jsonTask(string $schemaName, string $prompt, array $payload, array $schema, float $temperature = 0.1): array + { + $this->requireModuleEnabled(); + + $data = [ + 'model' => $this->model, + 'input' => [ + [ + 'role' => 'user', + 'content' => [ + [ + 'type' => 'input_text', + 'text' => $prompt . "\n\nData:\n" . json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ], + ], + ], + ], + 'text' => [ + 'format' => [ + 'type' => 'json_schema', + 'name' => $schemaName, + 'schema' => $schema, + 'strict' => true, + ], + ], + 'temperature' => $temperature, + ]; + + $response = $this->sendRequest($data); + $output = $response['output'][0]['content'][0]['text'] ?? null; + if (!is_string($output) || $output === '') { + throw new Exception('Invalid response format from OpenAI API. (Missing text field)'); + } + + $decoded = json_decode($output, true); + if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) { + throw new Exception('Error parsing JSON response: ' . json_last_error_msg()); + } + + return $decoded; + } + protected function getLPRSchema(): array { return [ @@ -248,4 +295,4 @@ class openai implements openai_i //print_r($responseData); return $responseData; } -} \ No newline at end of file +} diff --git a/services/nginx/app/classes/xlvask_automation_service.php b/services/nginx/app/classes/xlvask_automation_service.php new file mode 100644 index 00000000..cee9dcb9 --- /dev/null +++ b/services/nginx/app/classes/xlvask_automation_service.php @@ -0,0 +1,1394 @@ +loadUsageLogRow($usageLogId); + if ($row === null) { + return $this->emptyAutomation('XL Vask-vasken blev ikke fundet.'); + } + + return $this->evaluateUsageLogRow($row, $actorId, $allowExecute); + } + + public function evaluateUsageLogRow(array $row, ?int $actorId = null, bool $allowExecute = true): array + { + $usageLogId = (int)($row['id'] ?? 0); + if ($usageLogId < 1) { + return $this->emptyAutomation('XL Vask-vasken mangler et gyldigt id.'); + } + + try { + $log = $this->usageLogFromRow($row); + $guard = $this->guardReason($log); + $existing = $this->latestTerminalSuggestion($usageLogId); + if ($guard !== null) { + if ($existing !== null && in_array((string)$existing['status'], [ + self::STATUS_AUTO_ACCEPTED, + self::STATUS_ACCEPTED, + self::STATUS_DENIED, + ], true)) { + return $this->formatSuggestion($existing); + } + + return $this->emptyAutomation($guard); + } + + if ($existing !== null) { + if ((string)$existing['status'] === self::STATUS_SUGGESTED) { + $context = $this->buildContext($usageLogId, $log); + $contextGuard = $this->contextGuardReason($context); + if ($contextGuard !== null) { + return $this->emptyAutomation($contextGuard); + } + + $freshSuggestion = $this->buildSuggestionForContext($context); + if ($freshSuggestion !== null) { + $suggestionId = $this->persistSuggestion($context, $freshSuggestion, $actorId); + $existing = $this->loadSuggestion($suggestionId) ?? $existing; + } + + if ($allowExecute && $this->shouldAutoExecute($existing, $context)) { + return $this->executeSuggestion($existing, $context, $actorId, true); + } + } + + return $this->formatSuggestion($existing); + } + + $context = $this->buildContext($usageLogId, $log); + $contextGuard = $this->contextGuardReason($context); + if ($contextGuard !== null) { + return $this->emptyAutomation($contextGuard); + } + + if ($this->hasDeniedFeedback($context['signature_hash'], self::ACTION_ATTACH) + && $this->hasDeniedFeedback($context['signature_hash'], self::ACTION_CREATE)) { + return $this->emptyAutomation('Tidligere afvist for samme køretøjsmønster.'); + } + + $suggestion = $this->buildSuggestionForContext($context); + + if ($suggestion === null || $suggestion['confidence'] < self::MIN_SUGGESTION_CONFIDENCE) { + return $this->emptyAutomation('Ingen sikker automatiseringshandling fundet.'); + } + + if ($this->hasDeniedFeedback($context['signature_hash'], $suggestion['action'])) { + return $this->emptyAutomation('Tidligere afvist for samme køretøjsmønster.'); + } + + $suggestionId = $this->persistSuggestion($context, $suggestion, $actorId); + $suggestionRow = $this->loadSuggestion($suggestionId); + if ($suggestionRow === null) { + return $this->emptyAutomation('Forslaget kunne ikke gemmes.'); + } + + if ($allowExecute && $this->shouldAutoExecute($suggestionRow, $context)) { + return $this->executeSuggestion($suggestionRow, $context, $actorId, true); + } + + return $this->formatSuggestion($suggestionRow); + } catch (Exception $e) { + return [ + ...$this->emptyAutomation('Automatiseringen kunne ikke evaluere vasken.'), + 'status' => self::STATUS_FAILED, + 'error' => $e->getMessage(), + ]; + } + } + + public function acceptUsageLogById(int $usageLogId, ?int $actorId = null, ?int $suggestionId = null, ?string $reason = null): array + { + $row = $this->loadUsageLogRow($usageLogId); + if ($row === null) { + return $this->emptyAutomation('XL Vask-vasken blev ikke fundet.'); + } + + $log = $this->usageLogFromRow($row); + $guard = $this->guardReason($log); + if ($guard !== null) { + return $this->emptyAutomation($guard); + } + + $context = $this->buildContext($usageLogId, $log); + $contextGuard = $this->contextGuardReason($context); + if ($contextGuard !== null) { + return $this->emptyAutomation($contextGuard); + } + + $suggestion = $suggestionId !== null ? $this->loadSuggestion($suggestionId) : $this->latestActionableSuggestion($usageLogId); + if ($suggestion === null) { + $this->evaluateUsageLogRow($row, $actorId, false); + $suggestion = $this->latestActionableSuggestion($usageLogId); + } + + if ($suggestion === null) { + return $this->emptyAutomation('Der er intet forslag at acceptere.'); + } + + $result = $this->executeSuggestion($suggestion, $context, $actorId, false); + $this->persistFeedback($context, (string)$suggestion['action'], 'accepted', (int)($result['matched_order_id'] ?? $result['created_order_id'] ?? 0), $actorId, $reason); + + return $result; + } + + public function denyUsageLogById(int $usageLogId, ?int $actorId = null, ?int $suggestionId = null, ?string $reason = null): array + { + $row = $this->loadUsageLogRow($usageLogId); + if ($row === null) { + return $this->emptyAutomation('XL Vask-vasken blev ikke fundet.'); + } + + $log = $this->usageLogFromRow($row); + $context = $this->buildContext($usageLogId, $log); + $suggestion = $suggestionId !== null ? $this->loadSuggestion($suggestionId) : $this->latestActionableSuggestion($usageLogId); + + if ($suggestion === null) { + return $this->emptyAutomation('Der er intet forslag at afvise.'); + } + + $this->updateSuggestionStatus((int)$suggestion['id'], self::STATUS_DENIED, $actorId); + $this->persistFeedback($context, (string)$suggestion['action'], 'denied', (int)($suggestion['matched_order_id'] ?? 0), $actorId, $reason); + + return $this->formatSuggestion($this->loadSuggestion((int)$suggestion['id']) ?? $suggestion); + } + + public function runPending(?string $dateFrom = null, ?string $dateTo = null, array $ids = [], int $limit = 100, ?int $actorId = null): array + { + $rows = $ids !== [] ? $this->loadUsageLogRowsByIds($ids) : $this->loadPendingRows($dateFrom, $dateTo, $limit); + $results = []; + foreach ($rows as $row) { + $results[] = $this->evaluateUsageLogRow($row, $actorId, true); + } + + return [ + 'processed' => count($results), + 'results' => $results, + ]; + } + + public static function normalizeRegistrationForAutomation(string $registration): string + { + return strtoupper(preg_replace('/[^A-Z0-9]/i', '', $registration) ?? ''); + } + + public static function itemSignaturePartsForAutomation(array $items): array + { + $parts = []; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + + $parts[] = implode(':', [ + (int)($item['product_id'] ?? 0), + (int)($item['quantity'] ?? 0), + (int)($item['price'] ?? 0), + ]); + } + + sort($parts, SORT_STRING); + return $parts; + } + + public static function scoreItemMatchForAutomation(array $usageItems, array $orderItems): array + { + $usageSignature = self::itemSignaturePartsForAutomation($usageItems); + $orderSignature = self::itemSignaturePartsForAutomation($orderItems); + $usageTotal = self::itemsTotalForAutomation($usageItems); + $orderTotal = self::itemsTotalForAutomation($orderItems); + + if ($usageSignature === $orderSignature && $usageTotal === $orderTotal) { + return [ + 'confidence' => 0.95, + 'source' => self::SOURCE_DETERMINISTIC, + 'reason' => 'Produkterne og prisen matcher en ordre fra samme dag.', + ]; + } + + $usagePrimary = (int)($usageItems[0]['product_id'] ?? 0); + $orderPrimary = (int)($orderItems[0]['product_id'] ?? 0); + if ($usagePrimary < 1 || $usagePrimary !== $orderPrimary) { + return ['confidence' => 0.0, 'source' => self::SOURCE_DETERMINISTIC, 'reason' => '']; + } + + $overlap = self::productOverlapForAutomation($usageItems, $orderItems); + $totalDiff = abs($usageTotal - $orderTotal); + if ($overlap >= 0.70 && $totalDiff <= 50) { + return [ + 'confidence' => 0.93, + 'source' => self::SOURCE_FUZZY, + 'reason' => 'Samme primære produkt og relaterede tilføjelser matcher en ordre fra samme dag.', + ]; + } + + if ($overlap >= 0.50 && $totalDiff <= 150) { + return [ + 'confidence' => 0.80, + 'source' => self::SOURCE_FUZZY, + 'reason' => 'Vasken ligner en ordre fra samme dag, men kræver manuel godkendelse.', + ]; + } + + $matchableUsageItems = self::matchableUsageItemsForAutomation($usageItems); + $matchableOverlap = self::productOverlapForAutomation($matchableUsageItems, $orderItems); + if ( + $matchableUsageItems !== [] + && $matchableOverlap >= 0.95 + && self::orderHasAdditionsBeyondUsage($matchableUsageItems, $orderItems) + ) { + return [ + 'confidence' => 0.88, + 'source' => self::SOURCE_FUZZY, + 'reason' => 'Ordren indeholder XL Vask-produkterne samt ekstra ydelser fra samme dag.', + ]; + } + + return ['confidence' => 0.0, 'source' => self::SOURCE_DETERMINISTIC, 'reason' => '']; + } + + public static function itemsTotalForAutomation(array $items): int + { + return array_reduce($items, fn(int $total, array $item): int => $total + ((int)($item['price'] ?? 0) * (int)($item['quantity'] ?? 0)), 0); + } + + public static function productOverlapForAutomation(array $usageItems, array $orderItems): float + { + $usageBag = self::productBagForAutomation($usageItems); + $orderBag = self::productBagForAutomation($orderItems); + $usageTotal = array_sum($usageBag); + if ($usageTotal <= 0) { + return 0.0; + } + + $overlap = 0; + foreach ($usageBag as $productId => $quantity) { + $overlap += min($quantity, $orderBag[$productId] ?? 0); + } + + return $overlap / $usageTotal; + } + + public static function productBagForAutomation(array $items): array + { + $bag = []; + foreach ($items as $item) { + $productId = (int)($item['product_id'] ?? 0); + if ($productId < 1) { + continue; + } + $bag[$productId] = ($bag[$productId] ?? 0) + max(1, (int)($item['quantity'] ?? 1)); + } + + return $bag; + } + + private static function matchableUsageItemsForAutomation(array $items): array + { + $positiveItems = array_values(array_filter($items, static function (array $item): bool { + return (int)($item['product_id'] ?? 0) > 0 + && (int)($item['quantity'] ?? 0) > 0 + && (int)($item['price'] ?? 0) > 0; + })); + + if ($positiveItems !== []) { + return $positiveItems; + } + + return array_values(array_filter($items, static function (array $item): bool { + return (int)($item['product_id'] ?? 0) > 0 + && (int)($item['quantity'] ?? 0) > 0; + })); + } + + private static function orderHasAdditionsBeyondUsage(array $usageItems, array $orderItems): bool + { + $usageBag = self::productBagForAutomation($usageItems); + foreach (self::productBagForAutomation($orderItems) as $productId => $quantity) { + if ($quantity > ($usageBag[$productId] ?? 0)) { + return true; + } + } + + return false; + } + + public static function normalizeUsageLogRowForAutomation(array $row): array + { + unset($row['id']); + + $washItems = $row['WashItems'] ?? []; + if (is_string($washItems)) { + $decoded = json_decode($washItems, true); + $row['WashItems'] = is_array($decoded) ? $decoded : []; + } elseif (!is_array($washItems)) { + $row['WashItems'] = []; + } + + return $row; + } + + public static function openAiCacheKeyForAutomation( + string $schemaName, + string $prompt, + array $payload, + array $schema, + float $temperature + ): string { + $input = [ + 'version' => self::OPENAI_CACHE_VERSION, + 'schema_name' => $schemaName, + 'prompt' => $prompt, + 'payload' => $payload, + 'schema' => $schema, + 'temperature' => round($temperature, 4), + ]; + + return hash('sha256', self::stableJsonForAutomation($input)); + } + + public static function stableJsonForAutomation(mixed $value): string + { + $encoded = json_encode( + self::normalizeForStableJson($value), + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION + ); + + if ($encoded === false) { + throw new Exception('Kunne ikke opbygge en stabil cache-nøgle for XL Vask-automatisering.'); + } + + return $encoded; + } + + private static function normalizeForStableJson(mixed $value): mixed + { + if (!is_array($value)) { + return $value; + } + + $normalized = array_map(fn(mixed $item): mixed => self::normalizeForStableJson($item), $value); + $isList = $normalized === [] || array_keys($normalized) === range(0, count($normalized) - 1); + if (!$isList) { + ksort($normalized, SORT_STRING); + } + + return $normalized; + } + + private function buildDeterministicSuggestion(array $context): ?array + { + $best = null; + foreach ($context['candidate_orders'] as $candidate) { + $score = $this->scoreOrderMatch($context['items'], $candidate['order_items']); + if ($score['confidence'] < self::MIN_SUGGESTION_CONFIDENCE) { + continue; + } + + $candidateSuggestion = [ + 'action' => self::ACTION_ATTACH, + 'confidence' => $score['confidence'], + 'source' => $score['source'], + 'matched_order_id' => (int)$candidate['id'], + 'created_order_id' => null, + 'candidate_order' => $candidate, + 'proposed_order' => $context['proposed_order'], + 'reason' => $score['reason'] . ' Ordre #' . (int)$candidate['id'] . '.', + ]; + + if ($best === null || $candidateSuggestion['confidence'] > $best['confidence']) { + $best = $candidateSuggestion; + } + } + + if ($best !== null && $this->hasAcceptedFeedback($context['signature_hash'], self::ACTION_ATTACH)) { + $best['confidence'] = max($best['confidence'], 0.96); + $best['source'] = self::SOURCE_HISTORY; + $best['reason'] = 'Tidligere godkendt mønster for køretøjet matcher ordre #' . (int)$best['matched_order_id'] . '.'; + } + + if ($best !== null) { + return $best; + } + + if ($context['age_hours'] >= self::CREATE_MIN_AGE_HOURS) { + $history = $this->findMatchingHistoricalOrder($context); + if ($history !== null || $this->hasAcceptedFeedback($context['signature_hash'], self::ACTION_CREATE)) { + return [ + 'action' => self::ACTION_CREATE, + 'confidence' => 0.98, + 'source' => self::SOURCE_HISTORY, + 'matched_order_id' => null, + 'created_order_id' => null, + 'candidate_order' => $history, + 'proposed_order' => $context['proposed_order'], + 'reason' => 'Vasken er over 6 timer gammel og matcher et tidligere godkendt køretøjsmønster.', + ]; + } + } + + return null; + } + + private function buildSuggestionForContext(array $context): ?array + { + $suggestion = $this->buildDeterministicSuggestion($context); + + if ( + ($suggestion === null || $suggestion['confidence'] < self::MIN_SUGGESTION_CONFIDENCE) + && $this->isOpenAiEnabled() + ) { + $suggestion = $this->buildOpenAiSuggestion($context) ?? $suggestion; + } + + if ($suggestion === null || $suggestion['confidence'] < self::MIN_SUGGESTION_CONFIDENCE) { + return null; + } + + if ($this->hasDeniedFeedback($context['signature_hash'], $suggestion['action'])) { + return null; + } + + return $suggestion; + } + + private function buildOpenAiSuggestion(array $context): ?array + { + try { + $schemaName = 'xlvask_automation'; + $prompt = 'Vurder om en XL Vask-vask skal tilknyttes en eksisterende ordre, oprettes som ordre eller ikke behandles. Returner kun JSON efter skemaet.'; + $temperature = 0.1; + $schema = [ + 'type' => 'object', + 'properties' => [ + 'action' => ['type' => 'string', 'enum' => [self::ACTION_ATTACH, self::ACTION_CREATE, self::ACTION_NONE]], + 'confidence' => ['type' => 'number'], + 'reason_da' => ['type' => 'string'], + 'candidate_order_id' => ['type' => ['integer', 'null']], + 'proposed_order_items' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'product_id' => ['type' => 'integer'], + 'quantity' => ['type' => 'integer'], + 'price' => ['type' => 'integer'], + ], + 'required' => ['product_id', 'quantity', 'price'], + 'additionalProperties' => false, + ], + ], + 'risk_flags' => ['type' => 'array', 'items' => ['type' => 'string']], + ], + 'required' => ['action', 'confidence', 'reason_da', 'candidate_order_id', 'proposed_order_items', 'risk_flags'], + 'additionalProperties' => false, + ]; + + $creationAllowed = $context['age_hours'] >= self::CREATE_MIN_AGE_HOURS; + $payload = [ + 'usage_log' => [ + 'wash_id' => $context['wash_id'], + 'registration' => $context['signature']['registration'], + 'customer_number' => $context['signature']['customer_number'], + 'department_id' => $context['signature']['department_id'], + 'lane' => $context['signature']['lane'], + 'created_at' => $context['proposed_order']['created_at'] ?? null, + 'total_net_amount' => $context['total'], + 'items' => $this->compactItems($context['items']), + 'creation_allowed' => $creationAllowed, + 'age_bucket' => $creationAllowed ? 'older_than_6_hours' : 'newer_than_6_hours', + ], + 'candidate_orders' => array_map(fn(array $candidate): array => [ + 'id' => (int)$candidate['id'], + 'created_at' => $candidate['created_at'] ?? null, + 'total_net_amount' => (int)($candidate['total_net_amount'] ?? 0), + 'items' => $this->compactItems($candidate['order_items'] ?? []), + ], $context['candidate_orders']), + ]; + + $cacheKey = self::openAiCacheKeyForAutomation($schemaName, $prompt, $payload, $schema, $temperature); + $result = $this->loadOpenAiCacheResult($cacheKey); + if ($result === null) { + $openai = new openai(); + $result = $openai->jsonTask($schemaName, $prompt, $payload, $schema, $temperature); + $this->persistOpenAiCacheResult($cacheKey, $schemaName, $payload, $schema, $prompt, $temperature, $result); + } + + $action = (string)($result['action'] ?? self::ACTION_NONE); + $confidence = (float)($result['confidence'] ?? 0); + if (!in_array($action, [self::ACTION_ATTACH, self::ACTION_CREATE], true) || $confidence < self::MIN_SUGGESTION_CONFIDENCE) { + return null; + } + + if ($action === self::ACTION_CREATE && $context['age_hours'] < self::CREATE_MIN_AGE_HOURS) { + return null; + } + + $candidate = null; + $candidateOrderId = (int)($result['candidate_order_id'] ?? 0); + if ($action === self::ACTION_ATTACH) { + foreach ($context['candidate_orders'] as $candidateOrder) { + if ((int)$candidateOrder['id'] === $candidateOrderId) { + $candidate = $candidateOrder; + break; + } + } + if ($candidate === null) { + return null; + } + } + + return [ + 'action' => $action, + 'confidence' => min(1.0, max(0.0, $confidence)), + 'source' => self::SOURCE_OPENAI, + 'matched_order_id' => $candidateOrderId > 0 ? $candidateOrderId : null, + 'created_order_id' => null, + 'candidate_order' => $candidate, + 'proposed_order' => $context['proposed_order'], + 'reason' => (string)($result['reason_da'] ?? 'OpenAI foreslår handlingen ud fra tilgængelige ordredata.'), + ]; + } catch (Exception) { + return null; + } + } + + private function shouldAutoExecute(array $suggestion, array $context): bool + { + $confidence = (float)$suggestion['confidence']; + $action = (string)$suggestion['action']; + $xlvask = new xlvask(); + + if ($action === self::ACTION_ATTACH) { + return $xlvask->config->automatic_order_attachment_enabled->isTrue() + && $confidence >= self::AUTO_ATTACH_CONFIDENCE; + } + + if ($action === self::ACTION_CREATE) { + return $xlvask->config->automatic_order_creation_enabled->isTrue() + && $context['age_hours'] >= self::CREATE_MIN_AGE_HOURS + && $confidence >= self::AUTO_CREATE_CONFIDENCE; + } + + return false; + } + + private function executeSuggestion(array $suggestion, array $context, ?int $actorId, bool $automatic): array + { + try { + $action = (string)$suggestion['action']; + if ($action === self::ACTION_ATTACH) { + $orderId = (int)$suggestion['matched_order_id']; + if ($orderId < 1) { + throw new Exception('Forslaget mangler en ordre at tilknytte.'); + } + + if ((new orders_o())->selectByWashId($context['wash_id']) !== null) { + throw new Exception('Vasken er allerede tilknyttet en ordre.'); + } + + $order = (new orders_o())->select($orderId); + $order->wash_id->set($context['wash_id']); + $this->updateSuggestionExecution((int)$suggestion['id'], $automatic ? self::STATUS_AUTO_ACCEPTED : self::STATUS_ACCEPTED, $actorId, $orderId, null); + } elseif ($action === self::ACTION_CREATE) { + if ($context['age_hours'] < self::CREATE_MIN_AGE_HOURS) { + throw new Exception('Vasken er ikke gammel nok til automatisk ordreoprettelse.'); + } + + if ((new orders_o())->selectByWashId($context['wash_id']) !== null) { + throw new Exception('Vasken er allerede tilknyttet en ordre.'); + } + + $order = $this->createOrderFromContext($context); + $this->updateSuggestionExecution((int)$suggestion['id'], $automatic ? self::STATUS_AUTO_ACCEPTED : self::STATUS_ACCEPTED, $actorId, null, (int)$order->id); + } else { + throw new Exception('Ukendt automatiseringshandling.'); + } + + $latest = $this->loadSuggestion((int)$suggestion['id']) ?? $suggestion; + if ($automatic) { + $this->persistFeedback($context, $action, 'accepted', (int)($latest['matched_order_id'] ?? $latest['created_order_id'] ?? 0), $actorId, 'Automatisk accepteret.'); + } + + return $this->formatSuggestion($latest); + } catch (Exception $e) { + $this->updateSuggestionFailure((int)$suggestion['id'], $e->getMessage(), $actorId); + return [ + ...$this->formatSuggestion($this->loadSuggestion((int)$suggestion['id']) ?? $suggestion), + 'status' => self::STATUS_FAILED, + 'error' => $e->getMessage(), + ]; + } + } + + private function createOrderFromContext(array $context): orders_o + { + $orderData = $context['proposed_order']; + $items = $context['items']; + + $order = new orders_o(); + $order->add( + (int)$orderData['customer_id'], + self::AUTOMATION_CASHIER_ID, + (string)($orderData['reference'] ?? ''), + (string)($orderData['notes'] ?? ''), + (int)$orderData['department_id'], + (string)($orderData['reg_1'] ?? ''), + (string)($orderData['reg_2'] ?? ''), + (string)($orderData['reg_3'] ?? '') + ); + $order->wash_id->set($context['wash_id']); + if (isset($orderData['lane'])) { + $order->lane->set((int)$orderData['lane']); + } + if (!empty($orderData['created_at'])) { + $order->created_at->set((string)$orderData['created_at']); + } + + $firstItemId = null; + foreach ($items as $item) { + $orderItem = new order_items_o(); + $orderItem->add( + (int)$order->id, + (int)$item['product_id'], + (string)($item['reference'] ?? ''), + (string)($item['notes'] ?? ''), + self::AUTOMATION_CASHIER_ID, + (int)$item['price'], + (int)$item['quantity'], + $firstItemId + ); + if ($firstItemId === null) { + $firstItemId = (int)$orderItem->id; + } + } + + $order->objectChanged(); + return $order; + } + + private function buildContext(int $usageLogId, xlvask_usage_log $log): array + { + $simulated = (new orders_o())->simulateOrderFromXLVask($log, true); + $proposedOrder = $simulated['order'] ?? []; + $items = $simulated['order_items'] ?? []; + $signature = $this->buildSignature($log, $proposedOrder, $items); + $signatureJson = json_encode($signature, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($signatureJson === false) { + throw new Exception('Kunne ikke opbygge signatur for XL Vask-vasken.'); + } + + return [ + 'usage_log_id' => $usageLogId, + 'wash_id' => (string)$log->WashId, + 'log' => $log, + 'proposed_order' => $proposedOrder, + 'items' => $items, + 'total' => $this->itemsTotal($items), + 'signature' => $signature, + 'signature_json' => $signatureJson, + 'signature_hash' => hash('sha256', $signatureJson), + 'age_hours' => max(0.0, (time() - strtotime((string)$log->StartTime)) / 3600), + 'candidate_orders' => $this->findSameDayCandidateOrders($log, $proposedOrder), + ]; + } + + private function contextGuardReason(array $context): ?string + { + if ((int)($context['proposed_order']['customer_id'] ?? 0) < 1) { + return 'Vasken mangler en gyldig kundemapping.'; + } + + if ((int)($context['proposed_order']['department_id'] ?? 0) < 1) { + return 'Vasken mangler en gyldig afdelingsmapping.'; + } + + if (!is_array($context['items'] ?? null) || count($context['items']) < 1) { + return 'Vasken mangler gyldige produkter.'; + } + + foreach ($context['items'] as $item) { + if (!is_array($item) || (int)($item['product_id'] ?? 0) < 1) { + return 'Vasken mangler gyldige produkter.'; + } + } + + return null; + } + + private function buildSignature(xlvask_usage_log $log, array $proposedOrder, array $items): array + { + return [ + 'registration' => $this->normalizeRegistration((string)$log->RegistrationNumber), + 'customer_number' => (int)$log->CustomerId, + 'department_id' => (int)($proposedOrder['department_id'] ?? 0), + 'lane' => (int)($proposedOrder['lane'] ?? 0), + 'primary_product_id' => (int)($items[0]['product_id'] ?? 0), + 'items' => $this->itemSignatureParts($items), + 'total_net_amount' => $this->itemsTotal($items), + ]; + } + + private function scoreOrderMatch(array $usageItems, array $orderItems): array + { + return self::scoreItemMatchForAutomation($usageItems, $orderItems); + + $usageSignature = $this->itemSignatureParts($usageItems); + $orderSignature = $this->itemSignatureParts($orderItems); + $usageTotal = $this->itemsTotal($usageItems); + $orderTotal = $this->itemsTotal($orderItems); + + if ($usageSignature === $orderSignature && $usageTotal === $orderTotal) { + return [ + 'confidence' => 0.95, + 'source' => self::SOURCE_DETERMINISTIC, + 'reason' => 'Produkterne og prisen matcher en ordre fra samme dag.', + ]; + } + + $usagePrimary = (int)($usageItems[0]['product_id'] ?? 0); + $orderPrimary = (int)($orderItems[0]['product_id'] ?? 0); + if ($usagePrimary < 1 || $usagePrimary !== $orderPrimary) { + return ['confidence' => 0.0, 'source' => self::SOURCE_DETERMINISTIC, 'reason' => '']; + } + + $overlap = $this->productOverlap($usageItems, $orderItems); + $totalDiff = abs($usageTotal - $orderTotal); + if ($overlap >= 0.70 && $totalDiff <= 50) { + return [ + 'confidence' => 0.93, + 'source' => self::SOURCE_FUZZY, + 'reason' => 'Samme primære produkt og relaterede tillæg matcher en ordre fra samme dag.', + ]; + } + + if ($overlap >= 0.50 && $totalDiff <= 150) { + return [ + 'confidence' => 0.80, + 'source' => self::SOURCE_FUZZY, + 'reason' => 'Vasken ligner en ordre fra samme dag, men kræver manuel godkendelse.', + ]; + } + + return ['confidence' => 0.0, 'source' => self::SOURCE_DETERMINISTIC, 'reason' => '']; + } + + private function findSameDayCandidateOrders(xlvask_usage_log $log, array $proposedOrder): array + { + global $db; + + $registration = $db->escape_string($this->normalizeRegistration((string)$log->RegistrationNumber)); + $rawRegistration = $db->escape_string(trim((string)$log->RegistrationNumber)); + $customerNumber = (int)$log->CustomerId; + $departmentId = (int)($proposedOrder['department_id'] ?? 0); + $date = date('Y-m-d', strtotime((string)$log->StartTime)); + $from = $db->escape_string($date . ' 00:00:00'); + $to = $db->escape_string($date . ' 23:59:59'); + + if ($registration === '' || $customerNumber < 1 || $departmentId < 1) { + return []; + } + + $sql = "SELECT * + FROM orders + WHERE deleted_at IS NULL + AND customer_id = {$customerNumber} + AND department_id = {$departmentId} + AND cashier_id <> " . self::AUTOMATION_CASHIER_ID . " + AND created_at BETWEEN '{$from}' AND '{$to}' + AND (wash_id IS NULL OR wash_id = '') + AND ( + REPLACE(UPPER(reg_1), ' ', '') IN ('{$registration}', '{$rawRegistration}') + OR REPLACE(UPPER(reg_2), ' ', '') IN ('{$registration}', '{$rawRegistration}') + OR REPLACE(UPPER(reg_3), ' ', '') IN ('{$registration}', '{$rawRegistration}') + ) + ORDER BY ABS(TIMESTAMPDIFF(SECOND, created_at, '" . $db->escape_string(date('Y-m-d H:i:s', strtotime((string)$log->StartTime))) . "')) ASC + LIMIT 20"; + + $rows = $db->fetch_all($db->query($sql)); + return array_map(function (array $row): array { + $orderItems = (new orders_o())->getOrderItems((int)$row['id']); + return [ + ...$row, + 'id' => (int)$row['id'], + 'total_net_amount' => (int)($row['total_net_amount'] ?? $this->itemsTotal($orderItems)), + 'order_items' => $orderItems, + ]; + }, $rows); + } + + private function findMatchingHistoricalOrder(array $context): ?array + { + global $db; + + $signature = $context['signature']; + $registration = $db->escape_string((string)$signature['registration']); + $customerNumber = (int)$signature['customer_number']; + $departmentId = (int)$signature['department_id']; + $createdBefore = $db->escape_string((string)($context['proposed_order']['created_at'] ?? date('Y-m-d H:i:s'))); + + if ($registration === '' || $customerNumber < 1 || $departmentId < 1) { + return null; + } + + $sql = "SELECT * + FROM orders + WHERE deleted_at IS NULL + AND customer_id = {$customerNumber} + AND department_id = {$departmentId} + AND created_at < '{$createdBefore}' + AND ( + REPLACE(UPPER(reg_1), ' ', '') = '{$registration}' + OR REPLACE(UPPER(reg_2), ' ', '') = '{$registration}' + OR REPLACE(UPPER(reg_3), ' ', '') = '{$registration}' + ) + ORDER BY created_at DESC + LIMIT 10"; + + foreach ($db->fetch_all($db->query($sql)) as $row) { + $orderItems = (new orders_o())->getOrderItems((int)$row['id']); + if ($this->itemSignatureParts($orderItems) === $signature['items']) { + return [ + ...$row, + 'id' => (int)$row['id'], + 'order_items' => $orderItems, + ]; + } + } + + return null; + } + + private function guardReason(xlvask_usage_log $log): ?string + { + if (!empty($log->ignored_at)) { + return 'Vasken er ignoreret.'; + } + + if (!$log->isCompleted()) { + return 'Vasken er ikke afsluttet.'; + } + + if (!$log->hasBillableCustomer()) { + return 'Vasken mangler en fakturerbar kunde.'; + } + + if ((new orders_o())->selectByWashId($log->WashId) !== null) { + return 'Vasken er allerede tilknyttet en ordre.'; + } + + return null; + } + + private function persistSuggestion(array $context, array $suggestion, ?int $actorId): int + { + global $db; + + $existing = $this->latestActionableSuggestion((int)$context['usage_log_id']); + if ($existing !== null) { + $this->updateSuggestionProposal((int)$existing['id'], $context, $suggestion, $actorId); + return (int)$existing['id']; + } + + $fields = [ + 'usage_log_id' => (int)$context['usage_log_id'], + 'wash_id' => (string)$context['wash_id'], + 'signature_hash' => (string)$context['signature_hash'], + 'signature_json' => (string)$context['signature_json'], + 'action' => (string)$suggestion['action'], + 'status' => self::STATUS_SUGGESTED, + 'confidence' => (float)$suggestion['confidence'], + 'source' => (string)$suggestion['source'], + 'matched_order_id' => $suggestion['matched_order_id'] === null ? null : (int)$suggestion['matched_order_id'], + 'created_order_id' => null, + 'proposed_order_json' => json_encode($suggestion['proposed_order'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'candidate_order_json' => json_encode($suggestion['candidate_order'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'reason' => (string)$suggestion['reason'], + 'created_by' => $actorId, + ]; + + $columns = []; + $values = []; + foreach ($fields as $column => $value) { + $columns[] = "`{$column}`"; + if ($value === null) { + $values[] = 'NULL'; + } elseif (is_int($value) || is_float($value)) { + $values[] = (string)$value; + } else { + $values[] = "'" . $db->escape_string((string)$value) . "'"; + } + } + + $db->query('INSERT INTO xlvask_automation_suggestions (' . implode(', ', $columns) . ') VALUES (' . implode(', ', $values) . ')'); + return (int)$db->insert_id(); + } + + private function updateSuggestionProposal(int $suggestionId, array $context, array $suggestion, ?int $actorId): void + { + global $db; + + $fields = [ + 'signature_hash' => (string)$context['signature_hash'], + 'signature_json' => (string)$context['signature_json'], + 'action' => (string)$suggestion['action'], + 'confidence' => (float)$suggestion['confidence'], + 'source' => (string)$suggestion['source'], + 'matched_order_id' => $suggestion['matched_order_id'] === null ? null : (int)$suggestion['matched_order_id'], + 'created_order_id' => null, + 'proposed_order_json' => json_encode($suggestion['proposed_order'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'candidate_order_json' => json_encode($suggestion['candidate_order'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'reason' => (string)$suggestion['reason'], + ]; + + if ($actorId !== null) { + $fields['created_by'] = $actorId; + } + + $assignments = []; + foreach ($fields as $column => $value) { + if ($value === null) { + $sqlValue = 'NULL'; + } elseif (is_int($value) || is_float($value)) { + $sqlValue = (string)$value; + } else { + $sqlValue = "'" . $db->escape_string((string)$value) . "'"; + } + $assignments[] = "`{$column}` = {$sqlValue}"; + } + + $db->query( + 'UPDATE xlvask_automation_suggestions SET ' . implode(', ', $assignments) . + " WHERE id = {$suggestionId} AND status = '" . self::STATUS_SUGGESTED . "'" + ); + } + + private function persistFeedback(array $context, string $action, string $decision, int $orderId = 0, ?int $actorId = null, ?string $reason = null): void + { + global $db; + + $values = [ + 'usage_log_id' => (int)$context['usage_log_id'], + 'wash_id' => (string)$context['wash_id'], + 'signature_hash' => (string)$context['signature_hash'], + 'signature_json' => (string)$context['signature_json'], + 'action' => $action, + 'decision' => $decision, + 'order_id' => $orderId > 0 ? $orderId : null, + 'reason' => $reason, + 'created_by' => $actorId, + ]; + + $columns = []; + $sqlValues = []; + foreach ($values as $column => $value) { + $columns[] = "`{$column}`"; + if ($value === null) { + $sqlValues[] = 'NULL'; + } elseif (is_int($value)) { + $sqlValues[] = (string)$value; + } else { + $sqlValues[] = "'" . $db->escape_string((string)$value) . "'"; + } + } + + $db->query('INSERT INTO xlvask_automation_feedback (' . implode(', ', $columns) . ') VALUES (' . implode(', ', $sqlValues) . ')'); + } + + private function loadOpenAiCacheResult(string $cacheKey): ?array + { + global $db; + + $cacheKey = $db->escape_string($cacheKey); + $result = $db->query( + "SELECT result_json FROM xlvask_automation_openai_cache + WHERE cache_key = '{$cacheKey}' + LIMIT 1" + ); + if ($result === false || $result->num_rows < 1) { + return null; + } + + $row = $db->fetch_assoc($result); + $decoded = json_decode((string)($row['result_json'] ?? ''), true); + if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) { + return null; + } + + $db->query( + "UPDATE xlvask_automation_openai_cache + SET hits = hits + 1, last_hit_at = NOW() + WHERE cache_key = '{$cacheKey}'" + ); + + return $decoded; + } + + private function persistOpenAiCacheResult( + string $cacheKey, + string $schemaName, + array $payload, + array $schema, + string $prompt, + float $temperature, + array $result + ): void { + global $db; + + $input = [ + 'version' => self::OPENAI_CACHE_VERSION, + 'schema_name' => $schemaName, + 'prompt' => $prompt, + 'payload' => $payload, + 'schema' => $schema, + 'temperature' => round($temperature, 4), + ]; + + $inputJson = self::stableJsonForAutomation($input); + $resultJson = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION); + if ($resultJson === false) { + return; + } + + $cacheKey = $db->escape_string($cacheKey); + $schemaName = $db->escape_string($schemaName); + $inputJson = $db->escape_string($inputJson); + $resultJson = $db->escape_string($resultJson); + + $db->query( + "INSERT INTO xlvask_automation_openai_cache + (cache_key, schema_name, input_json, result_json) + VALUES + ('{$cacheKey}', '{$schemaName}', '{$inputJson}', '{$resultJson}') + ON DUPLICATE KEY UPDATE + result_json = VALUES(result_json), + input_json = VALUES(input_json), + updated_at = NOW()" + ); + } + + private function hasAcceptedFeedback(string $signatureHash, string $action): bool + { + return $this->hasFeedbackDecision($signatureHash, $action, 'accepted'); + } + + private function hasDeniedFeedback(string $signatureHash, string $action): bool + { + return $this->hasFeedbackDecision($signatureHash, $action, 'denied'); + } + + private function hasFeedbackDecision(string $signatureHash, string $action, string $decision): bool + { + global $db; + $signatureHash = $db->escape_string($signatureHash); + $action = $db->escape_string($action); + $decision = $db->escape_string($decision); + $result = $db->query( + "SELECT id FROM xlvask_automation_feedback + WHERE signature_hash = '{$signatureHash}' AND action = '{$action}' AND decision = '{$decision}' + ORDER BY id DESC LIMIT 1" + ); + return $result !== false && $result->num_rows > 0; + } + + private function latestTerminalSuggestion(int $usageLogId): ?array + { + return $this->latestSuggestionWhere($usageLogId, [ + self::STATUS_SUGGESTED, + self::STATUS_AUTO_ACCEPTED, + self::STATUS_ACCEPTED, + self::STATUS_DENIED, + ]); + } + + private function latestActionableSuggestion(int $usageLogId): ?array + { + return $this->latestSuggestionWhere($usageLogId, [self::STATUS_SUGGESTED]); + } + + private function latestSuggestionWhere(int $usageLogId, array $statuses): ?array + { + global $db; + $statusSql = implode(',', array_map(fn(string $status): string => "'" . $db->escape_string($status) . "'", $statuses)); + $result = $db->query( + "SELECT * FROM xlvask_automation_suggestions + WHERE usage_log_id = {$usageLogId} AND status IN ({$statusSql}) + ORDER BY id DESC LIMIT 1" + ); + if ($result === false || $result->num_rows < 1) { + return null; + } + + return $db->fetch_assoc($result); + } + + private function loadSuggestion(int $suggestionId): ?array + { + global $db; + $result = $db->query("SELECT * FROM xlvask_automation_suggestions WHERE id = {$suggestionId} LIMIT 1"); + if ($result === false || $result->num_rows < 1) { + return null; + } + return $db->fetch_assoc($result); + } + + private function updateSuggestionStatus(int $suggestionId, string $status, ?int $actorId): void + { + global $db; + $status = $db->escape_string($status); + $actorSql = $actorId === null ? 'NULL' : (string)(int)$actorId; + $db->query( + "UPDATE xlvask_automation_suggestions + SET status = '{$status}', decided_by = {$actorSql}, decided_at = NOW() + WHERE id = {$suggestionId}" + ); + } + + private function updateSuggestionExecution(int $suggestionId, string $status, ?int $actorId, ?int $matchedOrderId, ?int $createdOrderId): void + { + global $db; + $status = $db->escape_string($status); + $actorSql = $actorId === null ? 'NULL' : (string)(int)$actorId; + $matchedSql = $matchedOrderId === null ? 'matched_order_id' : (string)(int)$matchedOrderId; + $createdSql = $createdOrderId === null ? 'created_order_id' : (string)(int)$createdOrderId; + $db->query( + "UPDATE xlvask_automation_suggestions + SET status = '{$status}', + decided_by = {$actorSql}, + decided_at = NOW(), + executed_at = NOW(), + matched_order_id = {$matchedSql}, + created_order_id = {$createdSql} + WHERE id = {$suggestionId}" + ); + } + + private function updateSuggestionFailure(int $suggestionId, string $message, ?int $actorId): void + { + global $db; + $actorSql = $actorId === null ? 'NULL' : (string)(int)$actorId; + $message = $db->escape_string($message); + $db->query( + "UPDATE xlvask_automation_suggestions + SET status = '" . self::STATUS_FAILED . "', + reason = CONCAT(COALESCE(reason, ''), ' Fejl: {$message}'), + decided_by = {$actorSql}, + decided_at = NOW() + WHERE id = {$suggestionId}" + ); + } + + private function loadUsageLogRow(int $usageLogId): ?array + { + global $db; + (new xlvask_usage_logs_o())->structure(); + $result = $db->query("SELECT * FROM xlvask_usage_logs WHERE id = {$usageLogId} LIMIT 1"); + if ($result === false || $result->num_rows < 1) { + return null; + } + + return $db->fetch_assoc($result); + } + + private function loadUsageLogRowsByIds(array $ids): array + { + global $db; + $ids = array_values(array_filter(array_map('intval', $ids), fn(int $id): bool => $id > 0)); + if ($ids === []) { + return []; + } + + (new xlvask_usage_logs_o())->structure(); + $result = $db->query('SELECT * FROM xlvask_usage_logs WHERE id IN (' . implode(',', $ids) . ')'); + return $db->fetch_all($result); + } + + private function loadPendingRows(?string $dateFrom, ?string $dateTo, int $limit): array + { + global $db; + (new xlvask_usage_logs_o())->structure(); + $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))) . "'"; + } else { + $where[] = "StartTime >= '" . $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))) . "'"; + } + + $limit = max(1, min(500, $limit)); + $result = $db->query('SELECT * FROM xlvask_usage_logs WHERE ' . implode(' AND ', $where) . " ORDER BY StartTime DESC LIMIT {$limit}"); + return $db->fetch_all($result); + } + + private function usageLogFromRow(array $row): xlvask_usage_log + { + $row = self::normalizeUsageLogRowForAutomation($row); + + $xlvask = new xlvask(); + return $xlvask->new($xlvask->helpers->xlvask_usage_log)->setProperties($row); + } + + private function formatSuggestion(array $row): array + { + $status = (string)($row['status'] ?? self::STATUS_NONE); + return [ + 'id' => isset($row['id']) ? (int)$row['id'] : null, + 'status' => $status, + 'action' => (string)($row['action'] ?? self::ACTION_NONE), + 'confidence' => isset($row['confidence']) ? (float)$row['confidence'] : 0.0, + 'source' => (string)($row['source'] ?? ''), + 'reason' => (string)($row['reason'] ?? ''), + 'matched_order_id' => isset($row['matched_order_id']) && $row['matched_order_id'] !== null ? (int)$row['matched_order_id'] : null, + 'created_order_id' => isset($row['created_order_id']) && $row['created_order_id'] !== null ? (int)$row['created_order_id'] : null, + 'candidate_order' => $this->decodeJsonField($row['candidate_order_json'] ?? null), + 'proposed_order' => $this->decodeJsonField($row['proposed_order_json'] ?? null), + 'can_accept' => $status === self::STATUS_SUGGESTED, + 'can_deny' => $status === self::STATUS_SUGGESTED, + ]; + } + + private function emptyAutomation(string $reason = ''): array + { + return [ + 'id' => null, + 'status' => self::STATUS_NONE, + 'action' => self::ACTION_NONE, + 'confidence' => 0.0, + 'source' => '', + 'reason' => $reason, + 'matched_order_id' => null, + 'created_order_id' => null, + 'candidate_order' => null, + 'proposed_order' => null, + 'can_accept' => false, + 'can_deny' => false, + ]; + } + + private function decodeJsonField(?string $value): mixed + { + if ($value === null || $value === '') { + return null; + } + + $decoded = json_decode($value, true); + return json_last_error() === JSON_ERROR_NONE ? $decoded : null; + } + + private function itemSignatureParts(array $items): array + { + return self::itemSignaturePartsForAutomation($items); + } + + private function compactItems(array $items): array + { + return array_map(fn(array $item): array => [ + 'product_id' => (int)($item['product_id'] ?? 0), + 'product_name' => (string)($item['product']['name'] ?? $item['product_name'] ?? ''), + 'quantity' => (int)($item['quantity'] ?? 0), + 'price' => (int)($item['price'] ?? 0), + ], $items); + } + + private function itemsTotal(array $items): int + { + return array_reduce($items, fn(int $total, array $item): int => $total + ((int)($item['price'] ?? 0) * (int)($item['quantity'] ?? 0)), 0); + } + + private function productOverlap(array $usageItems, array $orderItems): float + { + $usageBag = $this->productBag($usageItems); + $orderBag = $this->productBag($orderItems); + $usageTotal = array_sum($usageBag); + if ($usageTotal <= 0) { + return 0.0; + } + + $overlap = 0; + foreach ($usageBag as $productId => $quantity) { + $overlap += min($quantity, $orderBag[$productId] ?? 0); + } + + return $overlap / $usageTotal; + } + + private function productBag(array $items): array + { + $bag = []; + foreach ($items as $item) { + $productId = (int)($item['product_id'] ?? 0); + if ($productId < 1) { + continue; + } + $bag[$productId] = ($bag[$productId] ?? 0) + max(1, (int)($item['quantity'] ?? 1)); + } + + return $bag; + } + + private function normalizeRegistration(string $registration): string + { + return self::normalizeRegistrationForAutomation($registration); + } + + private function isOpenAiEnabled(): bool + { + try { + $xlvask = new xlvask(); + if (!$xlvask->config->openai_integration_enabled->isTrue()) { + return false; + } + + $openai = new openai(); + return $openai->config->enabled->isTrue(); + } catch (Exception) { + return false; + } + } +} diff --git a/services/nginx/app/classes/xlvask_usage_logs_schema_bootstrap.php b/services/nginx/app/classes/xlvask_usage_logs_schema_bootstrap.php index 29cd3ed1..8aadcb98 100644 --- a/services/nginx/app/classes/xlvask_usage_logs_schema_bootstrap.php +++ b/services/nginx/app/classes/xlvask_usage_logs_schema_bootstrap.php @@ -28,10 +28,81 @@ class xlvask_usage_logs_schema_bootstrap self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_at', 'DATETIME NULL AFTER WashItems'); self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_by', 'INT NULL AFTER ignored_at'); self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_reason', 'TEXT NULL AFTER ignored_by'); + self::ensureAutomationTables($db); self::$initialized = true; } + private static function ensureAutomationTables(object $db): void + { + $db->query( + "CREATE TABLE IF NOT EXISTS `xlvask_automation_suggestions` ( + `id` INT NOT NULL AUTO_INCREMENT, + `usage_log_id` INT NOT NULL, + `wash_id` VARCHAR(128) NOT NULL, + `signature_hash` CHAR(64) NOT NULL, + `signature_json` LONGTEXT NULL, + `action` VARCHAR(32) NOT NULL, + `status` VARCHAR(32) NOT NULL DEFAULT 'suggested', + `confidence` DECIMAL(5,4) NOT NULL DEFAULT 0.0000, + `source` VARCHAR(32) NOT NULL DEFAULT 'deterministic', + `matched_order_id` INT NULL, + `created_order_id` INT NULL, + `proposed_order_json` LONGTEXT NULL, + `candidate_order_json` LONGTEXT NULL, + `reason` TEXT NULL, + `created_by` INT NULL, + `decided_by` INT NULL, + `decided_at` DATETIME NULL, + `executed_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_xlvask_automation_usage` (`usage_log_id`), + KEY `idx_xlvask_automation_wash` (`wash_id`), + KEY `idx_xlvask_automation_signature` (`signature_hash`), + KEY `idx_xlvask_automation_status` (`status`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + + $db->query( + "CREATE TABLE IF NOT EXISTS `xlvask_automation_feedback` ( + `id` INT NOT NULL AUTO_INCREMENT, + `usage_log_id` INT NULL, + `wash_id` VARCHAR(128) NULL, + `signature_hash` CHAR(64) NOT NULL, + `signature_json` LONGTEXT NULL, + `action` VARCHAR(32) NOT NULL, + `decision` VARCHAR(32) NOT NULL, + `order_id` INT NULL, + `reason` TEXT NULL, + `created_by` INT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_xlvask_feedback_signature_action` (`signature_hash`, `action`), + KEY `idx_xlvask_feedback_usage` (`usage_log_id`), + KEY `idx_xlvask_feedback_decision` (`decision`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + + $db->query( + "CREATE TABLE IF NOT EXISTS `xlvask_automation_openai_cache` ( + `id` INT NOT NULL AUTO_INCREMENT, + `cache_key` CHAR(64) NOT NULL, + `schema_name` VARCHAR(96) NOT NULL, + `input_json` LONGTEXT NOT NULL, + `result_json` LONGTEXT NOT NULL, + `hits` INT NOT NULL DEFAULT 0, + `last_hit_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_xlvask_openai_cache_key` (`cache_key`), + KEY `idx_xlvask_openai_cache_schema` (`schema_name`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + } + private static function addColumnIfMissing(object $db, string $table, string $column, string $definition): void { if (!self::columnExists($db, $table, $column)) { diff --git a/services/nginx/app/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php b/services/nginx/app/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php new file mode 100644 index 00000000..bcd47458 --- /dev/null +++ b/services/nginx/app/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php @@ -0,0 +1,29 @@ +importCustomers(); (new xlvask_vehicles_o())->importVehicles(); (new xlvask_usage_logs_o())->importUsageLogs(); + (new xlvask_automation_service())->runPending(null, null, [], 100, null); }; } @@ -608,4 +612,4 @@ class xlvask_tasks $xlvask->requireModuleEnabled(); // Run } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/xlvask/xlvask_c.php b/services/nginx/app/modules/xlvask/xlvask_c.php index 69560070..016334b8 100644 --- a/services/nginx/app/modules/xlvask/xlvask_c.php +++ b/services/nginx/app/modules/xlvask/xlvask_c.php @@ -3,11 +3,17 @@ namespace xlvask; require_once WD . '/modules/xlvask/config/xlvask_enabled_c.php'; require_once WD . '/modules/xlvask/config/xlvask_synchronization_enabled_c.php'; +require_once WD . '/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php'; +require_once WD . '/modules/xlvask/config/xlvask_automatic_order_creation_enabled_c.php'; +require_once WD . '/modules/xlvask/config/xlvask_openai_integration_enabled_c.php'; require_once WD . '/modules/xlvask/config/xlvask_username_c.php'; require_once WD . '/modules/xlvask/config/xlvask_password_c.php'; use traits\module_config_t; +use xlvask\config\xlvask_automatic_order_attachment_enabled_c; +use xlvask\config\xlvask_automatic_order_creation_enabled_c; use xlvask\config\xlvask_enabled_c; +use xlvask\config\xlvask_openai_integration_enabled_c; use xlvask\config\xlvask_password_c; use xlvask\config\xlvask_synchronization_enabled_c; use xlvask\config\xlvask_username_c; @@ -31,6 +37,18 @@ class xlvask_c * @var xlvask_synchronization_enabled_c $synchronization_enabled */ public xlvask_synchronization_enabled_c $synchronization_enabled; + /** + * @var xlvask_automatic_order_attachment_enabled_c $automatic_order_attachment_enabled + */ + public xlvask_automatic_order_attachment_enabled_c $automatic_order_attachment_enabled; + /** + * @var xlvask_automatic_order_creation_enabled_c $automatic_order_creation_enabled + */ + public xlvask_automatic_order_creation_enabled_c $automatic_order_creation_enabled; + /** + * @var xlvask_openai_integration_enabled_c $openai_integration_enabled + */ + public xlvask_openai_integration_enabled_c $openai_integration_enabled; /** * The username * @var xlvask_username_c @@ -53,12 +71,18 @@ class xlvask_c $this->allowUpdate([ xlvask_enabled_c::class, xlvask_synchronization_enabled_c::class, + xlvask_automatic_order_attachment_enabled_c::class, + xlvask_automatic_order_creation_enabled_c::class, + xlvask_openai_integration_enabled_c::class, xlvask_username_c::class, xlvask_password_c::class ]); $this->enabled = new xlvask_enabled_c(); $this->synchronization_enabled = new xlvask_synchronization_enabled_c(); + $this->automatic_order_attachment_enabled = new xlvask_automatic_order_attachment_enabled_c(); + $this->automatic_order_creation_enabled = new xlvask_automatic_order_creation_enabled_c(); + $this->openai_integration_enabled = new xlvask_openai_integration_enabled_c(); $this->username = new xlvask_username_c(); $this->password = new xlvask_password_c(); } -} \ No newline at end of file +} diff --git a/services/nginx/app/objects/users_o.php b/services/nginx/app/objects/users_o.php index d17fcc51..c8e275d0 100644 --- a/services/nginx/app/objects/users_o.php +++ b/services/nginx/app/objects/users_o.php @@ -48,6 +48,10 @@ class users_o extends db $this->setTable('users'); } + private static function redisCache(): ?redis + { + return defined('redis') ? constant('redis') : null; + } public function edit(int $id, string $customer_number, string|null $role, string|null $password, string|null $display_name): void { @@ -59,12 +63,12 @@ class users_o extends db if ($old_res && $old_res->num_rows > 0) { $old_cn = (int)$old_res->fetch_assoc()['customer_number']; if ($old_cn !== 0 && $old_cn !== (int)$customer_number) { - redis->clear_user_id_from_customer_number($old_cn); + self::redisCache()?->clear_user_id_from_customer_number($old_cn); } } // Cache the mapping from customer_number to user_id (new value) - redis->cache_user_id_from_customer_number((int)$customer_number, $this->id); - redis->cache_customer_number_from_user_id($this->id, (int)$customer_number); + self::redisCache()?->cache_user_id_from_customer_number((int)$customer_number, $this->id); + self::redisCache()?->cache_customer_number_from_user_id($this->id, (int)$customer_number); // Avoid SQL injection $customer_number = $db->escape_string($customer_number); @@ -235,8 +239,8 @@ class users_o extends db $this->id = (int)$db->insert_id(); // Cache the mapping from customer_number to user_id - redis->cache_user_id_from_customer_number((int)$customer_number, $this->id); - redis->cache_customer_number_from_user_id($this->id, (int)$customer_number); + self::redisCache()?->cache_user_id_from_customer_number((int)$customer_number, $this->id); + self::redisCache()?->cache_customer_number_from_user_id($this->id, (int)$customer_number); // Set the values of the object properties $this->getObjectProperties(); @@ -360,7 +364,7 @@ class users_o extends db { global $db; // Check Redis for existence (by checking if we have the customer number) - $customer_number = redis->get_customer_number_from_user_id($id); + $customer_number = self::redisCache()?->get_customer_number_from_user_id($id); if ($customer_number !== null) { $this->id = $id; $this->getObjectProperties(); @@ -374,7 +378,7 @@ class users_o extends db $this->id = $id; $customer_number = (int)$result->fetch_assoc()['customer_number']; // Cache the result - redis->cache_customer_number_from_user_id($id, $customer_number); + self::redisCache()?->cache_customer_number_from_user_id($id, $customer_number); $this->getObjectProperties(); } return $this; @@ -384,7 +388,7 @@ class users_o extends db { global $db; // Check Redis first - $user_id = redis->get_user_id_from_customer_number($customer_number); + $user_id = self::redisCache()?->get_user_id_from_customer_number($customer_number); if ($user_id !== null) { $this->id = (int)$user_id; $this->getObjectProperties(); @@ -397,7 +401,7 @@ class users_o extends db if ($result->num_rows > 0) { $this->id = (int)$result->fetch_assoc()['id']; // Cache the result - redis->cache_user_id_from_customer_number($customer_number, $this->id); + self::redisCache()?->cache_user_id_from_customer_number($customer_number, $this->id); $this->getObjectProperties(); } else { // Import the customer @@ -1073,22 +1077,22 @@ class users_o extends db public function clearAllUsersEconomicCustomerDiscountsFromCache(): void { // Get all the cached results matching the pattern 'users_*_economic_customer_discount_percentage' - $cached_results = redis->get_keys('users_*_economic_customer_discount_percentage'); + $cached_results = self::redisCache()?->get_keys('users_*_economic_customer_discount_percentage') ?? []; // Loop through the cached results foreach ( $cached_results as $key ) { // Clear the cached discount percentage - redis->delete($key); + self::redisCache()?->delete($key); } } public function clearAllUsersEconomicCustomerDetailsFromCache(): void { // Get all the cached results matching the pattern 'users_*_economic_customer' - $cached_results = redis->get_keys('users_*_economic_customer'); + $cached_results = self::redisCache()?->get_keys('users_*_economic_customer') ?? []; // Loop through the cached results foreach ( $cached_results as $key ) { // Clear the cached economic customer details - redis->delete($key); + self::redisCache()?->delete($key); } } @@ -1096,7 +1100,7 @@ class users_o extends db { self::requireSelected(); // Check if the discount percentage is cached - $cached_discount_percentage = redis->get_economic_customer_discount_percentage($this->id); + $cached_discount_percentage = self::redisCache()?->get_economic_customer_discount_percentage($this->id); if ($cached_discount_percentage !== null) { return $cached_discount_percentage; } @@ -1104,7 +1108,7 @@ class users_o extends db $economic = new economicCustomers(); $discount_percentage = $economic->getCustomerDiscountPercentage($this->customer_number->value()); // Cache the discount percentage - redis->cache_economic_customer_discount_percentage($this->id, $discount_percentage); + self::redisCache()?->cache_economic_customer_discount_percentage($this->id, $discount_percentage); return $discount_percentage; } @@ -1143,7 +1147,7 @@ class users_o extends db public function isImportedFromEconomic($customerNumber): bool { // Check Redis first - $user_id = redis->get_user_id_from_customer_number((int)$customerNumber); + $user_id = self::redisCache()?->get_user_id_from_customer_number((int)$customerNumber); if ($user_id !== null) { return true; } @@ -1158,7 +1162,7 @@ class users_o extends db public function getUserIdFromEconomic($customerNumber): int { // Check Redis first - $user_id = redis->get_user_id_from_customer_number((int)$customerNumber); + $user_id = self::redisCache()?->get_user_id_from_customer_number((int)$customerNumber); if ($user_id !== null) { return (int)$user_id; } @@ -1168,7 +1172,7 @@ class users_o extends db $id = (int)$user[0]['id']; // Cache the result - redis->cache_user_id_from_customer_number((int)$customerNumber, $id); + self::redisCache()?->cache_user_id_from_customer_number((int)$customerNumber, $id); return $id; } diff --git a/services/nginx/app/routes/moduleXLVaskRoute.php b/services/nginx/app/routes/moduleXLVaskRoute.php index d8b52a6d..9070abe8 100644 --- a/services/nginx/app/routes/moduleXLVaskRoute.php +++ b/services/nginx/app/routes/moduleXLVaskRoute.php @@ -2,10 +2,13 @@ namespace routes; +require_once WD . '/classes/xlvask_automation_service.php'; + use classes\authentication; use classes\response; use classes\router; use classes\xlvask; +use classes\xlvask_automation_service; use objects\orders_o; use objects\users_o; use objects\xlvask_customers_o; @@ -256,6 +259,7 @@ class moduleXLVaskRoute $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); // Response $response->success( 'Usage logs imported', @@ -267,4 +271,4 @@ class moduleXLVaskRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/xlvaskUsageLogsRoute.php b/services/nginx/app/routes/xlvaskUsageLogsRoute.php index 556f233a..fef17769 100644 --- a/services/nginx/app/routes/xlvaskUsageLogsRoute.php +++ b/services/nginx/app/routes/xlvaskUsageLogsRoute.php @@ -2,11 +2,14 @@ namespace routes; +require_once WD . '/classes/xlvask_automation_service.php'; + use classes\authentication; use classes\redis; use classes\response; use classes\stripe; use classes\xlvask; +use classes\xlvask_automation_service; use objects\collected_order_invoices_o; use objects\departments_o; use objects\economic_module_orders; @@ -14,7 +17,6 @@ use objects\logs_o; use objects\orders_o; use objects\stripe_module_orders_o; use objects\stripe_payment_intents_o; -use objects\users_o; use objects\xlvask_usage_logs_o; use traits\route_t; @@ -47,6 +49,8 @@ class xlvaskUsageLogsRoute (new logs_o())->add('xlvask_usage_orders', 'global', 1, $user->id, 'LIST_XLVASK_USAGE_ORDERS', 'User accessed the list of xlvask usage orders'); $xlvask_usage_logs = new xlvask_usage_logs_o(); $xlvask = new xlvask(); + $automation_service = new xlvask_automation_service(); + $linked_order_ids_by_wash_id = []; $xlvask->new($xlvask->helpers->xlvask_usage_log)->getDepartment(); $orders_o = new orders_o(); $xlvask_usage_log = $xlvask->new($xlvask->helpers->xlvask_usage_log); @@ -62,7 +66,8 @@ class xlvaskUsageLogsRoute // Make sure the Customer is not in the default customers list ->setAdditionalWhereClause("`Customer` NOT IN ('" . implode("', '", $xlvask_usage_log::$default_customers) . "')") ->listObjectsWithPaginationIfSet( - function ($log) use ($response_includes_items_link, $response_includes_items, $xlvask_usage_logs, $user, $xlvask) { + function ($log) use ($response_includes_items_link, $response_includes_items, $xlvask_usage_logs, $user, $xlvask, $automation_service, &$linked_order_ids_by_wash_id) { + $automation = $automation_service->evaluateUsageLogRow($log, (int)$user->id, true); // Remove the 'id' field from the log $id = (int)$log['id']; unset($log['id']); @@ -72,6 +77,12 @@ class xlvaskUsageLogsRoute $tmp = $xlvask->new($xlvask->helpers->xlvask_usage_log); // Set the properties of the temporary object $tmp->setProperties($log); + $wash_id = (string)$tmp->WashId; + if ($wash_id !== '' && !array_key_exists($wash_id, $linked_order_ids_by_wash_id)) { + $linked_order = (new orders_o())->selectByWashId($wash_id); + $linked_order_ids_by_wash_id[$wash_id] = $linked_order !== null ? (int)$linked_order->id : null; + } + $linked_order_id = $wash_id !== '' ? $linked_order_ids_by_wash_id[$wash_id] : null; // Define the result structure $isEligibleForAutomaticContinuance = $tmp->isEligibleForAutomaticContinuance(true); // Generate a fast link key for the order, used to retrieve the order with items later. @@ -94,7 +105,10 @@ class xlvaskUsageLogsRoute return [ 'id' => $id, // Return the ID of the log 'fast_link_key' => $fast_link_key ?? null, // Return the fast link key if it was generated + 'automation' => $automation, ...$tmp_res['order'], // Return the simulated order from XLVask (with or without items) + 'usage_log_id' => $id, + 'linked_order_id' => $linked_order_id, ]; }, $xlvask_usage_logs->forceRestrictFilters( @@ -160,6 +174,115 @@ class xlvaskUsageLogsRoute ] ); + $this->post('/modules/xlvask/services/usage/orders/automation/run', function () { + global $response; + $this->requirePermission('manage_xlvask_usage_automation'); + + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + $ids = $this->isParametersSet(['ids']) ? $this->getParameter('ids') : []; + if (!is_array($ids)) { + $ids = []; + } + + $dateFrom = $this->isParametersSet(['dateFrom']) ? (string)$this->getParameter('dateFrom') : null; + $dateTo = $this->isParametersSet(['dateTo']) ? (string)$this->getParameter('dateTo') : null; + $limit = $this->isParametersSet(['limit']) ? (int)$this->getParameter('limit') : 100; + + $response->success( + (new xlvask_automation_service())->runPending($dateFrom, $dateTo, $ids, $limit, (int)$user->id) + ); + }, + [ + 'manage_xlvask_usage_automation' => 'Evaluate and execute XL Vask usage-log automation', + ] + ); + + $this->post('/modules/xlvask/services/usage/orders/{id}/automation/evaluate', function () { + global $response; + $this->requirePermission('manage_xlvask_usage_automation'); + + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + $id = (int)($this->fromRoute('id') ?? 0); + if ($id < 1) { + $response->error('Invalid XL Vask usage log id', 400); + return; + } + + $response->success( + (new xlvask_automation_service())->evaluateUsageLogById($id, (int)$user->id, true) + ); + }, + [ + 'manage_xlvask_usage_automation' => 'Evaluate XL Vask usage-log automation', + ] + ); + + $this->post('/modules/xlvask/services/usage/orders/{id}/automation/accept', function () { + global $response; + $this->requirePermission('manage_xlvask_usage_automation'); + + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + $id = (int)($this->fromRoute('id') ?? 0); + if ($id < 1) { + $response->error('Invalid XL Vask usage log id', 400); + return; + } + + $suggestionId = $this->isParametersSet(['suggestion_id']) ? (int)$this->getParameter('suggestion_id') : null; + $reason = $this->isParametersSet(['reason']) ? trim((string)$this->getParameter('reason')) : null; + + $response->success( + (new xlvask_automation_service())->acceptUsageLogById($id, (int)$user->id, $suggestionId, $reason) + ); + }, + [ + 'manage_xlvask_usage_automation' => 'Accept an XL Vask usage-log automation suggestion', + ] + ); + + $this->post('/modules/xlvask/services/usage/orders/{id}/automation/deny', function () { + global $response; + $this->requirePermission('manage_xlvask_usage_automation'); + + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + return; + } + + $id = (int)($this->fromRoute('id') ?? 0); + if ($id < 1) { + $response->error('Invalid XL Vask usage log id', 400); + return; + } + + $suggestionId = $this->isParametersSet(['suggestion_id']) ? (int)$this->getParameter('suggestion_id') : null; + $reason = $this->isParametersSet(['reason']) ? trim((string)$this->getParameter('reason')) : null; + + $response->success( + (new xlvask_automation_service())->denyUsageLogById($id, (int)$user->id, $suggestionId, $reason) + ); + }, + [ + 'manage_xlvask_usage_automation' => 'Deny an XL Vask usage-log automation suggestion', + ] + ); + $this->get('/modules/xlvask/services/usage/orders/fast-link', function () { global $response; self::requireParameters([ diff --git a/services/nginx/app/tests/Unit/Auth/CreateTokenTest.php b/services/nginx/app/tests/Unit/Auth/CreateTokenTest.php index cffac520..10d7378b 100644 --- a/services/nginx/app/tests/Unit/Auth/CreateTokenTest.php +++ b/services/nginx/app/tests/Unit/Auth/CreateTokenTest.php @@ -1,5 +1,15 @@ null, + 'created' => [], + 'missing_error' => null, + ]; - $token = (new \classes\authentication())->create_token(111111); - - expect($token)->toBeString()->toMatch('/^[a-f0-9]{64}$/'); - expect(\objects\tokens_o::$created)->toHaveCount(1); - expect(\objects\tokens_o::$created[0]['user_id'])->toBe(123); - expect(\objects\tokens_o::$created[0]['type'])->toBe('AUTH_TOKEN'); - }); - - it('throws a clear exception when customer user is missing', function (): void { - \objects\users_o::$existing = []; + \objects\users_o::$existing = [111111]; + \objects\tokens_o::$created = []; + $result['success_token'] = (new \classes\authentication())->create_token(111111); + $result['created'] = \objects\tokens_o::$created; + \objects\users_o::$existing = []; + try { (new \classes\authentication())->create_token(222222); - })->throws(\Exception::class, 'User not found for customer number: 222222'); -} + } catch (\Throwable $exception) { + $result['missing_error'] = $exception->getMessage(); + } + echo json_encode($result, JSON_THROW_ON_ERROR); +} +PHP)); + + try { + $output = []; + $exitCode = 0; + exec(PHP_BINARY . ' ' . escapeshellarg($script), $output, $exitCode); + + expect($exitCode)->toBe(0); + $result = json_decode(implode("\n", $output), true, 512, JSON_THROW_ON_ERROR); + + expect($result['success_token'])->toBeString()->toMatch('/^[a-f0-9]{64}$/') + ->and($result['created'])->toHaveCount(1) + ->and($result['created'][0]['user_id'])->toBe(123) + ->and($result['created'][0]['type'])->toBe('AUTH_TOKEN') + ->and($result['missing_error'])->toBe('User not found for customer number: 222222'); + } finally { + if (is_file($script)) { + unlink($script); + } + } +}); diff --git a/services/nginx/app/tests/Unit/Database/DbObjectRedisNamespaceSafetyTest.php b/services/nginx/app/tests/Unit/Database/DbObjectRedisNamespaceSafetyTest.php new file mode 100644 index 00000000..7e6e0fe5 --- /dev/null +++ b/services/nginx/app/tests/Unit/Database/DbObjectRedisNamespaceSafetyTest.php @@ -0,0 +1,9 @@ +not->toContain('redis->') + ->and($content)->toContain("constant('redis')"); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php index 3dbf68da..4d64e042 100644 --- a/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php @@ -111,6 +111,29 @@ it('builds interactive message parts for order and wash certificate warnings', f [], ['department_id' => 1, 'order_id' => 9001, 'order_item_id' => 7001], ]); + $xlVaskFlag = invoice_period_flag_service_invoke('automaticFlag', [ + 'xlvask_missing_order_link', + 'xlvask_usage_log', + 55, + null, + [ + 'customer_number' => 424242, + 'customer_name' => 'Flagged Customer', + 'xlvask_usage_log_id' => 55, + ], + [ + 'wash_id' => 'wash-55', + 'registration_number' => 'AB12345', + ], + [ + 'customer_number' => 424242, + 'customer_name' => 'Flagged Customer', + 'xlvask_usage_log_id' => 55, + 'wash_id' => 'wash-55', + 'registration_number' => 'AB12345', + 'start_time' => '2026-05-11 10:00:00', + ], + ]); expect($orderFlag['message_parts'])->toBe([ ['type' => 'order', 'text' => 'Order'], @@ -120,6 +143,10 @@ it('builds interactive message parts for order and wash certificate warnings', f ['type' => 'order_item', 'text' => 'Wash certificate item'], ['type' => 'text', 'text' => ' is present without a wash certificate.'], ]); + expect($xlVaskFlag['message_parts'])->toBe([ + ['type' => 'xlvask_usage_log', 'text' => 'XL Vask wash'], + ['type' => 'text', 'text' => ' is neither ignored nor linked to an order in the selected period.'], + ]); }); it('includes order item preview context for required order field warnings', function (): void { @@ -227,6 +254,159 @@ it('includes order item preview context for required order field warnings', func } }); +it('allows tank cleaning products for only tank cleaning customers', function (): void { + $baseRow = [ + 'customer_number' => 424242, + 'customer_name' => 'Tank Customer', + 'order_id' => 61426, + 'invoice_collection_id' => 3090, + 'department_id' => 5, + 'related_item_id' => 0, + 'item_price' => 100, + 'order_reference' => 'REF', + 'order_po' => 'PO', + 'reg_1' => 'NI465', + ]; + $tankCleaningRow = $baseRow + [ + 'order_item_id' => 801, + 'product_id' => 30, + 'product_name' => 'Tank cleaning 4 spulehoveder', + 'product_category' => 5, + 'category_name' => 'Tank cleaning', + ]; + $tankCleaningAddonRow = $baseRow + [ + 'order_item_id' => 802, + 'product_id' => 33, + 'product_name' => 'Saebe/kemi, 1-4 spulehoveder', + 'product_category' => 5, + 'category_name' => 'Tank cleaning', + ]; + $washRow = $baseRow + [ + 'order_item_id' => 803, + 'product_id' => 3, + 'product_name' => 'Forvogn', + 'product_category' => 1, + 'category_name' => 'Vask', + ]; + + $onlyTankCleaningFlags = invoice_period_flag_service_invoke('detectCustomerRuleViolations', [ + [$tankCleaningRow, $tankCleaningAddonRow, $washRow], + [424242 => ['onlyTankCleaning' => true]], + ]); + + expect(array_column($onlyTankCleaningFlags, 'definition_key'))->toBe(['customer_rule_only_tank_cleaning']); + expect($onlyTankCleaningFlags[0]['target_id'])->toBe(803); + + $restrictedTankCleaningFlags = invoice_period_flag_service_invoke('detectCustomerRuleViolations', [ + [$tankCleaningRow, $washRow], + [424242 => ['restrictTankCleaning' => true]], + ]); + + expect(array_column($restrictedTankCleaningFlags, 'definition_key'))->toBe(['customer_rule_restrict_tank_cleaning']); + expect($restrictedTankCleaningFlags[0]['target_id'])->toBe(801); +}); + +it('does not flag interior wash variants as historical primary product mismatches', function (): void { + global $db; + + $hadDb = array_key_exists('db', $GLOBALS); + $previousDb = $GLOBALS['db'] ?? null; + $db = new class { + public function escape_string(string $value): string + { + return addslashes($value); + } + + public function query(string $sql): object|false + { + if (str_contains($sql, 'SHOW COLUMNS FROM `customer_vehicles`')) { + return $this->result([]); + } + + if (str_contains($sql, 'FROM customer_vehicles')) { + return $this->result([]); + } + + if (str_contains($sql, 'FROM orders o') && str_contains($sql, 'GROUP BY UPPER(TRIM(o.reg_1))')) { + return $this->result([ + [ + 'reg' => 'CN96636', + 'product_id' => 3, + 'product_name' => 'Forvogn', + 'usage_count' => 5, + ], + ]); + } + + return false; + } + + private function result(array $rows): object + { + return new class($rows) { + public int $num_rows; + public array $rows; + + public function __construct(array $rows) + { + $this->rows = $rows; + $this->num_rows = count($rows); + } + + public function fetch_assoc(): ?array + { + return array_shift($this->rows); + } + }; + } + }; + + try { + $baseRow = [ + 'customer_number' => 424242, + 'customer_name' => 'History Customer', + 'order_id' => 61311, + 'order_item_id' => 901, + 'invoice_collection_id' => 16912, + 'department_id' => 7, + 'reg_1' => 'CN96636', + 'is_wash' => 1, + 'related_item_id' => 0, + 'order_created_at' => '2026-05-11 08:05:21', + ]; + + $interiorVariantFlags = invoice_period_flag_service_invoke('detectVehicleTypeMismatches', [ + [ + $baseRow + [ + 'product_id' => 99, + 'product_name' => 'Indvendig vask Forvogn', + ], + ], + '2026-05-11 00:00:00', + ]); + + expect($interiorVariantFlags)->toBe([]); + + $mismatchFlags = invoice_period_flag_service_invoke('detectVehicleTypeMismatches', [ + [ + $baseRow + [ + 'product_id' => 17, + 'product_name' => 'Bus', + ], + ], + '2026-05-11 00:00:00', + ]); + + expect(array_column($mismatchFlags, 'definition_key'))->toBe(['historical_primary_product_mismatch']); + } finally { + if ($hadDb) { + $db = $previousDb; + } else { + unset($GLOBALS['db']); + } + } +}); + it('does not report duplicate primary vehicle products from duplicated detector rows for the same order item', function (): void { $row = [ 'customer_number' => 424242, diff --git a/services/nginx/app/tests/Unit/MotorApi/MotorApiCachedResultTest.php b/services/nginx/app/tests/Unit/MotorApi/MotorApiCachedResultTest.php new file mode 100644 index 00000000..4742168d --- /dev/null +++ b/services/nginx/app/tests/Unit/MotorApi/MotorApiCachedResultTest.php @@ -0,0 +1,22 @@ +meta[$key] = $value; + } + }; + + motorapi::addCachedMetaIfPossible($response); + + expect($response->meta)->toBe(['cached' => true]); +}); diff --git a/services/nginx/app/tests/Unit/Users/UsersRedisNamespaceSafetyTest.php b/services/nginx/app/tests/Unit/Users/UsersRedisNamespaceSafetyTest.php new file mode 100644 index 00000000..7d4d5012 --- /dev/null +++ b/services/nginx/app/tests/Unit/Users/UsersRedisNamespaceSafetyTest.php @@ -0,0 +1,9 @@ +not->toContain('redis->') + ->and($content)->toContain("constant('redis')"); +}); diff --git a/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php b/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php new file mode 100644 index 00000000..5651ebd3 --- /dev/null +++ b/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php @@ -0,0 +1,137 @@ +toBe('EC21233'); +}); + +it('builds stable XL Vask automation item signatures', function (): void { + $items = [ + ['product_id' => 20, 'quantity' => 1, 'price' => 275], + ['product_id' => 10, 'quantity' => 2, 'price' => 649], + ['product_id' => 20, 'quantity' => 1, 'price' => 0], + ]; + + expect(xlvask_automation_service::itemSignaturePartsForAutomation($items)) + ->toBe([ + '10:2:649', + '20:1:0', + '20:1:275', + ]); +}); + +it('normalizes persisted XL Vask usage-log rows before helper hydration', function (): void { + $row = xlvask_automation_service::normalizeUsageLogRowForAutomation([ + 'id' => 47086, + 'WashId' => 'cc1eabc1-b4e1-425b-ad7c-dc68f8c97ceb', + 'WashItems' => '[{"OriginalProductName":"Bus","Count":1}]', + ]); + + expect($row) + ->not->toHaveKey('id') + ->and($row['WashItems'])->toBe([ + [ + 'OriginalProductName' => 'Bus', + 'Count' => 1, + ], + ]); +}); + +it('builds stable OpenAI cache keys for identical automation input', function (): void { + $prompt = 'Prompt'; + $schemaName = 'xlvask_automation'; + $schema = [ + 'required' => ['action'], + 'properties' => [ + 'confidence' => ['type' => 'number'], + 'action' => ['type' => 'string'], + ], + ]; + $schemaWithDifferentKeyOrder = [ + 'properties' => [ + 'action' => ['type' => 'string'], + 'confidence' => ['type' => 'number'], + ], + 'required' => ['action'], + ]; + $payloadA = [ + 'usage_log' => [ + 'registration' => 'AB12345', + 'creation_allowed' => true, + ], + 'candidate_orders' => [ + ['id' => 10, 'items' => [['product_id' => 1, 'quantity' => 1, 'price' => 100]]], + ], + ]; + $payloadB = [ + 'candidate_orders' => [ + ['items' => [['price' => 100, 'quantity' => 1, 'product_id' => 1]], 'id' => 10], + ], + 'usage_log' => [ + 'creation_allowed' => true, + 'registration' => 'AB12345', + ], + ]; + + expect(xlvask_automation_service::openAiCacheKeyForAutomation($schemaName, $prompt, $payloadA, $schema, 0.1)) + ->toBe(xlvask_automation_service::openAiCacheKeyForAutomation($schemaName, $prompt, $payloadB, $schemaWithDifferentKeyOrder, 0.1)); +}); + +it('changes OpenAI cache keys when automation eligibility input changes', function (): void { + $schema = ['type' => 'object']; + $newerWashPayload = ['usage_log' => ['creation_allowed' => false, 'age_bucket' => 'newer_than_6_hours']]; + $olderWashPayload = ['usage_log' => ['creation_allowed' => true, 'age_bucket' => 'older_than_6_hours']]; + + expect(xlvask_automation_service::openAiCacheKeyForAutomation('xlvask_automation', 'Prompt', $newerWashPayload, $schema, 0.1)) + ->not->toBe(xlvask_automation_service::openAiCacheKeyForAutomation('xlvask_automation', 'Prompt', $olderWashPayload, $schema, 0.1)); +}); + +it('declares a persistent OpenAI cache table for XL Vask automation', function (): void { + $bootstrapContent = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php'); + + expect($bootstrapContent) + ->toContain('xlvask_automation_openai_cache') + ->toContain('UNIQUE KEY `uniq_xlvask_openai_cache_key` (`cache_key`)'); +}); + +it('scores same-day orders with matching XL Vask products and extra add-ons as attach suggestions', function (): void { + $usageItems = [ + ['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']], + ['product_id' => 24, 'quantity' => 1, 'price' => 39, 'product' => ['name' => 'Spot Free- Lastbil']], + ['product_id' => 21, 'quantity' => 1, 'price' => 79, 'product' => ['name' => 'Undervognskyl pr. Enhed']], + ['product_id' => 99, 'quantity' => 8, 'price' => 0, 'product' => ['name' => 'Halleje']], + ]; + $orderItems = [ + ['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']], + ['product_id' => 50, 'quantity' => 1, 'price' => 319, 'product' => ['name' => 'Indvendig vask Forvogn']], + ['product_id' => 21, 'quantity' => 1, 'price' => 79, 'product' => ['name' => 'Undervognskyl pr. Enhed']], + ['product_id' => 24, 'quantity' => 1, 'price' => 39, 'product' => ['name' => 'Spot Free- Lastbil']], + ]; + + $score = xlvask_automation_service::scoreItemMatchForAutomation($usageItems, $orderItems); + + expect($score['source']) + ->toBe('fuzzy') + ->and($score['confidence'])->toBeGreaterThanOrEqual(0.70) + ->and($score['confidence'])->toBeLessThan(0.92) + ->and($score['reason'])->toContain('ekstra ydelser'); +}); + +it('does not score an order with only the primary product as a matching add-on attachment', function (): void { + $usageItems = [ + ['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']], + ['product_id' => 24, 'quantity' => 1, 'price' => 39, 'product' => ['name' => 'Spot Free- Lastbil']], + ['product_id' => 21, 'quantity' => 1, 'price' => 79, 'product' => ['name' => 'Undervognskyl pr. Enhed']], + ]; + $orderItems = [ + ['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']], + ['product_id' => 50, 'quantity' => 1, 'price' => 319, 'product' => ['name' => 'Indvendig vask Forvogn']], + ]; + + expect(xlvask_automation_service::scoreItemMatchForAutomation($usageItems, $orderItems)['confidence']) + ->toBe(0.0); +}); diff --git a/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php new file mode 100644 index 00000000..8d0e031b --- /dev/null +++ b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php @@ -0,0 +1,16 @@ +not->toBeFalse(); + + $route = (string)$route; + + expect($route) + ->toContain('$linked_order_ids_by_wash_id = []') + ->and($route)->toContain('array_key_exists($wash_id, $linked_order_ids_by_wash_id)') + ->and($route)->toContain('selectByWashId($wash_id)') + ->and($route)->toContain("'linked_order_id' => \$linked_order_id") + ->and($route)->toContain("'usage_log_id' => \$id"); +}); diff --git a/services/nginx/app/traits/db_object_t.php b/services/nginx/app/traits/db_object_t.php index f2b3de52..cf973549 100644 --- a/services/nginx/app/traits/db_object_t.php +++ b/services/nginx/app/traits/db_object_t.php @@ -1029,6 +1029,11 @@ trait db_object_t $this->table = $table; } + private static function dbObjectRedisCache(): ?object + { + return defined('redis') ? constant('redis') : null; + } + /** * Cache object * @param string $key The key to cache the object @@ -1045,7 +1050,7 @@ trait db_object_t $data = json_encode($data); } // Cache the data - redis->set($this->table . '_' . $objectId . '_' . $key, $data); + self::dbObjectRedisCache()?->set($this->table . '_' . $objectId . '_' . $key, $data); } /** @@ -1060,9 +1065,9 @@ trait db_object_t return []; } - return redis->mget(array_map(function($objectId) use ($key) { + return self::dbObjectRedisCache()?->mget(array_map(function($objectId) use ($key) { return $this->table . '_' . $objectId . '_' . $key; - }, $objectIds)); + }, $objectIds)) ?? []; } /** @@ -1081,7 +1086,7 @@ trait db_object_t $objectId = $this->id; } // Set the expiration time for the cached data - redis->expire($this->table . '_' . $objectId . '_' . $key, $seconds); + self::dbObjectRedisCache()?->expire($this->table . '_' . $objectId . '_' . $key, $seconds); } /** @@ -1113,7 +1118,7 @@ trait db_object_t $objectId = $this->id; } // Get the cached data - $data = redis->get($this->table . '_' . $objectId . '_' . $key) ?? null; + $data = self::dbObjectRedisCache()?->get($this->table . '_' . $objectId . '_' . $key) ?? null; // if the data is a JSON string, convert it to an array if (is_string($data) && json_decode($data)) { $data = json_decode($data); @@ -1164,8 +1169,11 @@ trait db_object_t if (!$objectId) { $objectId = $this->id; } + if (!defined('redis')) { + return; + } // Delete the cached data - redis->delete($this->table . '_' . $objectId . '_' . $key); + \constant('redis')->delete($this->table . '_' . $objectId . '_' . $key); } /**