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 isExactItemMatchForAutomation(array $usageItems, array $orderItems): bool { return self::itemSignaturePartsForAutomation($usageItems) === self::itemSignaturePartsForAutomation($orderItems) && self::itemsTotalForAutomation($usageItems) === self::itemsTotalForAutomation($orderItems); } 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 && $this->isExactAttachSuggestionForContext($suggestion, $context); } if ($action === self::ACTION_CREATE) { return false; } return false; } private function isExactAttachSuggestionForContext(array $suggestion, array $context): bool { if ((string)($suggestion['action'] ?? '') !== self::ACTION_ATTACH) { return false; } $matchedOrderId = (int)($suggestion['matched_order_id'] ?? 0); $candidateOrder = $this->candidateOrderFromSuggestion($suggestion); if ($matchedOrderId < 1 || !is_array($candidateOrder) || (int)($candidateOrder['id'] ?? 0) !== $matchedOrderId) { return false; } $usageItems = $context['items'] ?? []; $orderItems = $candidateOrder['order_items'] ?? []; return is_array($usageItems) && is_array($orderItems) && self::isExactItemMatchForAutomation($usageItems, $orderItems); } private function candidateOrderFromSuggestion(array $suggestion): ?array { $candidateOrder = $suggestion['candidate_order'] ?? null; if (is_array($candidateOrder)) { return $candidateOrder; } $candidateOrderJson = $suggestion['candidate_order_json'] ?? null; if (!is_string($candidateOrderJson) || trim($candidateOrderJson) === '') { return null; } $decoded = json_decode($candidateOrderJson, true); return is_array($decoded) ? $decoded : null; } 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, $this->automaticFeedbackReason($suggestion, $context)); } 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 automaticFeedbackReason(array $suggestion, array $context): string { if ($this->isExactAttachSuggestionForContext($suggestion, $context)) { return 'Automatisk accepteret: Prisoverensstemmelse.'; } return 'Automatisk accepteret.'; } 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); } 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(); $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[] = "{$startTimeExpression} >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'"; } else { $where[] = "{$startTimeExpression} >= '" . $db->escape_string(date('Y-m-d H:i:s', strtotime('-7 days'))) . "'"; } if ($dateTo !== null && strtotime($dateTo) !== false) { $where[] = "{$startTimeExpression} <= '" . $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; } } }