From 3e89085296567b1211763b51993a10d808e92183 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Sun, 9 Aug 2026 21:48:03 +0200 Subject: [PATCH] feat(api): support manual XL Vask operator decisions via force_manual (#356) Adds a deterministic manual-suggestion path so operators can drive accept/reject/ignore decisions on the self-wash view before the AI autopilot has produced a suggestion. Whitelists force_manual in the preview route. Adds unit tests for the new constant, method, and route contract. --------- Co-authored-by: Cleanup Agent --- .../app/classes/xlvask_automation_service.php | 71 +++++++++++++++++++ .../app/classes/xlvask_autopilot_service.php | 32 ++++++++- .../nginx/app/routes/xlvaskUsageLogsRoute.php | 2 +- .../XLVask/XLVaskAutomationServiceTest.php | 23 ++++++ 4 files changed, 126 insertions(+), 2 deletions(-) diff --git a/services/nginx/app/classes/xlvask_automation_service.php b/services/nginx/app/classes/xlvask_automation_service.php index 712cd8a7..45fc5cae 100644 --- a/services/nginx/app/classes/xlvask_automation_service.php +++ b/services/nginx/app/classes/xlvask_automation_service.php @@ -27,6 +27,7 @@ class xlvask_automation_service private const SOURCE_FUZZY = 'fuzzy'; private const SOURCE_HISTORY = 'history'; private const SOURCE_OPENAI = 'openai'; + public const SOURCE_MANUAL = 'manual'; private const AUTOMATION_CASHIER_ID = 2285; private const MIN_SUGGESTION_CONFIDENCE = 0.70; @@ -1420,6 +1421,76 @@ class xlvask_automation_service } } + /** + * Create a manual operator suggestion so the standard decision preview/apply + * pipeline can run without requiring an AI autopilot suggestion. The proposed + * order and candidate scan are deterministic (driven by simulateOrderFromXLVask + + * findSameDayCandidateOrders) so we always have a proposal to act on. + * + * @return int Suggestion id (newly inserted or updated) + */ + public function createManualSuggestion(int $usageLogId, string $action, ?int $actorId, array $allowedHallIds = []): int + { + $allowedHallIds = $this->normalizeHallIds($allowedHallIds); + $action = strtolower(trim($action)); + if (!in_array($action, [self::ACTION_ATTACH, self::ACTION_CREATE, 'ignore'], true)) { + throw new Exception('Invalid manual XL Vask action.'); + } + + global $db; + $rowResult = $db->query( + 'SELECT * FROM xlvask_usage_logs WHERE id = ' . (int)$usageLogId + . ' AND HallId IN (' . $this->quotedHallIds($allowedHallIds) . ') LIMIT 1' + ); + $row = $rowResult !== false && $rowResult->num_rows > 0 ? $db->fetch_assoc($rowResult) : null; + if ($row === null) { + throw new Exception('XL Vask usage log not found or not in scope.'); + } + if ((string)($row['import_state'] ?? '') === 'invalid') { + throw new Exception('XL Vask usage log has invalid source data.'); + } + if (in_array((string)($row['resolution_state'] ?? ''), ['auto_created', 'auto_linked', 'reviewed'], true)) { + throw new Exception('XL Vask usage log is already resolved.'); + } + + $log = $this->usageLogFromRow($row); + $context = $this->buildContext($usageLogId, $log, $row); + $suggestion = [ + 'action' => $action, + 'confidence' => 0.0, + 'source' => self::SOURCE_MANUAL, + 'certainty' => 'manual', + 'evidence' => [['kind' => 'manual_review', 'detail' => 'Operator triggered review action without an AI suggestion.']], + 'contradictions' => [], + 'risk_flags' => [], + 'plan_steps' => [], + 'matched_order_id' => $action === self::ACTION_ATTACH + ? ($this->pickFirstCandidate($context)['id'] ?? null) + : null, + 'proposed_order' => $context['proposed_order'] ?? [], + 'candidate_order' => $action === self::ACTION_ATTACH + ? ($this->pickFirstCandidate($context) ?: null) + : null, + 'reason' => 'Manual operator review.', + ]; + + $this->runId = null; + $suggestionId = $this->persistSuggestion($context, $suggestion, $actorId); + $this->runId = null; + return $suggestionId; + } + + private function pickFirstCandidate(array $context): ?array + { + $candidates = is_array($context['candidate_orders'] ?? null) ? $context['candidate_orders'] : []; + foreach ($candidates as $candidate) { + if (is_array($candidate) && isset($candidate['id'])) { + return $candidate; + } + } + return null; + } + private function executeSuggestionWithinTransaction(array $suggestion, array $context, ?int $actorId, bool $automatic): array { global $db; diff --git a/services/nginx/app/classes/xlvask_autopilot_service.php b/services/nginx/app/classes/xlvask_autopilot_service.php index f41f7b7f..dbe2a680 100644 --- a/services/nginx/app/classes/xlvask_autopilot_service.php +++ b/services/nginx/app/classes/xlvask_autopilot_service.php @@ -904,7 +904,37 @@ class xlvask_autopilot_service $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."); + // Allow the operator to act on a wash that the AI autopilot has + // not yet scored. The manual suggestion is a deterministic + // proposal derived from the wash log itself. + $forceManual = (bool)($input['force_manual'] ?? false); + if ($forceManual) { + // Map the high-level review action to the action stored on + // the suggestion row. "ignore" is a no-op suggestion; the + // remaining actions become create_order / attach_order. + $manualSuggestionAction = match ($action) { + 'attach_order' => 'attach_order', + 'create_order', 'accept' => 'create_order', + 'deny', 'ignore' => 'ignore', + default => 'ignore', + }; + $automationService = new xlvask_automation_service(); + $suggestionId = $automationService->createManualSuggestion( + $usageId, + $manualSuggestionAction, + $actorId, + $this->normalizeHallIds($allowedHallIds) + ); + $suggestion = [ + 'id' => $suggestionId, + 'usage_log_id' => $usageId, + 'action' => $manualSuggestionAction, + 'matched_order_id' => null, + 'source' => 'manual', + ]; + } else { + throw new Exception("XL Vask usage log {$usageId} has no actionable suggestion. Pass force_manual to act on it without an autopilot 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.'); diff --git a/services/nginx/app/routes/xlvaskUsageLogsRoute.php b/services/nginx/app/routes/xlvaskUsageLogsRoute.php index 1472bdbf..2783a719 100644 --- a/services/nginx/app/routes/xlvaskUsageLogsRoute.php +++ b/services/nginx/app/routes/xlvaskUsageLogsRoute.php @@ -432,7 +432,7 @@ class xlvaskUsageLogsRoute $response->error('Invalid session', 400); } $input = []; - foreach (['usage_log_ids', 'action', 'suggestion_id', 'order_id', 'reason'] as $key) { + foreach (['usage_log_ids', 'action', 'suggestion_id', 'order_id', 'reason', 'force_manual'] as $key) { if ($this->isParametersSet([$key])) { $input[$key] = $this->getParameter($key); } diff --git a/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php b/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php index 32cebf07..782fc36e 100644 --- a/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php +++ b/services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php @@ -780,3 +780,26 @@ it('does not score an order with only the primary product as a matching add-on a expect(xlvask_automation_service::scoreItemMatchForAutomation($usageItems, $orderItems)['confidence']) ->toBe(0.0); }); + +it('creates a manual operator suggestion with a deterministic proposal', function (): void { + $service = new xlvask_automation_service(); + $reflection = new ReflectionClass($service); + + // The constant is private but must be 'manual'. + $source = $reflection->getConstant('SOURCE_MANUAL'); + expect($source)->toBe('manual'); + + // The method must exist and be invokable on partial inputs (no AI). + expect($reflection->hasMethod('createManualSuggestion'))->toBeTrue(); + + // Calling it with an invalid action should throw, not silently accept. + expect(fn () => $service->createManualSuggestion(0, 'not_a_real_action', null, [])) + ->toThrow(Exception::class); +}); + +it('accepts force_manual in the decision preview contract', function (): void { + // Read the route to confirm the preview endpoint whitelists force_manual. + $routeFile = file_get_contents(__DIR__ . '/../../../routes/xlvaskUsageLogsRoute.php'); + expect($routeFile)->toContain("'force_manual'"); + expect($routeFile)->toContain("'usage_log_ids', 'action', 'suggestion_id', 'order_id', 'reason', 'force_manual'"); +});