diff --git a/services/nginx/app/classes/openai.php b/services/nginx/app/classes/openai.php index 5fdc9696..e1a69c7f 100644 --- a/services/nginx/app/classes/openai.php +++ b/services/nginx/app/classes/openai.php @@ -44,12 +44,21 @@ class openai implements openai_i * * @throws Exception */ - public function jsonTask(string $schemaName, string $prompt, array $payload, array $schema, float $temperature = 0.1): array + public function jsonTask( + string $schemaName, + string $prompt, + array $payload, + array $schema, + float $temperature = 0.1, + ?string $model = null + ): array { $this->requireModuleEnabled(); $data = [ - 'model' => $this->model, + 'model' => $model ?? $this->model, + // The caller owns the durable audit record. Do not retain application state at OpenAI. + 'store' => false, 'input' => [ [ 'role' => 'user', @@ -278,6 +287,8 @@ class openai implements openai_i $curl = curl_init($this->api_url); curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); curl_setopt($curl, CURLOPT_POST, true); + curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 10); + curl_setopt($curl, CURLOPT_TIMEOUT, 45); curl_setopt($curl, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', 'Authorization: Bearer ' . $this->config->api_key->getVariableValue() diff --git a/services/nginx/app/classes/xlvask_automation_service.php b/services/nginx/app/classes/xlvask_automation_service.php index edc344eb..e894dd84 100644 --- a/services/nginx/app/classes/xlvask_automation_service.php +++ b/services/nginx/app/classes/xlvask_automation_service.php @@ -6,7 +6,6 @@ require_once WD . '/classes/xlvask_usage_logs_schema_bootstrap.php'; use Exception; use helpers\xlvask_usage_log; -use objects\order_items_o; use objects\orders_o; use objects\xlvask_usage_logs_o; @@ -34,14 +33,31 @@ class xlvask_automation_service private const AUTO_CREATE_CONFIDENCE = 0.97; private const CREATE_MIN_AGE_HOURS = 6.0; private const OPENAI_CACHE_VERSION = 1; + public const POLICY_VERSION = 'xlvask-autopilot-v1'; + private const PLANNER_MODEL = 'gpt-5.6-sol'; + private ?int $runId = null; + private bool $readOnlyEvaluation = false; public function __construct() { - xlvask_usage_logs_schema_bootstrap::ensureTables(); + // Read construction must not perform DDL. Mutation entry points bootstrap explicitly. + } + + public function setRunContext(?int $runId): self + { + $this->runId = $runId !== null && $runId > 0 ? $runId : null; + return $this; + } + + public function setReadOnlyEvaluation(bool $readOnly): self + { + $this->readOnlyEvaluation = $readOnly; + return $this; } public function evaluateUsageLogById(int $usageLogId, ?int $actorId = null, bool $allowExecute = true): array { + xlvask_usage_logs_schema_bootstrap::ensureTables(); $row = $this->loadUsageLogRow($usageLogId); if ($row === null) { return $this->emptyAutomation('XL Vask-vasken blev ikke fundet.'); @@ -56,33 +72,80 @@ class xlvask_automation_service if ($usageLogId < 1) { return $this->emptyAutomation('XL Vask-vasken mangler et gyldigt id.'); } + if ((string)($row['import_state'] ?? '') === 'invalid') { + return [ + ...$this->emptyAutomation('XL Vask-kildedata kunne ikke valideres.'), + 'status' => self::STATUS_FAILED, + 'resolution_state' => 'failed', + ]; + } try { $log = $this->usageLogFromRow($row); - $guard = $this->guardReason($log); - $existing = $this->latestTerminalSuggestion($usageLogId); + $guard = $this->guardReason($log, false); 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); } + $linkedOrder = $this->existingLinkedOrder($log); + if ($linkedOrder !== null) { + if ((string)($row['import_state'] ?? '') === 'updated' + || (string)($row['planned_action'] ?? '') === 'recheck') { + return [ + ...$this->emptyAutomation('Den tilknyttede ordre afventer revalidering mod den ændrede XL Vask-kilde.'), + 'resolution_state' => 'needs_review', + 'certainty' => 'uncertain', + 'planned_action' => 'recheck', + 'risk_flags' => ['linked_order_revision_mismatch'], + 'matched_order_id' => (int)($linkedOrder->id ?? 0), + ]; + } + $linkedContext = $this->buildContext($usageLogId, $log, $row); + $linkedItems = (new orders_o())->getOrderItems((int)($linkedOrder->id ?? 0)); + $linkedOrderData = $linkedOrder->asArray(true, false); + if (!self::linkedOrderMatchesForAutomation( + (array)$linkedContext['proposed_order'], + (array)$linkedContext['items'], + $linkedOrderData, + $linkedItems + )) { + return [ + ...$this->emptyAutomation('Den tilknyttede ordre matcher ikke den aktuelle XL Vask-revision.'), + 'resolution_state' => 'needs_review', + 'certainty' => 'uncertain', + 'planned_action' => 'recheck', + 'risk_flags' => ['linked_order_revision_mismatch'], + 'matched_order_id' => (int)($linkedOrder->id ?? 0), + ]; + } + return [ + ...$this->emptyAutomation('XL Vask-vasken er allerede knyttet til en ordre.'), + 'status' => self::STATUS_ACCEPTED, + 'matched_order_id' => (int)($linkedOrder->id ?? 0), + 'resolution_state' => 'already_linked', + 'certainty' => 'certain', + 'evidence' => [[ + 'type' => 'existing_link_semantically_revalidated', + 'value' => true, + ]], + ]; + } + $existing = $this->latestTerminalSuggestion($usageLogId); if ($existing !== null) { if ((string)$existing['status'] === self::STATUS_SUGGESTED) { - $context = $this->buildContext($usageLogId, $log); + if (!$allowExecute) { + return $this->formatSuggestion($existing); + } + + $context = $this->buildContext($usageLogId, $log, $row); $contextGuard = $this->contextGuardReason($context); if ($contextGuard !== null) { return $this->emptyAutomation($contextGuard); } $freshSuggestion = $this->buildSuggestionForContext($context); - if ($freshSuggestion !== null) { + if ($freshSuggestion !== null && !$this->readOnlyEvaluation) { + $freshSuggestion = $this->decorateSuggestionWithEvidence($freshSuggestion, $context); $suggestionId = $this->persistSuggestion($context, $freshSuggestion, $actorId); $existing = $this->loadSuggestion($suggestionId) ?? $existing; } @@ -95,7 +158,7 @@ class xlvask_automation_service return $this->formatSuggestion($existing); } - $context = $this->buildContext($usageLogId, $log); + $context = $this->buildContext($usageLogId, $log, $row); $contextGuard = $this->contextGuardReason($context); if ($contextGuard !== null) { return $this->emptyAutomation($contextGuard); @@ -116,6 +179,10 @@ class xlvask_automation_service return $this->emptyAutomation('Tidligere afvist for samme køretøjsmønster.'); } + $suggestion = $this->decorateSuggestionWithEvidence($suggestion, $context); + if ($this->readOnlyEvaluation) { + return $this->formatTransientSuggestion($usageLogId, $suggestion); + } $suggestionId = $this->persistSuggestion($context, $suggestion, $actorId); $suggestionRow = $this->loadSuggestion($suggestionId); if ($suggestionRow === null) { @@ -138,6 +205,7 @@ class xlvask_automation_service public function acceptUsageLogById(int $usageLogId, ?int $actorId = null, ?int $suggestionId = null, ?string $reason = null): array { + xlvask_usage_logs_schema_bootstrap::ensureTables(); $row = $this->loadUsageLogRow($usageLogId); if ($row === null) { return $this->emptyAutomation('XL Vask-vasken blev ikke fundet.'); @@ -149,7 +217,7 @@ class xlvask_automation_service return $this->emptyAutomation($guard); } - $context = $this->buildContext($usageLogId, $log); + $context = $this->buildContext($usageLogId, $log, $row); $contextGuard = $this->contextGuardReason($context); if ($contextGuard !== null) { return $this->emptyAutomation($contextGuard); @@ -164,53 +232,290 @@ class xlvask_automation_service if ($suggestion === null) { return $this->emptyAutomation('Der er intet forslag at acceptere.'); } + if ((int)($suggestion['usage_log_id'] ?? 0) !== $usageLogId) { + return $this->emptyAutomation('Forslaget tilhører en anden XL Vask-vask.'); + } $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); + $this->updateUsageLogAutomationState($usageLogId, $result, null); + if ((string)($result['status'] ?? '') === self::STATUS_ACCEPTED) { + $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 { + xlvask_usage_logs_schema_bootstrap::ensureTables(); $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); + $context = $this->buildContext($usageLogId, $log, $row); $suggestion = $suggestionId !== null ? $this->loadSuggestion($suggestionId) : $this->latestActionableSuggestion($usageLogId); if ($suggestion === null) { return $this->emptyAutomation('Der er intet forslag at afvise.'); } + if ((int)($suggestion['usage_log_id'] ?? 0) !== $usageLogId) { + return $this->emptyAutomation('Forslaget tilhører en anden XL Vask-vask.'); + } $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); + $result = $this->formatSuggestion($this->loadSuggestion((int)$suggestion['id']) ?? $suggestion); + $this->updateUsageLogAutomationState($usageLogId, $result, null); + global $db; + $db->query("UPDATE xlvask_usage_logs SET expected_version = expected_version + 1 WHERE id = {$usageLogId}"); + return $result; } - public function runPending(?string $dateFrom = null, ?string $dateTo = null, array $ids = [], int $limit = 100, ?int $actorId = null): array + /** Apply a preview-bound decision while the caller owns the transaction and row locks. */ + public function applyBoundDecisionWithinTransaction( + int $usageLogId, + string $requestedAction, + ?int $suggestionId, + ?int $candidateOrderId, + int $expectedVersion, + string $sourceHash, + ?int $actorId, + ?string $reason = null + ): array { + global $db; + $rowResult = $db->query("SELECT * FROM xlvask_usage_logs WHERE id = {$usageLogId} FOR UPDATE"); + $row = $rowResult !== false && $rowResult->num_rows > 0 ? $db->fetch_assoc($rowResult) : null; + if ($row === null + || (int)($row['expected_version'] ?? 0) !== $expectedVersion + || !hash_equals((string)($row['source_hash'] ?? ''), $sourceHash)) { + throw new Exception('XL Vask-vasken blev ændret efter forhåndsvisningen.'); + } + + $suggestionResult = $suggestionId === null ? false : $db->query( + "SELECT * FROM xlvask_automation_suggestions WHERE id = {$suggestionId} FOR UPDATE" + ); + $suggestion = $suggestionResult !== false && $suggestionResult->num_rows > 0 + ? $db->fetch_assoc($suggestionResult) + : null; + if ($suggestion === null || (int)($suggestion['usage_log_id'] ?? 0) !== $usageLogId) { + throw new Exception('Forslaget mangler eller tilhører en anden XL Vask-vask.'); + } + if ((string)($suggestion['status'] ?? '') !== self::STATUS_SUGGESTED) { + throw new Exception('Forslaget er ikke længere aktivt.'); + } + $suggestedAction = (string)$suggestion['action']; + if ($requestedAction === 'create_order' && $suggestedAction !== self::ACTION_CREATE) { + throw new Exception('Forslaget er ikke en ordreoprettelse.'); + } + + $context = $this->buildContext($usageLogId, $this->usageLogFromRow($row), $row); + if ($requestedAction === self::ACTION_ATTACH) { + $candidate = $this->candidateFromContext($context, (int)$candidateOrderId); + if ($candidate === null) { + throw new Exception('Den valgte ordre er ikke længere en tilladt kandidat.'); + } + // Manual alternative selection is always uncertain and can never feed automatic certainty. + $suggestion['action'] = self::ACTION_ATTACH; + $suggestion['matched_order_id'] = (int)$candidate['id']; + $suggestion['candidate_order'] = $candidate; + $suggestion['candidate_order_json'] = json_encode($candidate, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $suggestion['certainty'] = 'uncertain'; + } + if ($requestedAction === 'deny') { + $this->updateSuggestionStatus((int)$suggestion['id'], self::STATUS_DENIED, $actorId); + $this->persistFeedback($context, $suggestedAction, 'denied', (int)($suggestion['matched_order_id'] ?? 0), $actorId, $reason); + $this->syncUsageState($usageLogId, 'needs_review', 'none', 'none', $reason ?? 'Forslaget blev afvist.'); + if ($db->query("UPDATE xlvask_usage_logs SET expected_version = expected_version + 1 WHERE id = {$usageLogId}") === false + || $db->conn()->affected_rows !== 1) { + throw new Exception('The XL Vask usage-log version could not be advanced atomically.'); + } + return $this->formatSuggestion($this->loadSuggestion((int)$suggestion['id']) ?? $suggestion); + } + + $latest = $this->executeSuggestionWithinTransaction($suggestion, $context, $actorId, false); + $executedAction = $requestedAction === 'accept' ? $suggestedAction : $requestedAction; + $this->persistFeedback( + $context, + $executedAction, + 'accepted', + (int)($latest['matched_order_id'] ?? $latest['created_order_id'] ?? 0), + $actorId, + $reason + ); + return $this->formatSuggestion($latest); + } + + /** Rebuild and validate a manual candidate against current server-side usage context. */ + public function validateManualCandidate(int $usageLogId, int $orderId): array { - $rows = $ids !== [] ? $this->loadUsageLogRowsByIds($ids) : $this->loadPendingRows($dateFrom, $dateTo, $limit); + $row = $this->loadUsageLogRow($usageLogId); + if ($row === null || $orderId < 1) { + throw new Exception('The selected XL Vask order candidate was not found.'); + } + $context = $this->buildContext($usageLogId, $this->usageLogFromRow($row), $row); + $candidate = $this->candidateFromContext($context, $orderId); + if ($candidate === null) { + throw new Exception('The selected order is not a current server-derived candidate.'); + } + return [ + 'id' => (int)$candidate['id'], + 'customer_id' => (int)($candidate['customer_id'] ?? 0), + 'department_id' => (int)($candidate['department_id'] ?? 0), + 'created_at' => $candidate['created_at'] ?? null, + ]; + } + + public function runPending( + ?string $dateFrom = null, + ?string $dateTo = null, + array $ids = [], + int $limit = 100, + ?int $actorId = null, + bool $allowExecute = true, + ?int $runId = null, + array $allowedHallIds = [], + bool $readOnlyEvaluation = false, + ?callable $heartbeat = null + ): array + { + xlvask_usage_logs_schema_bootstrap::ensureTables(); + if ($actorId !== null && $allowedHallIds === []) { + throw new Exception('No XL Vask hall scope is available for this user.'); + } + $limit = max(1, min(500, $limit)); + $rows = $ids !== [] + ? array_slice($this->loadUsageLogRowsByIds($ids, $allowedHallIds), 0, $limit) + : $this->loadPendingRows($dateFrom, $dateTo, $limit, $allowedHallIds); + $eligibleTotal = $ids !== [] ? count($rows) : $this->countPendingRows($dateFrom, $dateTo, $allowedHallIds); $results = []; + $autoLinks = 0; + $autoCreates = 0; + $attemptedActions = 0; + $actionFailures = 0; + $consecutiveFailures = 0; + $circuitBreaker = null; + $this->setReadOnlyEvaluation($readOnlyEvaluation); foreach ($rows as $row) { - $results[] = $this->evaluateUsageLogRow($row, $actorId, true); + if ($heartbeat !== null) { + $heartbeat(); + } + $executionAllowed = !$readOnlyEvaluation && $allowExecute && $autoLinks < 500 && $autoCreates < 100; + $result = $this->evaluateUsageLogRow($row, $actorId, $executionAllowed); + if (!$readOnlyEvaluation && (string)($row['resolution_state'] ?? '') !== 'ignored') { + $this->updateUsageLogAutomationState((int)($row['id'] ?? 0), $result, $runId); + } + $results[] = ['usage_log_id' => (int)($row['id'] ?? 0), ...$result]; + $status = (string)($result['status'] ?? self::STATUS_NONE); + $action = (string)($result['action'] ?? self::ACTION_NONE); + if ($status === self::STATUS_AUTO_ACCEPTED) { + $attemptedActions++; + $consecutiveFailures = 0; + if ($action === self::ACTION_CREATE) { + $autoCreates++; + } elseif ($action === self::ACTION_ATTACH) { + $autoLinks++; + } + } elseif ($status === self::STATUS_FAILED && $action !== self::ACTION_NONE) { + $attemptedActions++; + $actionFailures++; + $consecutiveFailures++; + $error = strtolower((string)($result['error'] ?? '')); + if (str_contains($error, 'allerede tilknyttet') + || str_contains($error, 'ændret efter evalueringen') + || str_contains($error, 'atomisk') + || str_contains($error, 'unik wash_id')) { + $circuitBreaker = 'critical_execution_invariant'; + } + } else { + $consecutiveFailures = 0; + } + + if ($circuitBreaker !== null + || $consecutiveFailures >= 3 + || ($attemptedActions >= 20 && ($actionFailures / $attemptedActions) > 0.02)) { + $circuitBreaker ??= $consecutiveFailures >= 3 ? 'three_consecutive_failures' : 'action_failure_rate'; + break; + } } return [ + 'selected' => count($rows), + 'eligible_total' => $eligibleTotal, 'processed' => count($results), 'results' => $results, + 'automatic_links' => $autoLinks, + 'automatic_creations' => $autoCreates, + 'circuit_breaker' => $circuitBreaker, ]; } + /** Read-only automation projection for list/detail GET endpoints. */ + public function readAutomationStateByUsageLogId(int $usageLogId, array $usageRow = []): array + { + try { + $suggestion = $this->latestSuggestionWhere($usageLogId, [ + self::STATUS_SUGGESTED, + self::STATUS_AUTO_ACCEPTED, + self::STATUS_ACCEPTED, + self::STATUS_DENIED, + self::STATUS_FAILED, + ]); + $result = $suggestion === null ? $this->emptyAutomation((string)($usageRow['state_reason'] ?? '')) : $this->formatSuggestion($suggestion); + } catch (\Throwable) { + $result = $this->emptyAutomation((string)($usageRow['state_reason'] ?? '')); + } + + return [ + ...$result, + 'usage_log_id' => $usageLogId, + 'import_state' => (string)($usageRow['import_state'] ?? 'unchanged'), + 'resolution_state' => (string)($usageRow['resolution_state'] ?? ($result['status'] === self::STATUS_FAILED ? 'failed' : 'needs_review')), + 'certainty' => (string)($usageRow['certainty'] ?? $result['certainty'] ?? 'none'), + 'planned_action' => (string)($usageRow['planned_action'] ?? $result['action'] ?? self::ACTION_NONE), + 'source_revision' => $usageRow['source_revision'] ?? null, + 'source_hash' => $usageRow['source_hash'] ?? null, + 'expected_version' => isset($usageRow['expected_version']) ? (int)$usageRow['expected_version'] : null, + 'run_id' => isset($usageRow['last_run_id']) ? (int)$usageRow['last_run_id'] : ($result['run_id'] ?? null), + ]; + } + + public static function classifyCertaintyForAutomation( + array $calibration, + bool $hardGuardsPass = true, + array $contradictions = [] + ): string { + if (!$hardGuardsPass || $contradictions !== []) { + return 'uncertain'; + } + + return (bool)($calibration['active'] ?? false) + && (float)($calibration['precision_value'] ?? 0) >= 0.995 + && (float)($calibration['wilson_lower_bound'] ?? 0) >= 0.98 + && (int)($calibration['overall_examples'] ?? 0) >= 200 + && (int)($calibration['segment_examples'] ?? 0) >= 30 + && (int)($calibration['holdout_examples'] ?? 0) > 0 + && (int)($calibration['contradictions'] ?? 0) === 0 + ? 'certain' + : 'uncertain'; + } + public static function normalizeRegistrationForAutomation(string $registration): string { return strtoupper(preg_replace('/[^A-Z0-9]/i', '', $registration) ?? ''); } + public static function sourceIsStableForAutomatic(array $source, ?int $now = null): bool + { + $stableSince = strtotime((string)($source['source_stable_since'] ?? '')); + return (int)($source['source_observation_count'] ?? 0) >= 2 + && !empty($source['source_observed_at']) + && $stableSince !== false + && $stableSince <= (($now ?? time()) - (int)(self::CREATE_MIN_AGE_HOURS * 3600)); + } + public static function itemSignaturePartsForAutomation(array $items): array { $parts = []; @@ -297,6 +602,39 @@ class xlvask_automation_service && self::itemsTotalForAutomation($usageItems) === self::itemsTotalForAutomation($orderItems); } + public static function linkedOrderMatchesForAutomation( + array $proposedOrder, + array $proposedItems, + array $linkedOrder, + array $linkedItems + ): bool { + if (!self::isExactItemMatchForAutomation($proposedItems, $linkedItems) + || (int)($proposedOrder['customer_id'] ?? 0) < 1 + || (int)($proposedOrder['customer_id'] ?? 0) !== (int)($linkedOrder['customer_id'] ?? 0) + || (int)($proposedOrder['department_id'] ?? 0) < 1 + || (int)($proposedOrder['department_id'] ?? 0) !== (int)($linkedOrder['department_id'] ?? 0)) { + return false; + } + $proposedRegistrations = array_values(array_unique(array_filter(array_map( + [self::class, 'normalizeRegistrationForAutomation'], + [(string)($proposedOrder['reg_1'] ?? ''), (string)($proposedOrder['reg_2'] ?? ''), (string)($proposedOrder['reg_3'] ?? '')] + )))); + $linkedRegistrations = array_values(array_unique(array_filter(array_map( + [self::class, 'normalizeRegistrationForAutomation'], + [(string)($linkedOrder['reg_1'] ?? ''), (string)($linkedOrder['reg_2'] ?? ''), (string)($linkedOrder['reg_3'] ?? '')] + )))); + if ($proposedRegistrations === [] || array_diff($proposedRegistrations, $linkedRegistrations) !== []) { + return false; + } + $proposedLane = (int)($proposedOrder['lane'] ?? 0); + if ($proposedLane > 0 && $proposedLane !== (int)($linkedOrder['lane'] ?? 0)) { + return false; + } + $proposedDate = substr((string)($proposedOrder['created_at'] ?? ''), 0, 10); + $linkedDate = substr((string)($linkedOrder['created_at'] ?? ''), 0, 10); + return $proposedDate === '' || $linkedDate === '' || $proposedDate === $linkedDate; + } + public static function productOverlapForAutomation(array $usageItems, array $orderItems): float { $usageBag = self::productBagForAutomation($usageItems); @@ -524,18 +862,32 @@ class xlvask_automation_service ], ], 'risk_flags' => ['type' => 'array', 'items' => ['type' => 'string']], + 'evidence' => ['type' => 'array', 'items' => ['type' => 'string']], + 'contradictions' => ['type' => 'array', 'items' => ['type' => 'string']], + 'plan_steps' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'step' => ['type' => 'string'], + 'reason' => ['type' => 'string'], + ], + 'required' => ['step', 'reason'], + 'additionalProperties' => false, + ], + ], + ], + 'required' => [ + 'action', 'confidence', 'reason_da', 'candidate_order_id', 'proposed_order_items', + 'risk_flags', 'evidence', 'contradictions', 'plan_steps', ], - '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'], + 'opaque_context_id' => hash('sha256', (string)$context['signature_hash']), 'lane' => $context['signature']['lane'], 'created_at' => $context['proposed_order']['created_at'] ?? null, 'total_net_amount' => $context['total'], @@ -554,10 +906,17 @@ class xlvask_automation_service $cacheKey = self::openAiCacheKeyForAutomation($schemaName, $prompt, $payload, $schema, $temperature); $result = $this->loadOpenAiCacheResult($cacheKey); if ($result === null) { + if ($this->readOnlyEvaluation) { + return null; + } $openai = new openai(); - $result = $openai->jsonTask($schemaName, $prompt, $payload, $schema, $temperature); - $this->persistOpenAiCacheResult($cacheKey, $schemaName, $payload, $schema, $prompt, $temperature, $result); + $result = $openai->jsonTask($schemaName, $prompt, $payload, $schema, $temperature, self::PLANNER_MODEL); + $result = $this->sanitizeOpenAiResult($result, $context); + if (!$this->readOnlyEvaluation) { + $this->persistOpenAiCacheResult($cacheKey, $schemaName, $payload, $schema, $prompt, $temperature, $result); + } } + $result = $this->sanitizeOpenAiResult($result, $context); $action = (string)($result['action'] ?? self::ACTION_NONE); $confidence = (float)($result['confidence'] ?? 0); @@ -592,18 +951,51 @@ class xlvask_automation_service 'candidate_order' => $candidate, 'proposed_order' => $context['proposed_order'], 'reason' => (string)($result['reason_da'] ?? 'OpenAI foreslår handlingen ud fra tilgængelige ordredata.'), + 'risk_flags' => array_values(array_filter(array_map('strval', (array)($result['risk_flags'] ?? [])))), + 'contradictions' => array_values(array_filter(array_map('strval', (array)($result['contradictions'] ?? [])))), + 'plan_steps' => array_values(array_filter((array)($result['plan_steps'] ?? []), 'is_array')), ]; } catch (Exception) { return null; } } + private function sanitizeOpenAiResult(array $result, array $context): array + { + $redactions = array_values(array_filter([ + (string)($context['wash_id'] ?? ''), + (string)($context['signature']['registration'] ?? ''), + (string)($context['signature']['customer_number'] ?? ''), + ], static fn(string $value): bool => strlen($value) >= 3)); + $reason = mb_substr((string)($result['reason_da'] ?? ''), 0, 1000); + foreach ($redactions as $redaction) { + $reason = str_ireplace($redaction, '[redacted]', $reason); + } + + return [ + 'action' => (string)($result['action'] ?? self::ACTION_NONE), + 'confidence' => min(1.0, max(0.0, (float)($result['confidence'] ?? 0))), + 'reason_da' => $reason, + 'candidate_order_id' => isset($result['candidate_order_id']) ? (int)$result['candidate_order_id'] : null, + 'proposed_order_items' => array_slice(array_values(array_filter((array)($result['proposed_order_items'] ?? []), 'is_array')), 0, 50), + 'risk_flags' => array_slice(array_values(array_filter(array_map('strval', (array)($result['risk_flags'] ?? [])))), 0, 20), + 'evidence' => array_slice(array_values(array_filter(array_map('strval', (array)($result['evidence'] ?? [])))), 0, 20), + 'contradictions' => array_slice(array_values(array_filter(array_map('strval', (array)($result['contradictions'] ?? [])))), 0, 20), + 'plan_steps' => array_slice(array_values(array_filter((array)($result['plan_steps'] ?? []), 'is_array')), 0, 20), + ]; + } + private function shouldAutoExecute(array $suggestion, array $context): bool { $confidence = (float)$suggestion['confidence']; $action = (string)$suggestion['action']; $xlvask = new xlvask(); + if ((string)($suggestion['certainty'] ?? '') !== 'certain' + || !$this->hardGuardsPassForCertainty($suggestion, $context)) { + return false; + } + if ($action === self::ACTION_ATTACH) { return $xlvask->config->automatic_order_attachment_enabled->isTrue() && $confidence >= self::AUTO_ATTACH_CONFIDENCE @@ -611,12 +1003,132 @@ class xlvask_automation_service } if ($action === self::ACTION_CREATE) { - return false; + return $xlvask->config->automatic_order_creation_enabled->isTrue() + && $confidence >= self::AUTO_CREATE_CONFIDENCE + && $context['age_hours'] >= self::CREATE_MIN_AGE_HOURS + && $context['candidate_orders'] === []; } return false; } + private function hardGuardsPassForCertainty(array $suggestion, array $context): bool + { + if ((string)($suggestion['source'] ?? '') === self::SOURCE_OPENAI + || !xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady() + || !self::sourceIsStableForAutomatic($context)) { + return false; + } + + $action = (string)($suggestion['action'] ?? ''); + if ($action === self::ACTION_ATTACH) { + return $this->isExactAttachSuggestionForContext($suggestion, $context); + } + if ($action === self::ACTION_CREATE) { + return (float)($context['age_hours'] ?? 0) >= self::CREATE_MIN_AGE_HOURS + && (array)($context['candidate_orders'] ?? []) === [] + && (array)($context['items'] ?? []) !== []; + } + + return false; + } + + private function decorateSuggestionWithEvidence(array $suggestion, array $context): array + { + $evidence = [[ + 'type' => 'source_revision', + 'value' => (string)($context['source_revision'] ?? ''), + ]]; + if ($this->isExactAttachSuggestionForContext($suggestion, $context)) { + $evidence[] = ['type' => 'exact_item_and_total_match', 'value' => true]; + } + if ((string)$suggestion['source'] === self::SOURCE_HISTORY) { + $evidence[] = ['type' => 'audited_history_pattern', 'value' => true]; + } + + $contradictions = []; + if ((string)$suggestion['action'] === self::ACTION_ATTACH) { + $sameConfidenceCandidates = array_filter( + $context['candidate_orders'], + fn(array $candidate): bool => $this->scoreOrderMatch($context['items'], $candidate['order_items'])['confidence'] + >= (float)$suggestion['confidence'] + ); + if (count($sameConfidenceCandidates) > 1) { + $contradictions[] = 'multiple_equally_credible_orders'; + } + } + if ((string)$suggestion['source'] === self::SOURCE_OPENAI) { + $contradictions = array_values(array_unique([ + ...$contradictions, + ...(array)($suggestion['contradictions'] ?? []), + ])); + } + + $segment = (string)$suggestion['source'] . ':' . (string)$suggestion['action']; + $calibration = $this->loadCalibration($segment); + $certainty = self::classifyCertaintyForAutomation( + $calibration ?? [], + $this->hardGuardsPassForCertainty($suggestion, $context), + $contradictions + ); + $riskFlags = (array)($suggestion['risk_flags'] ?? []); + if ($calibration === null) { + $riskFlags[] = 'calibration_artifact_unavailable'; + } + if ((string)$suggestion['source'] === self::SOURCE_OPENAI) { + $riskFlags[] = 'ai_advisory_only'; + } + + return [ + ...$suggestion, + 'certainty' => $certainty, + 'model' => (string)$suggestion['source'] === self::SOURCE_OPENAI ? self::PLANNER_MODEL : null, + 'model_confidence' => (string)$suggestion['source'] === self::SOURCE_OPENAI ? (float)$suggestion['confidence'] : null, + 'calibrated_probability' => $calibration === null ? null : (float)$calibration['calibrated_probability'], + 'evidence' => $evidence, + 'contradictions' => $contradictions, + 'risk_flags' => array_values(array_unique($riskFlags)), + 'plan_steps' => (array)($suggestion['plan_steps'] ?? [ + ['step' => 'revalidate_source', 'status' => 'planned'], + ['step' => (string)$suggestion['action'], 'status' => 'planned'], + ['step' => 'verify_result', 'status' => 'planned'], + ]), + ]; + } + + private function loadCalibration(string $segmentKey): ?array + { + global $db; + $segmentKey = $db->escape_string($segmentKey); + $policy = $db->escape_string(self::POLICY_VERSION); + $result = $db->query( + "SELECT * FROM xlvask_automation_calibrations + WHERE policy_version = '{$policy}' AND segment_key = '{$segmentKey}' AND active = 1 + ORDER BY id DESC LIMIT 1" + ); + if ($result === false || $result->num_rows < 1) { + return null; + } + $row = $db->fetch_assoc($result); + $artifact = json_decode((string)($row['backtest_json'] ?? ''), true); + if (!is_array($artifact) + || ($artifact['split_rule'] ?? null) !== 'chronological_80_20_by_suggestion_created_at_and_id' + || ($artifact['policy_version'] ?? null) !== self::POLICY_VERSION + || ($artifact['segment_key'] ?? null) !== $segmentKey + || !hash_equals( + (string)($row['artifact_hash'] ?? ''), + hash('sha256', self::stableJsonForAutomation($artifact)) + )) { + return null; + } + return [ + ...$artifact, + 'id' => (int)$row['id'], + 'active' => (bool)$row['active'], + 'artifact_hash' => (string)$row['artifact_hash'], + ]; + } + private function isExactAttachSuggestionForContext(array $suggestion, array $context): bool { if ((string)($suggestion['action'] ?? '') !== self::ACTION_ATTACH) { @@ -654,43 +1166,21 @@ class xlvask_automation_service private function executeSuggestion(array $suggestion, array $context, ?int $actorId, bool $automatic): array { + global $db; + $connection = $db->conn(); 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; + $connection->begin_transaction(); + $latest = $this->executeSuggestionWithinTransaction($suggestion, $context, $actorId, $automatic); if ($automatic) { - $this->persistFeedback($context, $action, 'accepted', (int)($latest['matched_order_id'] ?? $latest['created_order_id'] ?? 0), $actorId, $this->automaticFeedbackReason($suggestion, $context)); + $this->persistFeedback($context, (string)$suggestion['action'], 'accepted', (int)($latest['matched_order_id'] ?? $latest['created_order_id'] ?? 0), $actorId, $this->automaticFeedbackReason($suggestion, $context)); } - + $connection->commit(); return $this->formatSuggestion($latest); - } catch (Exception $e) { + } catch (\Throwable $e) { + try { + $connection->rollback(); + } catch (\Throwable) { + } $this->updateSuggestionFailure((int)$suggestion['id'], $e->getMessage(), $actorId); return [ ...$this->formatSuggestion($this->loadSuggestion((int)$suggestion['id']) ?? $suggestion), @@ -700,6 +1190,113 @@ class xlvask_automation_service } } + private function executeSuggestionWithinTransaction(array $suggestion, array $context, ?int $actorId, bool $automatic): array + { + global $db; + if (!xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady()) { + throw new Exception('Aktivering er blokeret, indtil unik wash_id-migrering er verificeret.'); + } + + $usageLogId = (int)$context['usage_log_id']; + $usageResult = $db->query("SELECT * FROM xlvask_usage_logs WHERE id = {$usageLogId} FOR UPDATE"); + $lockedUsage = $usageResult !== false && $usageResult->num_rows > 0 ? $db->fetch_assoc($usageResult) : null; + if ($lockedUsage === null) { + throw new Exception('XL Vask-vasken findes ikke længere.'); + } + if ((int)($lockedUsage['expected_version'] ?? 0) !== (int)($context['expected_version'] ?? 0) + || !hash_equals((string)($lockedUsage['source_hash'] ?? ''), (string)($context['source_hash'] ?? ''))) { + throw new Exception('XL Vask-kildedata blev ændret efter evalueringen.'); + } + if (!empty($lockedUsage['ignored_at']) || (int)($lockedUsage['FinishStatus'] ?? 0) !== 1) { + throw new Exception('XL Vask-vasken er ikke længere behandlingsklar.'); + } + if ($automatic && !self::sourceIsStableForAutomatic($lockedUsage)) { + throw new Exception('XL Vask-kildedata mangler to observationer eller stabilitetsvinduet.'); + } + + $washId = $db->escape_string((string)$context['wash_id']); + $duplicateResult = $db->query( + "SELECT id FROM orders WHERE LOWER(TRIM(wash_id)) = LOWER(TRIM('{$washId}')) FOR UPDATE" + ); + if ($duplicateResult !== false && $duplicateResult->num_rows > 0) { + throw new Exception('Vasken er allerede tilknyttet en ordre.'); + } + + $action = (string)$suggestion['action']; + $matchedOrderId = null; + $createdOrderId = null; + if ($action === self::ACTION_ATTACH) { + $orderId = (int)$suggestion['matched_order_id']; + $allowedCandidateIds = array_map(static fn(array $candidate): int => (int)$candidate['id'], $context['candidate_orders']); + if ($orderId < 1 || !in_array($orderId, $allowedCandidateIds, true)) { + throw new Exception('Ordren er ikke længere en tilladt kandidat.'); + } + $orderResult = $db->query("SELECT * FROM orders WHERE id = {$orderId} FOR UPDATE"); + $orderRow = $orderResult !== false && $orderResult->num_rows > 0 ? $db->fetch_assoc($orderResult) : null; + if ($orderRow === null + || !empty($orderRow['deleted_at']) + || trim((string)($orderRow['wash_id'] ?? '')) !== '' + || (int)($orderRow['invoice_collection_id'] ?? 0) > 0 + || (int)($orderRow['booking_id'] ?? 0) > 0 + || (int)$orderRow['customer_id'] !== (int)$context['proposed_order']['customer_id'] + || (int)$orderRow['department_id'] !== (int)$context['proposed_order']['department_id']) { + throw new Exception('Ordren er ændret eller økonomisk låst.'); + } + $orderItems = (new orders_o())->getOrderItems($orderId); + if ($automatic && !self::isExactItemMatchForAutomation($context['items'], $orderItems)) { + throw new Exception('Ordrelinjer eller beløb matcher ikke længere præcist.'); + } + if ($db->query("UPDATE orders SET wash_id = '{$washId}' WHERE id = {$orderId}") === false + || $db->conn()->affected_rows !== 1) { + throw new Exception('Ordretilknytningen kunne ikke gemmes entydigt.'); + } + $matchedOrderId = $orderId; + } elseif ($action === self::ACTION_CREATE) { + if ($context['age_hours'] < self::CREATE_MIN_AGE_HOURS + || empty($lockedUsage['source_stable_since']) + || strtotime((string)$lockedUsage['source_stable_since']) > strtotime('-' . self::CREATE_MIN_AGE_HOURS . ' hours') + || empty($lockedUsage['source_observed_at'])) { + throw new Exception('Vasken har ikke været stabil gennem observationsvinduet.'); + } + if ($context['candidate_orders'] !== [] || $this->itemsTotal($context['items']) !== (int)$context['total']) { + throw new Exception('Ordreoprettelsen kan ikke afstemmes sikkert.'); + } + $order = $this->createOrderFromContext($context); + $createdOrderId = (int)$order->id; + } else { + throw new Exception('Ukendt automatiseringshandling.'); + } + + $status = $automatic ? self::STATUS_AUTO_ACCEPTED : self::STATUS_ACCEPTED; + $this->updateSuggestionExecution( + (int)$suggestion['id'], + $status, + $actorId, + $matchedOrderId, + $createdOrderId, + $action, + (string)($suggestion['certainty'] ?? 'uncertain'), + isset($suggestion['candidate_order_json']) + ? (string)$suggestion['candidate_order_json'] + : json_encode($suggestion['candidate_order'] ?? null, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ); + $resolution = $action === self::ACTION_CREATE + ? ($automatic ? 'auto_created' : 'already_linked') + : ($automatic ? 'auto_linked' : 'already_linked'); + $this->syncUsageState($usageLogId, $resolution, (string)($suggestion['certainty'] ?? 'uncertain'), 'none', 'Ordrehandlingen blev verificeret og udført.'); + if ($db->query("UPDATE xlvask_usage_logs SET expected_version = expected_version + 1 WHERE id = {$usageLogId}") === false + || $db->conn()->affected_rows !== 1) { + throw new Exception('XL Vask-versionen kunne ikke opdateres.'); + } + $this->recordAudit($context, 'action_executed', $action, $suggestion, $actorId, [ + 'matched_order_id' => $matchedOrderId, + 'created_order_id' => $createdOrderId, + 'status' => $status, + ]); + + return $this->loadSuggestion((int)$suggestion['id']) ?? $suggestion; + } + private function automaticFeedbackReason(array $suggestion, array $context): string { if ($this->isExactAttachSuggestionForContext($suggestion, $context)) { @@ -711,51 +1308,104 @@ class xlvask_automation_service private function createOrderFromContext(array $context): orders_o { + global $db; $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'] ?? '') + $connection = $db->conn(); + $orderStatement = $connection->prepare( + 'INSERT INTO orders + (customer_id, cashier_id, reference, notes, department_id, reg_1, reg_2, reg_3, wash_id, lane, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' ); - $order->wash_id->set($context['wash_id']); - if (isset($orderData['lane'])) { - $order->lane->set((int)$orderData['lane']); + if ($orderStatement === false) { + throw new Exception('Kunne ikke forberede sikker ordreoprettelse.'); } - if (!empty($orderData['created_at'])) { - $order->created_at->set((string)$orderData['created_at']); + $customerId = (int)$orderData['customer_id']; + $cashierId = self::AUTOMATION_CASHIER_ID; + $reference = (string)($orderData['reference'] ?? ''); + $notes = (string)($orderData['notes'] ?? ''); + $departmentId = (int)$orderData['department_id']; + $reg1 = (string)($orderData['reg_1'] ?? ''); + $reg2 = (string)($orderData['reg_2'] ?? ''); + $reg3 = (string)($orderData['reg_3'] ?? ''); + $washId = (string)$context['wash_id']; + $lane = (int)($orderData['lane'] ?? 0); + $createdAt = (string)($orderData['created_at'] ?? date('Y-m-d H:i:s')); + $orderStatement->bind_param( + 'iississssis', + $customerId, + $cashierId, + $reference, + $notes, + $departmentId, + $reg1, + $reg2, + $reg3, + $washId, + $lane, + $createdAt + ); + if (!$orderStatement->execute() || $orderStatement->affected_rows !== 1) { + throw new Exception('Ordren kunne ikke oprettes atomisk.'); } + $orderId = (int)$connection->insert_id; + $orderStatement->close(); + $itemStatement = $connection->prepare( + 'INSERT INTO order_items + (order_id, product_id, reference, notes, cashier_id, price, quantity, related_item_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + ); + if ($itemStatement === false) { + throw new Exception('Kunne ikke forberede sikker ordrelinjeoprettelse.'); + } $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 + $productId = (int)$item['product_id']; + $itemReference = (string)($item['reference'] ?? ''); + $itemNotes = (string)($item['notes'] ?? ''); + $price = (int)$item['price']; + $quantity = (int)$item['quantity']; + $relatedItemId = $firstItemId; + $itemStatement->bind_param( + 'iissiiii', + $orderId, + $productId, + $itemReference, + $itemNotes, + $cashierId, + $price, + $quantity, + $relatedItemId ); + if (!$itemStatement->execute() || $itemStatement->affected_rows !== 1) { + throw new Exception('En ordrelinje kunne ikke oprettes atomisk.'); + } if ($firstItemId === null) { - $firstItemId = (int)$orderItem->id; + $firstItemId = (int)$connection->insert_id; } } + $itemStatement->close(); + $verificationResult = $db->query( + "SELECT COUNT(*) item_count, COALESCE(SUM(price * quantity), 0) item_total + FROM order_items WHERE order_id = {$orderId} AND deleted_at IS NULL" + ); + if ($verificationResult === false) { + throw new Exception('Ordren kunne ikke efterkontrolleres.'); + } + $verification = $db->fetch_assoc($verificationResult); + if ((int)($verification['item_count'] ?? 0) !== count($items) + || (int)($verification['item_total'] ?? 0) !== $this->itemsTotal($items)) { + throw new Exception('Den oprettede ordre kunne ikke afstemmes.'); + } + $order = (new orders_o())->select($orderId); + $order->assignToInvoiceCollection(null, false); $order->objectChanged(); return $order; } - private function buildContext(int $usageLogId, xlvask_usage_log $log): array + private function buildContext(int $usageLogId, xlvask_usage_log $log, array $row = []): array { $simulated = (new orders_o())->simulateOrderFromXLVask($log, true); $proposedOrder = $simulated['order'] ?? []; @@ -776,6 +1426,12 @@ class xlvask_automation_service 'signature' => $signature, 'signature_json' => $signatureJson, 'signature_hash' => hash('sha256', $signatureJson), + 'source_hash' => (string)($row['source_hash'] ?? ''), + 'source_revision' => (string)($row['source_revision'] ?? $log->Updated ?? ''), + 'source_observed_at' => $row['source_observed_at'] ?? null, + 'source_stable_since' => $row['source_stable_since'] ?? null, + 'source_observation_count' => (int)($row['source_observation_count'] ?? 0), + 'expected_version' => (int)($row['expected_version'] ?? 1), 'age_hours' => max(0.0, (time() - strtotime((string)$log->StartTime)) / 3600), 'candidate_orders' => $this->findSameDayCandidateOrders($log, $proposedOrder), ]; @@ -846,6 +1502,8 @@ class xlvask_automation_service AND cashier_id <> " . self::AUTOMATION_CASHIER_ID . " AND created_at BETWEEN '{$from}' AND '{$to}' AND (wash_id IS NULL OR wash_id = '') + AND COALESCE(invoice_collection_id, 0) = 0 + AND COALESCE(booking_id, 0) = 0 AND ( REPLACE(UPPER(reg_1), ' ', '') IN ('{$registration}', '{$rawRegistration}') OR REPLACE(UPPER(reg_2), ' ', '') IN ('{$registration}', '{$rawRegistration}') @@ -866,6 +1524,16 @@ class xlvask_automation_service }, $rows); } + private function candidateFromContext(array $context, int $orderId): ?array + { + foreach ((array)($context['candidate_orders'] ?? []) as $candidate) { + if (is_array($candidate) && (int)($candidate['id'] ?? 0) === $orderId) { + return $candidate; + } + } + return null; + } + private function findMatchingHistoricalOrder(array $context): ?array { global $db; @@ -908,7 +1576,12 @@ class xlvask_automation_service return null; } - private function guardReason(xlvask_usage_log $log): ?string + private function existingLinkedOrder(xlvask_usage_log $log): ?orders_o + { + return (new orders_o())->selectByWashId($log->WashId); + } + + private function guardReason(xlvask_usage_log $log, bool $checkExistingLink = true): ?string { if (!empty($log->ignored_at)) { return 'Vasken er ignoreret.'; @@ -922,7 +1595,7 @@ class xlvask_automation_service return 'Vasken mangler en fakturerbar kunde.'; } - if ((new orders_o())->selectByWashId($log->WashId) !== null) { + if ($checkExistingLink && $this->existingLinkedOrder($log) !== null) { return 'Vasken er allerede tilknyttet en ordre.'; } @@ -941,6 +1614,7 @@ class xlvask_automation_service $fields = [ 'usage_log_id' => (int)$context['usage_log_id'], + 'run_id' => $this->runId, 'wash_id' => (string)$context['wash_id'], 'signature_hash' => (string)$context['signature_hash'], 'signature_json' => (string)$context['signature_json'], @@ -948,6 +1622,17 @@ class xlvask_automation_service 'status' => self::STATUS_SUGGESTED, 'confidence' => (float)$suggestion['confidence'], 'source' => (string)$suggestion['source'], + 'policy_version' => self::POLICY_VERSION, + 'model' => $suggestion['model'] ?? null, + 'model_confidence' => $suggestion['model_confidence'] ?? null, + 'calibrated_probability' => $suggestion['calibrated_probability'] ?? null, + 'certainty' => (string)($suggestion['certainty'] ?? 'uncertain'), + 'evidence_json' => json_encode($suggestion['evidence'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'contradictions_json' => json_encode($suggestion['contradictions'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'risk_flags_json' => json_encode($suggestion['risk_flags'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'plan_steps_json' => json_encode($suggestion['plan_steps'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'expected_version' => (int)($context['expected_version'] ?? 1), + 'input_hash' => (string)($context['source_hash'] ?? $context['signature_hash']), '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), @@ -970,7 +1655,15 @@ class xlvask_automation_service } $db->query('INSERT INTO xlvask_automation_suggestions (' . implode(', ', $columns) . ') VALUES (' . implode(', ', $values) . ')'); - return (int)$db->insert_id(); + $suggestionId = (int)$db->insert_id(); + $this->syncUsageState( + (int)$context['usage_log_id'], + 'needs_review', + (string)($suggestion['certainty'] ?? 'uncertain'), + (string)$suggestion['action'], + (string)$suggestion['reason'] + ); + return $suggestionId; } private function updateSuggestionProposal(int $suggestionId, array $context, array $suggestion, ?int $actorId): void @@ -978,11 +1671,23 @@ class xlvask_automation_service global $db; $fields = [ + 'run_id' => $this->runId, 'signature_hash' => (string)$context['signature_hash'], 'signature_json' => (string)$context['signature_json'], 'action' => (string)$suggestion['action'], 'confidence' => (float)$suggestion['confidence'], 'source' => (string)$suggestion['source'], + 'policy_version' => self::POLICY_VERSION, + 'model' => $suggestion['model'] ?? null, + 'model_confidence' => $suggestion['model_confidence'] ?? null, + 'calibrated_probability' => $suggestion['calibrated_probability'] ?? null, + 'certainty' => (string)($suggestion['certainty'] ?? 'uncertain'), + 'evidence_json' => json_encode($suggestion['evidence'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'contradictions_json' => json_encode($suggestion['contradictions'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'risk_flags_json' => json_encode($suggestion['risk_flags'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'plan_steps_json' => json_encode($suggestion['plan_steps'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'expected_version' => (int)($context['expected_version'] ?? 1), + 'input_hash' => (string)($context['source_hash'] ?? $context['signature_hash']), '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), @@ -1010,6 +1715,13 @@ class xlvask_automation_service 'UPDATE xlvask_automation_suggestions SET ' . implode(', ', $assignments) . " WHERE id = {$suggestionId} AND status = '" . self::STATUS_SUGGESTED . "'" ); + $this->syncUsageState( + (int)$context['usage_log_id'], + 'needs_review', + (string)($suggestion['certainty'] ?? 'uncertain'), + (string)$suggestion['action'], + (string)$suggestion['reason'] + ); } private function persistFeedback(array $context, string $action, string $decision, int $orderId = 0, ?int $actorId = null, ?string $reason = null): void @@ -1064,11 +1776,13 @@ class xlvask_automation_service return null; } - $db->query( - "UPDATE xlvask_automation_openai_cache - SET hits = hits + 1, last_hit_at = NOW() - WHERE cache_key = '{$cacheKey}'" - ); + if (!$this->readOnlyEvaluation) { + $db->query( + "UPDATE xlvask_automation_openai_cache + SET hits = hits + 1, last_hit_at = NOW() + WHERE cache_key = '{$cacheKey}'" + ); + } return $decoded; } @@ -1093,7 +1807,12 @@ class xlvask_automation_service 'temperature' => round($temperature, 4), ]; - $inputJson = self::stableJsonForAutomation($input); + // Do not retain the personal operational prompt payload in the cache. + $inputJson = self::stableJsonForAutomation([ + 'version' => self::OPENAI_CACHE_VERSION, + 'schema_name' => $schemaName, + 'input_hash' => hash('sha256', self::stableJsonForAutomation($input)), + ]); $resultJson = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION); if ($resultJson === false) { return; @@ -1114,6 +1833,7 @@ class xlvask_automation_service input_json = VALUES(input_json), updated_at = NOW()" ); + $db->query("DELETE FROM xlvask_automation_openai_cache WHERE updated_at < DATE_SUB(NOW(), INTERVAL 30 DAY)"); } private function hasAcceptedFeedback(string $signatureHash, string $action): bool @@ -1186,30 +1906,51 @@ class xlvask_automation_service global $db; $status = $db->escape_string($status); $actorSql = $actorId === null ? 'NULL' : (string)(int)$actorId; - $db->query( + if ($db->query( "UPDATE xlvask_automation_suggestions SET status = '{$status}', decided_by = {$actorSql}, decided_at = NOW() WHERE id = {$suggestionId}" - ); + ) === false || $db->conn()->affected_rows !== 1) { + throw new Exception('The XL Vask suggestion execution state could not be updated atomically.'); + } } - private function updateSuggestionExecution(int $suggestionId, string $status, ?int $actorId, ?int $matchedOrderId, ?int $createdOrderId): void + private function updateSuggestionExecution( + int $suggestionId, + string $status, + ?int $actorId, + ?int $matchedOrderId, + ?int $createdOrderId, + string $action, + string $certainty, + string|false|null $candidateOrderJson + ): void { global $db; $status = $db->escape_string($status); + $action = $db->escape_string($action); + $certainty = $db->escape_string($certainty); $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( + $candidateSql = $candidateOrderJson === false || $candidateOrderJson === null + ? 'candidate_order_json' + : "'" . $db->escape_string($candidateOrderJson) . "'"; + if ($db->query( "UPDATE xlvask_automation_suggestions SET status = '{$status}', decided_by = {$actorSql}, decided_at = NOW(), executed_at = NOW(), + action = '{$action}', + certainty = '{$certainty}', matched_order_id = {$matchedSql}, - created_order_id = {$createdSql} + created_order_id = {$createdSql}, + candidate_order_json = {$candidateSql} WHERE id = {$suggestionId}" - ); + ) === false || $db->conn()->affected_rows !== 1) { + throw new Exception('The XL Vask suggestion execution state could not be updated atomically.'); + } } private function updateSuggestionFailure(int $suggestionId, string $message, ?int $actorId): void @@ -1225,6 +1966,77 @@ class xlvask_automation_service decided_at = NOW() WHERE id = {$suggestionId}" ); + $suggestion = $this->loadSuggestion($suggestionId); + if ($suggestion !== null) { + $this->syncUsageState((int)$suggestion['usage_log_id'], 'failed', 'none', 'none', $message); + } + } + + private function syncUsageState( + int $usageLogId, + string $resolutionState, + string $certainty, + string $plannedAction, + string $reason + ): void { + global $db; + $resolutionState = $db->escape_string($resolutionState); + $certainty = $db->escape_string($certainty); + $plannedAction = $db->escape_string($plannedAction); + $reason = $db->escape_string($reason); + $runSql = $this->runId === null ? 'last_run_id' : (string)$this->runId; + if ($db->query( + "UPDATE xlvask_usage_logs SET resolution_state = '{$resolutionState}', certainty = '{$certainty}', + planned_action = '{$plannedAction}', state_reason = '{$reason}', last_run_id = {$runSql}, + last_evaluated_at = NOW() WHERE id = {$usageLogId}" + ) === false) { + throw new Exception('The XL Vask usage-log state could not be updated atomically.'); + } + } + + private function recordAudit( + array $context, + string $eventType, + ?string $action, + array $before, + ?int $actorId, + array $after = [] + ): void { + global $db; + $runSql = $this->runId === null ? 'NULL' : (string)$this->runId; + $actorSql = $actorId === null ? 'NULL' : (string)$actorId; + $safeBefore = $this->minimalAuditSuggestion($before); + $safeAfter = $this->minimalAuditSuggestion($after); + $beforeJson = $db->escape_string(json_encode($safeBefore, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'); + $afterJson = $db->escape_string(json_encode($safeAfter, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'); + $evidenceJson = $db->escape_string(json_encode($safeBefore['evidence_codes'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]'); + if ($db->query( + "INSERT INTO xlvask_automation_audit + (run_id, usage_log_id, wash_id, event_type, action, policy_version, input_hash, + source_revision, expected_version, before_json, after_json, evidence_json, actor_id) + VALUES ({$runSql}, " . (int)$context['usage_log_id'] . ", '" . $db->escape_string((string)$context['wash_id']) . "', + '" . $db->escape_string($eventType) . "', " . ($action === null ? 'NULL' : "'" . $db->escape_string($action) . "'") . ", + '" . self::POLICY_VERSION . "', '" . $db->escape_string((string)($context['source_hash'] ?? $context['signature_hash'])) . "', + '" . $db->escape_string((string)($context['source_revision'] ?? '')) . "', " . (int)($context['expected_version'] ?? 1) . ", + '{$beforeJson}', '{$afterJson}', '{$evidenceJson}', {$actorSql})" + ) === false || $db->conn()->affected_rows !== 1) { + throw new Exception('The XL Vask automation audit record could not be stored atomically.'); + } + } + + private function minimalAuditSuggestion(array $value): array + { + $minimal = array_intersect_key($value, array_flip([ + 'id', 'status', 'action', 'certainty', 'source', 'matched_order_id', 'created_order_id', + 'policy_version', 'input_hash', 'expected_version', + ])); + $minimal['evidence_codes'] = array_values(array_filter(array_map( + static fn(mixed $evidence): string => is_array($evidence) + ? mb_substr((string)($evidence['type'] ?? ''), 0, 64) + : mb_substr((string)$evidence, 0, 64), + (array)($value['evidence'] ?? []) + ))); + return $minimal; } private function loadUsageLogRow(int $usageLogId): ?array @@ -1239,7 +2051,7 @@ class xlvask_automation_service return $db->fetch_assoc($result); } - private function loadUsageLogRowsByIds(array $ids): array + private function loadUsageLogRowsByIds(array $ids, array $allowedHallIds = []): array { global $db; $ids = array_values(array_filter(array_map('intval', $ids), fn(int $id): bool => $id > 0)); @@ -1248,33 +2060,136 @@ class xlvask_automation_service } (new xlvask_usage_logs_o())->structure(); - $result = $db->query('SELECT * FROM xlvask_usage_logs WHERE id IN (' . implode(',', $ids) . ')'); + $hallSql = $this->hallScopeSql($allowedHallIds); + $result = $db->query('SELECT * FROM xlvask_usage_logs WHERE id IN (' . implode(',', $ids) . ')' . $hallSql); return $db->fetch_all($result); } - private function loadPendingRows(?string $dateFrom, ?string $dateTo, int $limit): array + private function updateUsageLogAutomationState(int $usageLogId, array $automation, ?int $runId): void + { + global $db; + if ($usageLogId < 1) { + return; + } + + $state = $this->stateForAutomationResult($automation); + $reason = $db->escape_string((string)($automation['reason'] ?? $automation['error'] ?? '')); + $runSql = $runId === null ? 'last_run_id' : (string)(int)$runId; + $db->query( + "UPDATE xlvask_usage_logs SET + resolution_state = '" . $db->escape_string($state['resolution_state']) . "', + certainty = '" . $db->escape_string($state['certainty']) . "', + planned_action = '" . $db->escape_string($state['planned_action']) . "', + state_reason = " . ($reason === '' ? 'NULL' : "'{$reason}'") . ", + last_run_id = {$runSql}, + last_evaluated_at = NOW() + WHERE id = {$usageLogId}" + ); + } + + private function stateForAutomationResult(array $automation): array + { + $status = (string)($automation['status'] ?? self::STATUS_NONE); + $action = (string)($automation['action'] ?? self::ACTION_NONE); + $validatedCertainty = (string)($automation['certainty'] ?? 'none'); + if (!in_array($validatedCertainty, ['certain', 'uncertain', 'none'], true)) { + $validatedCertainty = 'none'; + } + if ((string)($automation['resolution_state'] ?? '') === 'needs_review') { + return ['resolution_state' => 'needs_review', 'certainty' => 'uncertain', 'planned_action' => 'recheck']; + } + + if ($status === self::STATUS_AUTO_ACCEPTED) { + return [ + 'resolution_state' => $action === self::ACTION_CREATE ? 'auto_created' : 'auto_linked', + 'certainty' => 'certain', + 'planned_action' => $action, + ]; + } + if ($status === self::STATUS_ACCEPTED) { + return ['resolution_state' => 'already_linked', 'certainty' => $validatedCertainty, 'planned_action' => $action]; + } + if ($status === self::STATUS_DENIED) { + return ['resolution_state' => 'needs_review', 'certainty' => 'uncertain', 'planned_action' => 'recheck']; + } + if ($status === self::STATUS_FAILED) { + return ['resolution_state' => 'failed', 'certainty' => 'none', 'planned_action' => 'recheck']; + } + if ($status === self::STATUS_SUGGESTED) { + return [ + 'resolution_state' => 'needs_review', + 'certainty' => $validatedCertainty === 'certain' ? 'certain' : 'uncertain', + 'planned_action' => in_array($action, [self::ACTION_ATTACH, self::ACTION_CREATE], true) ? $action : 'none', + ]; + } + + return ['resolution_state' => 'needs_review', 'certainty' => 'none', 'planned_action' => 'none']; + } + + private function loadPendingRows(?string $dateFrom, ?string $dateTo, int $limit, array $allowedHallIds = []): 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 = $this->pendingWhere($dateFrom, $dateTo, $allowedHallIds); + $limit = max(1, min(500, $limit)); + $result = $db->query('SELECT * FROM xlvask_usage_logs WHERE ' . implode(' AND ', $where) + . " ORDER BY COALESCE(last_evaluated_at, '1970-01-01 00:00:00') ASC, id ASC LIMIT {$limit}"); + return $db->fetch_all($result); + } + + private function countPendingRows(?string $dateFrom, ?string $dateTo, array $allowedHallIds): int + { + global $db; + $row = $db->fetch_assoc($db->query( + 'SELECT COUNT(*) total FROM xlvask_usage_logs WHERE ' + . implode(' AND ', $this->pendingWhere($dateFrom, $dateTo, $allowedHallIds)) + )); + return (int)($row['total'] ?? 0); + } + + private function pendingWhere(?string $dateFrom, ?string $dateTo, array $allowedHallIds): array + { + global $db; + $start = "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 = "")', + "resolution_state NOT IN ('already_linked', 'auto_linked', 'auto_created', 'ignored')", ]; - - 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'))) . "'"; + $allowedHallIds = $this->normalizeHallIds($allowedHallIds); + if ($allowedHallIds !== []) { + $where[] = 'HallId IN (' . $this->quotedHallIds($allowedHallIds) . ')'; } - + $where[] = $dateFrom !== null && strtotime($dateFrom) !== false + ? "{$start} >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'" + : "{$start} >= '" . $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))) . "'"; + $where[] = "{$start} <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'"; } + return $where; + } - $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 hallScopeSql(array $allowedHallIds): string + { + $allowedHallIds = $this->normalizeHallIds($allowedHallIds); + return $allowedHallIds === [] ? '' : ' AND HallId IN (' . $this->quotedHallIds($allowedHallIds) . ')'; + } + + private function normalizeHallIds(array $hallIds): array + { + return array_values(array_unique(array_filter(array_map( + static fn(mixed $id): string => trim((string)$id), + $hallIds + ), static fn(string $id): bool => $id !== '' && strlen($id) <= 191))); + } + + private function quotedHallIds(array $hallIds): string + { + global $db; + return implode(',', array_map( + static fn(string $id): string => "'" . $db->escape_string($id) . "'", + $hallIds + )); } private function usageLogFromRow(array $row): xlvask_usage_log @@ -1294,6 +2209,19 @@ class xlvask_automation_service 'action' => (string)($row['action'] ?? self::ACTION_NONE), 'confidence' => isset($row['confidence']) ? (float)$row['confidence'] : 0.0, 'source' => (string)($row['source'] ?? ''), + 'certainty' => (string)($row['certainty'] ?? 'uncertain'), + 'calibrated_probability' => isset($row['calibrated_probability']) && $row['calibrated_probability'] !== null + ? (float)$row['calibrated_probability'] : null, + 'model' => $row['model'] ?? null, + 'model_confidence' => isset($row['model_confidence']) && $row['model_confidence'] !== null + ? (float)$row['model_confidence'] : null, + 'policy_version' => (string)($row['policy_version'] ?? self::POLICY_VERSION), + 'evidence' => $this->decodeJsonField($row['evidence_json'] ?? null) ?? [], + 'contradictions' => $this->decodeJsonField($row['contradictions_json'] ?? null) ?? [], + 'risk_flags' => $this->decodeJsonField($row['risk_flags_json'] ?? null) ?? [], + 'plan_steps' => $this->decodeJsonField($row['plan_steps_json'] ?? null) ?? [], + 'expected_version' => isset($row['expected_version']) ? (int)$row['expected_version'] : null, + 'run_id' => isset($row['run_id']) && $row['run_id'] !== null ? (int)$row['run_id'] : null, '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, @@ -1304,6 +2232,36 @@ class xlvask_automation_service ]; } + private function formatTransientSuggestion(int $usageLogId, array $suggestion): array + { + return [ + 'usage_log_id' => $usageLogId, + 'id' => null, + 'status' => self::STATUS_SUGGESTED, + 'action' => (string)($suggestion['action'] ?? self::ACTION_NONE), + 'confidence' => (float)($suggestion['confidence'] ?? 0), + 'source' => (string)($suggestion['source'] ?? ''), + 'certainty' => (string)($suggestion['certainty'] ?? 'uncertain'), + 'calibrated_probability' => $suggestion['calibrated_probability'] ?? null, + 'model' => $suggestion['model'] ?? null, + 'model_confidence' => $suggestion['model_confidence'] ?? null, + 'policy_version' => self::POLICY_VERSION, + 'reason' => (string)($suggestion['reason'] ?? ''), + 'matched_order_id' => $suggestion['matched_order_id'] ?? null, + 'created_order_id' => null, + 'candidate_order' => $suggestion['candidate_order'] ?? null, + 'proposed_order' => $suggestion['proposed_order'] ?? null, + 'evidence' => $suggestion['evidence'] ?? [], + 'contradictions' => $suggestion['contradictions'] ?? [], + 'risk_flags' => $suggestion['risk_flags'] ?? [], + 'plan_steps' => $suggestion['plan_steps'] ?? [], + 'expected_version' => $suggestion['expected_version'] ?? null, + 'run_id' => $this->runId, + 'can_accept' => false, + 'can_deny' => false, + ]; + } + private function emptyAutomation(string $reason = ''): array { return [ @@ -1312,6 +2270,17 @@ class xlvask_automation_service 'action' => self::ACTION_NONE, 'confidence' => 0.0, 'source' => '', + 'certainty' => 'none', + 'calibrated_probability' => null, + 'model' => null, + 'model_confidence' => null, + 'policy_version' => self::POLICY_VERSION, + 'evidence' => [], + 'contradictions' => [], + 'risk_flags' => [], + 'plan_steps' => [], + 'expected_version' => null, + 'run_id' => null, 'reason' => $reason, 'matched_order_id' => null, 'created_order_id' => null, diff --git a/services/nginx/app/classes/xlvask_autopilot_service.php b/services/nginx/app/classes/xlvask_autopilot_service.php new file mode 100644 index 00000000..0eeb36c7 --- /dev/null +++ b/services/nginx/app/classes/xlvask_autopilot_service.php @@ -0,0 +1,924 @@ + ['import' => true, 'persist_plans' => true, 'execute_actions' => true, 'run_artifacts' => true], + 'dry_run' => ['import' => true, 'persist_plans' => true, 'execute_actions' => false, 'run_artifacts' => true], + 'replay' => ['import' => false, 'persist_plans' => false, 'execute_actions' => false, 'run_artifacts' => true], + default => throw new Exception('Invalid XL Vask autopilot mode.'), + }; + } + + public static function normalizeHallScope(array $hallIds): array + { + $normalized = array_values(array_unique(array_filter(array_map( + static fn(mixed $id): string => trim((string)$id), + $hallIds + ), static fn(string $id): bool => $id !== '' && strlen($id) <= 191))); + sort($normalized, SORT_STRING); + return $normalized; + } + + public static function idempotencyKey(string $providedKey, ?int $actorId, string $nonce): string + { + $material = trim($providedKey) !== '' ? trim($providedKey) : $nonce; + return hash('sha256', self::POLICY_VERSION . ':' . $material . ':' . (string)$actorId); + } + + public static function requestFingerprint(array $request): string + { + return hash('sha256', xlvask_automation_service::stableJsonForAutomation($request)); + } + + public static function previewSnapshotMatches(int $expectedVersion, string $sourceHash, array $current): bool + { + return (int)($current['expected_version'] ?? 0) === $expectedVersion + && $sourceHash !== '' + && hash_equals($sourceHash, (string)($current['source_hash'] ?? '')); + } + + public function createRun(array $input, ?int $actorId = null, array $allowedHallIds = []): array + { + xlvask_usage_logs_schema_bootstrap::ensureTables(); + global $db; + + $mode = strtolower(trim((string)($input['mode'] ?? 'execute'))); + self::modeCapabilities($mode); + $dateFrom = $this->normalizeDate($input['dateFrom'] ?? null, 'dateFrom'); + $dateTo = $this->normalizeDate($input['dateTo'] ?? null, 'dateTo'); + if ($dateFrom !== null && $dateTo !== null && $dateFrom > $dateTo) { + throw new Exception('dateFrom must not be after dateTo.'); + } + $ids = array_values(array_unique(array_filter( + array_map('intval', is_array($input['ids'] ?? null) ? $input['ids'] : []), + static fn(int $id): bool => $id > 0 + ))); + if (count($ids) > 500) { + throw new Exception('At most 500 XL Vask usage logs can be included in one run.'); + } + sort($ids, SORT_NUMERIC); + $requestedLimit = max(1, min(500, (int)($input['limit'] ?? 500))); + if ($dateFrom !== null && $dateTo !== null) { + $rangeDays = (int)floor((strtotime($dateTo) - strtotime($dateFrom)) / 86400) + 1; + $maxRange = $mode === 'replay' ? 366 : 90; + if ($rangeDays > $maxRange) { + throw new Exception("XL Vask {$mode} range cannot exceed {$maxRange} days."); + } + } + $forceRefetch = filter_var($input['forceRefetch'] ?? false, FILTER_VALIDATE_BOOL); + $allowedHallIds = $this->normalizeHallIds($allowedHallIds); + if ($allowedHallIds === []) { + throw new Exception('No XL Vask hall scope is available for this user.'); + } + // An explicit retry key is stable; otherwise every requested rerun is a new durable run. + $providedKey = trim((string)($input['idempotency_key'] ?? '')); + $key = self::idempotencyKey($providedKey, $actorId, $this->uuidV4()); + $requestHash = self::requestFingerprint([ + 'mode' => $mode, + 'date_from' => $dateFrom, + 'date_to' => $dateTo, + 'ids' => $ids, + 'limit' => $requestedLimit, + 'force_refetch' => $forceRefetch, + 'scope_hall_ids' => $allowedHallIds, + ]); + $idsJson = $db->escape_string(json_encode($ids, JSON_UNESCAPED_SLASHES) ?: '[]'); + $scopeJson = $db->escape_string(json_encode($allowedHallIds, JSON_UNESCAPED_SLASHES) ?: '[]'); + $dateFromSql = $dateFrom === null ? 'NULL' : "'" . $db->escape_string($dateFrom) . "'"; + $dateToSql = $dateTo === null ? 'NULL' : "'" . $db->escape_string($dateTo) . "'"; + $actorSql = $actorId === null ? 'NULL' : (string)$actorId; + $keySql = $db->escape_string($key); + $modeSql = $db->escape_string($mode); + + $db->query( + "INSERT INTO xlvask_autopilot_runs + (idempotency_key, request_hash, mode, date_from, date_to, force_refetch, requested_ids_json, + requested_limit, scope_hall_ids_json, created_by) + VALUES ('{$keySql}', '{$requestHash}', '{$modeSql}', {$dateFromSql}, {$dateToSql}, " . ($forceRefetch ? '1' : '0') . ", + '{$idsJson}', {$requestedLimit}, '{$scopeJson}', {$actorSql}) + ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)" + ); + $runId = (int)$db->insert_id(); + $existing = $db->fetch_assoc($db->query("SELECT request_hash FROM xlvask_autopilot_runs WHERE id = {$runId} LIMIT 1")); + if (!hash_equals($requestHash, (string)($existing['request_hash'] ?? ''))) { + throw new Exception('XL Vask idempotency key was already used with a different request payload.'); + } + + return $this->getRun($runId, $actorId, $allowedHallIds); + } + + public function processQueuedRuns(int $limit = 3): array + { + global $db; + xlvask_usage_logs_schema_bootstrap::ensureTables(); + $limit = max(1, min(10, $limit)); + $db->query( + "UPDATE xlvask_autopilot_runs + SET status = 'failed', phase = 'failed', error = 'XL Vask autopilot retry budget exhausted.', + lease_token = NULL, lease_expires_at = NULL, finished_at = NOW() + WHERE status IN ('queued', 'running') AND attempt_count >= max_attempts + AND (status = 'queued' OR lease_expires_at < NOW())" + ); + $rows = $db->fetch_all($db->query( + "SELECT id FROM xlvask_autopilot_runs + WHERE attempt_count < max_attempts AND ( + (status = 'queued' AND (next_attempt_at IS NULL OR next_attempt_at <= NOW())) + OR (status = 'running' AND lease_expires_at < NOW()) + ) + ORDER BY id ASC LIMIT {$limit}" + )); + $runs = []; + foreach ($rows as $row) { + $runs[] = $this->processRun((int)$row['id']); + } + return $runs; + } + + public function processRun(int $runId): array + { + global $db; + xlvask_usage_logs_schema_bootstrap::ensureTables(); + $connection = $db->conn(); + $connection->begin_transaction(); + try { + $result = $db->query("SELECT * FROM xlvask_autopilot_runs WHERE id = {$runId} FOR UPDATE"); + if ($result === false || $result->num_rows < 1) { + throw new Exception('XL Vask autopilot run not found.'); + } + $run = $db->fetch_assoc($result); + $leaseExpired = $run['lease_expires_at'] !== null && strtotime((string)$run['lease_expires_at']) < time(); + if (in_array((string)$run['status'], ['completed', 'completed_with_warnings', 'failed'], true) + || ((string)$run['status'] === 'running' && !$leaseExpired)) { + $connection->commit(); + return $this->formatRun($run); + } + $leaseToken = $this->uuidV4(); + $db->query( + "UPDATE xlvask_autopilot_runs SET status = 'running', phase = 'importing', + lease_token = '{$leaseToken}', lease_expires_at = DATE_ADD(NOW(), INTERVAL 15 MINUTE), + attempt_count = attempt_count + 1, next_attempt_at = NULL, + started_at = COALESCE(started_at, NOW()) WHERE id = {$runId}" + ); + $run['attempt_count'] = (int)($run['attempt_count'] ?? 0) + 1; + $connection->commit(); + } catch (Throwable $throwable) { + $connection->rollback(); + throw $throwable; + } + + try { + $dateFrom = $run['date_from'] ?: null; + $dateTo = $run['date_to'] ?: null; + $ids = json_decode((string)($run['requested_ids_json'] ?? '[]'), true); + $ids = is_array($ids) ? array_values(array_filter(array_map('intval', $ids))) : []; + $scopeHallIds = json_decode((string)($run['scope_hall_ids_json'] ?? '[]'), true); + $scopeHallIds = is_array($scopeHallIds) ? $this->normalizeHallIds($scopeHallIds) : []; + $importSummary = ['fetched' => 0, 'upstream_fetched' => 0, 'new' => 0, 'updated' => 0, 'unchanged' => 0, 'invalid' => 0, 'errors' => []]; + $capabilities = self::modeCapabilities((string)$run['mode']); + $isReplay = !$capabilities['persist_plans']; + if ($capabilities['import'] && ($ids === [] || (int)$run['force_refetch'] === 1)) { + $importSummary = (new xlvask_usage_logs_o())->importUsageLogsWithSummary($dateFrom, $dateTo, $scopeHallIds); + } + + $leaseTokenSql = $db->escape_string($leaseToken); + $renewLease = static function () use ($db, $runId, $leaseTokenSql): void { + if ($db->query( + "UPDATE xlvask_autopilot_runs + SET lease_expires_at = DATE_ADD(NOW(), INTERVAL 15 MINUTE) + WHERE id = {$runId} AND status = 'running' AND lease_token = '{$leaseTokenSql}'" + ) === false) { + throw new Exception('XL Vask autopilot lease ownership was lost during processing.'); + } + if ($db->conn()->affected_rows !== 1) { + $owned = $db->query( + "SELECT id FROM xlvask_autopilot_runs + WHERE id = {$runId} AND status = 'running' AND lease_token = '{$leaseTokenSql}' LIMIT 1" + ); + if ($owned === false || $owned->num_rows !== 1) { + throw new Exception('XL Vask autopilot lease ownership was lost during processing.'); + } + } + }; + $db->query("UPDATE xlvask_autopilot_runs SET phase = 'evaluating' WHERE id = {$runId}"); + $renewLease(); + $automation = new xlvask_automation_service(); + $automation->setRunContext($runId); + $allowExecute = $capabilities['execute_actions']; + $results = $automation->runPending( + $dateFrom, + $dateTo, + $ids, + max(1, min(500, (int)($run['requested_limit'] ?? 500))), + isset($run['created_by']) ? (int)$run['created_by'] : null, + $allowExecute, + $runId, + $scopeHallIds, + $isReplay, + $renewLease + ); + $this->persistRunItems($runId, $results['results'] ?? []); + $summary = $this->getSummary($dateFrom, $dateTo, $scopeHallIds); + $summary['import'] = $importSummary; + $circuitBreaker = $results['circuit_breaker'] ?? null; + $warning = $circuitBreaker === null ? null : 'Autopilot stopped early: ' . (string)$circuitBreaker; + $summary['circuit_breaker'] = $circuitBreaker; + $summaryJson = $db->escape_string(json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'); + $processed = (int)($results['processed'] ?? 0); + $total = (int)($results['eligible_total'] ?? $results['selected'] ?? $processed); + if ($circuitBreaker === null && $processed < $total) { + $circuitBreaker = 'batch_limit_reached'; + $warning = 'Autopilot stopped early: batch_limit_reached'; + $summary['circuit_breaker'] = $circuitBreaker; + $summaryJson = $db->escape_string(json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'); + } + $status = $circuitBreaker === null ? 'completed' : 'completed_with_warnings'; + $phase = $circuitBreaker === null ? 'completed' : 'circuit_breaker'; + $warningSql = $warning === null ? 'NULL' : "'" . $db->escape_string($warning) . "'"; + $db->query( + "UPDATE xlvask_autopilot_runs SET status = '{$status}', phase = '{$phase}', processed = {$processed}, + total = {$total}, summary_json = '{$summaryJson}', warning = {$warningSql}, finished_at = NOW() + WHERE id = {$runId} AND lease_token = '" . $db->escape_string($leaseToken) . "'" + ); + if ($db->conn()->affected_rows !== 1) { + throw new Exception('XL Vask autopilot lease ownership was lost before completion.'); + } + if (!$isReplay && $dateFrom !== null && $dateTo !== null) { + (new redis())->clear_invoice_period_automatic_flags($dateFrom, $dateTo); + } + } catch (Throwable $throwable) { + $message = $db->escape_string('XL Vask autopilot run failed. Review the server-side audit trail.'); + $attempt = (int)($run['attempt_count'] ?? 1); + $maxAttempts = max(1, (int)($run['max_attempts'] ?? 3)); + if ($attempt < $maxAttempts) { + $backoffMinutes = min(15, 2 ** max(0, $attempt - 1)); + $db->query( + "UPDATE xlvask_autopilot_runs SET status = 'queued', phase = 'retry_wait', error = '{$message}', + warning = 'Transient failure; retry scheduled.', lease_token = NULL, lease_expires_at = NULL, + next_attempt_at = DATE_ADD(NOW(), INTERVAL {$backoffMinutes} MINUTE) + WHERE id = {$runId} AND lease_token = '" . $db->escape_string($leaseToken) . "'" + ); + } else { + $db->query( + "UPDATE xlvask_autopilot_runs SET status = 'failed', phase = 'failed', error = '{$message}', + lease_token = NULL, lease_expires_at = NULL, finished_at = NOW() + WHERE id = {$runId} AND lease_token = '" . $db->escape_string($leaseToken) . "'" + ); + } + } + + return $this->getRun($runId, isset($run['created_by']) ? (int)$run['created_by'] : null, $scopeHallIds ?? []); + } + + public function getRun(int $runId, ?int $actorId = null, array $allowedHallIds = []): array + { + global $db; + $result = $db->query("SELECT * FROM xlvask_autopilot_runs WHERE id = {$runId} LIMIT 1"); + if ($result === false || $result->num_rows < 1) { + throw new Exception('XL Vask autopilot run not found.'); + } + $row = $db->fetch_assoc($result); + $runScope = json_decode((string)($row['scope_hall_ids_json'] ?? '[]'), true); + $runScope = is_array($runScope) ? $this->normalizeHallIds($runScope) : []; + $allowedHallIds = $this->normalizeHallIds($allowedHallIds); + if ($actorId !== null && $allowedHallIds === []) { + throw new Exception('No XL Vask hall scope is available for this user.'); + } + if ($actorId !== null && (int)($row['created_by'] ?? 0) !== $actorId) { + throw new Exception('XL Vask autopilot run belongs to another user.'); + } + if ($allowedHallIds !== [] && array_diff($runScope, $allowedHallIds) !== []) { + throw new Exception('XL Vask autopilot run is outside the current hall scope.'); + } + return $this->formatRun($row); + } + + public function getSummary(?string $dateFrom = null, ?string $dateTo = null, array $allowedHallIds = []): array + { + global $db; + $states = [ + 'total', 'new', 'updated', 'unchanged', 'invalid', 'already_linked', 'auto_linked', + 'auto_created', 'needs_review', 'blocked', 'ignored', 'failed', 'certain', 'uncertain', 'none', + ]; + $summary = array_fill_keys($states, 0); + $startExpression = "STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')"; + $where = ['1=1']; + $allowedHallIds = $this->normalizeHallIds($allowedHallIds); + if ($allowedHallIds === []) { + throw new Exception('No XL Vask hall scope is available for this user.'); + } + $where[] = 'HallId IN (' . $this->quotedHallIds($allowedHallIds) . ')'; + if ($dateFrom !== null && strtotime($dateFrom) !== false) { + $where[] = "{$startExpression} >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'"; + } + if ($dateTo !== null && strtotime($dateTo) !== false) { + $where[] = "{$startExpression} <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'"; + } + + try { + $row = $db->fetch_assoc($db->query( + "SELECT COUNT(*) total, + SUM(import_state = 'new') `new`, SUM(import_state = 'updated') updated, + SUM(import_state = 'unchanged') unchanged, SUM(import_state = 'invalid') invalid, + SUM(resolution_state = 'already_linked') already_linked, + SUM(resolution_state = 'auto_linked') auto_linked, + SUM(resolution_state = 'auto_created') auto_created, + SUM(resolution_state = 'needs_review') needs_review, + SUM(resolution_state = 'blocked') blocked, + SUM(resolution_state = 'ignored') ignored, + SUM(resolution_state = 'failed') failed, + SUM(certainty = 'certain') certain, SUM(certainty = 'uncertain') uncertain, + SUM(certainty = 'none') none + FROM xlvask_usage_logs WHERE " . implode(' AND ', $where) + )); + foreach ($summary as $key => $_) { + $summary[$key] = (int)($row[$key] ?? 0); + } + } catch (Throwable) { + $row = $db->fetch_assoc($db->query( + 'SELECT COUNT(*) total FROM xlvask_usage_logs WHERE ' . implode(' AND ', $where) + )); + $summary['total'] = (int)($row['total'] ?? 0); + $summary['needs_review'] = $summary['total']; + } + + return $summary; + } + + public function activationReadiness(): array + { + global $db; + $active = []; + try { + $result = $db->query( + "SELECT id, policy_version, segment_key, precision_value, wilson_lower_bound, + holdout_examples, segment_examples, contradictions, calibrated_probability, + artifact_hash, backtest_json, activated_at + FROM xlvask_automation_calibrations WHERE active = 1 ORDER BY segment_key" + ); + if ($result !== false) { + $verified = []; + foreach ($db->fetch_all($result) as $row) { + $artifact = json_decode((string)($row['backtest_json'] ?? ''), true); + if (!is_array($artifact) + || ($artifact['split_rule'] ?? null) !== 'chronological_80_20_by_suggestion_created_at_and_id' + || ($artifact['policy_version'] ?? null) !== (string)$row['policy_version'] + || ($artifact['segment_key'] ?? null) !== (string)$row['segment_key'] + || !hash_equals( + (string)($row['artifact_hash'] ?? ''), + hash('sha256', xlvask_automation_service::stableJsonForAutomation($artifact)) + )) { + continue; + } + $verified[] = [ + ...$artifact, + 'id' => (int)$row['id'], + 'active' => true, + 'artifact_hash' => (string)$row['artifact_hash'], + 'activated_at' => $row['activated_at'] ?? null, + ]; + } + $active = array_values(array_filter( + $verified, + static fn(array $artifact): bool => xlvask_automation_service::classifyCertaintyForAutomation($artifact) === 'certain' + )); + } + } catch (Throwable) { + // Readiness GET is intentionally read-only and fails closed when schema is not ready. + } + return [ + 'wash_id_uniqueness_ready' => xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady(), + 'active_calibrations' => $active, + 'automatic_actions_ready' => xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady() && $active !== [], + 'wash_id_activation_phrase' => 'ACTIVATE-WASH-ID-UNIQUENESS', + ]; + } + + public function adjudicateCalibrationLabel(int $suggestionId, string $outcome, ?int $actorId): array + { + global $db; + xlvask_usage_logs_schema_bootstrap::ensureTables(); + if ($suggestionId < 1 || !in_array($outcome, ['correct', 'incorrect'], true) || $actorId === null) { + throw new Exception('Invalid XL Vask calibration adjudication.'); + } + $suggestion = $db->query( + "SELECT id, policy_version, source, action FROM xlvask_automation_suggestions WHERE id = {$suggestionId} LIMIT 1" + ); + if ($suggestion === false || $suggestion->num_rows < 1) { + throw new Exception('XL Vask suggestion not found for calibration adjudication.'); + } + $outcomeSql = $db->escape_string($outcome); + if ($db->query( + "INSERT INTO xlvask_automation_calibration_label_events + (suggestion_id, outcome, adjudicated_by, adjudicated_at) + VALUES ({$suggestionId}, '{$outcomeSql}', {$actorId}, NOW())" + ) === false) { + throw new Exception('XL Vask calibration adjudication could not be stored.'); + } + return ['suggestion_id' => $suggestionId, 'outcome' => $outcome, 'adjudicated' => true]; + } + + /** Generate an inactive, PII-free artifact from exact admin-adjudicated suggestion labels. */ + public function generateCalibrationArtifact(string $segmentKey, ?int $actorId): array + { + global $db; + xlvask_usage_logs_schema_bootstrap::ensureTables(); + if (!preg_match('/^(deterministic|fuzzy|history):(attach_order|create_order)$/', $segmentKey)) { + throw new Exception('Invalid XL Vask calibration segment.'); + } + [$source, $action] = explode(':', $segmentKey, 2); + $sourceSql = $db->escape_string($source); + $actionSql = $db->escape_string($action); + $labels = $db->fetch_all($db->query( + "SELECT l.id, l.suggestion_id, l.outcome, l.adjudicated_at, + s.policy_version, s.source, s.action, s.created_at AS suggestion_created_at + FROM xlvask_automation_calibration_label_events l + LEFT JOIN xlvask_automation_calibration_label_events newer + ON newer.suggestion_id = l.suggestion_id AND newer.id > l.id + INNER JOIN xlvask_automation_suggestions s ON s.id = l.suggestion_id + WHERE s.source = '{$sourceSql}' AND s.action = '{$actionSql}' + AND s.policy_version = '" . self::POLICY_VERSION . "' + AND newer.id IS NULL + ORDER BY s.created_at ASC, s.id ASC, l.id ASC" + )); + $overallRow = $db->fetch_assoc($db->query( + "SELECT COUNT(*) AS total + FROM xlvask_automation_calibration_label_events l + LEFT JOIN xlvask_automation_calibration_label_events newer + ON newer.suggestion_id = l.suggestion_id AND newer.id > l.id + INNER JOIN xlvask_automation_suggestions s ON s.id = l.suggestion_id + WHERE s.policy_version = '" . self::POLICY_VERSION . "' AND newer.id IS NULL" + )); + $overallExamples = (int)($overallRow['total'] ?? 0); + $total = count($labels); + $trainingExamples = (int)floor($total * 0.8); + $holdout = array_slice($labels, $trainingExamples); + $holdoutExamples = count($holdout); + $accepted = count(array_filter($holdout, static fn(array $label): bool => $label['outcome'] === 'correct')); + $denied = count(array_filter($holdout, static fn(array $label): bool => $label['outcome'] === 'incorrect')); + $contradictions = count(array_filter($labels, static fn(array $label): bool => $label['outcome'] === 'incorrect')); + $precision = $holdoutExamples > 0 ? $accepted / $holdoutExamples : 0.0; + $wilson = $this->wilsonLowerBound($accepted, $holdoutExamples); + $snapshot = array_map(static fn(array $label): array => [ + 'id' => (int)$label['id'], + 'suggestion_id' => (int)$label['suggestion_id'], + 'outcome' => (string)$label['outcome'], + 'adjudicated_at' => (string)$label['adjudicated_at'], + 'suggestion_created_at' => (string)$label['suggestion_created_at'], + 'policy_version' => (string)$label['policy_version'], + 'source' => (string)$label['source'], + 'action' => (string)$label['action'], + ], $labels); + $artifact = [ + 'policy_version' => self::POLICY_VERSION, + 'segment_key' => $segmentKey, + 'split_rule' => 'chronological_80_20_by_suggestion_created_at_and_id', + 'training_examples' => $trainingExamples, + 'holdout_examples' => $holdoutExamples, + 'overall_examples' => $overallExamples, + 'segment_examples' => $total, + 'correct_holdout_examples' => $accepted, + 'incorrect_holdout_examples' => $denied, + 'contradictions' => $contradictions, + 'precision_value' => round($precision, 6), + 'wilson_lower_bound' => round($wilson, 6), + 'calibrated_probability' => round($precision, 6), + 'label_snapshot' => $snapshot, + 'label_snapshot_hash' => hash('sha256', xlvask_automation_service::stableJsonForAutomation($snapshot)), + ]; + $artifactHash = hash('sha256', xlvask_automation_service::stableJsonForAutomation($artifact)); + $artifactJson = $db->escape_string(xlvask_automation_service::stableJsonForAutomation($artifact)); + $actorSql = $actorId === null ? 'NULL' : (string)$actorId; + $db->query( + "INSERT INTO xlvask_automation_calibrations + (policy_version, segment_key, precision_value, wilson_lower_bound, holdout_examples, + segment_examples, contradictions, calibrated_probability, artifact_hash, active, backtest_json, created_by) + VALUES ('" . self::POLICY_VERSION . "', '" . $db->escape_string($segmentKey) . "', " . round($precision, 6) . ", + " . round($wilson, 6) . ", {$holdoutExamples}, {$total}, {$contradictions}, " . round($precision, 6) . ", + '{$artifactHash}', 0, '{$artifactJson}', {$actorSql}) + ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)" + ); + $id = (int)$db->insert_id(); + $qualifies = xlvask_automation_service::classifyCertaintyForAutomation([ + ...$artifact, + 'active' => true, + ]) === 'certain'; + return [ + 'id' => $id, + ...$artifact, + 'artifact_hash' => $artifactHash, + 'active' => false, + 'qualifies_for_activation' => $qualifies, + 'activation_phrase' => 'ACTIVATE-CALIBRATION-' . $id, + ]; + } + + public function activateCalibration(int $id, string $artifactHash, string $confirmationText, ?int $actorId): array + { + global $db; + xlvask_usage_logs_schema_bootstrap::ensureTables(); + if ($id < 1 || !preg_match('/^[0-9a-f]{64}$/', $artifactHash) + || !hash_equals('ACTIVATE-CALIBRATION-' . $id, trim($confirmationText))) { + throw new Exception('Invalid XL Vask calibration activation confirmation.'); + } + $connection = $db->conn(); + $connection->begin_transaction(); + try { + $result = $db->query("SELECT * FROM xlvask_automation_calibrations WHERE id = {$id} FOR UPDATE"); + $row = $result !== false && $result->num_rows > 0 ? $db->fetch_assoc($result) : null; + if ($row === null || !hash_equals((string)$row['artifact_hash'], $artifactHash)) { + throw new Exception('XL Vask calibration artifact not found or changed.'); + } + $backtest = json_decode((string)($row['backtest_json'] ?? ''), true); + if (!is_array($backtest) + || ($backtest['split_rule'] ?? null) !== 'chronological_80_20_by_suggestion_created_at_and_id' + || ($backtest['policy_version'] ?? null) !== (string)$row['policy_version'] + || ($backtest['segment_key'] ?? null) !== (string)$row['segment_key'] + || !hash_equals($artifactHash, hash('sha256', xlvask_automation_service::stableJsonForAutomation($backtest)))) { + throw new Exception('XL Vask calibration artifact payload failed integrity verification.'); + } + if (xlvask_automation_service::classifyCertaintyForAutomation([...$backtest, 'active' => true]) !== 'certain') { + throw new Exception('XL Vask calibration artifact does not meet the activation thresholds.'); + } + $policy = $db->escape_string((string)$row['policy_version']); + $segment = $db->escape_string((string)$row['segment_key']); + $db->query( + "UPDATE xlvask_automation_calibrations SET active = 0 + WHERE policy_version = '{$policy}' AND segment_key = '{$segment}' AND active = 1" + ); + $actorSql = $actorId === null ? 'NULL' : (string)$actorId; + if ($db->query( + "UPDATE xlvask_automation_calibrations + SET active = 1, activated_by = {$actorSql}, activated_at = NOW() WHERE id = {$id} AND active = 0" + ) === false || $db->conn()->affected_rows !== 1) { + throw new Exception('XL Vask calibration artifact could not be activated atomically.'); + } + $connection->commit(); + } catch (Throwable $throwable) { + $connection->rollback(); + throw $throwable; + } + return ['id' => $id, 'active' => true, 'segment_key' => (string)$row['segment_key']]; + } + + public function activateWashIdUniqueness(string $confirmationText): array + { + if (!hash_equals('ACTIVATE-WASH-ID-UNIQUENESS', trim($confirmationText))) { + throw new Exception('Invalid wash-id uniqueness activation confirmation.'); + } + $ready = xlvask_usage_logs_schema_bootstrap::applyWashIdUniquenessMigration(); + if (!$ready) { + throw new Exception('Wash-id uniqueness activation is blocked by schema readiness or duplicate wash IDs.'); + } + return ['wash_id_uniqueness_ready' => true]; + } + + public function createDecisionPreview(array $input, ?int $actorId = null, array $allowedHallIds = []): array + { + global $db; + xlvask_usage_logs_schema_bootstrap::ensureTables(); + $ids = array_values(array_unique(array_filter(array_map( + 'intval', + is_array($input['usage_log_ids'] ?? null) ? $input['usage_log_ids'] : [] + )))); + if ($ids === []) { + throw new Exception('At least one XL Vask usage log is required.'); + } + if (count($ids) > 100) { + throw new Exception('At most 100 XL Vask usage logs can be reviewed at once.'); + } + $action = strtolower(trim((string)($input['action'] ?? ''))); + if (!in_array($action, ['accept', 'deny', 'ignore', 'attach_order', 'create_order'], true)) { + throw new Exception('Invalid XL Vask review action.'); + } + $allowedHallIds = $this->normalizeHallIds($allowedHallIds); + if ($allowedHallIds === []) { + throw new Exception('No XL Vask hall scope is available for this user.'); + } + $result = $db->query( + 'SELECT id, WashId, import_state, resolution_state, certainty, planned_action, expected_version, source_hash ' + . 'FROM xlvask_usage_logs WHERE id IN (' . implode(',', $ids) . ') AND HallId IN (' . $this->quotedHallIds($allowedHallIds) . ') ORDER BY id' + ); + $rows = $db->fetch_all($result); + if (count($rows) !== count($ids)) { + throw new Exception('One or more XL Vask usage logs were not found.'); + } + $items = []; + foreach ($rows as $row) { + $usageId = (int)$row['id']; + if ((string)($row['import_state'] ?? '') === 'invalid') { + throw new Exception("XL Vask usage log {$usageId} has invalid source data."); + } + $suggestion = null; + if ($action !== 'ignore') { + $suggestionResult = $db->query( + "SELECT id, usage_log_id, action, matched_order_id FROM xlvask_automation_suggestions + WHERE usage_log_id = {$usageId} AND status = 'suggested' ORDER BY id DESC LIMIT 1" + ); + $suggestion = $suggestionResult !== false && $suggestionResult->num_rows > 0 + ? $db->fetch_assoc($suggestionResult) : null; + if ($suggestion === null) { + throw new Exception("XL Vask usage log {$usageId} has no actionable suggestion."); + } + if (isset($input['suggestion_id']) && count($rows) === 1 && (int)$suggestion['id'] !== (int)$input['suggestion_id']) { + throw new Exception('The selected XL Vask suggestion is stale.'); + } + if ($action === 'attach_order' && (int)($input['order_id'] ?? 0) > 0) { + $candidate = (new xlvask_automation_service())->validateManualCandidate( + $usageId, + (int)$input['order_id'] + ); + $suggestion['matched_order_id'] = (int)$candidate['id']; + } + } + $items[] = [ + 'usage_log_id' => $usageId, + 'suggestion_id' => $suggestion === null ? null : (int)$suggestion['id'], + 'candidate_order_id' => $suggestion === null || $suggestion['matched_order_id'] === null + ? null : (int)$suggestion['matched_order_id'], + 'suggested_action' => $suggestion['action'] ?? null, + 'before' => [ + 'resolution_state' => (string)$row['resolution_state'], + 'certainty' => (string)$row['certainty'], + 'planned_action' => (string)$row['planned_action'], + 'expected_version' => (int)$row['expected_version'], + 'source_hash' => (string)$row['source_hash'], + ], + 'after' => ['action' => $action], + 'warnings' => [], + ]; + } + $requiresConfirmation = count($items) > 1 || in_array($action, ['deny', 'ignore'], true); + $confirmationPhrase = $requiresConfirmation ? 'CONFIRM-' . strtoupper(bin2hex(random_bytes(4))) : null; + $payload = [ + 'action' => $action, + 'items' => $items, + 'reason' => mb_substr(trim((string)($input['reason'] ?? '')), 0, 1000), + 'confirmation_phrase' => $confirmationPhrase, + ]; + $selectionHash = hash('sha256', xlvask_automation_service::stableJsonForAutomation($payload)); + $id = $this->uuidV4(); + $expiresAt = date('Y-m-d H:i:s', time() + self::PREVIEW_TTL_SECONDS); + $payloadJson = $db->escape_string(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'); + $actorSql = $actorId === null ? 'NULL' : (string)$actorId; + $db->query( + "INSERT INTO xlvask_automation_decision_previews + (id, selection_hash, action, payload_json, created_by, expires_at) + VALUES ('{$id}', '{$selectionHash}', '" . $db->escape_string($action) . "', '{$payloadJson}', {$actorSql}, '{$expiresAt}')" + ); + + return [ + 'id' => $id, + 'selection_hash' => $selectionHash, + 'expires_at' => $expiresAt, + 'action' => $action, + 'items' => $items, + 'requires_confirmation' => $requiresConfirmation, + 'confirmation_phrase' => $confirmationPhrase, + ]; + } + + public function applyDecision(array $input, ?int $actorId = null, array $allowedHallIds = []): array + { + global $db; + xlvask_usage_logs_schema_bootstrap::ensureTables(); + $previewId = trim((string)($input['preview_id'] ?? '')); + $selectionHash = trim((string)($input['selection_hash'] ?? '')); + if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $previewId) + || !preg_match('/^[0-9a-f]{64}$/i', $selectionHash)) { + throw new Exception('Invalid XL Vask decision preview identifiers.'); + } + $connection = $db->conn(); + $allowedHallIds = $this->normalizeHallIds($allowedHallIds); + if ($allowedHallIds === []) { + throw new Exception('No XL Vask hall scope is available for this user.'); + } + $connection->begin_transaction(); + try { + $result = $db->query( + "SELECT * FROM xlvask_automation_decision_previews WHERE id = '" . $db->escape_string($previewId) . "' FOR UPDATE" + ); + if ($result === false || $result->num_rows < 1) { + throw new Exception('XL Vask decision preview not found.'); + } + $preview = $db->fetch_assoc($result); + if ((int)($preview['created_by'] ?? 0) !== (int)($actorId ?? 0)) { + throw new Exception('XL Vask decision preview belongs to another user.'); + } + if (!hash_equals((string)$preview['selection_hash'], $selectionHash)) { + throw new Exception('XL Vask decision preview changed.'); + } + if ($preview['applied_at'] !== null || strtotime((string)$preview['expires_at']) < time()) { + throw new Exception('XL Vask decision preview has expired or was already applied.'); + } + $payload = json_decode((string)$preview['payload_json'], true); + if (!is_array($payload)) { + throw new Exception('XL Vask decision preview is invalid.'); + } + $requiresConfirmation = count($payload['items'] ?? []) > 1 || in_array($payload['action'] ?? '', ['deny', 'ignore'], true); + $confirmationPhrase = (string)($payload['confirmation_phrase'] ?? ''); + if ($requiresConfirmation && ($confirmationPhrase === '' + || !hash_equals($confirmationPhrase, trim((string)($input['confirmation_text'] ?? ''))))) { + throw new Exception('Confirmation text does not match the preview-issued phrase.'); + } + $automation = new xlvask_automation_service(); + $results = []; + foreach ($payload['items'] as $item) { + $usageId = (int)$item['usage_log_id']; + $expected = (int)$item['before']['expected_version']; + $locked = $db->query( + "SELECT expected_version, source_hash, HallId FROM xlvask_usage_logs WHERE id = {$usageId} FOR UPDATE" + ); + $current = $locked !== false && $locked->num_rows > 0 ? $db->fetch_assoc($locked) : null; + if ($current === null + || !in_array(trim((string)$current['HallId']), $allowedHallIds, true) + || !self::previewSnapshotMatches($expected, (string)$item['before']['source_hash'], $current)) { + throw new Exception("XL Vask usage log {$usageId} changed after preview."); + } + $action = (string)$payload['action']; + if ($action === 'ignore') { + $reason = $db->escape_string((string)$payload['reason']); + $actorSql = $actorId === null ? 'NULL' : (string)$actorId; + if ($db->query( + "UPDATE xlvask_usage_logs SET ignored_at = NOW(), ignored_by = {$actorSql}, ignored_reason = '{$reason}', + resolution_state = 'ignored', certainty = 'none', planned_action = 'none', expected_version = expected_version + 1 + WHERE id = {$usageId}" + ) === false || $db->conn()->affected_rows !== 1) { + throw new Exception('The XL Vask ignore decision could not be applied atomically.'); + } + $results[] = ['usage_log_id' => $usageId, 'resolution_state' => 'ignored']; + } else { + $results[] = $automation->applyBoundDecisionWithinTransaction( + $usageId, + $action, + isset($item['suggestion_id']) ? (int)$item['suggestion_id'] : null, + isset($item['candidate_order_id']) && $item['candidate_order_id'] !== null + ? (int)$item['candidate_order_id'] : null, + $expected, + (string)$item['before']['source_hash'], + $actorId, + $payload['reason'] ?: null + ); + } + } + if ($db->query( + "UPDATE xlvask_automation_decision_previews SET applied_at = NOW() WHERE id = '" . $db->escape_string($previewId) . "'" + ) === false || $db->conn()->affected_rows !== 1) { + throw new Exception('The XL Vask decision preview could not be finalized atomically.'); + } + $connection->commit(); + } catch (Throwable $throwable) { + $connection->rollback(); + throw $throwable; + } + + return ['applied' => count($results), 'results' => $results, 'failed' => []]; + } + + private function persistRunItems(int $runId, array $results): void + { + global $db; + foreach ($results as $result) { + $usageId = (int)($result['usage_log_id'] ?? 0); + if ($usageId < 1) { + continue; + } + $rowResult = $db->query("SELECT * FROM xlvask_usage_logs WHERE id = {$usageId} LIMIT 1"); + if ($rowResult === false || $rowResult->num_rows < 1) { + continue; + } + $row = $db->fetch_assoc($rowResult); + $durableResult = array_intersect_key($result, array_flip([ + 'usage_log_id', 'id', 'status', 'action', 'certainty', 'calibrated_probability', + 'source', 'matched_order_id', 'created_order_id', 'risk_flags', 'policy_version', + ])); + $resultJson = $db->escape_string(json_encode($durableResult, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'); + $db->query( + "INSERT INTO xlvask_autopilot_run_items + (run_id, usage_log_id, wash_id, import_state, resolution_state, certainty, planned_action, source_hash, expected_version, result_json) + VALUES ({$runId}, {$usageId}, '" . $db->escape_string((string)$row['WashId']) . "', + '" . $db->escape_string((string)($row['import_state'] ?? 'unchanged')) . "', + '" . $db->escape_string((string)($result['resolution_state'] ?? $row['resolution_state'] ?? 'needs_review')) . "', + '" . $db->escape_string((string)($result['certainty'] ?? $row['certainty'] ?? 'none')) . "', + '" . $db->escape_string((string)($result['planned_action'] ?? $row['planned_action'] ?? 'none')) . "', + '" . $db->escape_string((string)($row['source_hash'] ?? '')) . "', " . (int)($row['expected_version'] ?? 1) . ", '{$resultJson}') + ON DUPLICATE KEY UPDATE resolution_state = VALUES(resolution_state), certainty = VALUES(certainty), + planned_action = VALUES(planned_action), result_json = VALUES(result_json), updated_at = NOW()" + ); + } + } + + private function formatRun(array $row): array + { + $summary = json_decode((string)($row['summary_json'] ?? ''), true); + return [ + 'id' => (int)$row['id'], + 'status' => (string)$row['status'], + 'mode' => (string)$row['mode'], + 'date_from' => $row['date_from'] ?: null, + 'date_to' => $row['date_to'] ?: null, + 'phase' => (string)$row['phase'], + 'processed' => (int)$row['processed'], + 'total' => (int)$row['total'], + 'summary' => is_array($summary) ? $summary : null, + 'warning' => $row['warning'] ?: null, + 'error' => $row['error'] ?: null, + 'created_at' => $row['created_at'], + 'started_at' => $row['started_at'] ?: null, + 'finished_at' => $row['finished_at'] ?: null, + 'attempt_count' => (int)($row['attempt_count'] ?? 0), + 'max_attempts' => (int)($row['max_attempts'] ?? 3), + 'next_attempt_at' => $row['next_attempt_at'] ?: null, + ]; + } + + public function pruneExpiredData(): array + { + global $db; + xlvask_usage_logs_schema_bootstrap::ensureTables(); + $deleted = []; + foreach ([ + 'previews' => 'DELETE FROM xlvask_automation_decision_previews WHERE expires_at < DATE_SUB(NOW(), INTERVAL 1 DAY)', + 'run_items' => 'DELETE FROM xlvask_autopilot_run_items WHERE created_at < DATE_SUB(NOW(), INTERVAL 90 DAY)', + 'audit_events' => 'DELETE FROM xlvask_automation_audit WHERE created_at < DATE_SUB(NOW(), INTERVAL 730 DAY)', + ] as $key => $sql) { + if ($db->query($sql) === false) { + throw new Exception('XL Vask autopilot retention cleanup failed.'); + } + $deleted[$key] = (int)$db->conn()->affected_rows; + } + return $deleted; + } + + private function normalizeDate(mixed $value, string $name): ?string + { + $value = trim((string)($value ?? '')); + if ($value === '') { + return null; + } + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) { + throw new Exception("Invalid {$name}."); + } + [$year, $month, $day] = array_map('intval', explode('-', $value)); + if (!checkdate($month, $day, $year)) { + throw new Exception("Invalid {$name}."); + } + return $value; + } + + private function wilsonLowerBound(int $successes, int $total): float + { + if ($total < 1) { + return 0.0; + } + $z = 1.959963984540054; + $p = $successes / $total; + $zSquared = $z * $z; + $denominator = 1 + ($zSquared / $total); + $centre = $p + ($zSquared / (2 * $total)); + $margin = $z * sqrt((($p * (1 - $p)) + ($zSquared / (4 * $total))) / $total); + return max(0.0, ($centre - $margin) / $denominator); + } + + private function normalizeHallIds(array $hallIds): array + { + return self::normalizeHallScope($hallIds); + } + + private function quotedHallIds(array $hallIds): string + { + global $db; + return implode(',', array_map( + static fn(string $id): string => "'" . $db->escape_string($id) . "'", + $hallIds + )); + } + + private function uuidV4(): string + { + $data = random_bytes(16); + $data[6] = chr((ord($data[6]) & 0x0f) | 0x40); + $data[8] = chr((ord($data[8]) & 0x3f) | 0x80); + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); + } +} 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 b300973c..2b843d97 100644 --- a/services/nginx/app/classes/xlvask_usage_logs_schema_bootstrap.php +++ b/services/nginx/app/classes/xlvask_usage_logs_schema_bootstrap.php @@ -31,6 +31,19 @@ class xlvask_usage_logs_schema_bootstrap self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_total_net_amount', 'DECIMAL(12,2) NULL AFTER ignored_reason'); self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_primary_product_name', 'VARCHAR(255) NULL AFTER cached_total_net_amount'); self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_amount_at', 'DATETIME NULL AFTER cached_primary_product_name'); + self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_hash', 'CHAR(64) NULL AFTER cached_amount_at'); + self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_revision', 'VARCHAR(128) NULL AFTER source_hash'); + self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_observed_at', 'DATETIME NULL AFTER source_revision'); + self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_stable_since', 'DATETIME NULL AFTER source_observed_at'); + self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_observation_count', 'INT NOT NULL DEFAULT 0 AFTER source_stable_since'); + self::addColumnIfMissing($db, 'xlvask_usage_logs', 'import_state', "VARCHAR(24) NOT NULL DEFAULT 'unchanged' AFTER source_observation_count"); + self::addColumnIfMissing($db, 'xlvask_usage_logs', 'resolution_state', "VARCHAR(32) NOT NULL DEFAULT 'needs_review' AFTER import_state"); + self::addColumnIfMissing($db, 'xlvask_usage_logs', 'certainty', "VARCHAR(16) NOT NULL DEFAULT 'none' AFTER resolution_state"); + self::addColumnIfMissing($db, 'xlvask_usage_logs', 'planned_action', "VARCHAR(32) NOT NULL DEFAULT 'none' AFTER certainty"); + self::addColumnIfMissing($db, 'xlvask_usage_logs', 'state_reason', 'TEXT NULL AFTER planned_action'); + self::addColumnIfMissing($db, 'xlvask_usage_logs', 'expected_version', 'INT NOT NULL DEFAULT 1 AFTER state_reason'); + self::addColumnIfMissing($db, 'xlvask_usage_logs', 'last_run_id', 'BIGINT NULL AFTER expected_version'); + self::addColumnIfMissing($db, 'xlvask_usage_logs', 'last_evaluated_at', 'DATETIME NULL AFTER last_run_id'); self::ensureAutomationTables($db); self::$initialized = true; @@ -68,6 +81,23 @@ class xlvask_usage_logs_schema_bootstrap ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); + foreach ([ + 'run_id' => 'BIGINT NULL AFTER usage_log_id', + 'policy_version' => "VARCHAR(64) NOT NULL DEFAULT 'xlvask-autopilot-v1' AFTER source", + 'model' => 'VARCHAR(96) NULL AFTER policy_version', + 'model_confidence' => 'DECIMAL(5,4) NULL AFTER model', + 'calibrated_probability' => 'DECIMAL(5,4) NULL AFTER model_confidence', + 'certainty' => "VARCHAR(16) NOT NULL DEFAULT 'uncertain' AFTER calibrated_probability", + 'evidence_json' => 'LONGTEXT NULL AFTER certainty', + 'contradictions_json' => 'LONGTEXT NULL AFTER evidence_json', + 'risk_flags_json' => 'LONGTEXT NULL AFTER contradictions_json', + 'plan_steps_json' => 'LONGTEXT NULL AFTER risk_flags_json', + 'expected_version' => 'INT NULL AFTER plan_steps_json', + 'input_hash' => 'CHAR(64) NULL AFTER expected_version', + ] as $column => $definition) { + self::addColumnIfMissing($db, 'xlvask_automation_suggestions', $column, $definition); + } + $db->query( "CREATE TABLE IF NOT EXISTS `xlvask_automation_feedback` ( `id` INT NOT NULL AUTO_INCREMENT, @@ -104,6 +134,220 @@ class xlvask_usage_logs_schema_bootstrap KEY `idx_xlvask_openai_cache_schema` (`schema_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); + + $db->query( + "CREATE TABLE IF NOT EXISTS `xlvask_autopilot_runs` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `idempotency_key` CHAR(64) NOT NULL, + `mode` VARCHAR(16) NOT NULL, + `status` VARCHAR(24) NOT NULL DEFAULT 'queued', + `phase` VARCHAR(32) NOT NULL DEFAULT 'queued', + `date_from` DATE NULL, + `date_to` DATE NULL, + `force_refetch` TINYINT(1) NOT NULL DEFAULT 0, + `requested_ids_json` LONGTEXT NULL, + `requested_limit` INT NOT NULL DEFAULT 500, + `request_hash` CHAR(64) NOT NULL, + `scope_hall_ids_json` LONGTEXT NOT NULL, + `processed` INT NOT NULL DEFAULT 0, + `total` INT NOT NULL DEFAULT 0, + `summary_json` LONGTEXT NULL, + `warning` TEXT NULL, + `error` TEXT NULL, + `lease_token` CHAR(36) NULL, + `lease_expires_at` DATETIME NULL, + `attempt_count` INT NOT NULL DEFAULT 0, + `max_attempts` INT NOT NULL DEFAULT 3, + `next_attempt_at` DATETIME NULL, + `created_by` INT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `started_at` DATETIME NULL, + `finished_at` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_xlvask_autopilot_run_idempotency` (`idempotency_key`), + KEY `idx_xlvask_autopilot_run_status` (`status`, `created_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'lease_token', 'CHAR(36) NULL AFTER error'); + self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'lease_expires_at', 'DATETIME NULL AFTER lease_token'); + self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'scope_hall_ids_json', "LONGTEXT NULL AFTER requested_ids_json"); + self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'requested_limit', 'INT NOT NULL DEFAULT 500 AFTER requested_ids_json'); + self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'request_hash', "CHAR(64) NOT NULL DEFAULT '' AFTER requested_limit"); + self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'attempt_count', 'INT NOT NULL DEFAULT 0 AFTER lease_expires_at'); + self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'max_attempts', 'INT NOT NULL DEFAULT 3 AFTER attempt_count'); + self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'next_attempt_at', 'DATETIME NULL AFTER max_attempts'); + + $db->query( + "CREATE TABLE IF NOT EXISTS `xlvask_autopilot_run_items` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `run_id` BIGINT NOT NULL, + `usage_log_id` INT NULL, + `wash_id` VARCHAR(128) NULL, + `import_state` VARCHAR(24) NOT NULL DEFAULT 'unchanged', + `resolution_state` VARCHAR(32) NOT NULL DEFAULT 'needs_review', + `certainty` VARCHAR(16) NOT NULL DEFAULT 'none', + `planned_action` VARCHAR(32) NOT NULL DEFAULT 'none', + `source_hash` CHAR(64) NULL, + `expected_version` INT NULL, + `result_json` LONGTEXT NULL, + `error` TEXT 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_autopilot_run_usage` (`run_id`, `usage_log_id`), + KEY `idx_xlvask_autopilot_run_item_state` (`run_id`, `resolution_state`, `certainty`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'updated_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at'); + + $db->query( + "CREATE TABLE IF NOT EXISTS `xlvask_automation_audit` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `run_id` BIGINT NULL, + `usage_log_id` INT NULL, + `wash_id` VARCHAR(128) NULL, + `event_type` VARCHAR(48) NOT NULL, + `action` VARCHAR(32) NULL, + `policy_version` VARCHAR(64) NOT NULL, + `input_hash` CHAR(64) NULL, + `source_revision` VARCHAR(128) NULL, + `expected_version` INT NULL, + `before_json` LONGTEXT NULL, + `after_json` LONGTEXT NULL, + `evidence_json` LONGTEXT NULL, + `actor_id` INT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_xlvask_audit_usage` (`usage_log_id`, `created_at`), + KEY `idx_xlvask_audit_run` (`run_id`, `created_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + + $db->query( + "CREATE TABLE IF NOT EXISTS `xlvask_automation_calibrations` ( + `id` INT NOT NULL AUTO_INCREMENT, + `policy_version` VARCHAR(64) NOT NULL, + `segment_key` VARCHAR(191) NOT NULL, + `precision_value` DECIMAL(7,6) NOT NULL, + `wilson_lower_bound` DECIMAL(7,6) NOT NULL, + `holdout_examples` INT NOT NULL, + `segment_examples` INT NOT NULL, + `contradictions` INT NOT NULL DEFAULT 0, + `calibrated_probability` DECIMAL(7,6) NOT NULL, + `artifact_hash` CHAR(64) NOT NULL, + `active` TINYINT(1) NOT NULL DEFAULT 0, + `backtest_json` LONGTEXT NULL, + `created_by` INT NULL, + `activated_by` INT NULL, + `activated_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_xlvask_calibration_artifact` (`artifact_hash`), + KEY `idx_xlvask_calibration_lookup` (`policy_version`, `segment_key`, `active`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'backtest_json', 'LONGTEXT NULL AFTER active'); + self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'created_by', 'INT NULL AFTER backtest_json'); + self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'activated_by', 'INT NULL AFTER created_by'); + self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'activated_at', 'DATETIME NULL AFTER activated_by'); + + $db->query( + "CREATE TABLE IF NOT EXISTS `xlvask_automation_calibration_labels` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `suggestion_id` INT NOT NULL, + `outcome` VARCHAR(16) NOT NULL, + `adjudicated_by` INT NOT NULL, + `adjudicated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_xlvask_calibration_label_suggestion` (`suggestion_id`), + KEY `idx_xlvask_calibration_label_time` (`adjudicated_at`, `id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + + // Immutable adjudication events supersede the legacy one-row-per-suggestion table. + // The nullable legacy id supports an idempotent, non-destructive backfill. + $db->query( + "CREATE TABLE IF NOT EXISTS `xlvask_automation_calibration_label_events` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `suggestion_id` INT NOT NULL, + `outcome` VARCHAR(16) NOT NULL, + `adjudicated_by` INT NOT NULL, + `adjudicated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `legacy_label_id` BIGINT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_xlvask_calibration_legacy_label` (`legacy_label_id`), + KEY `idx_xlvask_calibration_event_suggestion` (`suggestion_id`, `id`), + KEY `idx_xlvask_calibration_event_time` (`adjudicated_at`, `id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + $db->query( + "INSERT IGNORE INTO xlvask_automation_calibration_label_events + (suggestion_id, outcome, adjudicated_by, adjudicated_at, legacy_label_id) + SELECT suggestion_id, outcome, adjudicated_by, adjudicated_at, id + FROM xlvask_automation_calibration_labels" + ); + + $db->query( + "CREATE TABLE IF NOT EXISTS `xlvask_automation_decision_previews` ( + `id` CHAR(36) NOT NULL, + `selection_hash` CHAR(64) NOT NULL, + `action` VARCHAR(32) NOT NULL, + `payload_json` LONGTEXT NOT NULL, + `created_by` INT NULL, + `expires_at` DATETIME NOT NULL, + `applied_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_xlvask_preview_expiry` (`expires_at`, `applied_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + } + + public static function washIdUniquenessReady(): bool + { + global $db; + if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) { + return false; + } + + return self::indexExists($db, 'orders', 'uniq_orders_xlvask_wash_id'); + } + + /** + * Explicit activation migration. Never call this from constructors, GETs, or normal runs. + * Returns false without modifying conflicting records when duplicate wash IDs exist. + */ + public static function applyWashIdUniquenessMigration(): bool + { + global $db; + if (!self::tableExists($db, 'orders') || !self::columnExists($db, 'orders', 'wash_id')) { + return false; + } + + if (self::indexExists($db, 'orders', 'uniq_orders_xlvask_wash_id')) { + return true; + } + + $duplicates = $db->query( + "SELECT LOWER(TRIM(`wash_id`)) normalized_wash_id FROM `orders` + WHERE `wash_id` IS NOT NULL AND TRIM(`wash_id`) <> '' + GROUP BY LOWER(TRIM(`wash_id`)) HAVING COUNT(*) > 1 LIMIT 1" + ); + if ($duplicates !== false && is_object($duplicates) && (int)$duplicates->num_rows === 0) { + if (!self::columnExists($db, 'orders', 'xlvask_normalized_wash_id')) { + $db->query( + "ALTER TABLE `orders` ADD COLUMN `xlvask_normalized_wash_id` VARCHAR(128) + GENERATED ALWAYS AS (NULLIF(LOWER(TRIM(`wash_id`)), '')) STORED" + ); + } + $db->query( + "ALTER TABLE `orders` ADD UNIQUE KEY `uniq_orders_xlvask_wash_id` (`xlvask_normalized_wash_id`)" + ); + return self::indexExists($db, 'orders', 'uniq_orders_xlvask_wash_id'); + } + + return false; } private static function addColumnIfMissing(object $db, string $table, string $column, string $definition): void @@ -138,6 +382,18 @@ class xlvask_usage_logs_schema_bootstrap return (int)$result->num_rows > 0; } + private static function indexExists(object $db, string $table, string $index): bool + { + if (!self::tableExists($db, $table)) { + return false; + } + + $table = self::escapeIdentifier($table); + $index = self::escapeIdentifier($index); + $result = $db->query("SHOW INDEX FROM `{$table}` WHERE Key_name = '{$index}'"); + return $result !== false && is_object($result) && (int)$result->num_rows > 0; + } + private static function escapeIdentifier(string $value): string { return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value); diff --git a/services/nginx/app/cron/Cron.php b/services/nginx/app/cron/Cron.php index 76c42c81..2bdf152f 100644 --- a/services/nginx/app/cron/Cron.php +++ b/services/nginx/app/cron/Cron.php @@ -150,6 +150,12 @@ $cron_tasks = [ 'next_run' => 0, 'function' => 'SyncXLVaskModuleCron', ], + 'ProcessXLVaskAutopilotQueueCron' => [ + 'interval' => 60, + 'last_run' => 0, + 'next_run' => 0, + 'function' => 'ProcessXLVaskAutopilotQueueCron', + ], 'SystemSearchCacheMaintenanceCron' => [ 'interval' => 300, // 5 minutes 'last_run' => 0, @@ -656,6 +662,15 @@ function SyncXLVaskModuleCron(): void } } +function ProcessXLVaskAutopilotQueueCron(): array +{ + $xlvask = new xlvask(); + if (!$xlvask->config->enabled->isTrue()) { + return []; + } + return $xlvask->getTasks()->processAutopilotQueue(3); +} + function EconomicTransferQueueCron(): void { try { diff --git a/services/nginx/app/modules/xlvask/classes/xlvask_request.php b/services/nginx/app/modules/xlvask/classes/xlvask_request.php index 4a1be60c..09ab02b8 100644 --- a/services/nginx/app/modules/xlvask/classes/xlvask_request.php +++ b/services/nginx/app/modules/xlvask/classes/xlvask_request.php @@ -28,10 +28,12 @@ class xlvask_request implements xlvask_request_i }; // Set the headers curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - // Set options to return the response and handle SSL + // Verify the upstream certificate and keep requests bounded. curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15); + curl_setopt($ch, CURLOPT_TIMEOUT, 60); // Execute the request $response = curl_exec($ch); // Check for errors @@ -45,9 +47,12 @@ class xlvask_request implements xlvask_request_i // Check if the response is successful if ($httpCode < 200 || $httpCode >= 300) { $slack = new slack(); + $urlParts = parse_url($url); + $safeEndpoint = (string)($urlParts['host'] ?? 'unknown-host') . (string)($urlParts['path'] ?? ''); $slack->send_message( 'XLVask API Request Failed', - "URL: $url\nMethod: $method\nData: " . json_encode($data) . "\nHeaders: " . implode(', ', $headers) . "\nHTTP Code: $httpCode\nResponse: $response" + "Endpoint: {$safeEndpoint}\nMethod: {$method}\nHTTP Code: {$httpCode}\n" + . 'Request fields: ' . count($data) . "\nResponse bytes: " . strlen((string)$response) ); throw new \Exception('Request failed with status code ' . $httpCode); } @@ -71,4 +76,4 @@ class xlvask_request implements xlvask_request_i { return 'Authorization: Basic ' . base64_encode($username . ':' . $password); } -} \ No newline at end of file +} diff --git a/services/nginx/app/modules/xlvask/cron/tasks.php b/services/nginx/app/modules/xlvask/cron/tasks.php index 5c6c7bb2..17f2545a 100644 --- a/services/nginx/app/modules/xlvask/cron/tasks.php +++ b/services/nginx/app/modules/xlvask/cron/tasks.php @@ -1,6 +1,18 @@ 'xlvask.autopilot_queue', + 'legacy_name' => 'ProcessXLVaskAutopilotQueueCron', + 'name' => 'Process XL Vask autopilot queue', + 'description' => 'Claims and processes a bounded batch of queued XL Vask autopilot runs.', + 'module' => 'xlvask', + 'handler' => 'ProcessXLVaskAutopilotQueueCron', + 'schedule' => ['type' => 'interval', 'seconds' => 60], + 'timeout_seconds' => 300, + 'estimated_duration_ms' => 5000, + 'priority' => 40, + ], [ 'id' => 'xlvask.sync_module', 'legacy_name' => 'SyncXLVaskModuleCron', diff --git a/services/nginx/app/modules/xlvask/helpers/xlvask_tasks.php b/services/nginx/app/modules/xlvask/helpers/xlvask_tasks.php index 9a633f5c..a339bb37 100644 --- a/services/nginx/app/modules/xlvask/helpers/xlvask_tasks.php +++ b/services/nginx/app/modules/xlvask/helpers/xlvask_tasks.php @@ -2,15 +2,15 @@ namespace helpers; -require_once WD . '/classes/xlvask_automation_service.php'; +require_once WD . '/classes/xlvask_autopilot_service.php'; -use classes\xlvask_automation_service; +use classes\xlvask_autopilot_service; use Exception; use objects\orders_o; +use objects\plate_scanners_o; use objects\users_o; use objects\xlvask_customers_o; use objects\xlvask_potential_order_matches_o; -use objects\xlvask_usage_logs_o; use objects\xlvask_vehicles_o; class xlvask_tasks @@ -69,11 +69,45 @@ class xlvask_tasks // - xlvask_usage_logs (new xlvask_customers_o())->importCustomers(); (new xlvask_vehicles_o())->importVehicles(); - (new xlvask_usage_logs_o())->importUsageLogs(); - (new xlvask_automation_service())->runPending(null, null, [], 100, null); + $hallIds = $this->configuredHallIds(); + if ($hallIds !== []) { + $autopilot = new xlvask_autopilot_service(); + $autopilot->createRun(['mode' => 'execute'], null, $hallIds); + $autopilot->processQueuedRuns(3); + } }; } + /** Drain the durable autopilot queue without running the hourly upstream synchronization. */ + public function processAutopilotQueue(int $limit = 3): array + { + $xlvask = new \classes\xlvask(); + $xlvask->requireModuleEnabled(); + if (!$xlvask->config->synchronization_enabled->isTrue()) { + return []; + } + return (new xlvask_autopilot_service())->processQueuedRuns($limit); + } + + /** Hall GUIDs are configuration, not derived from already-imported usage rows. */ + private function configuredHallIds(): array + { + global $db; + (new plate_scanners_o())->structure(); + plate_scanners_o::ensureHallIdSchemaForMutation(); + $result = $db->query( + "SELECT DISTINCT HallId FROM plate_scanners + WHERE HallId IS NOT NULL AND TRIM(HallId) <> '' AND deleted_at IS NULL" + ); + if ($result === false) { + return []; + } + return array_values(array_unique(array_filter(array_map( + static fn(array $row): string => trim((string)($row['HallId'] ?? '')), + $db->fetch_all($result) + ), static fn(string $id): bool => $id !== '' && strlen($id) <= 191))); + } + /** * Run the synchronize users task * This task fetches the users from XL Vask, and synchronizes them with this system. @@ -324,6 +358,6 @@ class xlvask_tasks $xlvask = new \classes\xlvask(); // Require the module to be enabled $xlvask->requireModuleEnabled(); - // Run + (new xlvask_autopilot_service())->pruneExpiredData(); } } diff --git a/services/nginx/app/objects/plate_scanners_o.php b/services/nginx/app/objects/plate_scanners_o.php index 196b5b76..217f8dc8 100644 --- a/services/nginx/app/objects/plate_scanners_o.php +++ b/services/nginx/app/objects/plate_scanners_o.php @@ -201,10 +201,18 @@ class plate_scanners_o extends db if (!self::tableHasColumn('plate_scanners', 'lane_id')) { $db->query("ALTER TABLE `plate_scanners` ADD COLUMN `lane_id` INT NULL AFTER `department_id`"); } - self::$schemaInitialized = true; } + /** Explicit mutation-path compatibility bootstrap for XL Vask hall scoping. */ + public static function ensureHallIdSchemaForMutation(): void + { + global $db; + if (!self::tableHasColumn('plate_scanners', 'HallId')) { + $db->query("ALTER TABLE `plate_scanners` ADD COLUMN `HallId` VARCHAR(191) NULL AFTER `lane_id`"); + } + } + private static function tableHasColumn(string $table, string $column): bool { global $db; diff --git a/services/nginx/app/objects/xlvask_usage_logs_o.php b/services/nginx/app/objects/xlvask_usage_logs_o.php index 5c6db532..3ff4229c 100644 --- a/services/nginx/app/objects/xlvask_usage_logs_o.php +++ b/services/nginx/app/objects/xlvask_usage_logs_o.php @@ -42,7 +42,6 @@ class xlvask_usage_logs_o extends db public function structure(): void { - xlvask_usage_logs_schema_bootstrap::ensureTables(); $this->setTable('xlvask_usage_logs'); } @@ -117,6 +116,22 @@ class xlvask_usage_logs_o extends db ]; } + /** Read-only amount projection for GET routes. */ + public function getAmountSummaryReadOnly(array $row): array + { + $cachedAmount = self::normalizeMoneyValue($row['cached_total_net_amount'] ?? null); + $cachedAt = trim((string)($row['cached_amount_at'] ?? '')); + if ($cachedAmount !== null && $cachedAt !== '') { + return [ + 'total_net_amount' => $cachedAmount, + 'primary_product_name' => (string)($row['cached_primary_product_name'] ?? ''), + 'cached' => true, + ]; + } + + return [...self::calculateAmountSummaryFromWashItems($row['WashItems'] ?? []), 'cached' => false]; + } + public static function calculateAmountSummaryFromWashItems(array|string|null $washItems): array { if (is_string($washItems)) { @@ -202,40 +217,284 @@ class xlvask_usage_logs_o extends db */ public function importUsageLogs(?string $dateFrom = null, ?string $dateTo = null): void { + $this->importUsageLogsWithSummary($dateFrom, $dateTo); + } + + /** + * Revision-aware and idempotent XL Vask import. + * + * @return array{fetched:int,new:int,updated:int,unchanged:int,invalid:int,errors:array>} + */ + public function importUsageLogsWithSummary(?string $dateFrom = null, ?string $dateTo = null, array $allowedHallIds = []): array + { + global $db; if (!empty($this->id)) { throw new Exception('To prevent issues, having a selected object is not allowed.'); } + xlvask_usage_logs_schema_bootstrap::ensureTables(); $usage_logs = $this->getUsageLogsFromXLVask( self::formatImportDateFrom($dateFrom) // Example: '2025-05-01T00:00:00.000' ); $usage_logs = self::filterUsageLogsUntil($usage_logs, $dateTo); - /** @var string[] $known_usage_logIds The XL Vask usage logIds currently known */ - $known_usage_logIds = array_map(function ($log) { - return $log['WashId']; - }, - self::getFieldsWhere( - ['WashId' => array_column($usage_logs, 'WashId')], - ['WashId'] + $upstreamFetched = count($usage_logs); + $allowedHallIds = array_values(array_unique(array_filter(array_map( + static fn(mixed $id): string => trim((string)$id), + $allowedHallIds + ), static fn(string $id): bool => $id !== ''))); + if ($allowedHallIds !== []) { + $usage_logs = array_values(array_filter( + $usage_logs, + static fn(xlvask_usage_log $log): bool => in_array(trim((string)$log->HallId), $allowedHallIds, true) )); - /** The XL Vask usage logs without a matching WashId in the database */ - $new_usage_logs = array_filter($usage_logs, function ($log) use ($known_usage_logIds) { - return !in_array($log->WashId, $known_usage_logIds); - }); - unset($known_usage_logIds); - unset($usage_logs); - /** Adding the new usage logs */ - foreach ($new_usage_logs as $log) { - if (!$log->isValid()) { - echo $log->formattedDetails(); - throw new Exception('A usage log from XL Vask is not valid, have the structure changed?'); + } + + $washIds = array_values(array_filter(array_map( + static fn(xlvask_usage_log $log): string => trim((string)$log->WashId), + $usage_logs + ))); + $knownRows = $washIds === [] ? [] : self::getFieldsWhere( + ['WashId' => $washIds], + ['id', 'WashId', 'source_hash', 'source_revision', 'expected_version'] + ); + $knownByWashId = []; + foreach ($knownRows as $knownRow) { + $knownByWashId[(string)$knownRow['WashId']] = $knownRow; + } + + $summary = [ + 'fetched' => count($usage_logs), + 'upstream_fetched' => $upstreamFetched, + 'new' => 0, + 'updated' => 0, + 'unchanged' => 0, + 'invalid' => 0, + 'errors' => [], + ]; + + foreach ($usage_logs as $log) { + $washId = trim((string)$log->WashId); + if ($washId === '' || !$log->isValid()) { + $summary['invalid']++; + $summary['errors'][] = [ + 'wash_id' => $washId, + 'error' => 'XL Vask usage log failed structural validation.', + ]; + if ($washId !== '' && isset($knownByWashId[$washId])) { + $this->markImportState((int)$knownByWashId[$washId]['id'], 'invalid', 'XL Vask-kildedata kunne ikke valideres.'); + } + continue; + } + + $payload = self::normalizeSourcePayload($log->toArray()); + $sourceHash = self::sourceHashForAutomation($payload); + $sourceRevision = trim((string)($payload['Updated'] ?? '')) ?: $sourceHash; + $known = $knownByWashId[$washId] ?? null; + + if ($known === null) { + $this->add($payload); + $this->updateSourceMetadata((int)$this->id, $sourceHash, $sourceRevision, 'new', true); + $knownByWashId[$washId] = ['id' => (int)$this->id, 'WashId' => $washId, 'source_hash' => $sourceHash]; + $summary['new']++; + continue; + } + + $existingHash = trim((string)($known['source_hash'] ?? '')); + if ($existingHash !== '' && hash_equals($existingHash, $sourceHash)) { + $this->updateSourceMetadata((int)$known['id'], $sourceHash, $sourceRevision, 'unchanged', false); + $summary['unchanged']++; + continue; + } + + $before = $known; + $connection = $db->conn(); + $connection->begin_transaction(); + try { + $this->updateExistingSourceRow((int)$known['id'], $payload, $sourceHash, $sourceRevision); + $this->supersedeSuggestionsForSourceRevision((int)$known['id']); + $this->recordSourceRevision((int)$known['id'], $washId, $before, $payload, $sourceHash, $sourceRevision); + $connection->commit(); + } catch (\Throwable $throwable) { + $connection->rollback(); + throw $throwable; + } + $knownByWashId[$washId]['source_hash'] = $sourceHash; + $summary['updated']++; + } + + return $summary; + } + + public static function sourceHashForAutomation(array $payload): string + { + $encoded = json_encode( + self::sortSourceValue($payload), + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION + ); + if ($encoded === false) { + throw new Exception('Could not build XL Vask source hash.'); + } + + return hash('sha256', $encoded); + } + + private static function normalizeSourcePayload(array $payload): array + { + $allowed = [ + 'WashId', 'CustomerId', 'Customer', 'VatNumber', 'Location', 'Hall', 'HallId', + 'StartTime', 'FinishTime', 'RegistrationNumber', 'VehicleType', 'IdentificationType', + 'IdentificationId', 'Info', 'Updated', 'Prepaid', 'FinishStatus', 'CustomerGuid', + 'VehicleId', 'WashItems', + ]; + $payload = array_intersect_key($payload, array_flip($allowed)); + if (isset($payload['WashItems']) && is_string($payload['WashItems'])) { + $decoded = json_decode($payload['WashItems'], true); + if (is_array($decoded)) { + $payload['WashItems'] = $decoded; } } - // Actually save the usage log - foreach ($new_usage_logs as $log) { - /** @var xlvask_usage_log $log */ - $this->add($log->toArray()); + + return $payload; + } + + private static function sortSourceValue(mixed $value): mixed + { + if (!is_array($value)) { + return $value; } - unset($new_usage_logs); + $sorted = array_map(static fn(mixed $item): mixed => self::sortSourceValue($item), $value); + $isList = $sorted === [] || array_keys($sorted) === range(0, count($sorted) - 1); + if (!$isList) { + ksort($sorted, SORT_STRING); + } + return $sorted; + } + + private function updateExistingSourceRow(int $id, array $payload, string $sourceHash, string $sourceRevision): void + { + global $db; + $assignments = []; + foreach ($payload as $column => $value) { + $assignments[] = '`' . str_replace('`', '', $column) . '` = ' . $this->sqlValue($value); + } + $assignments[] = "source_hash = '" . $db->escape_string($sourceHash) . "'"; + $assignments[] = "source_revision = '" . $db->escape_string($sourceRevision) . "'"; + $assignments[] = 'source_observed_at = NOW()'; + $assignments[] = 'source_stable_since = NOW()'; + $assignments[] = 'source_observation_count = 1'; + $assignments[] = "import_state = 'updated'"; + $assignments[] = "resolution_state = 'needs_review'"; + $assignments[] = "certainty = 'none'"; + $assignments[] = "planned_action = 'recheck'"; + $assignments[] = "state_reason = 'XL Vask-kildedata blev opdateret.'"; + $assignments[] = 'expected_version = expected_version + 1'; + $assignments[] = 'cached_total_net_amount = NULL'; + $assignments[] = 'cached_primary_product_name = NULL'; + $assignments[] = 'cached_amount_at = NULL'; + if ($db->query('UPDATE xlvask_usage_logs SET ' . implode(', ', $assignments) . " WHERE id = {$id}") === false + || $db->conn()->affected_rows !== 1) { + throw new Exception('Could not atomically update the XL Vask source row.'); + } + } + + private function supersedeSuggestionsForSourceRevision(int $usageLogId): void + { + global $db; + if ($db->query( + "UPDATE xlvask_automation_suggestions + SET status = 'superseded', updated_at = NOW() + WHERE usage_log_id = {$usageLogId} AND status <> 'superseded'" + ) === false) { + throw new Exception('Could not invalidate stale XL Vask automation suggestions.'); + } + } + + private function updateSourceMetadata(int $id, string $sourceHash, string $sourceRevision, string $state, bool $new): void + { + global $db; + $sourceHash = $db->escape_string($sourceHash); + $sourceRevision = $db->escape_string($sourceRevision); + $state = $db->escape_string($state); + $stable = $new ? 'NOW()' : "COALESCE(source_stable_since, NOW())"; + $observations = $new ? '1' : 'GREATEST(1, source_observation_count) + 1'; + $db->query( + "UPDATE xlvask_usage_logs SET + source_hash = '{$sourceHash}', source_revision = '{$sourceRevision}', + source_observed_at = NOW(), source_stable_since = {$stable}, + source_observation_count = {$observations}, import_state = '{$state}' + WHERE id = {$id}" + ); + } + + private function markImportState(int $id, string $state, string $reason): void + { + global $db; + $state = $db->escape_string($state); + $reason = $db->escape_string($reason); + $connection = $db->conn(); + $connection->begin_transaction(); + try { + if ($db->query( + "UPDATE xlvask_usage_logs SET import_state = '{$state}', resolution_state = 'failed', + certainty = 'none', planned_action = 'none', state_reason = '{$reason}', + source_stable_since = NULL, source_observation_count = 0, source_observed_at = NOW(), + expected_version = expected_version + 1 WHERE id = {$id}" + ) === false || $db->conn()->affected_rows !== 1) { + throw new Exception('Could not atomically mark invalid XL Vask source data.'); + } + $this->supersedeSuggestionsForSourceRevision($id); + $connection->commit(); + } catch (\Throwable $throwable) { + $connection->rollback(); + throw $throwable; + } + } + + private function recordSourceRevision( + int $id, + string $washId, + array $before, + array $after, + string $sourceHash, + string $sourceRevision + ): void { + global $db; + $beforeJson = $db->escape_string(json_encode([ + 'source_hash' => (string)($before['source_hash'] ?? ''), + 'source_revision' => (string)($before['source_revision'] ?? ''), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'); + $afterJson = $db->escape_string(json_encode([ + 'source_hash' => $sourceHash, + 'source_revision' => $sourceRevision, + 'observed_fields' => array_values(array_keys($after)), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'); + $washId = $db->escape_string($washId); + $sourceHash = $db->escape_string($sourceHash); + $sourceRevision = $db->escape_string($sourceRevision); + if ($db->query( + "INSERT INTO xlvask_automation_audit + (usage_log_id, wash_id, event_type, policy_version, input_hash, source_revision, before_json, after_json) + VALUES ({$id}, '{$washId}', 'source_updated', 'xlvask-autopilot-v1', '{$sourceHash}', '{$sourceRevision}', '{$beforeJson}', '{$afterJson}')" + ) === false || $db->conn()->affected_rows !== 1) { + throw new Exception('Could not record the XL Vask source revision audit event.'); + } + } + + private function sqlValue(mixed $value): string + { + global $db; + if ($value === null) { + return 'NULL'; + } + if (is_bool($value)) { + return $value ? '1' : '0'; + } + if (is_int($value) || is_float($value)) { + return (string)$value; + } + if (is_array($value)) { + $value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]'; + } + return "'" . $db->escape_string((string)$value) . "'"; } private static function formatImportDateFrom(?string $dateFrom): string diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index d221f796..faf1d912 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -10624,6 +10624,338 @@ paths: application/json: schema: {} + /modules/xlvask/services/usage/orders/summary: + get: + tags: + - Modules + summary: Summarize XLVask usage automation states + description: Returns read-only import, resolution, and certainty counts for XLVask usage rows in an invoice period. + operationId: summarizeXlvaskUsageAutomation + parameters: + - in: query + name: dateFrom + required: false + schema: + type: string + format: date + - in: query + name: dateTo + required: false + schema: + type: string + format: date + responses: + '200': + description: XLVask usage automation summary returned successfully + content: + application/json: + schema: + type: object + properties: + summary: + type: object + additionalProperties: + type: integer + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /modules/xlvask/services/usage/autopilot-runs: + post: + tags: + - Modules + summary: Create an XLVask usage autopilot run + description: Queues an idempotent invoice-period import and automation evaluation run. Dry-run mode evaluates without automatic execution. + operationId: createXlvaskUsageAutopilotRun + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + dateFrom: + type: string + format: date + dateTo: + type: string + format: date + ids: + type: array + items: + type: integer + limit: + type: integer + minimum: 1 + maximum: 500 + forceRefetch: + type: boolean + mode: + type: string + enum: [execute, dry_run, replay] + responses: + '202': + description: XLVask usage autopilot run queued successfully + content: + application/json: + schema: + type: object + properties: + run: + type: object + properties: + id: + type: integer + status: + type: string + phase: + type: string + mode: + type: string + processed: + type: integer + total: + type: integer + summary: + type: object + additionalProperties: + type: integer + warning: + type: string + error: + type: string + created_at: + type: string + nullable: true + started_at: + type: string + nullable: true + finished_at: + type: string + nullable: true + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /modules/xlvask/services/usage/autopilot-runs/{id}: + get: + tags: + - Modules + summary: Get an XLVask usage autopilot run + description: Returns status and summary metadata for a previously requested autopilot run. + operationId: getXlvaskUsageAutopilotRun + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '200': + description: XLVask usage autopilot run returned successfully + content: + application/json: + schema: + type: object + properties: + run: + type: object + '400': + description: Invalid XLVask autopilot run id + '404': + description: XLVask autopilot run not found + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /modules/xlvask/services/usage/automation/decisions/preview: + post: + tags: + - Modules + summary: Preview an XLVask automation decision + description: Creates a short-lived preview token for applying bulk accept, deny, ignore, or link decisions after source revision revalidation. + operationId: previewXlvaskUsageAutomationDecision + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + usage_log_ids: + type: array + items: + type: integer + action: + type: string + enum: [accept, attach_order, create_order, deny, ignore] + suggestion_id: + type: integer + nullable: true + order_id: + type: integer + nullable: true + reason: + type: string + responses: + '200': + description: XLVask automation decision preview created successfully + content: + application/json: + schema: + type: object + properties: + preview: + type: object + properties: + id: { type: string } + selection_hash: { type: string } + requires_confirmation: { type: boolean } + confirmation_phrase: + type: string + nullable: true + description: Opaque preview-issued phrase that must be submitted exactly when confirmation is required. + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /modules/xlvask/services/usage/automation/decisions/apply: + post: + tags: + - Modules + summary: Apply an XLVask automation decision preview + description: Applies a previewed decision inside a transactional policy boundary after source hash, expected version, hall scope, and selection hash are revalidated. + operationId: applyXlvaskUsageAutomationDecision + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [preview_id, selection_hash] + properties: + preview_id: + type: string + selection_hash: + type: string + confirmation_text: + type: string + responses: + '200': + description: XLVask automation decision applied successfully + content: + application/json: + schema: + type: object + properties: + applied: + type: integer + results: + type: array + items: + type: object + failed: + type: array + items: + type: object + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /modules/xlvask/services/usage/automation/admin/readiness: + get: + tags: [Modules] + summary: Inspect XLVask automation activation readiness + description: Read-only, fail-closed view of wash-id uniqueness and active calibration artifacts. + operationId: getXlvaskAutomationActivationReadiness + responses: + '200': + description: Activation readiness returned successfully + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /modules/xlvask/services/usage/automation/admin/calibrations/labels: + post: + tags: [Modules] + summary: Adjudicate one exact XLVask suggestion + description: Stores an administrator-adjudicated correct or incorrect label bound to one suggestion ID. + operationId: adjudicateXlvaskCalibrationLabel + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [suggestion_id, outcome] + properties: + suggestion_id: { type: integer } + outcome: { type: string, enum: [correct, incorrect] } + responses: + '200': { description: Calibration label stored successfully } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /modules/xlvask/services/usage/automation/admin/calibrations/backtest: + post: + tags: [Modules] + summary: Generate an inactive XLVask calibration artifact + description: Uses exact adjudicated labels and a chronological 80/20 holdout; generation never activates the artifact. + operationId: generateXlvaskCalibrationArtifact + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [segment_key] + properties: + segment_key: { type: string } + responses: + '200': { description: Inactive calibration artifact generated successfully } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /modules/xlvask/services/usage/automation/admin/calibrations/{id}/activate: + post: + tags: [Modules] + summary: Activate a qualifying XLVask calibration artifact + operationId: activateXlvaskCalibrationArtifact + parameters: + - in: path + name: id + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [artifact_hash, confirmation_text] + properties: + artifact_hash: { type: string } + confirmation_text: { type: string } + responses: + '200': { description: Calibration artifact activated successfully } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /modules/xlvask/services/usage/automation/admin/wash-id-uniqueness/activate: + post: + tags: [Modules] + summary: Activate guarded wash-id uniqueness + description: Explicitly verifies duplicates, adds the normalized wash-id column and unique index, and fails closed on conflicts. + operationId: activateXlvaskWashIdUniqueness + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [confirmation_text] + properties: + confirmation_text: { type: string } + responses: + '200': { description: Wash-id uniqueness activated successfully } + '409': { description: Duplicate wash IDs or schema readiness blocked activation } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + /modules/action-logs: get: tags: diff --git a/services/nginx/app/routes/moduleXLVaskRoute.php b/services/nginx/app/routes/moduleXLVaskRoute.php index 8c10432d..59b0692e 100644 --- a/services/nginx/app/routes/moduleXLVaskRoute.php +++ b/services/nginx/app/routes/moduleXLVaskRoute.php @@ -2,13 +2,13 @@ namespace routes; -require_once WD . '/classes/xlvask_automation_service.php'; +require_once WD . '/classes/xlvask_autopilot_service.php'; use classes\authentication; use classes\response; use classes\router; use classes\xlvask; -use classes\xlvask_automation_service; +use classes\xlvask_autopilot_service; use objects\orders_o; use objects\users_o; use objects\xlvask_customers_o; @@ -255,18 +255,27 @@ class moduleXLVaskRoute $this->get('/modules/xlvask/tasks/import-usage', function () { global $response; self::requirePermission('modules_xlvask_import_usage'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } $dateFrom = $this->isParametersSet(['dateFrom']) ? trim((string)$this->getParameter('dateFrom')) : null; $dateTo = $this->isParametersSet(['dateTo']) ? trim((string)$this->getParameter('dateTo')) : null; - // Create the xlvask_usage_logs_o object - $xlvask_usage_logs_o = new \objects\xlvask_usage_logs_o(); - // Import usage logs - $xlvask_usage_logs_o->importUsageLogs($dateFrom, $dateTo); - (new xlvask_automation_service())->runPending($dateFrom, $dateTo, [], 100, null); - // Response - $response->success( - 'Usage logs imported', - 200 - ); + $hallIds = array_values(array_filter(array_map( + static fn(mixed $id): string => trim((string)$id), + (array)$user->getGroup()->getDepartmentsScannersHallIds() + ), static fn(string $id): bool => $id !== '' && strlen($id) <= 191)); + $run = (new xlvask_autopilot_service())->createRun([ + 'dateFrom' => $dateFrom, + 'dateTo' => $dateTo, + 'mode' => 'execute', + 'forceRefetch' => true, + ], (int)$user->id, $hallIds); + $response->success([ + 'deprecated' => true, + 'replacement' => '/modules/xlvask/services/usage/autopilot-runs', + 'run' => $run, + ], 202); }, [ 'modules_xlvask_import_usage' => 'Import usage from the xlvask module' diff --git a/services/nginx/app/routes/xlvaskUsageLogsRoute.php b/services/nginx/app/routes/xlvaskUsageLogsRoute.php index 08ee4a97..724aa415 100644 --- a/services/nginx/app/routes/xlvaskUsageLogsRoute.php +++ b/services/nginx/app/routes/xlvaskUsageLogsRoute.php @@ -3,17 +3,18 @@ namespace routes; require_once WD . '/classes/xlvask_automation_service.php'; +require_once WD . '/classes/xlvask_autopilot_service.php'; use classes\authentication; use classes\redis; use classes\response; use classes\stripe; use classes\xlvask; +use classes\xlvask_autopilot_service; use classes\xlvask_automation_service; use objects\collected_order_invoices_o; use objects\departments_o; use objects\economic_module_orders; -use objects\logs_o; use objects\orders_o; use objects\stripe_module_orders_o; use objects\stripe_payment_intents_o; @@ -31,7 +32,6 @@ class xlvaskUsageLogsRoute $permission_list_own = 'list_xlvask_usage_orders_own'; // Permission to list own orders (Without department filter) $permission_list_all = 'list_xlvask_usage_orders_all'; // Permission to list all orders (With department filter) $response_includes_items = false; // Whether to include items in the response (This would increase the memory usage significantly) - $response_includes_items_link = true; // Whether to include a cached direct link to fetch the order with items (This is used to avoid memory issues when the items are not needed) // Require the user to be logged in global $response; self::requirePermission($permission_list_own); @@ -45,8 +45,11 @@ class xlvaskUsageLogsRoute $user = (new authentication())->get_user(); // Check if the request was successful if ($user) { - // Log the incident - (new logs_o())->add('xlvask_usage_orders', 'global', 1, $user->id, 'LIST_XLVASK_USAGE_ORDERS', 'User accessed the list of xlvask usage orders'); + $allowedHallIds = self::allowedHallIdsForUser($user); + if ($allowedHallIds === []) { + $response->error('No XL Vask hall scope is available', 403); + return; + } $xlvask_usage_logs = new xlvask_usage_logs_o(); $xlvask = new xlvask(); $automation_service = new xlvask_automation_service(); @@ -58,19 +61,16 @@ class xlvaskUsageLogsRoute if (ini_get('memory_limit') < '5120M') { ini_set('memory_limit', '5120M'); } - // Set debug php - error_reporting(E_ALL); - ini_set('display_errors', '1'); // Return the list of usage logs $result = $xlvask_usage_logs // 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, $automation_service, &$linked_order_ids_by_wash_id) { - $automation = $automation_service->evaluateUsageLogRow($log, (int)$user->id, false); + function ($log) use ($response_includes_items, $xlvask_usage_logs, $xlvask, $automation_service, &$linked_order_ids_by_wash_id) { // Remove the 'id' field from the log $id = (int)$log['id']; - $amount_summary = $xlvask_usage_logs->getCachedAmountSummaryFromRow($log); + $automation = $automation_service->readAutomationStateByUsageLogId($id, $log); + $amount_summary = $xlvask_usage_logs->getAmountSummaryReadOnly($log); unset($log['id']); // Convert the 'WashItems' field from JSON to an array $log['WashItems'] = json_decode($log['WashItems'], true); @@ -98,6 +98,19 @@ class xlvaskUsageLogsRoute 'ignored_at', 'ignored_by', 'ignored_reason', + 'source_hash', + 'source_revision', + 'source_observed_at', + 'source_stable_since', + 'source_observation_count', + 'import_state', + 'resolution_state', + 'certainty', + 'planned_action', + 'state_reason', + 'expected_version', + 'last_run_id', + 'last_evaluated_at', ])); // Create a new xlvask usage log object $tmp = $xlvask->new($xlvask->helpers->xlvask_usage_log); @@ -111,16 +124,6 @@ class xlvaskUsageLogsRoute $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. - if ($response_includes_items_link && $isEligibleForAutomaticContinuance) { - // Add a link to the order with items - $fast_link_key = redis->generateTemporaryCacheKey(); - redis->set( - $fast_link_key, - json_encode($tmp->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - ); - redis->expire($fast_link_key, 3600); // Set the link to expire in 1 hour - } // Return the result $tmp_res = ($isEligibleForAutomaticContinuance ? (new orders_o())->simulateOrderFromXLVask($tmp, $response_includes_items) : []); $tmp_res['order']['customer_name'] = $tmp->Customer; // Add the customer name to the order @@ -129,12 +132,24 @@ class xlvaskUsageLogsRoute $tmp_res['order']['xlvask_amount_cached'] = $amount_summary['cached']; // Clear memory unset($tmp); - unset($log); // Return the result 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 + 'fast_link_key' => null, 'automation' => $automation, + 'source_hash' => $log['source_hash'] ?? null, + 'source_revision' => $log['source_revision'] ?? null, + 'source_observed_at' => $log['source_observed_at'] ?? null, + 'source_stable_since' => $log['source_stable_since'] ?? null, + 'source_observation_count' => (int)($log['source_observation_count'] ?? 0), + 'import_state' => $log['import_state'] ?? 'unchanged', + 'resolution_state' => $log['resolution_state'] ?? 'needs_review', + 'certainty' => $log['certainty'] ?? 'none', + 'planned_action' => $log['planned_action'] ?? 'none', + 'state_reason' => $log['state_reason'] ?? null, + 'expected_version' => isset($log['expected_version']) ? (int)$log['expected_version'] : 1, + 'last_run_id' => isset($log['last_run_id']) ? (int)$log['last_run_id'] : null, + 'last_evaluated_at' => $log['last_evaluated_at'] ?? null, ...$tmp_res['order'], // Return the simulated order from XLVask (with or without items) 'usage_log_id' => $id, 'linked_order_id' => $linked_order_id, @@ -143,7 +158,7 @@ class xlvaskUsageLogsRoute $xlvask_usage_logs->forceRestrictFilters( [ // This makes sure that the user can only see department logs that belong to their departments - 'HallId' => $user->getGroup()->getDepartmentsScannersHallIds(), + 'HallId' => $allowedHallIds, 'FinishStatus' => ['1'], // Only show finished logs ] ) @@ -151,8 +166,6 @@ class xlvaskUsageLogsRoute // Return the response $response->success($result); } else { - // Log the incident - (new logs_o())->add('xlvask_usage_orders', 'global', 1, 0, 'LIST_XLVASK_USAGE_ORDERS', 'No user found, or invalid session'); // Return an error $response->error('Invalid session', 400); } @@ -163,6 +176,197 @@ class xlvaskUsageLogsRoute ] ); + $this->get('/modules/xlvask/services/usage/orders/summary', function () { + global $response; + $this->requirePermission('list_xlvask_usage_orders_own'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $dateFrom = $this->isParametersSet(['dateFrom']) ? (string)$this->getParameter('dateFrom') : null; + $dateTo = $this->isParametersSet(['dateTo']) ? (string)$this->getParameter('dateTo') : null; + + $response->success([ + 'summary' => (new xlvask_autopilot_service())->getSummary( + $dateFrom, + $dateTo, + self::allowedHallIdsForUser($user) + ), + ]); + }, + [ + 'list_xlvask_usage_orders_own' => 'Read XL Vask usage-log automation summary', + ] + ); + + $this->post('/modules/xlvask/services/usage/autopilot-runs', function () { + global $response; + $this->requirePermission('manage_xlvask_usage_automation'); + + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + + $input = []; + foreach (['ids', 'dateFrom', 'dateTo', 'limit', 'forceRefetch', 'mode', 'idempotency_key'] as $key) { + if ($this->isParametersSet([$key])) { + $input[$key] = $this->getParameter($key); + } + } + + $response->success([ + 'run' => (new xlvask_autopilot_service())->createRun( + $input, + (int)$user->id, + self::allowedHallIdsForUser($user) + ), + ], 202); + }, + [ + 'manage_xlvask_usage_automation' => 'Create an XL Vask usage-log autopilot run', + ] + ); + + $this->get('/modules/xlvask/services/usage/automation/admin/readiness', function () { + global $response; + $this->requirePermission('superuser_xlvask_automation_activate'); + $response->success((new xlvask_autopilot_service())->activationReadiness()); + }, [ + 'superuser_xlvask_automation_activate' => 'Inspect XL Vask automation activation readiness', + ]); + + $this->post('/modules/xlvask/services/usage/automation/admin/calibrations/backtest', function () { + global $response; + $this->requirePermission('superuser_xlvask_automation_activate'); + self::requireParameters(['segment_key']); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $response->success([ + 'artifact' => (new xlvask_autopilot_service())->generateCalibrationArtifact( + trim((string)$this->getParameter('segment_key')), + (int)$user->id + ), + ]); + }, [ + 'superuser_xlvask_automation_activate' => 'Generate an inactive XL Vask historical calibration artifact', + ]); + + $this->post('/modules/xlvask/services/usage/automation/admin/calibrations/labels', function () { + global $response; + $this->requirePermission('superuser_xlvask_automation_activate'); + self::requireParameters(['suggestion_id', 'outcome']); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $response->success((new xlvask_autopilot_service())->adjudicateCalibrationLabel( + (int)$this->getParameter('suggestion_id'), + trim((string)$this->getParameter('outcome')), + (int)$user->id + )); + }, [ + 'superuser_xlvask_automation_activate' => 'Adjudicate one exact XL Vask suggestion for calibration evidence', + ]); + + $this->post('/modules/xlvask/services/usage/automation/admin/calibrations/{id}/activate', function () { + global $response; + $this->requirePermission('superuser_xlvask_automation_activate'); + self::requireParameters(['artifact_hash', 'confirmation_text']); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $response->success((new xlvask_autopilot_service())->activateCalibration( + (int)($this->fromRoute('id') ?? 0), + trim((string)$this->getParameter('artifact_hash')), + (string)$this->getParameter('confirmation_text'), + (int)$user->id + )); + }, [ + 'superuser_xlvask_automation_activate' => 'Explicitly activate a qualifying XL Vask calibration artifact', + ]); + + $this->post('/modules/xlvask/services/usage/automation/admin/wash-id-uniqueness/activate', function () { + global $response; + $this->requirePermission('superuser_xlvask_automation_activate'); + self::requireParameters(['confirmation_text']); + $response->success((new xlvask_autopilot_service())->activateWashIdUniqueness( + (string)$this->getParameter('confirmation_text') + )); + }, [ + 'superuser_xlvask_automation_activate' => 'Explicitly activate the guarded XL Vask wash-id uniqueness migration', + ]); + + $this->get('/modules/xlvask/services/usage/autopilot-runs/{id}', function () { + global $response; + $this->requirePermission('manage_xlvask_usage_automation'); + $id = (int)($this->fromRoute('id') ?? 0); + if ($id < 1) { + $response->error('Invalid XL Vask autopilot run id', 400); + } + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $run = (new xlvask_autopilot_service())->getRun( + $id, + (int)$user->id, + self::allowedHallIdsForUser($user) + ); + $response->success(['run' => $run]); + }, + [ + 'manage_xlvask_usage_automation' => 'Read XL Vask usage-log autopilot run status', + ] + ); + + $this->post('/modules/xlvask/services/usage/automation/decisions/preview', function () { + global $response; + $this->requirePermission('manage_xlvask_usage_automation'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $input = []; + foreach (['usage_log_ids', 'action', 'suggestion_id', 'order_id', 'reason'] as $key) { + if ($this->isParametersSet([$key])) { + $input[$key] = $this->getParameter($key); + } + } + $response->success([ + 'preview' => (new xlvask_autopilot_service())->createDecisionPreview( + $input, + (int)$user->id, + self::allowedHallIdsForUser($user) + ), + ]); + }, ['manage_xlvask_usage_automation' => 'Preview an XL Vask automation decision']); + + $this->post('/modules/xlvask/services/usage/automation/decisions/apply', function () { + global $response; + $this->requirePermission('manage_xlvask_usage_automation'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $input = []; + foreach (['preview_id', 'selection_hash', 'confirmation_text'] as $key) { + if ($this->isParametersSet([$key])) { + $input[$key] = $this->getParameter($key); + } + } + $response->success( + (new xlvask_autopilot_service())->applyDecision( + $input, + (int)$user->id, + self::allowedHallIdsForUser($user) + ) + ); + }, ['manage_xlvask_usage_automation' => 'Apply a previewed XL Vask automation decision']); + $this->patch('/modules/xlvask/services/usage/orders/{id}/ignore', function () { global $db, $response; $this->requirePermission('ignore_xlvask_usage_order'); @@ -176,8 +380,9 @@ class xlvaskUsageLogsRoute if ($id < 1) { $response->error('Invalid XL Vask usage log id', 400); } + self::requireUsageLogInHallScope($id, self::allowedHallIdsForUser($user)); - $reason = $this->isParametersSet(['reason']) ? trim((string)$this->getParameter('reason')) : null; + $reason = $this->isParametersSet(['reason']) ? mb_substr(trim((string)$this->getParameter('reason')), 0, 1000) : null; $reasonSql = $reason === null || $reason === '' ? 'NULL' : "'" . $db->escape_string($reason) . "'"; @@ -187,7 +392,9 @@ class xlvaskUsageLogsRoute "UPDATE xlvask_usage_logs SET ignored_at = NOW(), ignored_by = " . (int)$user->id . ", - ignored_reason = {$reasonSql} + ignored_reason = {$reasonSql}, + resolution_state = 'ignored', certainty = 'none', planned_action = 'none', + expected_version = expected_version + 1 WHERE id = {$id}" ); @@ -218,10 +425,20 @@ class xlvaskUsageLogsRoute $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; + $allowedHallIds = self::allowedHallIdsForUser($user); + if ($allowedHallIds === []) { + $response->error('No XL Vask hall scope is available', 403); + } - $response->success( - (new xlvask_automation_service())->runPending($dateFrom, $dateTo, $ids, $limit, (int)$user->id) - ); + $response->success([ + 'run' => (new xlvask_autopilot_service())->createRun([ + 'mode' => 'execute', + 'dateFrom' => $dateFrom, + 'dateTo' => $dateTo, + 'ids' => $ids, + 'limit' => $limit, + ], (int)$user->id, $allowedHallIds), + ], 202); }, [ 'manage_xlvask_usage_automation' => 'Evaluate and execute XL Vask usage-log automation', @@ -241,10 +458,15 @@ class xlvaskUsageLogsRoute if ($id < 1) { $response->error('Invalid XL Vask usage log id', 400); } + self::requireUsageLogInHallScope($id, self::allowedHallIdsForUser($user)); - $response->success( - (new xlvask_automation_service())->evaluateUsageLogById($id, (int)$user->id, true) - ); + $response->success([ + 'run' => (new xlvask_autopilot_service())->createRun([ + 'mode' => 'dry_run', + 'ids' => [$id], + 'limit' => 1, + ], (int)$user->id, self::allowedHallIdsForUser($user)), + ], 202); }, [ 'manage_xlvask_usage_automation' => 'Evaluate XL Vask usage-log automation', @@ -264,13 +486,9 @@ class xlvaskUsageLogsRoute if ($id < 1) { $response->error('Invalid XL Vask usage log id', 400); } + self::requireUsageLogInHallScope($id, self::allowedHallIdsForUser($user)); - $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) - ); + $response->error('Use the server-generated automation decision preview and apply endpoints.', 409); }, [ 'manage_xlvask_usage_automation' => 'Accept an XL Vask usage-log automation suggestion', @@ -290,13 +508,9 @@ class xlvaskUsageLogsRoute if ($id < 1) { $response->error('Invalid XL Vask usage log id', 400); } + self::requireUsageLogInHallScope($id, self::allowedHallIdsForUser($user)); - $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) - ); + $response->error('Use the server-generated automation decision preview and apply endpoints.', 409); }, [ 'manage_xlvask_usage_automation' => 'Deny an XL Vask usage-log automation suggestion', @@ -305,6 +519,11 @@ class xlvaskUsageLogsRoute $this->get('/modules/xlvask/services/usage/orders/fast-link', function () { global $response; + $this->requirePermission('list_xlvask_usage_orders_own'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } self::requireParameters([ 'fast_link_key', // Example: 'temporary_cache_6878cf0603d77' ]); @@ -324,6 +543,10 @@ class xlvaskUsageLogsRoute $data = json_decode($cached_data, true); // Check if the data is valid if (is_array($data)) { + $allowedHallIds = self::allowedHallIdsForUser($user); + if (!in_array(trim((string)($data['HallId'] ?? '')), $allowedHallIds, true)) { + $response->error('Fast link is outside the current XL Vask hall scope', 403); + } // Delete the cached data from Redis redis->delete($fast_link_key); // Return the data @@ -387,4 +610,30 @@ class xlvaskUsageLogsRoute ] ); } + + private static function allowedHallIdsForUser(object $user): array + { + return array_values(array_unique(array_filter( + array_map(static fn(mixed $id): string => trim((string)$id), (array)$user->getGroup()->getDepartmentsScannersHallIds()), + static fn(string $id): bool => $id !== '' && strlen($id) <= 191 + ))); + } + + private static function requireUsageLogInHallScope(int $usageLogId, array $allowedHallIds): void + { + global $db, $response; + if ($allowedHallIds === []) { + $response->error('XL Vask usage log not found', 404); + } + $hallSql = implode(',', array_map( + static fn(string $hallId): string => "'" . $db->escape_string($hallId) . "'", + $allowedHallIds + )); + $result = $db->query( + "SELECT id FROM xlvask_usage_logs WHERE id = {$usageLogId} AND HallId IN ({$hallSql}) LIMIT 1" + ); + if ($result === false || $result->num_rows < 1) { + $response->error('XL Vask usage log not found', 404); + } + } } diff --git a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php index 8c06a7ef..c6601637 100644 --- a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php +++ b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php @@ -524,6 +524,7 @@ CREATE TABLE IF NOT EXISTS `plate_scanners` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `department_id` INT NOT NULL, `lane_id` INT NULL, + `HallId` VARCHAR(191) NULL, `name` VARCHAR(255) NOT NULL, `notes` TEXT NULL, `api_key` VARCHAR(191) NOT NULL, diff --git a/services/nginx/app/tests/Unit/Cron/CronTaskRegistryTest.php b/services/nginx/app/tests/Unit/Cron/CronTaskRegistryTest.php index fd7d35d3..b2cb974e 100644 --- a/services/nginx/app/tests/Unit/Cron/CronTaskRegistryTest.php +++ b/services/nginx/app/tests/Unit/Cron/CronTaskRegistryTest.php @@ -7,7 +7,7 @@ it('discovers module-owned cron task definitions', function (): void { $registry = new cron_task_registry(app_path('modules')); $definitions = $registry->definitions(); - expect($definitions)->toHaveCount(23); + expect($definitions)->toHaveCount(24); expect(array_keys($definitions))->toContain( 'system.sync_logs', 'backups.process_jobs', @@ -16,6 +16,7 @@ it('discovers module-owned cron task definitions', function (): void { 'dynamicimages.pre_render', 'weatherapi.preload_department_responses', 'goals.progress_alerts', + 'xlvask.autopilot_queue', 'account.process_deletion_requests', 'selfserve.activate_opening_cleaner_relays' ); diff --git a/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php b/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php index 1aca4dbf..85140bc4 100644 --- a/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php +++ b/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php @@ -1,8 +1,12 @@ toContain('UNIQUE KEY `uniq_xlvask_openai_cache_key` (`cache_key`)'); }); +it('declares auditable and idempotent XL Vask autopilot run tables', function (): void { + $bootstrapContent = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php'); + + expect($bootstrapContent) + ->toContain('xlvask_autopilot_runs') + ->toContain('xlvask_autopilot_run_items') + ->toContain('xlvask_automation_audit') + ->toContain('UNIQUE KEY `uniq_xlvask_autopilot_run_idempotency` (`idempotency_key`)') + ->toContain('updated_at'); +}); + it('declares cached amount summary columns for XL Vask usage logs', function (): void { $bootstrapContent = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php'); @@ -148,12 +163,227 @@ it('keeps automatic XL Vask execution scoped to exact attachments', function (): ->toContain("return 'Automatisk accepteret: Prisoverensstemmelse.';"); }); -it('does not automatically create XL Vask orders', function (): void { +it('keeps automatic order creation behind calibration and uniqueness readiness gates', function (): void { $serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php'); expect($serviceContent) ->toContain('if ($action === self::ACTION_CREATE) {') - ->toContain('return false;'); + ->toContain('automatic_order_creation_enabled->isTrue()') + ->toContain("(string)(\$suggestion['certainty'] ?? '') !== 'certain'") + ->toContain('washIdUniquenessReady()'); +}); + +it('never classifies missing or undersized calibration evidence as certain', function (): void { + expect(xlvask_automation_service::classifyCertaintyForAutomation([]))->toBe('uncertain') + ->and(xlvask_automation_service::classifyCertaintyForAutomation([ + 'active' => true, + 'precision_value' => 0.999, + 'wilson_lower_bound' => 0.99, + 'holdout_examples' => 40, + 'overall_examples' => 199, + 'segment_examples' => 30, + 'contradictions' => 0, + ]))->toBe('uncertain'); +}); + +it('classifies only a qualifying contradiction-free calibration artifact as certain', function (): void { + $artifact = [ + 'active' => true, + 'precision_value' => 0.995, + 'wilson_lower_bound' => 0.98, + 'holdout_examples' => 200, + 'overall_examples' => 200, + 'segment_examples' => 30, + 'contradictions' => 0, + ]; + + expect(xlvask_automation_service::classifyCertaintyForAutomation($artifact))->toBe('certain') + ->and(xlvask_automation_service::classifyCertaintyForAutomation($artifact, true, ['conflict']))->toBe('uncertain'); +}); + +it('requires two source observations and a six-hour stable window for automatic actions', function (): void { + $now = strtotime('2026-08-03 12:00:00'); + $stable = [ + 'source_observation_count' => 2, + 'source_observed_at' => '2026-08-03 11:55:00', + 'source_stable_since' => '2026-08-03 06:00:00', + ]; + expect(xlvask_automation_service::sourceIsStableForAutomatic($stable, $now))->toBeTrue() + ->and(xlvask_automation_service::sourceIsStableForAutomatic([ + ...$stable, 'source_observation_count' => 1, + ], $now))->toBeFalse() + ->and(xlvask_automation_service::sourceIsStableForAutomatic([ + ...$stable, 'source_stable_since' => '2026-08-03 06:00:01', + ], $now))->toBeFalse(); +}); + +it('builds order-independent revision hashes for XL Vask source payloads', function (): void { + $first = ['WashId' => 'wash-1', 'Customer' => 'A', 'WashItems' => [['Count' => 1, 'Name' => 'Vask']]]; + $second = ['WashItems' => [['Name' => 'Vask', 'Count' => 1]], 'Customer' => 'A', 'WashId' => 'wash-1']; + + expect(xlvask_usage_logs_o::sourceHashForAutomation($first)) + ->toBe(xlvask_usage_logs_o::sourceHashForAutomation($second)); +}); + +it('uses the existing OpenAI module with strict no-retention planner settings', function (): void { + $openAi = file_get_contents(WD . '/classes/openai.php'); + $automation = file_get_contents(WD . '/classes/xlvask_automation_service.php'); + + expect($openAi)->toContain("'store' => false") + ->and($automation)->toContain("private const PLANNER_MODEL = 'gpt-5.6-sol'") + ->toContain('candidate_order_id') + ->toContain('opaque_context_id'); +}); + +it('verifies XL Vask TLS and never logs authorization headers or response bodies', function (): void { + $client = file_get_contents(WD . '/modules/xlvask/classes/xlvask_request.php'); + + expect($client)->toContain('CURLOPT_SSL_VERIFYPEER, true') + ->toContain('CURLOPT_SSL_VERIFYHOST, 2') + ->not->toContain('Headers: " . implode') + ->not->toContain('Response: $response'); +}); + +it('uses a dedicated queued XL Vask autopilot service with scoped durable runs', function (): void { + $serviceContent = file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); + + expect($serviceContent) + ->toContain('public function createRun(') + ->toContain('public function processQueuedRuns(') + ->toContain('public function getRun(') + ->toContain('ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)') + ->toContain('scope_hall_ids_json') + ->toContain('lease_expires_at') + ->toContain('attempt_count') + ->toContain('next_attempt_at') + ->toContain('$renewLease') + ->toContain("phase = 'retry_wait'"); +}); + +it('enforces distinct runtime capabilities for execute dry-run and replay modes', function (): void { + expect(xlvask_autopilot_service::modeCapabilities('execute'))->toBe([ + 'import' => true, 'persist_plans' => true, 'execute_actions' => true, 'run_artifacts' => true, + ])->and(xlvask_autopilot_service::modeCapabilities('dry_run'))->toBe([ + 'import' => true, 'persist_plans' => true, 'execute_actions' => false, 'run_artifacts' => true, + ])->and(xlvask_autopilot_service::modeCapabilities('replay'))->toBe([ + 'import' => false, 'persist_plans' => false, 'execute_actions' => false, 'run_artifacts' => true, + ]); +}); + +it('preserves GUID hall scopes and rejects empty scope values at runtime', function (): void { + expect(xlvask_autopilot_service::normalizeHallScope([ + ' 845d29a1-a7d2-4e3b-bbc3-2b13242d744a ', '', '845d29a1-a7d2-4e3b-bbc3-2b13242d744a', + ]))->toBe(['845d29a1-a7d2-4e3b-bbc3-2b13242d744a']); +}); + +it('keeps explicit retry keys idempotent and actor-bound', function (): void { + expect(xlvask_autopilot_service::idempotencyKey('retry-1', 10, 'nonce-a')) + ->toBe(xlvask_autopilot_service::idempotencyKey('retry-1', 10, 'nonce-b')) + ->not->toBe(xlvask_autopilot_service::idempotencyKey('retry-1', 11, 'nonce-a')) + ->and(xlvask_autopilot_service::idempotencyKey('', 10, 'nonce-a')) + ->not->toBe(xlvask_autopilot_service::idempotencyKey('', 10, 'nonce-b')); + expect(xlvask_autopilot_service::requestFingerprint(['mode' => 'execute', 'ids' => [1]])) + ->not->toBe(xlvask_autopilot_service::requestFingerprint(['mode' => 'dry_run', 'ids' => [1]])); +}); + +it('fails preview snapshots closed when source hash or optimistic version changes', function (): void { + $current = ['expected_version' => 4, 'source_hash' => str_repeat('a', 64)]; + expect(xlvask_autopilot_service::previewSnapshotMatches(4, str_repeat('a', 64), $current))->toBeTrue() + ->and(xlvask_autopilot_service::previewSnapshotMatches(5, str_repeat('a', 64), $current))->toBeFalse() + ->and(xlvask_autopilot_service::previewSnapshotMatches(4, str_repeat('b', 64), $current))->toBeFalse(); +}); + +it('uses exact adjudicated suggestion labels and a chronological holdout for calibration', function (): void { + $serviceContent = file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); + expect($serviceContent) + ->toContain('INNER JOIN xlvask_automation_suggestions s ON s.id = l.suggestion_id') + ->toContain('chronological_80_20_by_suggestion_created_at_and_id') + ->toContain('xlvask_automation_calibration_label_events') + ->toContain("'label_snapshot' => \$snapshot") + ->not->toContain("SUM(f.decision = 'accepted')"); +}); + +it('keeps XL Vask automation execution inside a transactional revalidation boundary', function (): void { + $serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php'); + + expect($serviceContent) + ->toContain('$connection->begin_transaction()') + ->toContain('$connection->commit()') + ->toContain('$connection->rollback()') + ->toContain('SELECT * FROM xlvask_usage_logs WHERE id = {$usageLogId} FOR UPDATE') + ->toContain("SELECT id FROM orders WHERE LOWER(TRIM(wash_id)) = LOWER(TRIM('{\$washId}')) FOR UPDATE") + ->toContain('XL Vask-kildedata blev ændret efter evalueringen.'); +}); + +it('keeps OpenAI advisory-only and derives automatic certainty from hard guards', function (): void { + $serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php'); + expect($serviceContent) + ->toContain("=== self::SOURCE_OPENAI") + ->toContain('hardGuardsPassForCertainty($suggestion, $context)') + ->toContain('sourceIsStableForAutomatic($context)') + ->toContain('washIdUniquenessReady()'); +}); + +it('rotates pending rows fairly and invalidates suggestions atomically on source changes', function (): void { + $automation = file_get_contents(WD . '/classes/xlvask_automation_service.php'); + $usageLogs = file_get_contents(WD . '/objects/xlvask_usage_logs_o.php'); + expect($automation) + ->toContain("ORDER BY COALESCE(last_evaluated_at, '1970-01-01 00:00:00') ASC, id ASC") + ->toContain('eligible_total') + ->and($usageLogs) + ->toContain('supersedeSuggestionsForSourceRevision') + ->toContain("SET status = 'superseded'") + ->toContain('$connection->begin_transaction()'); +}); + +it('captures the eligible population before processing and fails invalid revisions closed', function (): void { + $automation = file_get_contents(WD . '/classes/xlvask_automation_service.php'); + $autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); + $usageLogs = file_get_contents(WD . '/objects/xlvask_usage_logs_o.php'); + expect(strpos($automation, '$eligibleTotal =')) + ->toBeLessThan(strpos($automation, 'foreach ($rows as $row)')) + ->and($automation)->toContain("=== 'invalid'") + ->toContain("=== 'updated'") + ->toContain("=== 'recheck'") + ->toContain('$linkedOrder->asArray(true, false)') + ->toContain("'certainty' => 'certain'") + ->toContain('existing_link_semantically_revalidated') + ->toContain('linked_order_revision_mismatch') + ->and($autopilot)->toContain('has invalid source data') + ->and($usageLogs)->toContain('source_stable_since = NULL') + ->toContain('supersedeSuggestionsForSourceRevision($id)'); +}); + +it('keeps read-only replay cache-only without new OpenAI network calls', function (): void { + $automation = file_get_contents(WD . '/classes/xlvask_automation_service.php'); + expect($automation)->toContain("if (\$this->readOnlyEvaluation) {\n return null;"); +}); + +it('revalidates linked orders against customer department registration lane date and normalized items', function (): void { + $proposedOrder = [ + 'customer_id' => 10, 'department_id' => 2, 'reg_1' => 'AB 12 345', + 'lane' => 3, 'created_at' => '2026-08-03 10:00:00', + ]; + $linkedOrder = [ + 'customer_id' => 10, 'department_id' => 2, 'reg_1' => 'AB12345', + 'lane' => 3, 'created_at' => '2026-08-03 10:05:00', + ]; + $items = [['product_id' => 5, 'quantity' => 1, 'price' => 500]]; + expect(xlvask_automation_service::linkedOrderMatchesForAutomation($proposedOrder, $items, $linkedOrder, $items)) + ->toBeTrue() + ->and(xlvask_automation_service::linkedOrderMatchesForAutomation( + $proposedOrder, + $items, + [...$linkedOrder, 'customer_id' => 11], + $items + ))->toBeFalse(); +}); + +it('runs autopilot retention from the hourly XL Vask cleanup path', function (): void { + $autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); + $tasks = file_get_contents(WD . '/modules/xlvask/helpers/xlvask_tasks.php'); + expect($autopilot)->toContain('public function pruneExpiredData(): array') + ->and($tasks)->toContain('(new xlvask_autopilot_service())->pruneExpiredData();'); }); it('scores same-day orders with matching XL Vask products and extra add-ons as attach suggestions', function (): void { diff --git a/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php index 406c1c43..943a38de 100644 --- a/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php +++ b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php @@ -23,9 +23,13 @@ it('does not execute XL Vask usage automation while listing usage order rows', f $route = (string)$route; expect($route) - ->toContain('$automation = $automation_service->evaluateUsageLogRow($log, (int)$user->id, false);') + ->toContain('$automation = $automation_service->readAutomationStateByUsageLogId($id, $log);') ->and($route)->not->toContain('$automation = $automation_service->evaluateUsageLogRow($log, (int)$user->id, true);') ->and($route)->toContain("requirePermission('manage_xlvask_usage_automation')"); + expect($route) + ->toContain("if (\$allowedHallIds === [])") + ->toContain("No XL Vask hall scope is available', 403") + ->not->toContain('__no_authorized_xlvask_hall__'); }); it('returns cached amount summaries on XL Vask usage order rows without widening the usage-log object payload', function (): void { @@ -36,7 +40,7 @@ it('returns cached amount summaries on XL Vask usage order rows without widening $route = (string)$route; expect($route) - ->toContain('$amount_summary = $xlvask_usage_logs->getCachedAmountSummaryFromRow($log)') + ->toContain('$amount_summary = $xlvask_usage_logs->getAmountSummaryReadOnly($log)') ->and($route)->toContain('$usage_log_payload = array_intersect_key($log, array_flip([') ->and($route)->toContain('$tmp->setProperties($usage_log_payload)') ->and($route)->toContain("\$tmp_res['order']['total_net_amount'] = \$amount_summary['total_net_amount']") @@ -44,7 +48,7 @@ it('returns cached amount summaries on XL Vask usage order rows without widening ->and($route)->toContain("\$tmp_res['order']['xlvask_amount_cached'] = \$amount_summary['cached']"); }); -it('scopes manual XL Vask usage import and automation to optional period dates', function (): void { +it('adapts the legacy XL Vask usage import route to a scoped queued autopilot run', function (): void { $route = file_get_contents(WD . '/routes/moduleXLVaskRoute.php'); $automation = file_get_contents(WD . '/classes/xlvask_automation_service.php'); @@ -58,7 +62,102 @@ it('scopes manual XL Vask usage import and automation to optional period dates', expect($route) ->toContain("getParameter('dateFrom')") ->toContain("getParameter('dateTo')") - ->toContain('$xlvask_usage_logs_o->importUsageLogs($dateFrom, $dateTo)') - ->toContain('runPending($dateFrom, $dateTo, [], 100, null)') + ->toContain("'replacement' => '/modules/xlvask/services/usage/autopilot-runs'") + ->toContain("'forceRefetch' => true") + ->not->toContain('runPending($dateFrom, $dateTo, [], 100, null)') ->and($automation)->toContain("STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')"); }); + +it('exposes additive XL Vask autopilot run and summary routes', function (): void { + $route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php'); + + expect($route)->not->toBeFalse(); + + $route = (string)$route; + + expect($route) + ->toContain("get('/modules/xlvask/services/usage/orders/summary'") + ->toContain("post('/modules/xlvask/services/usage/autopilot-runs'") + ->toContain("get('/modules/xlvask/services/usage/autopilot-runs/{id}'") + ->toContain('(new xlvask_autopilot_service())->getSummary(') + ->toContain('(new xlvask_autopilot_service())->createRun(') + ->toContain('(new xlvask_autopilot_service())->getRun(') + ->toContain('self::allowedHallIdsForUser($user)') + ->toContain('], 202);'); +}); + +it('routes legacy automation entry points through the durable queue and preview lifecycle', function (): void { + $route = (string)file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php'); + expect($route) + ->toContain("'mode' => 'execute'") + ->toContain("'mode' => 'dry_run'") + ->toContain('Use the server-generated automation decision preview and apply endpoints.') + ->not->toContain('(new xlvask_automation_service())->runPending(') + ->not->toContain('(new xlvask_automation_service())->evaluateUsageLogById('); +}); + +it('returns revision and resolution state on XL Vask usage order rows', function (): void { + $route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php'); + + expect($route)->not->toBeFalse(); + + $route = (string)$route; + + expect($route) + ->toContain("'source_hash' => \$log['source_hash'] ?? null") + ->toContain("'source_revision' => \$log['source_revision'] ?? null") + ->toContain("'import_state' => \$log['import_state'] ?? 'unchanged'") + ->toContain("'resolution_state' => \$log['resolution_state'] ?? 'needs_review'") + ->toContain("'certainty' => \$log['certainty'] ?? 'none'") + ->toContain("'planned_action' => \$log['planned_action'] ?? 'none'") + ->toContain("'expected_version' => isset(\$log['expected_version']) ? (int)\$log['expected_version'] : 1") + ->toContain("'automation' => \$automation"); +}); + +it('documents XL Vask autopilot summary and run APIs in OpenAPI', function (): void { + $openApi = file_get_contents(WD . '/openapi.yaml'); + + expect($openApi)->not->toBeFalse(); + + $openApi = (string)$openApi; + + expect($openApi) + ->toContain('/modules/xlvask/services/usage/orders/summary:') + ->toContain('operationId: summarizeXlvaskUsageAutomation') + ->toContain('/modules/xlvask/services/usage/autopilot-runs:') + ->toContain('operationId: createXlvaskUsageAutopilotRun') + ->toContain('/modules/xlvask/services/usage/autopilot-runs/{id}:') + ->toContain('operationId: getXlvaskUsageAutopilotRun') + ->toContain('/modules/xlvask/services/usage/automation/decisions/preview:') + ->toContain('operationId: previewXlvaskUsageAutomationDecision') + ->toContain('/modules/xlvask/services/usage/automation/decisions/apply:') + ->toContain('operationId: applyXlvaskUsageAutomationDecision') + ->toContain('/modules/xlvask/services/usage/automation/admin/readiness:') + ->toContain('operationId: adjudicateXlvaskCalibrationLabel') + ->toContain('operationId: generateXlvaskCalibrationArtifact') + ->toContain('operationId: activateXlvaskCalibrationArtifact') + ->toContain('operationId: activateXlvaskWashIdUniqueness'); +}); + +it('wires preview-bound bulk decisions through the transactional autopilot service', function (): void { + $route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php'); + $autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php'); + + expect($route) + ->not->toBeFalse() + ->and($autopilot)->not->toBeFalse(); + + $route = (string)$route; + $autopilot = (string)$autopilot; + + expect($route) + ->toContain("post('/modules/xlvask/services/usage/automation/decisions/preview'") + ->toContain("post('/modules/xlvask/services/usage/automation/decisions/apply'") + ->toContain('createDecisionPreview(') + ->toContain('applyDecision(') + ->and($autopilot)->toContain("SELECT * FROM xlvask_automation_decision_previews WHERE id = '") + ->toContain('FOR UPDATE') + ->toContain('applyBoundDecisionWithinTransaction(') + ->toContain('expected_version') + ->toContain('source_hash'); +});