fix(api): simplify XLVask Selvvask surface by removing AI autopilot pipeline (#367)

## Summary

Removes the XLVask autopilot / automation / MiniMax / OpenAI pipeline
and the related module config, CLI, cron, and migration scaffolding. The
Selvvask view (Superuser -> Fakturaer -> Periode -> Selvvask) is reduced
to a single read-only listing of usage logs plus operator-driven ignore
/ unignore / accept / reject endpoints gated on the
`review_xlvask_usage_order` permission.

See `inventory/self-serve-inventory.md` for the full surface map.

## Test plan

- [x] `vendor/bin/pest --testsuite=Unit` -> **1266 passed**, 1 unrelated
pre-existing failure (`BirdControlPlaneActivationTest`, needs
`PLENO_REPO_ROOT_FOR_TESTS`).
- [x] `php -l` on every modified PHP file -> no syntax errors.
- [x] Grep validation -> zero production-code references to removed
surfaces (`xlvask_autopilot_service`, `xlvask_automation_service`,
`xlvask_automation_policy_service`, `EnsureXLVaskAutomationSchema`,
`runScheduledAutomationIfReady`, `processAutopilotQueue`, `MiniMax`,
`minimax`, ...).
- [ ] Qodana + Tests workflows green on this PR.

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Jeppe B
2026-08-12 20:02:30 +02:00
committed by GitHub
co-authored by openhands
parent 02a4665bc7
commit 5441fea665
32 changed files with 675 additions and 8254 deletions
+95
View File
@@ -0,0 +1,95 @@
# XL Vask Selvvask surface — inventory & simplification plan
## Scope
The XLVask surface that powers the **Superuser → Fakturaer → Periode → Selvvask**
view. Goal: remove the AI / MiniMax / autopilot pipeline, leaving only the
operator-facing review and order-creation flow.
Out of scope: any other XLVask, plate scanner, customer, or vehicle surface.
## Files removed
| Path | Reason |
| --- | --- |
| `services/nginx/app/classes/xlvask_autopilot_service.php` | AI autopilot pipeline |
| `services/nginx/app/classes/xlvask_automation_service.php` | AI automation pipeline |
| `services/nginx/app/classes/xlvask_automation_policy_service.php` | AI policy service |
| `services/nginx/app/classes/minimax.php` | MiniMax integration |
| `services/nginx/app/modules/miniMax/` | MiniMax module (config + class) |
| `services/nginx/app/modules/xlvask/AUTOMATION_RUNBOOK.md` | Runbook for removed pipeline |
| `services/nginx/app/modules/xlvask/cron/tasks.php` | Module-owned cron registry (replaced by empty `cron_task_registry` discovery) |
| `services/nginx/app/modules/xlvask/migrations/20260804_xlvask_ai_auto_policy_v2.php` | Migration for removed AI schema |
| `services/nginx/app/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php` | Legacy autopilot gate |
| `services/nginx/app/modules/xlvask/config/xlvask_automatic_order_creation_enabled_c.php` | Legacy autopilot gate |
| `services/nginx/app/modules/xlvask/config/xlvask_minimax_integration_enabled_c.php` | MiniMax gate |
| `services/nginx/app/modules/xlvask/config/xlvask_openai_integration_enabled_c.php` | OpenAI gate |
| `services/nginx/app/cron/EnsureXLVaskAutomationSchema.php` | Migration helper |
| `scripts/xlvask-automation-migrate.php` | CLI wrapper for migration |
| `services/nginx/app/tests/Unit/XLVask/XLVaskAutomationMigrateScriptTest.php` | Removed migration test |
| `services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php` | Removed automation test |
| `services/nginx/app/tests/Api/XLVaskReviewApiTest.php` | Replaced by Selvvask route contract test |
## Code changes (kept & simplified)
| Path | Change |
| --- | --- |
| `services/nginx/app/cron/Cron.php` | Drop `ProcessXLVaskAutopilotQueueCron` registration + function |
| `services/nginx/app/cli.php` | Drop `xlvask-automation-migrate` case |
| `services/nginx/app/routes/moduleConfigRoute.php` | Drop `/minimax/config` GET/POST endpoints |
| `services/nginx/app/routes/moduleXLVaskRoute.php` | Drop `/modules/xlvask/tasks/import-usage` 410 stub and `/tasks/debug` route |
| `services/nginx/app/routes/xlvaskUsageLogsRoute.php` | Slim to operator-only: list, summary, ignore/unignore, accept, reject, fast-link |
| `services/nginx/app/modules/xlvask/helpers/xlvask_tasks.php` | Drop `runScheduledAutomationIfReady`, `processAutopilotQueue`, autopilot cleanup, legacy auto-creation branch |
| `services/nginx/app/modules/xlvask/xlvask_c.php` | Drop `minimax_integration_enabled`, `automatic_order_attachment_enabled`, `automatic_order_creation_enabled`, `openai_integration_enabled` |
| `services/nginx/app/objects/xlvask_usage_logs_o.php` | Add `summarizeUsageOrdersReadOnly` (replaces autopilot summary) |
| `services/nginx/app/openapi.yaml` | Replace autopilot/automation openapi block with operator-flow endpoints |
| `services/nginx/app/tests/Unit/Cron/CronTaskRegistryTest.php` | Update count: 24 → 22, drop `xlvask.autopilot_queue` assertion |
| `services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php` | Replaced with end-to-end contract assertions for the new operator surface |
## New operator-facing endpoints
All under `routes/xlvaskUsageLogsRoute.php` and scoped to the operator's
`allowedHallIds` (all-scope users see every configured scanner hall; own-scope
users see only their group's halls).
| Method | Path | Permission | Purpose |
| --- | --- | --- | --- |
| `GET` | `/modules/xlvask/services/usage/orders` | `list_xlvask_usage_orders_own/all` | List usage logs with direct linked order id, amount summary, ignored metadata |
| `GET` | `/modules/xlvask/services/usage/orders/summary` | `list_xlvask_usage_orders_own/all` | Read-only per-period summary (counts + net amount) |
| `PATCH` | `/modules/xlvask/services/usage/orders/{id}/ignore` | `review_xlvask_usage_order` | Mark ignored with reason |
| `POST` | `/modules/xlvask/services/usage/orders/{id}/unignore` | `review_xlvask_usage_order` | Clear ignored metadata |
| `POST` | `/modules/xlvask/services/usage/orders/{id}/accept` | `review_xlvask_usage_order` | Convert to order via `createOrderFromWash` |
| `POST` | `/modules/xlvask/services/usage/orders/{id}/reject` | `review_xlvask_usage_order` | Mark ignored with reject reason |
| `GET` | `/modules/xlvask/services/usage/orders/fast-link` | `list_xlvask_usage_orders_own` | Cached fast-link redeem (existing) |
## Permissions
The Selvvask surface uses these permissions only:
- `list_xlvask_usage_orders_own`
- `list_xlvask_usage_orders_all`
- `review_xlvask_usage_order`
`manage_xlvask_usage_automation`, `ignore_xlvask_usage_order`,
`superuser_xlvask_automation_activate` are not referenced anywhere in the
slimmed surface.
## Persistence model
`xlvask_usage_logs_o` already exposes `ignored_at`, `ignored_by`, `ignored_reason`
columns — no migration required for the simplified flow.
`orders_o::selectByWashId(int|string $WashId)` and
`orders_o::addXLVaskOrder(users_o $user, xlvask_usage_log $xlvask_usage_log)` are
the only integration points with the order pipeline.
## Tests
- `vendor/bin/pest --testsuite=Unit --colors=never` passes 1266 tests.
- One pre-existing failure (`BirdControlPlaneActivationTest`) requires
`PLENO_REPO_ROOT_FOR_TESTS` (coolify repo) and is unrelated to this change.
## Repo scope
This inventory covers `api`. The `pleno-vue` side has not yet been updated in
this session and will be handled in a follow-up PR.
-54
View File
@@ -1,54 +0,0 @@
#!/usr/bin/env php
<?php
/**
* XL Vask automation schema migration script.
*
* Mirrors the scripts/account-deletion-schema.php and
* scripts/bird-control-plane-schema.php patterns so ops can run an explicit,
* non-cron, non-HTTP migration from the API container.
*
* Usage (from the api repo root, against the configured DB):
* php scripts/xlvask-automation-migrate.php check
* php scripts/xlvask-automation-migrate.php apply --yes
*
* "check" never mutates state and always exits 0 when ready / 1 when not.
* "apply" requires an explicit --yes flag before calling the gated
* migration_20260804_xlvask_ai_auto_policy_v2::apply() entry point, which
* itself is operator-only by design (see AUTOMATION_RUNBOOK §2).
*/
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "This command is CLI-only.\n");
exit(2);
}
const WD = __DIR__ . '/../services/nginx/app';
require_once WD . '/vendor/autoload.php';
require_once WD . '/config.php';
require_once WD . '/classes/db.php';
require_once WD . '/classes/xlvask_usage_logs_schema_bootstrap.php';
require_once WD . '/modules/xlvask/migrations/20260804_xlvask_ai_auto_policy_v2.php';
$command = $argv[1] ?? 'check';
if (!in_array($command, ['check', 'apply'], true)) {
fwrite(STDERR, "Usage: scripts/xlvask-automation-migrate.php check|apply --yes\n");
exit(2);
}
$db = new \classes\db($CONFIG_DB);
$db->connect();
if ($command === 'apply') {
if (($argv[2] ?? '') !== '--yes') {
fwrite(STDERR, "Refusing schema mutation without: apply --yes\n");
exit(2);
}
$status = \classes\xlvask_usage_logs_schema_bootstrap::applyExplicitMigration();
} else {
$status = \classes\xlvask_usage_logs_schema_bootstrap::migrationStatus();
}
fwrite(STDOUT, json_encode($status, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL);
exit((bool)($status['ready'] ?? false) ? 0 : 1);
-196
View File
@@ -1,196 +0,0 @@
<?php
namespace classes;
require_once WD . '/modules/openAI/openAI_c.php';
require_once WD . '/modules/miniMax/miniMax_c.php';
use Exception;
use miniMax\miniMax_c;
/**
* Thrown when a MiniMax API request fails. Extends openai_request_exception so the
* autopilot's existing `catch (openai_request_exception $e)` blocks keep working
* when the model is swapped from OpenAI to MiniMax — no other code needs to change.
*/
class minimax_request_exception extends openai_request_exception
{
}
/**
* MiniMax M3 client.
*
* Uses the Anthropic-messages format (https://api.minimax.io/anthropic/v1/messages),
* which is the same endpoint OpenClaw's minimax-portal provider uses. The caller
* can pass `MiniMax-M3` (and any other model the operator has provisioned) via
* the `$model` argument.
*
* The response shape returned from jsonTask() matches openai::jsonTask() so callers
* (notably xlvask_automation_service) can switch providers with minimal plumbing.
*/
class minimax
{
public miniMax_c $config;
private string $api_url = 'https://api.minimax.io/anthropic/v1/messages';
protected string $model = 'MiniMax-M3';
protected string $temperature = '0.1';
protected string $max_tokens = '4096';
public function __construct()
{
$this->config = new miniMax_c();
}
public function requireModuleEnabled(): void
{
if (!$this->config->enabled->isTrue()) {
throw new Exception('MiniMax module is not enabled.');
}
$apiKey = trim((string)$this->config->api_key->getVariableValue());
if ($apiKey === '') {
throw new Exception('MiniMax API key is not configured.');
}
}
/**
* Send a structured JSON text task to MiniMax M3 (Anthropic-messages format).
*
* Returns the parsed JSON object plus `_minimax_response_model` and `_minimax_usage`
* so the autopilot can compare against the resolved model id and track tokens.
*
* @throws Exception
*/
public function jsonTask(
string $schemaName,
string $prompt,
array $payload,
array $schema,
float $temperature = 0.1,
?string $model = null
): array {
$this->requireModuleEnabled();
// Anthropic-messages uses a single `messages` array, system prompt is separate,
// and structured output goes in `tools` with `input_schema`.
$data = [
'model' => $model ?? $this->model,
'max_tokens' => (int)$this->max_tokens,
'temperature' => $temperature,
'system' => $prompt,
'messages' => [
[
'role' => 'user',
'content' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
],
],
'tools' => [
[
'name' => $schemaName,
'description' => 'Return the structured decision for the XL Vask automation planner.',
'input_schema' => $schema,
],
],
// Force the model to call the tool — guarantees a structured JSON object back.
'tool_choice' => ['type' => 'tool', 'name' => $schemaName],
];
$response = $this->sendRequest($data);
return self::parseJsonTaskResponse($response, $schemaName);
}
public static function parseJsonTaskResponse(array $response, string $expectedToolName): array
{
// Anthropic-messages stop_reason: end_turn | tool_use | max_tokens | stop_sequence
$stopReason = (string)($response['stop_reason'] ?? '');
if ($stopReason === 'max_tokens') {
throw new minimax_request_exception('MiniMax response was truncated by max_tokens.', true);
}
if (!in_array($stopReason, ['end_turn', 'tool_use'], true)) {
throw new minimax_request_exception('MiniMax response did not complete (stop_reason=' . $stopReason . ').', true);
}
$toolInput = null;
$toolName = null;
foreach ((array)($response['content'] ?? []) as $block) {
if (($block['type'] ?? null) === 'tool_use') {
$toolName = (string)($block['name'] ?? '');
$toolInput = (array)($block['input'] ?? []);
break;
}
}
if ($toolInput === null) {
throw new minimax_request_exception('MiniMax completed without a structured tool_use block.', false);
}
if ($toolName !== $expectedToolName) {
throw new minimax_request_exception(
'MiniMax returned tool "' . $toolName . '", expected "' . $expectedToolName . '".',
false
);
}
$resolvedModel = trim((string)($response['model'] ?? ''));
if ($resolvedModel === '') {
throw new minimax_request_exception('MiniMax response omitted the resolved model.', false);
}
$usage = (array)($response['usage'] ?? []);
$inputTokens = max(0, (int)($usage['input_tokens'] ?? 0));
$outputTokens = max(0, (int)($usage['output_tokens'] ?? 0));
// The legacy `_openai_*` aliases keep the autopilot's sanitizeOpenAiResult() working
// unchanged — it reads those keys regardless of which provider produced the result.
return [
...$toolInput,
'_minimax_response_model' => $resolvedModel,
'_minimax_usage' => [
'input_tokens' => $inputTokens,
'output_tokens' => $outputTokens,
'total_tokens' => $inputTokens + $outputTokens,
'service_tier' => '',
],
'_openai_response_model' => $resolvedModel,
'_openai_usage' => [
'input_tokens' => $inputTokens,
'output_tokens' => $outputTokens,
'total_tokens' => $inputTokens + $outputTokens,
'service_tier' => '',
],
];
}
/**
* @throws Exception
*/
private function sendRequest(array $data): array
{
$this->requireModuleEnabled();
$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, 60);
// MiniMax uses Anthropic-style auth headers
curl_setopt($curl, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'x-api-key: ' . $this->config->api_key->getVariableValue(),
'anthropic-version: 2023-06-01',
]);
curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($data));
$response = curl_exec($curl);
if (curl_errno($curl)) {
$curlCode = curl_errno($curl);
curl_close($curl);
throw new minimax_request_exception(
'MiniMax transport failed.',
in_array($curlCode, [CURLE_OPERATION_TIMEDOUT, CURLE_COULDNT_CONNECT, CURLE_COULDNT_RESOLVE_HOST], true)
);
}
$httpStatus = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
$responseData = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new minimax_request_exception('MiniMax returned a non-JSON response.', $httpStatus === 429 || $httpStatus >= 500, $httpStatus);
}
if ($httpStatus < 200 || $httpStatus >= 300 || !is_array($responseData)) {
throw new minimax_request_exception('MiniMax request failed with HTTP status ' . $httpStatus . '.', $httpStatus === 429 || $httpStatus >= 500, $httpStatus);
}
return $responseData;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-4
View File
@@ -106,10 +106,6 @@ if ($args[1] === 'run') {
echo "[" . date('Y-m-d H:i:s') . "][CRON_WORKER] Starting cron worker\n";
(new \classes\cron_worker())->run();
break;
case 'xlvask-automation-migrate':
echo "[" . date('Y-m-d H:i:s') . "][XLVASK] Ensuring automation schema readiness\n";
require_once 'cron/EnsureXLVaskAutomationSchema.php';
break;
default:
echo "Invalid script name";
break;
-15
View File
@@ -150,12 +150,6 @@ $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,
@@ -679,15 +673,6 @@ function SyncXLVaskModuleCron(): void
}
}
function ProcessXLVaskAutopilotQueueCron(): array
{
$xlvask = new xlvask();
if (!$xlvask->config->enabled->isTrue()) {
return [];
}
return $xlvask->getTasks()->processAutopilotQueue(3);
}
function EconomicTransferQueueCron(): void
{
try {
@@ -1,71 +0,0 @@
<?php
use classes\xlvask_usage_logs_schema_bootstrap;
use xlvask\migrations\migration_20260804_xlvask_ai_auto_policy_v2;
require_once WD . '/classes/xlvask_usage_logs_schema_bootstrap.php';
require_once WD . '/classes/xlvask_autopilot_service.php';
require_once WD . '/modules/xlvask/migrations/20260804_xlvask_ai_auto_policy_v2.php';
if (!defined('WD')) {
exit;
}
$dbTarget = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live')));
$startedAt = date('Y-m-d H:i:s');
echo "[{$startedAt}][XLVASK] Target database: {$dbTarget}" . PHP_EOL;
$result = [
'success' => false,
'db_target' => $dbTarget,
'preflight' => null,
'applied' => false,
'postflight' => null,
'wash_id_uniqueness_ready' => false,
'wash_id_uniqueness_activated' => false,
'wash_id_uniqueness_blocked' => false,
'error' => null,
];
try {
$preflight = migration_20260804_xlvask_ai_auto_policy_v2::preflight();
$result['preflight'] = $preflight;
if (!(bool)($preflight['ready'] ?? false)) {
$result['postflight'] = migration_20260804_xlvask_ai_auto_policy_v2::apply();
$result['applied'] = true;
} else {
$result['postflight'] = $preflight;
}
$postflight = (array)$result['postflight'];
if (!(bool)($postflight['ready'] ?? false)) {
throw new RuntimeException('XL Vask automation schema is still not ready after apply.');
}
if (xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady()) {
$result['wash_id_uniqueness_ready'] = true;
} else {
$activated = xlvask_usage_logs_schema_bootstrap::applyWashIdUniquenessMigration();
$result['wash_id_uniqueness_ready'] = $activated && xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady();
$result['wash_id_uniqueness_activated'] = $result['wash_id_uniqueness_ready'];
$result['wash_id_uniqueness_blocked'] = !$result['wash_id_uniqueness_ready'];
}
$result['success'] = (bool)$result['wash_id_uniqueness_ready'];
if (!$result['success']) {
$result['error'] = 'Wash-id uniqueness is blocked, likely due duplicate normalized wash_id values.';
}
} catch (Throwable $throwable) {
// The wrapper cron entry may swallow the runtime exception that is
// re-thrown below, so emit a container-log breadcrumb here too.
error_log('[cron-ensure-xlvask-automation-schema] apply failed: ' . $throwable->getMessage());
$result['error'] = $throwable->getMessage();
}
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL;
if (!$result['success']) {
throw new RuntimeException((string)($result['error'] ?: 'XL Vask schema readiness failed.'));
}
@@ -1,29 +0,0 @@
<?php
namespace miniMax\config;
use Exception;
use traits\module_config_variable;
class miniMax_api_key_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'miniMax',
'api_key',
'string',
false,
null,
'The secret API key for MiniMax (M3). Obtain from the MiniMax Portal dashboard; the operator can rotate or remove it from the superuser XL Vask module settings.',
'1',
true,
''
);
}
}
@@ -1,29 +0,0 @@
<?php
namespace miniMax\config;
use Exception;
use traits\module_config_variable;
class miniMax_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'miniMax',
'enabled',
'bool',
true,
null,
'Whether the MiniMax integration is enabled for XL Vask autopilot and other AI-driven features.',
'1',
false,
'false'
);
}
}
@@ -1,28 +0,0 @@
<?php
namespace miniMax;
require_once WD . '/modules/miniMax/config/miniMax_enabled_c.php';
require_once WD . '/modules/miniMax/config/miniMax_api_key_c.php';
use miniMax\config\miniMax_api_key_c;
use miniMax\config\miniMax_enabled_c;
use traits\module_config_t;
class miniMax_c
{
use module_config_t;
public miniMax_enabled_c $enabled;
public miniMax_api_key_c $api_key;
public function __construct()
{
$this->setupConfig('miniMax');
$this->allowUpdate([
miniMax_enabled_c::class,
miniMax_api_key_c::class,
]);
$this->enabled = new miniMax_enabled_c();
$this->api_key = new miniMax_api_key_c();
}
}
@@ -1,86 +0,0 @@
# XL Vask AI automation runbook
This runbook is an operator procedure. None of its gates are applied by deployment, HTTP GETs, constructors, or workers. Every production-changing step requires a human approval tied to the exact deployed backend and frontend SHAs.
## 0. Deploy-order and rollback invariant
The legacy attachment/creation config values are kill switches, but an old backend treats them as direct enable switches. Old code cannot interpret the new policy stages, calibration identity, rolling caps, action latches, or canary soak. Therefore old-backend traffic is forbidden whenever either legacy switch is true, including during a new-policy canary.
Use this exact forward sequence:
1. While the old backend is still serving, set both legacy automatic-order switches to false through the approved config procedure and verify the persisted values from every serving instance.
2. Stop/disable old XL Vask automation workers and verify there is no active automatic run. Ordinary XL Vask synchronization may continue.
3. Deploy the new backend with policy effectively `off`; verify ordinary synchronization still completes and scheduled automation no-ops while schema readiness is false.
4. Run read-only migration preflight, then the separately approved explicit additive migration. If it is partial or fails, keep the new backend deployed, policy `off`, both legacy switches false, and workers no-op; repair or complete the migration before continuing. Never route old code as a partial-migration workaround.
5. Verify migration readiness and the new backend SHA, then deploy/verify the compatible frontend. Only after that generate advisory evidence and use preview-bound policy transitions.
Use this exact rollback sequence before any old-code traffic:
1. Keep all traffic on the new backend, call the dedicated halt endpoint, and verify policy `halted` plus both persisted legacy switches false.
2. Stop new-backend automation workers, wait for or safely reconcile the active run, and verify no financial mutation is in flight.
3. Roll back the frontend if required, then deploy the old backend with both legacy switches still false. Verify ordinary sync only.
4. Do not re-enable either legacy switch on old code. Recovery of automatic actions requires redeploying the new policy-aware backend and repeating readiness, advisory calibration, canary, and soak.
## 1. Read-only preflight
1. Record the backend/frontend SHAs, environment, operator, invoice period, and scanner-hall scope.
2. Call the scoped capabilities and admin-readiness GETs with `dateFrom` and `dateTo`.
3. Confirm `migration.ready`, `missing_tables`, `missing_columns`, `missing_indexes`, `preflight_conflicts`, `worker_healthy`, WashId uniqueness, planner identity, resolved model, active run, scoped eligible counts, rolling budgets, and reviewed soak counts.
4. Stop if dates are invalid, hall scope is empty, an execute run is active, identity changed, a latch is halted, or any readiness field fails closed.
## 2. Explicit schema migration
Use the controlled database migration procedure to invoke only
`migration_20260804_xlvask_ai_auto_policy_v2::apply()`. First retain its read-only
`preflight()` output. Review the additive SQL and backup/restore point, approve the exact SHA, run it once, retain the returned status, and rerun readiness. Do not invoke `applyExplicitMigration()` from a request, worker, cron task, or application startup.
If preflight reports multiple legacy execute runs in `queued`, `running`, or `retry_wait`, stop. Reconcile those runs through a separately approved operational procedure; the migration never auto-resolves or modifies the conflicting run records.
### 2a. Operator entry points
There are two equivalent ways to apply the migration from a privileged
container with the configured DB credentials. Both call the same gated
`migration_20260804_xlvask_ai_auto_policy_v2::apply()` entry point and
produce identical status output. Pick whichever fits the workflow.
```
# Option A — standalone script (mirrors scripts/account-deletion-schema.php)
php scripts/xlvask-automation-migrate.php check # read-only preflight
php scripts/xlvask-automation-migrate.php apply --yes # apply, gated by --yes
# Option B — CLI dispatcher inside index.php (defines WD + composes bootstrap)
php index.php run xlvask-automation-migrate # preflight, applies if !ready
```
Both exit 0 when `ready=true` and 1 otherwise. Always retain the JSON
status artifact for the audit log and rerun `check` to confirm the
postflight is green.
## 3. WashId uniqueness
Inspect normalized duplicate WashIds. Resolve conflicts through an independently approved data procedure. Only then use the guarded uniqueness activation with the exact typed phrase. Recheck the generated normalized column and unique index before any automatic action.
## 4. Advisory evidence and calibration
Keep policy at `advisory`. Run explicit `dry_run` requests to import and persist plans, or `replay` for cache-only read-only evaluation. Review suggestions in hall scope. Label exact OpenAI attach/create suggestions; model identity, prompt hash, schema hash, policy version, resolved model, and chronological label snapshot are part of the artifact identity. Generate inactive backtests, independently review qualification thresholds and contradictions, then activate the exact artifact hash with its typed phrase.
## 5. Staged policy transitions
Every transition uses a bounded human reason, server-generated policy preview, exact confirmation phrase, and apply-time revalidation. The reason is bound into the preview hash and retained in the immutable policy event:
`off` -> `advisory` -> `ai_attach_canary` -> `ai_attach_verified` -> `ai_create_canary` -> `verified_capped`
Stages may not be skipped. An active execute run, stale preview, changed policy version, changed model/planner identity, missing exact calibration, incomplete reviewed soak, invalid period scope, or exhausted readiness gate blocks promotion.
## 6. Reviewed soak and caps
Volume alone never completes soak. Every auto-accepted action must be adjudicated. Only explicit `correct` outcomes from the current action canary activation epoch count: 200 correct reviewed links before attach verification/create eligibility and 50 correct reviewed creates before `verified_capped`. `incorrect`, `duplicate`, `cross_hall`, or `unaudited` persistently halts the relevant action latch, invalidates the active action calibration in the same transaction, and requires investigation. Re-entering that canary creates a fresh soak epoch after a new qualifying calibration is activated.
Caps are atomic rolling 24-hour limits: 100 links globally and 10 per hall; 20 creates globally and 3 per hall. Cap exhaustion is a normal policy stop: the suggestion remains reviewable and the execute run pauses without recording a permanent action failure. Cap reservation, policy/model/calibration revalidation, current-candidate requery, financial locks, mutation, and audit commit in one transaction.
List responses intentionally use only persisted revision/hash eligibility and do not reconstruct same-day candidates per row. This avoids an unbounded N+1 query path. Candidate existence, uniqueness, customer/department/registration/lane/date/items/totals, and financial locks are authoritatively rebuilt during preview/apply and again inside the mutation transaction. Treat a preview/apply stale-candidate rejection as a normal fail-closed refresh signal; monitor list latency and preview rejection rates during advisory/canary.
## 7. Halt, recovery, and rollback
Use the dedicated halt endpoint immediately on any unexplained result, duplicate, cross-hall action, missing audit, model mismatch, financial invariant, worker lease failure, or upstream revision anomaly. Halt disables legacy compatibility switches and preserves the reason. Generic config may disable a switch but cannot enable it.
Rollback means: follow the exact sequence in section 0; halt; stop new execute runs; retain audit/action/review evidence; reconcile affected orders and invoice collections; restore data only through a separately approved, previewed procedure; fix and redeploy; repeat advisory calibration and staged previews. Recovery from `halted` starts at `off` or `advisory` and requires new exact-SHA human approval. Never infer activation, soak completion, or production safety from green CI alone.
@@ -1,52 +0,0 @@
<?php
namespace xlvask\config;
use Exception;
use traits\module_config_variable;
class xlvask_automatic_order_attachment_enabled_c
{
use module_config_variable {
setVariableValue as private setVariableValueInternal;
}
private static bool $policyWrite = false;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'xlvask',
'automatic_order_attachment_enabled',
'bool',
true,
null,
'Whether XL Vask usage logs may automatically be attached to existing same-day employee orders.',
'0',
false,
'false'
);
}
/** Generic module config may kill automation but cannot activate it. */
public function setVariableValue(mixed $value): void
{
if (!self::$policyWrite && self::inputToBool($value)) {
throw new Exception('Automatic XL Vask attachment can only be enabled through the policy preview/apply flow.');
}
$this->setVariableValueInternal($value);
}
public function setFromAutomationPolicy(bool $enabled): void
{
self::$policyWrite = true;
try {
$this->setVariableValueInternal($enabled);
} finally {
self::$policyWrite = false;
}
}
}
@@ -1,52 +0,0 @@
<?php
namespace xlvask\config;
use Exception;
use traits\module_config_variable;
class xlvask_automatic_order_creation_enabled_c
{
use module_config_variable {
setVariableValue as private setVariableValueInternal;
}
private static bool $policyWrite = false;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'xlvask',
'automatic_order_creation_enabled',
'bool',
true,
null,
'Whether XL Vask usage logs may automatically create orders when no same-day order can be attached.',
'0',
false,
'false'
);
}
/** Generic module config may kill automation but cannot activate it. */
public function setVariableValue(mixed $value): void
{
if (!self::$policyWrite && self::inputToBool($value)) {
throw new Exception('Automatic XL Vask creation can only be enabled through the policy preview/apply flow.');
}
$this->setVariableValueInternal($value);
}
public function setFromAutomationPolicy(bool $enabled): void
{
self::$policyWrite = true;
try {
$this->setVariableValueInternal($enabled);
} finally {
self::$policyWrite = false;
}
}
}
@@ -1,29 +0,0 @@
<?php
namespace xlvask\config;
use Exception;
use traits\module_config_variable;
class xlvask_minimax_integration_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'xlvask',
'minimax_integration_enabled',
'bool',
true,
null,
'Whether XL Vask automation may ask MiniMax (M3) for attachment or creation suggestions. Replaces the OpenAI integration.',
'0',
false,
'false'
);
}
}
@@ -1,29 +0,0 @@
<?php
namespace xlvask\config;
use Exception;
use traits\module_config_variable;
class xlvask_openai_integration_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'xlvask',
'openai_integration_enabled',
'bool',
true,
null,
'Whether XL Vask automation may ask OpenAI for attachment or creation suggestions.',
'0',
false,
'false'
);
}
}
@@ -1,28 +0,0 @@
<?php
return [
[
'id' => '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',
'name' => 'Sync XL Vask module',
'description' => 'Runs scheduled XL Vask synchronization tasks when the module is enabled.',
'module' => 'xlvask',
'handler' => 'SyncXLVaskModuleCron',
'schedule' => ['type' => 'interval', 'seconds' => 3600],
'timeout_seconds' => 900,
'estimated_duration_ms' => 10000,
'priority' => 95,
],
];
@@ -2,9 +2,6 @@
namespace helpers;
require_once WD . '/classes/xlvask_autopilot_service.php';
use classes\xlvask_autopilot_service;
use Exception;
use objects\orders_o;
use objects\plate_scanners_o;
@@ -46,9 +43,6 @@ class xlvask_tasks
$this->runSyncUsage();
$this->runSyncVehicles();
$this->runCleanupTasks();
// Automation is an optional final phase. A pending migration or an
// off/advisory policy must never interrupt the ordinary XL Vask sync.
$this->runScheduledAutomationIfReady();
};
}
@@ -76,47 +70,6 @@ class xlvask_tasks
};
}
/** Enqueue automatic work only after explicit migration and policy activation. */
public function runScheduledAutomationIfReady(): array
{
try {
$migrationStatus = \classes\xlvask_usage_logs_schema_bootstrap::migrationStatus();
if (!(bool)($migrationStatus['ready'] ?? false)) {
return [];
}
$hallIds = $this->configuredHallIds();
if ($hallIds === []) {
return [];
}
$autopilot = new xlvask_autopilot_service();
$capabilities = (new \classes\xlvask_automation_policy_service())->capabilitiesReadOnly(null, null, $hallIds);
if (!xlvask_autopilot_service::scheduledExecutionAllowed($migrationStatus, $capabilities)) {
return [];
}
$autopilot->createRun(['mode' => 'execute'], null, $hallIds);
return $autopilot->processQueuedRuns(3);
} catch (\Throwable) {
// Fail closed for automation while preserving the completed ordinary sync.
return [];
}
}
/** 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 [];
}
if (!(bool)(\classes\xlvask_usage_logs_schema_bootstrap::migrationStatus()['ready'] ?? false)) {
return [];
}
// Off/advisory cannot contain execute runs because createRun is server-gated.
// Explicit dry-run/replay evidence may still drain in advisory mode.
return (new xlvask_autopilot_service())->processQueuedRuns($limit);
}
/** Hall GUIDs are configuration, not derived from already-imported usage rows. */
private function configuredHallIds(): array
{
@@ -328,10 +281,8 @@ class xlvask_tasks
{
global $db;
$xlvask = new \classes\xlvask();
$orders_o = new orders_o();
$matches = new xlvask_potential_order_matches_o();
$linked = 0;
$ordersCreated = 0;
$dateFromSql = $dateFrom !== null && $dateFrom !== ''
? "'" . $db->escape_string(xlvask_usage_logs_o::formatImportDateFrom($dateFrom)) . "'"
@@ -352,8 +303,6 @@ class xlvask_tasks
}
$rows = $db->fetch_all($result);
$createEnabled = $xlvask->config->automatic_order_creation_enabled->isTrue();
foreach ($rows as $row) {
$log = (new $xlvask->helpers->xlvask_usage_log())->setProperties($row);
$washId = (string)$log->WashId;
@@ -374,25 +323,10 @@ class xlvask_tasks
(int)$log->getDepartment()->id,
);
$linked++;
continue;
}
// No matching order — try to create one if automatic creation is on.
if (!$createEnabled || !$log->isEligibleForAutomaticContinuance(false)) {
continue;
}
try {
$customer = $log->getCustomer();
$order = (new self())->createOrderFromWash($log, $customer);
if ($order !== null) {
$ordersCreated++;
}
} catch (Exception $e) {
error_log('[xlvask-tasks] auto-create order failed for wash ' . $washId . ': ' . $e->getMessage());
}
}
return ['linked' => $linked, 'orders_created' => $ordersCreated];
return ['linked' => $linked, 'orders_created' => 0];
}
private static function formatUsageLogs(array $getUsageLog): array
@@ -502,9 +436,5 @@ class xlvask_tasks
$xlvask = new \classes\xlvask();
// Require the module to be enabled
$xlvask->requireModuleEnabled();
if (!(bool)(\classes\xlvask_usage_logs_schema_bootstrap::migrationStatus()['ready'] ?? false)) {
return;
}
(new xlvask_autopilot_service())->pruneExpiredData();
}
}
@@ -1,27 +0,0 @@
<?php
namespace xlvask\migrations;
require_once WD . '/classes/xlvask_usage_logs_schema_bootstrap.php';
use classes\xlvask_usage_logs_schema_bootstrap;
/**
* Versioned, operator-invoked XL Vask AI auto-action migration.
*
* Preflight is read-only. apply() is intentionally not wired to HTTP routes, cron, constructors,
* readiness, or normal run processing. Operators must execute it through the controlled database
* migration procedure and retain the returned status artifact.
*/
final class migration_20260804_xlvask_ai_auto_policy_v2
{
public static function preflight(): array
{
return xlvask_usage_logs_schema_bootstrap::migrationStatus();
}
public static function apply(): array
{
return xlvask_usage_logs_schema_bootstrap::applyExplicitMigration();
}
}
@@ -3,19 +3,11 @@
namespace xlvask;
require_once WD . '/modules/xlvask/config/xlvask_enabled_c.php';
require_once WD . '/modules/xlvask/config/xlvask_synchronization_enabled_c.php';
require_once WD . '/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php';
require_once WD . '/modules/xlvask/config/xlvask_automatic_order_creation_enabled_c.php';
require_once WD . '/modules/xlvask/config/xlvask_minimax_integration_enabled_c.php';
require_once WD . '/modules/xlvask/config/xlvask_openai_integration_enabled_c.php';
require_once WD . '/modules/xlvask/config/xlvask_username_c.php';
require_once WD . '/modules/xlvask/config/xlvask_password_c.php';
use traits\module_config_t;
use xlvask\config\xlvask_automatic_order_attachment_enabled_c;
use xlvask\config\xlvask_automatic_order_creation_enabled_c;
use xlvask\config\xlvask_enabled_c;
use xlvask\config\xlvask_minimax_integration_enabled_c;
use xlvask\config\xlvask_openai_integration_enabled_c;
use xlvask\config\xlvask_password_c;
use xlvask\config\xlvask_synchronization_enabled_c;
use xlvask\config\xlvask_username_c;
@@ -39,22 +31,6 @@ class xlvask_c
* @var xlvask_synchronization_enabled_c $synchronization_enabled
*/
public xlvask_synchronization_enabled_c $synchronization_enabled;
/**
* @var xlvask_automatic_order_attachment_enabled_c $automatic_order_attachment_enabled
*/
public xlvask_automatic_order_attachment_enabled_c $automatic_order_attachment_enabled;
/**
* @var xlvask_automatic_order_creation_enabled_c $automatic_order_creation_enabled
*/
public xlvask_automatic_order_creation_enabled_c $automatic_order_creation_enabled;
/**
* @var xlvask_minimax_integration_enabled_c $minimax_integration_enabled
*/
public xlvask_minimax_integration_enabled_c $minimax_integration_enabled;
/**
* @var xlvask_openai_integration_enabled_c $openai_integration_enabled
*/
public xlvask_openai_integration_enabled_c $openai_integration_enabled;
/**
* The username
* @var xlvask_username_c
@@ -77,19 +53,11 @@ class xlvask_c
$this->allowUpdate([
xlvask_enabled_c::class,
xlvask_synchronization_enabled_c::class,
xlvask_automatic_order_attachment_enabled_c::class,
xlvask_automatic_order_creation_enabled_c::class,
xlvask_minimax_integration_enabled_c::class,
xlvask_openai_integration_enabled_c::class,
xlvask_username_c::class,
xlvask_password_c::class
]);
$this->enabled = new xlvask_enabled_c();
$this->synchronization_enabled = new xlvask_synchronization_enabled_c();
$this->automatic_order_attachment_enabled = new xlvask_automatic_order_attachment_enabled_c();
$this->automatic_order_creation_enabled = new xlvask_automatic_order_creation_enabled_c();
$this->minimax_integration_enabled = new xlvask_minimax_integration_enabled_c();
$this->openai_integration_enabled = new xlvask_openai_integration_enabled_c();
$this->username = new xlvask_username_c();
$this->password = new xlvask_password_c();
}
@@ -569,4 +569,81 @@ class xlvask_usage_logs_o extends db
));
return $vehicles;
}
/**
* Read-only per-period usage-log summary used by the Selvvask view.
* Replaces the legacy autopilot-service summary with a thin SQL aggregate
* over xlvask_usage_logs that stays well within the operator's hall scope.
*
* @param array<int,string> $allowedHallIds
* @return array{counts: array<string,int>, total_net_amount: float, ignored_count: int, window: array{from:?string,to:?string}}
*/
public function summarizeUsageOrdersReadOnly(?string $dateFrom, ?string $dateTo, array $allowedHallIds): array
{
global $db;
if ($allowedHallIds === []) {
return [
'counts' => ['total' => 0, 'needs_review' => 0, 'ignored' => 0],
'total_net_amount' => 0.0,
'ignored_count' => 0,
'window' => ['from' => $dateFrom, 'to' => $dateTo],
];
}
$hallSql = implode(',', array_map(
static fn(string $hallId): string => "'" . $db->escape_string($hallId) . "'",
$allowedHallIds
));
$fromSql = $dateFrom !== null && $dateFrom !== ''
? "'" . $db->escape_string(xlvask_usage_logs_o::formatImportDateFrom($dateFrom)) . "'"
: 'DATE_SUB(NOW(), INTERVAL 30 DAY)';
$toSql = $dateTo !== null && $dateTo !== ''
? "'" . $db->escape_string((string)$dateTo) . " 23:59:59'"
: 'NOW()';
$sql = "SELECT
COUNT(*) AS total,
SUM(CASE WHEN ignored_at IS NULL THEN 1 ELSE 0 END) AS needs_review,
SUM(CASE WHEN ignored_at IS NOT NULL THEN 1 ELSE 0 END) AS ignored_count
FROM xlvask_usage_logs
WHERE StartTime >= {$fromSql}
AND StartTime <= {$toSql}
AND FinishStatus = 1
AND HallId IN ({$hallSql})";
$result = $db->query($sql);
$row = ($result !== false && $result->num_rows > 0)
? $db->fetch_all($result)[0]
: ['total' => 0, 'needs_review' => 0, 'ignored_count' => 0];
$netSql = "SELECT
COALESCE(SUM(
CAST(
REPLACE(JSON_UNQUOTE(JSON_EXTRACT(WashItems, '$[0].PriceIncVat')), '\"', '') AS DECIMAL(10,2)
)
- COALESCE(
CAST(
REPLACE(JSON_UNQUOTE(JSON_EXTRACT(WashItems, '$[0].Vat')), '\"', '') AS DECIMAL(10,2)
), 0
)
), 0) AS period_net
FROM xlvask_usage_logs
WHERE StartTime >= {$fromSql}
AND StartTime <= {$toSql}
AND FinishStatus = 1
AND HallId IN ({$hallSql})";
$netResult = $db->query($netSql);
$netRow = ($netResult !== false && $netResult->num_rows > 0)
? $db->fetch_all($netResult)[0]
: ['period_net' => 0];
return [
'counts' => [
'total' => (int)($row['total'] ?? 0),
'needs_review' => (int)($row['needs_review'] ?? 0),
'ignored' => (int)($row['ignored_count'] ?? 0),
],
'total_net_amount' => round((float)($netRow['period_net'] ?? 0), 2),
'ignored_count' => (int)($row['ignored_count'] ?? 0),
'window' => ['from' => $dateFrom, 'to' => $dateTo],
];
}
}
+81 -378
View File
@@ -10659,372 +10659,16 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/autopilot-runs:
post:
/modules/xlvask/services/usage/orders/{id}/ignore:
patch:
tags:
- Modules
summary: Create an XLVask usage autopilot run
description: Queues an idempotent invoice-period import and automation evaluation run. Dry-run imports and persists plans without automatic execution; replay is cache-only and read-only.
operationId: createXlvaskUsageAutopilotRun
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [mode]
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]
idempotency_key:
type: string
maxLength: 191
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
summary: Ignore an XL Vask usage log
description: Mark an XL Vask usage log as ignored for invoice-period flagging. The change is scoped to the operator's hall scope and recorded with operator id and reason.
operationId: ignoreXlvaskUsageOrder
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
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: Activation readiness returned successfully
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/automation/capabilities:
get:
tags: [Modules]
summary: Inspect effective XLVask automation capabilities
operationId: getXlvaskAutomationCapabilities
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: Permission-aware capabilities, stage, readiness, active run, budgets, and reviewed soak returned.
content:
application/json:
schema:
type: object
properties:
effective_action_sources:
type: array
description: Empty unless automatic financial actions are currently effective; OpenAI is the only supported source.
items: { type: string, enum: [openai] }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/autopilot-runs/active:
get:
tags: [Modules]
summary: Inspect the active XLVask execute run
operationId: getActiveXlvaskUsageAutopilotRun
responses:
'200':
description: "Returns {run: null} or the oldest active execute run."
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/automation/admin/policy/previews:
post:
tags: [Modules]
summary: Preview an XLVask server-policy stage transition
operationId: previewXlvaskAutomationPolicyTransition
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [target_stage, reason]
properties:
target_stage:
type: string
enum: [off, advisory, ai_attach_canary, ai_attach_verified, ai_create_canary, verified_capped]
reason:
type: string
minLength: 1
maxLength: 1000
responses:
'200': { description: Short-lived, readiness-bound policy preview returned. }
'409': { description: Stage ordering, calibration, active run, schema, uniqueness, or reviewed-soak gate blocked the transition. }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/automation/admin/policy/apply:
post:
tags: [Modules]
summary: Apply a previewed XLVask server-policy transition
operationId: applyXlvaskAutomationPolicyTransition
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [preview_id, selection_hash, confirmation_text]
properties:
preview_id: { type: string, format: uuid }
selection_hash: { type: string }
confirmation_text: { type: string }
responses:
'200': { description: Policy and re-evaluated readiness returned. }
'409': { description: Preview expired or policy, identity, calibration, run, or readiness changed. }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/automation/admin/halt:
post:
tags: [Modules]
summary: Immediately halt XLVask automatic actions
operationId: haltXlvaskAutomation
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
reason: { type: string, maxLength: 1000 }
responses:
'200': { description: Automatic actions halted and kill switches disabled atomically. }
'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, duplicate, cross_hall, unaudited] }
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
- name: id
in: path
required: true
schema: { type: integer }
requestBody:
@@ -11033,35 +10677,94 @@ paths:
application/json:
schema:
type: object
required: [artifact_hash, confirmation_text]
required: [reason]
properties:
artifact_hash: { type: string }
confirmation_text: { type: string }
reason:
type: string
maxLength: 500
responses:
'200': { description: Calibration artifact activated successfully }
'401': { $ref: '#/components/responses/Unauthorized' }
'200':
description: XL Vask usage log marked as ignored
'400': { $ref: '#/components/responses/BadRequest' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
/modules/xlvask/services/usage/automation/admin/wash-id-uniqueness/activate:
/modules/xlvask/services/usage/orders/{id}/unignore:
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
tags:
- Modules
summary: Clear ignore metadata on an XL Vask usage log
description: Resets ignored_at, ignored_by and ignored_reason on an XL Vask usage log scoped to the operator's hall scope.
operationId: unignoreXlvaskUsageOrder
parameters:
- name: id
in: path
required: true
schema: { type: integer }
responses:
'200':
description: Ignore metadata cleared
'400': { $ref: '#/components/responses/BadRequest' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
/modules/xlvask/services/usage/orders/{id}/accept:
post:
tags:
- Modules
summary: Convert an XL Vask usage log into an order
description: Creates an order from the XL Vask usage log in the operator's hall scope and records the converted usage log as ignored with a stable reason referencing the order id.
operationId: acceptXlvaskUsageOrder
parameters:
- name: id
in: path
required: true
schema: { type: integer }
responses:
'200':
description: Order created from XL Vask usage log
content:
application/json:
schema:
type: object
properties:
order_id: { type: integer }
usage_log_id: { type: integer }
'400': { $ref: '#/components/responses/BadRequest' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'422': { description: XL Vask customer is not linkable }
/modules/xlvask/services/usage/orders/{id}/reject:
post:
tags:
- Modules
summary: Reject an XL Vask usage log with a reviewer note
description: Marks the XL Vask usage log as ignored with a reviewer-provided reason. Scoped to the operator's hall scope.
operationId: rejectXlvaskUsageOrder
parameters:
- name: id
in: path
required: true
schema: { type: integer }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [confirmation_text]
required: [reason]
properties:
confirmation_text: { type: string }
reason:
type: string
maxLength: 500
responses:
'200': { description: Wash-id uniqueness activated successfully }
'409': { description: Duplicate wash IDs or schema readiness blocked activation }
'401': { $ref: '#/components/responses/Unauthorized' }
'200':
description: XL Vask usage log rejected
'400': { $ref: '#/components/responses/BadRequest' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
/modules/action-logs:
get:
@@ -952,44 +952,6 @@ class moduleConfigRoute
'modules_openai_config' => 'Update openai config'
]
);
/** MiniMax config > GET */
$this->get('/minimax/config', function () {
global $response;
$this->requirePermission('modules_minimax_config');
$user = (new authentication())->get_user();
if ($user) {
(new logs_o())->add('minimax_config', 'global', 1, $user->id, 'MINIMAX_CONFIG', 'Successfully fetched MiniMax config');
$response->success(
(new \classes\minimax())->config->getConfigRequest()
);
} else {
(new logs_o())->add('minimax_config', 'global', 1, 0, 'MINIMAX_CONFIG', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
},
[
'modules_minimax_config' => 'Get MiniMax config'
]
);
/** MiniMax config > POST */
$this->post('/minimax/config', function () {
global $response;
$this->requirePermission('modules_minimax_config');
$user = (new authentication())->get_user();
if ($user) {
(new logs_o())->add('minimax_config', 'global', 1, $user->id, 'MINIMAX_CONFIG', 'Successfully updated MiniMax config');
$response->success(
(new \classes\minimax())->config->postConfigRequest()
);
} else {
(new logs_o())->add('minimax_config', 'global', 1, 0, 'MINIMAX_CONFIG', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
},
[
'modules_minimax_config' => 'Update MiniMax config'
]
);
/** LicensePlateRecognizer config > GET */
$this->get('/licenseplaterecognizer/config', function () {
global $response;
@@ -2,15 +2,11 @@
namespace routes;
require_once WD . '/classes/xlvask_autopilot_service.php';
use classes\authentication;
use classes\response;
use classes\router;
use classes\xlvask;
use classes\xlvask_autopilot_service;
use objects\orders_o;
use objects\users_o;
use objects\xlvask_customers_o;
use traits\route_t;
@@ -180,10 +176,6 @@ class moduleXLVaskRoute
$this->get('/modules/xlvask/tasks/sync-usage', function () {
global $response;
$this->requirePermission('modules_xlvask_sync_usage');
// Remove the memory limit
// ini_set('memory_limit', '-1');
// Remove the execution time limit
// set_time_limit(300);
// Create the xlvask tasks object
$xlvask = new xlvask();
// Run the sync usage task
@@ -199,27 +191,6 @@ class moduleXLVaskRoute
]
);
$this->get('/modules/xlvask/tasks/debug', function () {
global $response;
$this->requirePermission('modules_xlvask_sync_usage');
// Create the xlvask tasks object
$xlvask = new xlvask();
$user = new users_o();
$user->getUserByCustomerNumber(12345679);
//$result = $xlvask->getTasks()->runSyncVehicles(false);
$vehicles = $xlvask->new($xlvask->helpers->xlvask_vehicles);
//print_r($vehicles::getVehicleByRegistrationNumber('BW93159'));
// Response
$response->success(
'Debugging xlvask tasks',
200
);
},
[
'modules_xlvask_sync_usage' => 'Synchronize usage with the xlvask module'
]
);
$this->get('/modules/xlvask/tasks/import-customers', function () {
global $response;
$this->requirePermission('modules_xlvask_import_customers');
@@ -251,15 +222,5 @@ class moduleXLVaskRoute
'modules_xlvask_import_vehicles' => 'Import vehicles from the xlvask module'
]
);
$this->get('/modules/xlvask/tasks/import-usage', function () {
global $response;
$this->requirePermission('modules_xlvask_import_usage');
$response->error('Deprecated state-changing GET. Use POST /modules/xlvask/services/usage/autopilot-runs.', 410);
},
[
'modules_xlvask_import_usage' => 'Import usage from the xlvask module'
]
);
}
}
+297 -576
View File
@@ -2,24 +2,10 @@
namespace routes;
require_once WD . '/classes/xlvask_automation_service.php';
require_once WD . '/classes/xlvask_autopilot_service.php';
require_once WD . '/classes/xlvask_automation_policy_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 classes\xlvask_automation_policy_service;
use objects\collected_order_invoices_o;
use objects\departments_o;
use objects\economic_module_orders;
use objects\orders_o;
use objects\stripe_module_orders_o;
use objects\stripe_payment_intents_o;
use objects\xlvask_usage_logs_o;
use traits\route_t;
@@ -30,136 +16,110 @@ class xlvaskUsageLogsRoute
public function run(): void
{
$this->get('/modules/xlvask/services/usage/orders', function () {
// Define the permissions:
$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)
// Require the user to be logged in
$permission_list_own = 'list_xlvask_usage_orders_own';
$permission_list_all = 'list_xlvask_usage_orders_all';
$response_includes_items = false;
global $response;
if (!$this->hasPermission($permission_list_all)) {
$this->requirePermission($permission_list_own);
}
// Get the user object
$user = (new authentication())->get_user();
// Check if the request was successful
if ($user) {
$allowedHallIds = $this->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();
$linked_order_ids_by_wash_id = [];
$xlvask->new($xlvask->helpers->xlvask_usage_log)->getDepartment();
$orders_o = new orders_o();
$xlvask_usage_log = $xlvask->new($xlvask->helpers->xlvask_usage_log);
// Increase the memory limit to 512MB (Provided it's currently less than that)
if (ini_get('memory_limit') < '5120M') {
ini_set('memory_limit', '5120M');
}
// 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, $xlvask_usage_logs, $xlvask, $automation_service, &$linked_order_ids_by_wash_id) {
// Remove the 'id' field from the log
$id = (int)$log['id'];
$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);
$usage_log_payload = array_intersect_key($log, array_flip([
'WashId',
'CustomerId',
'Customer',
'VatNumber',
'Location',
'Hall',
'HallId',
'StartTime',
'FinishTime',
'RegistrationNumber',
'VehicleType',
'IdentificationType',
'IdentificationId',
'Info',
'Updated',
'Prepaid',
'FinishStatus',
'CustomerGuid',
'VehicleId',
'WashItems',
'ignored_at',
'ignored_by',
'ignored_reason',
]));
// Create a new xlvask usage log object
$tmp = $xlvask->new($xlvask->helpers->xlvask_usage_log);
// Set the properties of the temporary object
$tmp->setProperties($usage_log_payload);
$wash_id = (string)$tmp->WashId;
if ($wash_id !== '' && !array_key_exists($wash_id, $linked_order_ids_by_wash_id)) {
$linked_order = (new orders_o())->selectByWashId($wash_id);
$linked_order_ids_by_wash_id[$wash_id] = $linked_order !== null ? (int)$linked_order->id : null;
}
$linked_order_id = $wash_id !== '' ? $linked_order_ids_by_wash_id[$wash_id] : null;
// Define the result structure
$isEligibleForAutomaticContinuance = $tmp->isEligibleForAutomaticContinuance(true);
// 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
$tmp_res['order']['total_net_amount'] = $amount_summary['total_net_amount'];
$tmp_res['order']['xlvask_primary_product_name'] = $amount_summary['primary_product_name'];
$tmp_res['order']['xlvask_amount_cached'] = $amount_summary['cached'];
// Clear memory
unset($tmp);
// Return the result
return [
'id' => $id, // Return the ID of the log
'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,
];
},
$xlvask_usage_logs->forceRestrictFilters(
[
// This makes sure that the user can only see department logs that belong to their departments
'HallId' => $allowedHallIds,
'FinishStatus' => ['1'], // Only show finished logs
]
)
);
// Return the response
$response->success($result);
} else {
// Return an error
if (!$user) {
$response->error('Invalid session', 400);
}
},
[
'list_xlvask_usage_orders_own' => 'List own xlvask usage orders (Without department filter)',
'list_xlvask_usage_orders_all' => 'List all xlvask usage orders (With department filter)',
]
);
$allowedHallIds = $this->allowedHallIdsForUser($user);
if ($allowedHallIds === []) {
$response->error('No XL Vask hall scope is available', 403);
}
$xlvask_usage_logs = new xlvask_usage_logs_o();
$xlvask = new xlvask();
$linked_order_ids_by_wash_id = [];
$xlvask_usage_log_class = $xlvask->helpers->xlvask_usage_log;
if (ini_get('memory_limit') < '5120M') {
ini_set('memory_limit', '5120M');
}
$result = $xlvask_usage_logs
->setAdditionalWhereClause(
"`Customer` NOT IN ('" . implode("', '", $xlvask_usage_log_class::$default_customers) . "')"
)
->listObjectsWithPaginationIfSet(
function ($log) use (
$response_includes_items,
$xlvask_usage_logs,
$xlvask,
&$linked_order_ids_by_wash_id
) {
$id = (int)$log['id'];
$amount_summary = $xlvask_usage_logs->getAmountSummaryReadOnly($log);
unset($log['id']);
$log['WashItems'] = json_decode($log['WashItems'] ?? '[]', true);
$usage_log_payload = array_intersect_key($log, array_flip([
'WashId',
'CustomerId',
'Customer',
'VatNumber',
'Location',
'Hall',
'HallId',
'StartTime',
'FinishTime',
'RegistrationNumber',
'VehicleType',
'IdentificationType',
'IdentificationId',
'Info',
'Updated',
'Prepaid',
'FinishStatus',
'CustomerGuid',
'VehicleId',
'WashItems',
'ignored_at',
'ignored_by',
'ignored_reason',
]));
$tmp = $xlvask->new($xlvask_usage_log_class);
$tmp->setProperties($usage_log_payload);
$wash_id = (string)$tmp->WashId;
if ($wash_id !== '' && !array_key_exists($wash_id, $linked_order_ids_by_wash_id)) {
$linked_order = (new orders_o())->selectByWashId($wash_id);
$linked_order_ids_by_wash_id[$wash_id] = $linked_order !== null
? (int)$linked_order->id
: null;
}
$linked_order_id = $wash_id !== ''
? $linked_order_ids_by_wash_id[$wash_id]
: null;
$isEligibleForAutomaticContinuance = $tmp->isEligibleForAutomaticContinuance(true);
$tmp_res = $isEligibleForAutomaticContinuance
? (new orders_o())->simulateOrderFromXLVask($tmp, $response_includes_items)
: [];
$tmp_res['order']['customer_name'] = $tmp->Customer;
$tmp_res['order']['total_net_amount'] = $amount_summary['total_net_amount'];
$tmp_res['order']['xlvask_primary_product_name'] = $amount_summary['primary_product_name'];
$tmp_res['order']['xlvask_amount_cached'] = $amount_summary['cached'];
unset($tmp);
return [
'id' => $id,
'fast_link_key' => null,
'usage_log_id' => $id,
'linked_order_id' => $linked_order_id,
'ignored_at' => $log['ignored_at'] ?? null,
'ignored_by' => isset($log['ignored_by']) ? (int)$log['ignored_by'] : null,
'ignored_reason' => $log['ignored_reason'] ?? null,
...$tmp_res['order'],
];
},
$xlvask_usage_logs->forceRestrictFilters([
'HallId' => $allowedHallIds,
'FinishStatus' => ['1'],
])
);
$response->success($result);
}, [
'list_xlvask_usage_orders_own' => 'List own xlvask usage orders (Without department filter)',
'list_xlvask_usage_orders_all' => 'List all xlvask usage orders (With department filter)',
]);
$this->get('/modules/xlvask/services/usage/orders/summary', function () {
global $response;
@@ -172,392 +132,189 @@ class xlvaskUsageLogsRoute
}
$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,
$this->allowedHallIdsForUser($user)
),
]);
},
[
'list_xlvask_usage_orders_own' => 'Read XL Vask usage-log automation summary',
'list_xlvask_usage_orders_all' => 'Read all XL Vask usage-log automation summaries',
]
);
$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',
'aiTimeline',
'aiBatchSize',
'aiMaxCostUsd',
'aiInputUsdPer1mUsd',
'aiOutputUsdPer1mUsd',
] as $key) {
if ($this->isParametersSet([$key])) {
$input[$key] = $this->getParameter($key);
}
}
$response->success([
'run' => (new xlvask_autopilot_service())->createRun(
$input,
(int)$user->id,
$this->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');
$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;
$response->success((new xlvask_automation_policy_service())->readinessReadOnly(
$allowedHallIds = $this->allowedHallIdsForUser($user);
$summary = (new xlvask_usage_logs_o())->summarizeUsageOrdersReadOnly(
$dateFrom,
$dateTo,
$this->allowedHallIdsForUser($user)
));
}, [
'superuser_xlvask_automation_activate' => 'Inspect XL Vask automation activation readiness',
]);
$this->get('/modules/xlvask/services/usage/automation/capabilities', function () {
global $response;
if (!$this->hasPermission('list_xlvask_usage_orders_all')
&& !$this->hasPermission('list_xlvask_usage_orders_own')) {
$this->requirePermission('list_xlvask_usage_orders_own');
}
$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;
$service = new xlvask_automation_policy_service();
$capabilities = $service->capabilitiesReadOnly($dateFrom, $dateTo, $this->allowedHallIdsForUser($user));
$canManage = $this->hasPermission('manage_xlvask_usage_automation');
$canReview = $canManage
|| $this->hasPermission('review_xlvask_usage_order')
|| $this->hasPermission('list_xlvask_usage_orders_all')
|| $this->hasPermission('list_xlvask_usage_orders_own');
$canManagePolicy = $this->hasPermission('superuser_xlvask_automation_activate');
$response->success([
'can_view' => true,
'can_review' => $canReview,
'can_dry_run' => $canManage,
'can_execute' => $canManage && in_array('execute', $capabilities['allowed_modes'], true),
'can_manage_policy' => $canManagePolicy,
'can_halt' => $canManagePolicy,
...$capabilities,
]);
}, [
'list_xlvask_usage_orders_own' => 'Inspect XL Vask automation capabilities',
'review_xlvask_usage_order' => 'Inspect XL Vask automation capabilities as a reviewer',
]);
$this->get('/modules/xlvask/services/usage/autopilot-runs/active', function () {
global $response;
$this->requirePermission('manage_xlvask_usage_automation');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$response->success(['run' => (new xlvask_automation_policy_service())->activeRunReadOnly(
$this->allowedHallIdsForUser($user)
)]);
}, ['manage_xlvask_usage_automation' => 'Inspect the active XL Vask automation run']);
$this->post('/modules/xlvask/services/usage/automation/admin/policy/previews', function () {
global $response;
$this->requirePermission('superuser_xlvask_automation_activate');
self::requireParameters(['target_stage', 'reason']);
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$response->success(['preview' => (new xlvask_automation_policy_service())->createPolicyPreview(
(string)$this->getParameter('target_stage'),
(string)$this->getParameter('reason'),
(int)$user->id
)]);
}, ['superuser_xlvask_automation_activate' => 'Preview an XL Vask automation stage transition']);
$this->post('/modules/xlvask/services/usage/automation/admin/policy/apply', function () {
global $response;
$this->requirePermission('superuser_xlvask_automation_activate');
self::requireParameters(['preview_id', 'selection_hash', 'confirmation_text']);
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$response->success((new xlvask_automation_policy_service())->applyPolicyPreview([
'preview_id' => $this->getParameter('preview_id'),
'selection_hash' => $this->getParameter('selection_hash'),
'confirmation_text' => $this->getParameter('confirmation_text'),
], (int)$user->id));
}, ['superuser_xlvask_automation_activate' => 'Apply a previewed XL Vask automation stage transition']);
$this->post('/modules/xlvask/services/usage/automation/admin/halt', function () {
global $response;
$this->requirePermission('superuser_xlvask_automation_activate');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$reason = $this->isParametersSet(['reason']) ? (string)$this->getParameter('reason') : '';
$response->success((new xlvask_automation_policy_service())->halt((int)$user->id, $reason));
}, ['superuser_xlvask_automation_activate' => 'Immediately halt XL Vask automatic actions']);
$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);
}
$allowedHallIds = $this->allowedHallIdsForUser($user);
$result = (new xlvask_autopilot_service())->adjudicateCalibrationLabel(
(int)$this->getParameter('suggestion_id'),
trim((string)$this->getParameter('outcome')),
(int)$user->id,
$allowedHallIds
);
$dateFrom = $this->isParametersSet(['dateFrom']) ? trim((string)$this->getParameter('dateFrom')) : null;
$dateTo = $this->isParametersSet(['dateTo']) ? trim((string)$this->getParameter('dateTo')) : null;
$response->success([
...$result,
'readiness' => (new xlvask_automation_policy_service())->readinessReadOnly($dateFrom, $dateTo, $allowedHallIds),
]);
$response->success(['summary' => $summary]);
}, [
'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,
$this->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;
if (!$this->hasPermission('manage_xlvask_usage_automation')
&& !$this->hasPermission('review_xlvask_usage_order')) {
$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', 'force_manual'] as $key) {
if ($this->isParametersSet([$key])) {
$input[$key] = $this->getParameter($key);
}
}
$response->success([
'preview' => (new xlvask_autopilot_service())->createDecisionPreview(
$input,
(int)$user->id,
$this->allowedHallIdsForUser($user)
),
]);
}, [
'manage_xlvask_usage_automation' => 'Preview an XL Vask automation decision',
'review_xlvask_usage_order' => 'Preview an XL Vask automation decision as a reviewer',
]);
$this->post('/modules/xlvask/services/usage/automation/decisions/apply', function () {
global $response;
if (!$this->hasPermission('manage_xlvask_usage_automation')
&& !$this->hasPermission('review_xlvask_usage_order')) {
$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,
$this->allowedHallIdsForUser($user)
)
);
}, [
'manage_xlvask_usage_automation' => 'Apply a previewed XL Vask automation decision',
'review_xlvask_usage_order' => 'Apply a previewed XL Vask automation decision as a reviewer',
'list_xlvask_usage_orders_own' => 'Read XL Vask usage-log summary',
'list_xlvask_usage_orders_all' => 'Read all XL Vask usage-log summaries',
]);
$this->patch('/modules/xlvask/services/usage/orders/{id}/ignore', function () {
global $response;
$this->requirePermission('ignore_xlvask_usage_order');
$response->error('Use the server-generated automation decision preview and apply endpoints.', 409);
},
[
'ignore_xlvask_usage_order' => 'Ignore an XL Vask usage log for invoice period flagging',
]
);
$this->post('/modules/xlvask/services/usage/orders/automation/run', function () {
global $response;
$this->requirePermission('manage_xlvask_usage_automation');
$response->error('Deprecated. Use /modules/xlvask/services/usage/autopilot-runs.', 410);
},
[
'manage_xlvask_usage_automation' => 'Evaluate and execute XL Vask usage-log automation',
]
);
$this->post('/modules/xlvask/services/usage/orders/{id}/automation/evaluate', function () {
global $response;
$this->requirePermission('manage_xlvask_usage_automation');
$response->error('Deprecated. Use /modules/xlvask/services/usage/autopilot-runs.', 410);
},
[
'manage_xlvask_usage_automation' => 'Evaluate XL Vask usage-log automation',
]
);
$this->post('/modules/xlvask/services/usage/orders/{id}/automation/accept', function () {
global $response;
$this->requirePermission('manage_xlvask_usage_automation');
$this->requirePermission('review_xlvask_usage_order');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$id = (int)($this->fromRoute('id') ?? 0);
if ($id < 1) {
$response->error('Invalid XL Vask usage log id', 400);
}
self::requireUsageLogInHallScope($id, $this->allowedHallIdsForUser($user));
self::requireParameters(['reason']);
$reason = trim((string)$this->getParameter('reason'));
if (mb_strlen($reason) > 500) {
$response->error('Reason is too long (max 500 characters)', 400);
}
$allowedHallIds = $this->allowedHallIdsForUser($user);
self::requireUsageLogInHallScope($id, $allowedHallIds);
$log = new xlvask_usage_logs_o();
$log->getById($id);
if (!$log->id) {
$response->error('XL Vask usage log not found', 404);
}
$log->ignored_at->set(date('Y-m-d H:i:s'));
$log->ignored_by->set((int)$user->id);
$log->ignored_reason->set($reason);
$log->objectChanged();
$response->success([
'id' => $id,
'ignored_at' => $log->ignored_at->get(),
'ignored_by' => (int)$log->ignored_by->get(),
'ignored_reason' => $log->ignored_reason->get(),
]);
}, [
'review_xlvask_usage_order' => 'Ignore an XL Vask usage log for invoice period flagging',
]);
$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',
]
);
$this->post('/modules/xlvask/services/usage/orders/{id}/automation/deny', function () {
$this->post('/modules/xlvask/services/usage/orders/{id}/unignore', function () {
global $response;
$this->requirePermission('manage_xlvask_usage_automation');
$this->requirePermission('review_xlvask_usage_order');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$id = (int)($this->fromRoute('id') ?? 0);
if ($id < 1) {
$response->error('Invalid XL Vask usage log id', 400);
}
self::requireUsageLogInHallScope($id, $this->allowedHallIdsForUser($user));
$allowedHallIds = $this->allowedHallIdsForUser($user);
self::requireUsageLogInHallScope($id, $allowedHallIds);
$log = new xlvask_usage_logs_o();
$log->getById($id);
if (!$log->id) {
$response->error('XL Vask usage log not found', 404);
}
$log->ignored_at->set(null);
$log->ignored_by->set(null);
$log->ignored_reason->set(null);
$log->objectChanged();
$response->success(['id' => $id]);
}, [
'review_xlvask_usage_order' => 'Clear ignore metadata on an XL Vask usage log',
]);
$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',
]
);
$this->post('/modules/xlvask/services/usage/orders/{id}/accept', function () {
global $response;
$this->requirePermission('review_xlvask_usage_order');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$id = (int)($this->fromRoute('id') ?? 0);
if ($id < 1) {
$response->error('Invalid XL Vask usage log id', 400);
}
$allowedHallIds = $this->allowedHallIdsForUser($user);
self::requireUsageLogInHallScope($id, $allowedHallIds);
$log = new xlvask_usage_logs_o();
$log->getById($id);
if (!$log->id) {
$response->error('XL Vask usage log not found', 404);
}
$xlvask = new xlvask();
$log_helper = new ($xlvask->helpers->xlvask_usage_log)();
$log_helper->setProperties(array_intersect_key($log->toArray(), array_flip([
'WashId',
'CustomerId',
'Customer',
'VatNumber',
'Location',
'Hall',
'HallId',
'StartTime',
'FinishTime',
'RegistrationNumber',
'VehicleType',
'IdentificationType',
'IdentificationId',
'Info',
'Updated',
'Prepaid',
'FinishStatus',
'CustomerGuid',
'VehicleId',
'WashItems',
])));
$customer = $log_helper->getCustomer();
if ($customer === null) {
$response->error('XL Vask customer is not linkable', 422);
}
if (empty($customer->externId)) {
$response->error('XL Vask customer has no external id', 422);
}
$tmp_user = $customer->getUser();
if (!$tmp_user) {
$response->error('XL Vask customer is not provisioned in this system', 422);
}
try {
$order = $xlvask->getTasks()->createOrderFromWash($log_helper, $customer);
} catch (\Throwable $e) {
error_log('[xlvask-accept] createOrderFromWash failed: ' . $e->getMessage());
$response->error('Could not create order from XL Vask usage log: ' . $e->getMessage(), 422);
}
$log->ignored_at->set(date('Y-m-d H:i:s'));
$log->ignored_by->set((int)$user->id);
$log->ignored_reason->set('Accepted and converted to order ' . (int)$order->id);
$log->objectChanged();
$response->success([
'order_id' => (int)$order->id,
'usage_log_id' => $id,
]);
}, [
'review_xlvask_usage_order' => 'Convert an XL Vask usage log into an order',
]);
$this->post('/modules/xlvask/services/usage/orders/{id}/reject', function () {
global $response;
$this->requirePermission('review_xlvask_usage_order');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$id = (int)($this->fromRoute('id') ?? 0);
if ($id < 1) {
$response->error('Invalid XL Vask usage log id', 400);
}
self::requireParameters(['reason']);
$reason = trim((string)$this->getParameter('reason'));
if (mb_strlen($reason) > 500) {
$response->error('Reason is too long (max 500 characters)', 400);
}
$allowedHallIds = $this->allowedHallIdsForUser($user);
self::requireUsageLogInHallScope($id, $allowedHallIds);
$log = new xlvask_usage_logs_o();
$log->getById($id);
if (!$log->id) {
$response->error('XL Vask usage log not found', 404);
}
$log->ignored_at->set(date('Y-m-d H:i:s'));
$log->ignored_by->set((int)$user->id);
$log->ignored_reason->set('Rejected: ' . $reason);
$log->objectChanged();
$response->success([
'id' => $id,
'ignored_at' => $log->ignored_at->get(),
'ignored_by' => (int)$log->ignored_by->get(),
'ignored_reason' => $log->ignored_reason->get(),
]);
}, [
'review_xlvask_usage_order' => 'Reject an XL Vask usage log with a reviewer note',
]);
$this->get('/modules/xlvask/services/usage/orders/fast-link', function () {
global $response;
@@ -566,91 +323,55 @@ class xlvaskUsageLogsRoute
if (!$user) {
$response->error('Invalid session', 400);
}
self::requireParameters([
'fast_link_key', // Example: 'temporary_cache_6878cf0603d77'
]);
// Get the fast link key from the request
self::requireParameters(['fast_link_key']);
$fast_link_key = (string)self::getParameter('fast_link_key');
self::requireType($fast_link_key, self::type_string());
self::requireMinLength('fast_link_key', 20); // Minimum length of the fast link key
self::requireMaxLength('fast_link_key', 50); // Maximum length of the fast link key
// Check if the fast link key is valid
// First, check if the key has the correct format
if (preg_match('/^temporary_cache_[a-z0-9]{12,32}$/', $fast_link_key)) {
// Get the cached data from Redis
$cached_data = redis->get($fast_link_key);
// Check if the cached data is valid
if ($cached_data) {
// Decode the cached data
$data = json_decode($cached_data, true);
// Check if the data is valid
if (is_array($data)) {
$allowedHallIds = $this->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
$xlvask = new xlvask();
$order_arr = (new orders_o())->simulateOrderFromXLVask($xlvask->new($xlvask->helpers->xlvask_usage_log)->setProperties($data), true); // ['order' => $order_arr, 'order_items' => $items_arr]
$tmp_order_obj = (object)[];
$tmp_order_arr = $order_arr['order'] ?? [];
/**
* "id": -1,
* "customer_id": 39159000,
* "cashier_id": 2285,
* "reference": "Simulated Order from XL Vask",
* "notes": "This is a simulated order generated from an XL Vask usage log",
* "department_id": 1,
* "reg_1": "DE55248",
* "reg_2": "",
* "reg_3": "",
* "completed_at": null,
* "created_at": "2025-07-16 13:04:54",
* "deleted_at": null,
* "total_net_amount": 683,
* "invoice_collection_id": 0,
* "booking_id": 0,
* "wash_id": "b24728ea-e22b-4dce-8cd3-a0998f7fdc5e",
* "lane": 1,
* "closed_at": null
*/
$tmp_order_obj->customer_id = $tmp_order_arr['customer_id'] ?? 0;
$tmp_order_obj->department_id = $tmp_order_arr['department_id'] ?? 0;
$tmp_order_obj->reg_1 = $tmp_order_arr['reg_1'] ?? '';
$tmp_order_obj->reg_2 = $tmp_order_arr['reg_2'] ?? '';
$tmp_order_obj->reg_3 = $tmp_order_arr['reg_3'] ?? '';
$tmp_order_obj->created_at = $tmp_order_arr['created_at'] ?? '';
$tmp_order_obj->wash_id = $tmp_order_arr['wash_id'] ?? '';
$tmp_order_obj->lane = $tmp_order_arr['lane'] ?? 0;
$response->success([
...$order_arr,
'potential_duplicates' => (new orders_o())->getOrderPotentialDuplicatesIfFound(
$tmp_order_obj->reg_1,
$tmp_order_obj->reg_2,
$tmp_order_obj->reg_3,
$tmp_order_obj->department_id,
$tmp_order_obj->created_at,
),
]);
} else {
// Return an error if the data is not valid
$response->error('Invalid cached data', 400);
}
} else {
// Return an error if the fast link key does not exist in Redis
$response->error('Fast link key not found', 404);
}
} else {
// Return an error if the fast link key is invalid
self::requireMinLength('fast_link_key', 20);
self::requireMaxLength('fast_link_key', 50);
if (!preg_match('/^temporary_cache_[a-z0-9]{12,32}$/', $fast_link_key)) {
$response->error('Invalid fast link key format', 400);
}
},
[
'fast_link_key' => 'string', // Example: 'temporary_cache_6878cf0603d77'
]
);
$cached_data = redis->get($fast_link_key);
if (!$cached_data) {
$response->error('Fast link key not found', 404);
}
$data = json_decode($cached_data, true);
if (!is_array($data)) {
$response->error('Invalid cached data', 400);
}
$allowedHallIds = $this->allowedHallIdsForUser($user);
if (!in_array(trim((string)($data['HallId'] ?? '')), $allowedHallIds, true)) {
$response->error('Fast link is outside the current XL Vask hall scope', 403);
}
redis->delete($fast_link_key);
$xlvask = new xlvask();
$order_arr = (new orders_o())->simulateOrderFromXLVask(
$xlvask->new($xlvask->helpers->xlvask_usage_log)->setProperties($data),
true
);
$tmp_order_obj = (object)[];
$tmp_order_arr = $order_arr['order'] ?? [];
$tmp_order_obj->customer_id = $tmp_order_arr['customer_id'] ?? 0;
$tmp_order_obj->department_id = $tmp_order_arr['department_id'] ?? 0;
$tmp_order_obj->reg_1 = $tmp_order_arr['reg_1'] ?? '';
$tmp_order_obj->reg_2 = $tmp_order_arr['reg_2'] ?? '';
$tmp_order_obj->reg_3 = $tmp_order_arr['reg_3'] ?? '';
$tmp_order_obj->created_at = $tmp_order_arr['created_at'] ?? '';
$tmp_order_obj->wash_id = $tmp_order_arr['wash_id'] ?? '';
$tmp_order_obj->lane = $tmp_order_arr['lane'] ?? 0;
$response->success([
...$order_arr,
'potential_duplicates' => (new orders_o())->getOrderPotentialDuplicatesIfFound(
$tmp_order_obj->reg_1,
$tmp_order_obj->reg_2,
$tmp_order_obj->reg_3,
$tmp_order_obj->department_id,
$tmp_order_obj->created_at,
),
]);
}, [
'fast_link_key' => 'string',
]);
}
private function allowedHallIdsForUser(object $user): array
@@ -1,153 +0,0 @@
<?php
declare(strict_types=1);
usesApiSuite();
/**
* End-to-end API tests for the XL Vask Selvvask review surface.
*
* The Superuser → Fakturaer → Periode → Selvvask view only shows the
* Accept / Reject / Ignore / Link / Compare buttons when the API returns
* `can_review=true` for the authenticated user, and only succeeds at
* posting the decision when the same user can hit /decisions/preview
* and /decisions/apply. Both of those gating decisions were previously
* locked to `manage_xlvask_usage_automation`, an admin-only permission,
* which silently disabled the buttons for every ordinary operator. This
* test suite locks in the new operator-friendly contract end to end.
*/
it('lights can_review for an operator who only has list_xlvask_usage_orders_own', function (): void {
api_test_covers('GET /modules/xlvask/services/usage/automation/capabilities', 'happy');
api_test_covers('GET /modules/xlvask/services/usage/automation/capabilities', 'review-can-review-operator');
$operator = api_fixtures()->createUserSession(['list_xlvask_usage_orders_own']);
$response = api_client()->get(
'/modules/xlvask/services/usage/automation/capabilities?dateFrom=2026-07-01&dateTo=2026-07-31',
$operator['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$payload = $response->data();
expect($payload)->toBeArray();
// Operator with list_xlvask_usage_orders_own must light can_review so the
// Selvvask view actually renders the Accept / Reject / Ignore buttons.
expect($payload['can_review'] ?? null)->toBeTrue();
// The AI-administrator-only flags must stay false so the operator can
// never trigger the autopilot or change policy from the selvvask view.
expect($payload['can_dry_run'] ?? null)->toBeFalse();
expect($payload['can_execute'] ?? null)->toBeFalse();
expect($payload['can_manage_policy'] ?? null)->toBeFalse();
expect($payload['can_halt'] ?? null)->toBeFalse();
});
it('lights can_review for an operator granted the dedicated review_xlvask_usage_order permission (and the list permission to reach the endpoint)', function (): void {
api_test_covers('GET /modules/xlvask/services/usage/automation/capabilities', 'review-can-review-permission');
$operator = api_fixtures()->createUserSession([
'review_xlvask_usage_order',
'list_xlvask_usage_orders_own',
]);
$response = api_client()->get(
'/modules/xlvask/services/usage/automation/capabilities?dateFrom=2026-07-01&dateTo=2026-07-31',
$operator['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$payload = $response->data();
expect($payload['can_review'] ?? null)->toBeTrue();
// Review-only grant must NOT unlock AI admin powers.
expect($payload['can_dry_run'] ?? null)->toBeFalse();
expect($payload['can_execute'] ?? null)->toBeFalse();
expect($payload['can_manage_policy'] ?? null)->toBeFalse();
});
it('keeps can_review false for an operator with no xlvask permissions', function (): void {
api_test_covers('GET /modules/xlvask/services/usage/automation/capabilities', 'auth');
api_test_covers('GET /modules/xlvask/services/usage/automation/capabilities', 'failure');
api_test_covers('GET /modules/xlvask/services/usage/automation/capabilities', 'review-can-review-blocked');
$operator = api_fixtures()->createUserSession(['list_departments']);
$response = api_client()->get(
'/modules/xlvask/services/usage/automation/capabilities?dateFrom=2026-07-01&dateTo=2026-07-31',
$operator['headers']
);
// The endpoint should 403 because the user lacks the list_* permission
// required to even inspect the capabilities surface.
$response
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['list_xlvask_usage_orders_own']);
});
it('admits the operator to the decisions preview endpoint with review_xlvask_usage_order', function (): void {
api_test_covers('POST /modules/xlvask/services/usage/automation/decisions/preview', 'happy');
api_test_covers('POST /modules/xlvask/services/usage/automation/decisions/preview', 'review-permission-allows');
$operator = api_fixtures()->createUserSession([
'review_xlvask_usage_order',
'list_xlvask_usage_orders_own',
]);
// Post against a known-bad usage_log_id. The exact validation failure
// does not matter; we only assert that the operator is NOT 403'd at
// the permission gate. A 4xx or 5xx response from the downstream
// autopilot service is the expected "got past the gate" signal.
$response = api_client()->post(
'/modules/xlvask/services/usage/automation/decisions/preview',
[
'usage_log_ids' => [99999999],
'action' => 'accept',
'force_manual' => true,
],
$operator['headers'],
);
// Permission gate is what we care about — anything other than 403 means
// the operator got past it. The downstream autopilot service may
// return 4xx (validation) or 5xx (idempotency / token) for a synthetic
// usage log id; both are acceptable for this contract test.
$status = $response->status;
expect($status)->not->toBe(403);
});
it('rejects an operator without the new permission from the decisions preview endpoint', function (): void {
api_test_covers('POST /modules/xlvask/services/usage/automation/decisions/preview', 'auth');
api_test_covers('POST /modules/xlvask/services/usage/automation/decisions/preview', 'failure');
api_test_covers('POST /modules/xlvask/services/usage/automation/decisions/preview', 'review-permission-blocks');
$operator = api_fixtures()->createUserSession(['list_xlvask_usage_orders_own']);
$response = api_client()->post(
'/modules/xlvask/services/usage/automation/decisions/preview',
[
'usage_log_ids' => [99999999],
'action' => 'accept',
'force_manual' => true,
],
$operator['headers'],
);
// list_xlvask_usage_orders_own is enough to inspect capabilities, but
// NOT enough to post a decision — only review_xlvask_usage_order and
// manage_xlvask_usage_automation can. The route must 403.
$response
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['manage_xlvask_usage_automation']);
});
@@ -24,8 +24,6 @@ return [
'GET /superuser/departments/{id}/overview',
'PUT /superuser/department/branding',
'POST /bird/voice/calls/webhook/inbound',
'GET /modules/xlvask/services/usage/automation/capabilities',
'POST /modules/xlvask/services/usage/automation/decisions/preview',
],
'manual_operations' => [
'GET /ping',
@@ -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(24);
expect($definitions)->toHaveCount(22);
expect(array_keys($definitions))->toContain(
'system.sync_logs',
'backups.process_jobs',
@@ -16,10 +16,10 @@ 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'
);
expect(array_keys($definitions))->not->toContain('xlvask.autopilot_queue');
$transferQueue = $registry->get('EconomicTransferQueueCron');
expect($transferQueue)->not->toBeNull();
@@ -1,65 +0,0 @@
<?php
/**
* Guard the operator entry point contract for the XL Vask automation
* schema migration. The migration itself is intentionally operator-only
* (see services/nginx/app/modules/xlvask/AUTOMATION_RUNBOOK.md §2), but
* the CLI surface that wraps it must remain gated, idempotent, and
* discoverable.
*
* When PLENO_REPO_ROOT_FOR_TESTS is set (the CI layout, where the repo
* root is bind-mounted alongside services/nginx/app), the test also
* inspects the standalone scripts/xlvask-automation-migrate.php wrapper
* to keep it in lockstep with the cron entry point.
*/
it('routes the xlvask automation migrate CLI command through the gated migration entry point', function (): void {
$cli = file_get_contents(WD . '/cli.php');
expect($cli)
->toContain("case 'xlvask-automation-migrate':")
->toContain("require_once 'cron/EnsureXLVaskAutomationSchema.php'");
});
it('keeps the cron entry point gated by the WD constant and the migration class', function (): void {
$cron = file_get_contents(WD . '/cron/EnsureXLVaskAutomationSchema.php');
expect($cron)
->toContain("if (!defined('WD'))")
->toContain('migration_20260804_xlvask_ai_auto_policy_v2::preflight')
->toContain('migration_20260804_xlvask_ai_auto_policy_v2::apply')
->toContain('xlvask_usage_logs_schema_bootstrap::applyWashIdUniquenessMigration');
});
it('keeps the migration class operator-only and references the bootstrap entry point', function (): void {
$migration = file_get_contents(WD . '/modules/xlvask/migrations/20260804_xlvask_ai_auto_policy_v2.php');
expect($migration)
->toContain('operator-invoked')
->toContain('xlvask_usage_logs_schema_bootstrap::applyExplicitMigration')
->toContain('xlvask_usage_logs_schema_bootstrap::migrationStatus');
});
it('documents both operator entry points in the XL Vask automation runbook', function (): void {
$runbook = file_get_contents(WD . '/modules/xlvask/AUTOMATION_RUNBOOK.md');
expect($runbook)
->toContain('## 2. Explicit schema migration')
->toContain('## 2a. Operator entry points')
->toContain('scripts/xlvask-automation-migrate.php')
->toContain("php index.php run xlvask-automation-migrate");
});
it('keeps the standalone xlvask automation migration script gated and idempotent when the repo root is mounted', function (): void {
$repoRoot = getenv('PLENO_REPO_ROOT_FOR_TESTS');
if ($repoRoot === false || $repoRoot === '') {
expect(true)->toBeTrue(); // covered by CI; local docker lacks the repo-root bind mount
return;
}
$scriptPath = realpath($repoRoot . '/scripts/xlvask-automation-migrate.php');
expect($scriptPath)->not->toBeFalse();
$source = file_get_contents($scriptPath);
expect($source)
->toContain("if (PHP_SAPI !== 'cli')")
->toContain("Refusing schema mutation without: apply --yes")
->toContain('xlvask_usage_logs_schema_bootstrap::applyExplicitMigration')
->toContain('xlvask_usage_logs_schema_bootstrap::migrationStatus');
});
@@ -1,805 +0,0 @@
<?php
use classes\xlvask_automation_service;
use classes\xlvask_autopilot_service;
use classes\openai;
use objects\xlvask_usage_logs_o;
require_once WD . '/classes/xlvask_automation_service.php';
require_once WD . '/classes/xlvask_autopilot_service.php';
require_once WD . '/objects/xlvask_usage_logs_o.php';
it('normalizes registrations for XL Vask automation signatures', function (): void {
expect(xlvask_automation_service::normalizeRegistrationForAutomation(' ec 21-233 '))
->toBe('EC21233');
});
it('builds stable XL Vask automation item signatures', function (): void {
$items = [
['product_id' => 20, 'quantity' => 1, 'price' => 275],
['product_id' => 10, 'quantity' => 2, 'price' => 649],
['product_id' => 20, 'quantity' => 1, 'price' => 0],
];
expect(xlvask_automation_service::itemSignaturePartsForAutomation($items))
->toBe([
'10:2:649',
'20:1:0',
'20:1:275',
]);
});
it('identifies strict price agreement matches by product, quantity, and total', function (): void {
$usageItems = [
['product_id' => 10, 'quantity' => 1, 'price' => 519],
['product_id' => 24, 'quantity' => 1, 'price' => 39],
['product_id' => 21, 'quantity' => 1, 'price' => 79],
];
$orderItems = [
['product_id' => 21, 'quantity' => 1, 'price' => 79],
['product_id' => 10, 'quantity' => 1, 'price' => 519],
['product_id' => 24, 'quantity' => 1, 'price' => 39],
];
expect(xlvask_automation_service::isExactItemMatchForAutomation($usageItems, $orderItems))
->toBeTrue();
});
it('rejects price agreement automation when product lines differ despite equal total', function (): void {
$usageItems = [
['product_id' => 10, 'quantity' => 1, 'price' => 519],
['product_id' => 24, 'quantity' => 1, 'price' => 39],
];
$orderItems = [
['product_id' => 10, 'quantity' => 1, 'price' => 519],
['product_id' => 50, 'quantity' => 1, 'price' => 39],
];
expect(xlvask_automation_service::itemsTotalForAutomation($usageItems))
->toBe(xlvask_automation_service::itemsTotalForAutomation($orderItems))
->and(xlvask_automation_service::isExactItemMatchForAutomation($usageItems, $orderItems))
->toBeFalse();
});
it('normalizes persisted XL Vask usage-log rows before helper hydration', function (): void {
$row = xlvask_automation_service::normalizeUsageLogRowForAutomation([
'id' => 47086,
'WashId' => 'cc1eabc1-b4e1-425b-ad7c-dc68f8c97ceb',
'WashItems' => '[{"OriginalProductName":"Bus","Count":1}]',
]);
expect($row)
->not->toHaveKey('id')
->and($row['WashItems'])->toBe([
[
'OriginalProductName' => 'Bus',
'Count' => 1,
],
]);
});
it('builds stable OpenAI cache keys for identical automation input', function (): void {
$prompt = 'Prompt';
$schemaName = 'xlvask_automation';
$schema = [
'required' => ['action'],
'properties' => [
'confidence' => ['type' => 'number'],
'action' => ['type' => 'string'],
],
];
$schemaWithDifferentKeyOrder = [
'properties' => [
'action' => ['type' => 'string'],
'confidence' => ['type' => 'number'],
],
'required' => ['action'],
];
$payloadA = [
'usage_log' => [
'registration' => 'AB12345',
'creation_allowed' => true,
],
'candidate_orders' => [
['id' => 10, 'items' => [['product_id' => 1, 'quantity' => 1, 'price' => 100]]],
],
];
$payloadB = [
'candidate_orders' => [
['items' => [['price' => 100, 'quantity' => 1, 'product_id' => 1]], 'id' => 10],
],
'usage_log' => [
'creation_allowed' => true,
'registration' => 'AB12345',
],
];
expect(xlvask_automation_service::openAiCacheKeyForAutomation($schemaName, $prompt, $payloadA, $schema, 0.1))
->toBe(xlvask_automation_service::openAiCacheKeyForAutomation($schemaName, $prompt, $payloadB, $schemaWithDifferentKeyOrder, 0.1));
});
it('changes OpenAI cache keys when automation eligibility input changes', function (): void {
$schema = ['type' => 'object'];
$newerWashPayload = ['usage_log' => ['creation_allowed' => false, 'age_bucket' => 'newer_than_6_hours']];
$olderWashPayload = ['usage_log' => ['creation_allowed' => true, 'age_bucket' => 'older_than_6_hours']];
expect(xlvask_automation_service::openAiCacheKeyForAutomation('xlvask_automation', 'Prompt', $newerWashPayload, $schema, 0.1))
->not->toBe(xlvask_automation_service::openAiCacheKeyForAutomation('xlvask_automation', 'Prompt', $olderWashPayload, $schema, 0.1));
});
it('declares a persistent OpenAI cache table for XL Vask automation', function (): void {
$bootstrapContent = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php');
expect($bootstrapContent)
->toContain('xlvask_automation_openai_cache')
->toContain('UNIQUE KEY `uniq_xlvask_openai_cache_key` (`cache_key`)');
});
it('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');
expect($bootstrapContent)
->toContain('cached_total_net_amount')
->toContain('cached_primary_product_name')
->toContain('cached_amount_at');
});
it('keeps automatic XL Vask execution scoped to exact attachments', function (): void {
$serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php');
expect($serviceContent)
->toContain('openAiAttachHardGuardsPass($suggestion, $context)')
->toContain('self::isExactItemMatchForAutomation((array)($context[\'items\'] ?? []), (array)($candidate[\'order_items\'] ?? []))')
->toContain("\$candidateOrderJson = \$suggestion['candidate_order_json'] ?? null;")
->toContain("return 'Automatisk accepteret: Prisoverensstemmelse.';");
});
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('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('treats reordered XL Vask wash items as the same source revision', function (): void {
$first = ['WashId' => 'wash-1', 'WashItems' => [
['ProductId' => 2, 'Count' => 1],
['ProductId' => 1, 'Count' => 2],
]];
$second = ['WashId' => 'wash-1', 'WashItems' => [
['Count' => 2, 'ProductId' => 1],
['Count' => 1, 'ProductId' => 2],
]];
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")
->toContain("'role' => 'developer'")
->toContain("'role' => 'user'")
->toContain("if (\$status !== 'completed')")
->toContain("=== 'refusal'")
->toContain("'_openai_usage' => [")
->and($automation)->toContain("private const PLANNER_MODEL = 'MiniMax-M3'")
->toContain('candidate_order_id')
->not->toContain('opaque_context_id')
->not->toContain("'product_name' =>");
});
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('ai_timeline')
->toContain('ai_batch_size')
->toContain('ai_max_cost_usd')
->toContain('lease_expires_at')
->toContain('attempt_count')
->toContain('next_attempt_at')
->toContain('$renewLease')
->toContain("phase = 'retry_wait'");
});
it('rejects execute run creation unless current server readiness allows execute mode', function (): void {
$serviceContent = file_get_contents(WD . '/classes/xlvask_autopilot_service.php');
expect($serviceContent)
->toContain("if (\$mode === 'execute')")
->toContain('capabilitiesReadOnly(')
->toContain("!in_array('execute', (array)(\$capabilities['allowed_modes'] ?? []), true)");
});
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('requires explicit run modes and serializes active execute runs in schema', function (): void {
$autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php');
$schema = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php');
expect($autopilot)
->toContain("(\$input['mode'] ?? '')")
->toContain('An explicit XL Vask autopilot mode is required.')
->and($schema)
->toContain('active_execute_slot')
->toContain('uniq_xlvask_active_execute_run');
});
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("findSameDayCandidateOrders(\$lockedLog, (array)\$context['proposed_order'], true)")
->toContain('SELECT id FROM order_items WHERE order_id IN (')
->toContain("if (\$action === self::ACTION_CREATE && \$currentCandidates !== [])")
->toContain("WHERE xlvask_normalized_wash_id = NULLIF(LOWER(TRIM('{\$washId}')), '') FOR UPDATE")
->toContain('XL Vask-kildedata blev ændret efter evalueringen.');
});
it('permits only identity-bound calibrated OpenAI automatic actions behind deterministic 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('openAiAttachHardGuardsPass($suggestion, $context)')
->toContain('policyAllowsActionReadOnly($action)')
->toContain('sourceIsStableForAutomatic($context)')
->toContain('washIdUniquenessReady()');
});
it('pins the complete planner identity and invalidates cache keys with it', function (): void {
$identity = xlvask_automation_service::automationIdentityForAutomation();
expect($identity)
->toMatchArray([
'policy_version' => 'xlvask-ai-auto-v2',
'model' => 'MiniMax-M3',
'prompt_version' => 'xlvask-planner-da-v2',
'schema_version' => 'xlvask-automation-schema-v2',
'cache_version' => 2,
])
->and($identity['identity_hash'])->toHaveLength(64)
->and($identity['prompt_hash'])->toHaveLength(64)
->and($identity['schema_hash'])->toHaveLength(64);
});
it('accepts only completed structured OpenAI responses and records the resolved model', function (): void {
$parsed = openai::parseJsonTaskResponse([
'status' => 'completed',
'model' => 'gpt-5.6-sol',
'output' => [[
'content' => [[
'type' => 'output_text',
'text' => '{"action":"none"}',
]],
]],
]);
expect($parsed)->toBe([
'action' => 'none',
'_openai_response_model' => 'gpt-5.6-sol',
'_openai_usage' => [
'input_tokens' => 0,
'output_tokens' => 0,
'total_tokens' => 0,
'service_tier' => '',
],
]);
});
it('fails incomplete and refusal OpenAI responses closed', function (): void {
expect(fn() => openai::parseJsonTaskResponse([
'status' => 'incomplete',
'incomplete_details' => ['reason' => 'max_output_tokens'],
]))->toThrow(\classes\openai_request_exception::class)
->and(fn() => openai::parseJsonTaskResponse([
'status' => 'completed',
'model' => 'gpt-5.6-sol',
'output' => [['content' => [['type' => 'refusal', 'refusal' => 'no']]]],
]))->toThrow(\classes\openai_request_exception::class);
try {
openai::parseJsonTaskResponse([
'status' => 'incomplete',
'incomplete_details' => ['reason' => 'max_output_tokens'],
]);
$incompleteRetryable = null;
} catch (\classes\openai_request_exception $exception) {
$incompleteRetryable = $exception->retryable;
}
try {
openai::parseJsonTaskResponse([
'status' => 'completed',
'model' => 'gpt-5.6-sol',
'output' => [['content' => [['type' => 'refusal', 'refusal' => 'no']]]],
]);
$refusalRetryable = null;
} catch (\classes\openai_request_exception $exception) {
$refusalRetryable = $exception->retryable;
}
expect($incompleteRetryable)->toBeTrue()
->and($refusalRetryable)->toBeFalse();
});
it('declares explicit migration-only schema activation and server policy controls', function (): void {
$schema = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php');
$migration = file_get_contents(WD . '/modules/xlvask/migrations/20260804_xlvask_ai_auto_policy_v2.php');
$policy = file_get_contents(WD . '/classes/xlvask_automation_policy_service.php');
expect($schema)
->toContain('applyExplicitMigration')
->toContain('migrationStatus')
->toContain('xlvask_automation_policy_state')
->toContain('xlvask_automation_action_events')
->and($migration)->toContain('operator-invoked')
->toContain('applyExplicitMigration')
->and($policy)->toContain('ATTACH_DAILY_CAP = 100')
->toContain('ATTACH_PER_HALL_DAILY_CAP = 10')
->toContain('CREATE_DAILY_CAP = 20')
->toContain('CREATE_PER_HALL_DAILY_CAP = 3')
->toContain("review_outcome = 'correct'");
});
it('fails migration readiness closed for partial runtime schema and missing active-run uniqueness', function (): void {
$schema = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php');
expect($schema)
->toContain("'required_indexes' => \$requiredIndexes")
->toContain("'missing_indexes' => \$missingIndexes")
->toContain("'preflight_conflicts' => \$conflicts")
->toContain('multiple_active_execute_runs:')
->toContain('uniq_xlvask_active_execute_run')
->toContain("'xlvask_automation_policy_state' => [")
->toContain("'xlvask_automation_action_events' => [")
->toContain("'xlvask_automation_calibrations' => [")
->toContain("'xlvask_autopilot_runs' => [");
});
it('treats policy and budget stops as resumable run pauses instead of suggestion failures', function (): void {
$policy = file_get_contents(WD . '/classes/xlvask_automation_policy_service.php');
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
expect($policy)
->toContain('final class xlvask_automation_control_stop')
->toContain("'budget_exhausted'")
->toContain("'calibration_revoked'")
->and($automation)
->toContain('if ($e instanceof xlvask_automation_control_stop)')
->toContain("'control_stop' => true")
->toContain("\$circuitBreaker = (string)(\$result['control_stop_reason'] ?? 'policy_control_stop')");
});
it('makes exact adjudication retries idempotent and rejects a changed outcome', function (): void {
expect(\classes\xlvask_automation_policy_service::adjudicationRetryMatches('correct', 'correct'))->toBeTrue()
->and(\classes\xlvask_automation_policy_service::adjudicationRetryMatches('correct', 'duplicate'))->toBeFalse()
->and(\classes\xlvask_automation_policy_service::adjudicationRetryMatches(null, 'correct'))->toBeFalse();
});
it('requires current usage revision and review state for row action eligibility', function (): void {
$suggestion = [
'status' => 'suggested', 'action' => 'attach_order', 'expected_version' => 7,
'input_hash' => str_repeat('a', 64),
];
$usage = [
'resolution_state' => 'needs_review', 'import_state' => 'unchanged', 'ignored_at' => null,
'FinishStatus' => 1, 'expected_version' => 7, 'source_hash' => str_repeat('a', 64),
];
expect(xlvask_automation_service::suggestionMatchesCurrentUsageForReview($suggestion, $usage))->toBeTrue()
->and(xlvask_automation_service::suggestionMatchesCurrentUsageForReview($suggestion, [...$usage, 'ignored_at' => '2026-08-04 12:00:00']))->toBeFalse()
->and(xlvask_automation_service::suggestionMatchesCurrentUsageForReview($suggestion, [...$usage, 'expected_version' => 8]))->toBeFalse()
->and(xlvask_automation_service::suggestionMatchesCurrentUsageForReview($suggestion, [...$usage, 'import_state' => 'invalid']))->toBeFalse();
});
it('emits explicit fail-closed per-row action flags and supersedes ignored suggestions', function (): void {
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
$autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php');
$projectionStart = strpos((string)$automation, 'private function readProjectionActionFlags');
$projectionEnd = strpos((string)$automation, 'private function decodeJsonField', (int)$projectionStart);
$projection = substr((string)$automation, (int)$projectionStart, (int)$projectionEnd - (int)$projectionStart);
expect($automation)
->toContain("'can_ignore' =>")
->toContain("'can_attach_order' =>")
->toContain("'can_create_order' =>")
->toContain('suggestionMatchesCurrentUsageForReview($suggestion, $usageRow)')
->toContain('Current candidate')
->and($autopilot)
->toContain("SET status = 'superseded', updated_at = NOW()")
->toContain("WHERE usage_log_id = {\$usageId} AND status = 'suggested'");
expect($projection)->not->toContain('$this->buildContext(');
});
it('keeps scheduled automation deploy-order safe without interrupting ordinary sync', function (): void {
$tasks = (string)file_get_contents(WD . '/modules/xlvask/helpers/xlvask_tasks.php');
$importStart = strpos($tasks, 'public function runImportTasks(): void');
$importEnd = strpos($tasks, '/** Enqueue automatic work', (int)$importStart);
$importBody = substr($tasks, (int)$importStart, (int)$importEnd - (int)$importStart);
expect($tasks)
->toContain('$this->runCleanupTasks();')
->toContain('$this->runScheduledAutomationIfReady();')
->toContain('scheduledExecutionAllowed($migrationStatus, $capabilities)')
->and($importBody)->not->toContain("createRun(['mode' => 'execute']");
expect(strpos($tasks, '$this->runCleanupTasks();'))
->toBeLessThan(strpos($tasks, '$this->runScheduledAutomationIfReady();'));
expect(xlvask_autopilot_service::scheduledExecutionAllowed(['ready' => false], ['allowed_modes' => ['execute']]))->toBeFalse()
->and(xlvask_autopilot_service::scheduledExecutionAllowed(['ready' => true], ['allowed_modes' => ['dry_run', 'replay']]))->toBeFalse()
->and(xlvask_autopilot_service::scheduledExecutionAllowed(['ready' => true], ['allowed_modes' => ['execute']]))->toBeTrue();
});
it('propagates only retryable OpenAI failures into a durable run retry', function (): void {
$retryable = new \classes\openai_request_exception('temporary', true, 503);
$refusal = new \classes\openai_request_exception('refused', false, null);
expect(xlvask_automation_service::openAiFailureRequiresDurableRetry($retryable, 42))->toBeTrue()
->and(xlvask_automation_service::openAiFailureRequiresDurableRetry($retryable, null))->toBeFalse()
->and(xlvask_automation_service::openAiFailureRequiresDurableRetry($refusal, 42))->toBeFalse();
$automation = (string)file_get_contents(WD . '/classes/xlvask_automation_service.php');
expect($automation)
->toContain('catch (openai_request_exception $exception)')
->toContain('throw $exception;')
->toContain('OpenAI kunne ikke levere et anvendeligt forslag.');
});
it('binds financial execution to the locked suggestion revision and indexed wash id', function (): void {
$automation = (string)file_get_contents(WD . '/classes/xlvask_automation_service.php');
$usage = ['id' => 7, 'expected_version' => 3, 'source_hash' => str_repeat('a', 64)];
$suggestion = ['usage_log_id' => 7, 'expected_version' => 3, 'input_hash' => str_repeat('a', 64)];
expect(xlvask_automation_service::suggestionMatchesLockedUsageForExecution($suggestion, $usage))->toBeTrue()
->and(xlvask_automation_service::suggestionMatchesLockedUsageForExecution([...$suggestion, 'expected_version' => 4], $usage))->toBeFalse()
->and(xlvask_automation_service::suggestionMatchesLockedUsageForExecution([...$suggestion, 'input_hash' => str_repeat('b', 64)], $usage))->toBeFalse()
->and(xlvask_automation_service::suggestionMatchesLockedUsageForExecution([...$suggestion, 'usage_log_id' => 8], $usage))->toBeFalse();
expect($automation)
->toContain('suggestionMatchesLockedUsageForExecution($suggestion, $lockedUsage)')
->toContain('WHERE xlvask_normalized_wash_id = NULLIF(LOWER(TRIM')
->not->toContain('WHERE LOWER(TRIM(wash_id)) = LOWER(TRIM');
});
it('binds calibration evidence and snapshots to current planner identity and resolved model', function (): void {
$autopilot = (string)file_get_contents(WD . '/classes/xlvask_autopilot_service.php');
$policy = (string)file_get_contents(WD . '/classes/xlvask_automation_policy_service.php');
$identity = ['policy_version' => 'v2', 'identity_hash' => str_repeat('a', 64), 'model' => 'gpt-current'];
$evidence = ['policy_version' => 'v2', 'planner_identity_hash' => str_repeat('a', 64), 'model' => 'gpt-current'];
expect(xlvask_autopilot_service::calibrationEvidenceMatchesIdentity($evidence, $identity))->toBeTrue()
->and(xlvask_autopilot_service::calibrationEvidenceMatchesIdentity([...$evidence, 'planner_identity_hash' => str_repeat('b', 64)], $identity))->toBeFalse()
->and(xlvask_autopilot_service::calibrationEvidenceMatchesIdentity([...$evidence, 'model' => 'gpt-stale'], $identity))->toBeFalse()
->and(xlvask_autopilot_service::calibrationEvidenceMatchesIdentity([...$evidence, 'policy_version' => 'v1'], $identity))->toBeFalse();
expect($autopilot)
->toContain("BINARY s.planner_identity_hash = BINARY '{\$identitySql}'")
->toContain("BINARY s.model = BINARY '{\$modelSql}'")
->toContain("'planner_identity_hash' => (string)\$label['planner_identity_hash']")
->toContain("'resolved_model' => (string)\$label['model']")
->toContain("(string)(\$backtest['resolved_model'] ?? '')")
->and($policy)->toContain("(string)(\$artifact['resolved_model'] ?? '')");
});
it('authorizes calibration adjudication from an action event or an exact current reviewable suggestion', function (): void {
$autopilot = (string)file_get_contents(WD . '/classes/xlvask_autopilot_service.php');
expect($autopilot)
->toContain('LEFT JOIN xlvask_automation_action_events ae ON ae.suggestion_id = s.id')
->toContain('ae.id IS NOT NULL')
->toContain("s.status = 'suggested' AND s.source = 'openai'")
->toContain('s.expected_version = u.expected_version AND s.input_hash = u.source_hash')
->toContain("u.resolution_state = 'needs_review'")
->toContain('newer.usage_log_id = s.usage_log_id AND newer.id > s.id')
->toContain("BINARY s.planner_identity_hash = BINARY '{\$identitySql}'")
->toContain("BINARY s.model = BINARY '{\$modelSql}'");
});
it('advertises OpenAI as the only effective automatic action source', function (): void {
$policy = (string)file_get_contents(WD . '/classes/xlvask_automation_policy_service.php');
expect($policy)
->toContain("'effective_action_sources' => \$executeEnabled")
->toContain("? ['openai']")
->toContain(': []');
});
it('documents exact safe deploy partial migration rollback and bounded list projection', function (): void {
$runbook = (string)file_get_contents(WD . '/modules/xlvask/AUTOMATION_RUNBOOK.md');
expect($runbook)
->toContain('Old code cannot interpret the new policy stages')
->toContain('set both legacy automatic-order switches to false')
->toContain('Never route old code as a partial-migration workaround')
->toContain('Use this exact rollback sequence before any old-code traffic')
->toContain('do not reconstruct same-day candidates per row')
->toContain('authoritatively rebuilt during preview/apply');
});
it('invalidates stale calibration and restarts soak at each canary activation epoch', function (): void {
$policy = file_get_contents(WD . '/classes/xlvask_automation_policy_service.php');
$autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php');
$schema = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php');
expect($policy)
->toContain("UPDATE xlvask_automation_calibrations SET active = 0, invalidated_at = NOW()")
->toContain("'invalidated_calibration_segment' => \$segment")
->toContain("'ai_attach_canary' => [")
->toContain("'ai_attach_verified' => [")
->toContain("'ai_create_canary' => [")
->toContain("'verified_capped' => [")
->toContain("AND created_at >= '{\$sinceSql}'")
->toContain("\$state['attach_activated_at'] ?? null")
->toContain("\$state['create_activated_at'] ?? null")
->and($autopilot)
->toContain("'safety_epoch' => \$this->calibrationSafetyEpoch(\$segmentKey)")
->toContain('XL Vask calibration artifact was invalidated by an action safety latch.')
->toContain('XL Vask calibration labels changed after this artifact was generated.')
->and($schema)->toContain("'invalidated_at'");
});
it('scopes eligible suggestions and visible hall budgets to the caller revision and hall scope', function (): void {
$policy = file_get_contents(WD . '/classes/xlvask_automation_policy_service.php');
expect($policy)
->toContain('s.expected_version = u.expected_version')
->toContain('s.input_hash = u.source_hash')
->toContain('budgetSnapshotReadOnly($hallIds, $state)')
->toContain('$visibleHallWhere')
->toContain("AND hall_id IN (");
});
it('exposes distinct pre-action review and post-action adjudication state', function (): void {
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
$autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php');
expect($automation)
->toContain("'review_eligible' =>")
->toContain("'adjudication_eligible' =>")
->toContain("'allowed_adjudication_outcomes' =>")
->toContain("'adjudication_outcome' =>")
->and($autopilot)
->toContain('reviewAutomaticActionBySuggestion(')
->toContain('true')
->toContain('$connection->begin_transaction()')
->toContain("'action_halted' =>")
->toContain("'affected_action' =>");
});
it('fails malformed or reversed invoice-period readiness scopes closed', function (): void {
expect(\classes\xlvask_automation_policy_service::scopeDatesAreValid('2026-08-01', '2026-08-31'))->toBeTrue()
->and(\classes\xlvask_automation_policy_service::scopeDatesAreValid('2026-02-30', '2026-03-01'))->toBeFalse()
->and(\classes\xlvask_automation_policy_service::scopeDatesAreValid('2026-08-31', '2026-08-01'))->toBeFalse();
});
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 {
$usageItems = [
['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']],
['product_id' => 24, 'quantity' => 1, 'price' => 39, 'product' => ['name' => 'Spot Free- Lastbil']],
['product_id' => 21, 'quantity' => 1, 'price' => 79, 'product' => ['name' => 'Undervognskyl pr. Enhed']],
['product_id' => 99, 'quantity' => 8, 'price' => 0, 'product' => ['name' => 'Halleje']],
];
$orderItems = [
['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']],
['product_id' => 50, 'quantity' => 1, 'price' => 319, 'product' => ['name' => 'Indvendig vask Forvogn']],
['product_id' => 21, 'quantity' => 1, 'price' => 79, 'product' => ['name' => 'Undervognskyl pr. Enhed']],
['product_id' => 24, 'quantity' => 1, 'price' => 39, 'product' => ['name' => 'Spot Free- Lastbil']],
];
$score = xlvask_automation_service::scoreItemMatchForAutomation($usageItems, $orderItems);
expect($score['source'])
->toBe('fuzzy')
->and($score['confidence'])->toBeGreaterThanOrEqual(0.70)
->and($score['confidence'])->toBeLessThan(0.92)
->and($score['reason'])->toContain('ekstra ydelser');
});
it('does not score an order with only the primary product as a matching add-on attachment', function (): void {
$usageItems = [
['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']],
['product_id' => 24, 'quantity' => 1, 'price' => 39, 'product' => ['name' => 'Spot Free- Lastbil']],
['product_id' => 21, 'quantity' => 1, 'price' => 79, 'product' => ['name' => 'Undervognskyl pr. Enhed']],
];
$orderItems = [
['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']],
['product_id' => 50, 'quantity' => 1, 'price' => 319, 'product' => ['name' => 'Indvendig vask Forvogn']],
];
expect(xlvask_automation_service::scoreItemMatchForAutomation($usageItems, $orderItems)['confidence'])
->toBe(0.0);
});
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'");
});
@@ -15,7 +15,7 @@ it('exposes direct linked order metadata on XL Vask usage order rows', function
->and($route)->toContain("'usage_log_id' => \$id");
});
it('does not execute XL Vask usage automation while listing usage order rows', function (): void {
it('limits the usage-logs endpoint to the reviewer permission set', function (): void {
$route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
expect($route)->not->toBeFalse();
@@ -23,9 +23,21 @@ it('does not execute XL Vask usage automation while listing usage order rows', f
$route = (string)$route;
expect($route)
->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')");
->toContain("list_xlvask_usage_orders_own")
->toContain("list_xlvask_usage_orders_all")
->not->toContain('xlvask_autopilot_service')
->not->toContain('xlvask_automation_service')
->not->toContain('xlvask_automation_policy_service')
->not->toContain('manage_xlvask_usage_automation')
->not->toContain('evaluateUsageLogRow')
->not->toContain('source_hash')
->not->toContain('source_revision')
->not->toContain('import_state')
->not->toContain('resolution_state')
->not->toContain('certainty')
->not->toContain('planned_action')
->not->toContain('expected_version')
->not->toContain('last_run_id');
expect($route)
->toContain("if (\$allowedHallIds === [])")
->toContain("No XL Vask hall scope is available', 403")
@@ -41,27 +53,21 @@ it('returns cached amount summaries on XL Vask usage order rows without widening
expect($route)
->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("\$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']")
->and($route)->toContain("\$tmp_res['order']['xlvask_primary_product_name'] = \$amount_summary['primary_product_name']")
->and($route)->toContain("\$tmp_res['order']['xlvask_amount_cached'] = \$amount_summary['cached']");
});
it('keeps automation metadata out of the strict legacy XL Vask helper payload', function (): void {
it('keeps ignore metadata out of the strict legacy XL Vask helper payload', function (): void {
$route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
expect($route)->not->toBeFalse();
$payloadStart = strpos((string)$route, '$usage_log_payload = array_intersect_key(');
$payloadEnd = strpos((string)$route, '// Create a new xlvask usage log object', $payloadStart ?: 0);
$route = (string)$route;
expect($payloadStart)->not->toBeFalse()
->and($payloadEnd)->not->toBeFalse();
$payloadDefinition = substr((string)$route, (int)$payloadStart, (int)$payloadEnd - (int)$payloadStart);
expect($payloadDefinition)
expect($route)
->not->toContain("'source_hash'")
->not->toContain("'source_revision'")
->not->toContain("'import_state'")
@@ -72,26 +78,26 @@ it('keeps automation metadata out of the strict legacy XL Vask helper payload',
->not->toContain("'last_run_id'");
});
it('keeps the legacy state-changing XL Vask usage import GET non-mutating', function (): void {
$route = file_get_contents(WD . '/routes/moduleXLVaskRoute.php');
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
it('exposes review, accept, reject and ignore endpoints gated on review_xlvask_usage_order', function (): void {
$route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
expect($route)
->not->toBeFalse()
->and($automation)->not->toBeFalse();
expect($route)->not->toBeFalse();
$route = (string)$route;
$automation = (string)$automation;
expect($route)
->toContain('Deprecated state-changing GET.')
->toContain('Use POST /modules/xlvask/services/usage/autopilot-runs.')
->not->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')");
->toContain("patch('/modules/xlvask/services/usage/orders/{id}/ignore'")
->toContain("post('/modules/xlvask/services/usage/orders/{id}/unignore'")
->toContain("post('/modules/xlvask/services/usage/orders/{id}/accept'")
->toContain("post('/modules/xlvask/services/usage/orders/{id}/reject'")
->toContain("requirePermission('review_xlvask_usage_order')")
->toContain("'ignored_at' => \$log['ignored_at'] ?? null")
->toContain("'ignored_by' => isset(\$log['ignored_by']) ? (int)\$log['ignored_by'] : null")
->toContain("'ignored_reason' => \$log['ignored_reason'] ?? null")
->not->toContain('Ignored at server-generated automation decision preview');
});
it('exposes additive XL Vask autopilot run and summary routes', function (): void {
it('exposes a reviewer summary endpoint that does not invoke the autopilot service', function (): void {
$route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
expect($route)->not->toBeFalse();
@@ -100,66 +106,11 @@ it('exposes additive XL Vask autopilot run and summary routes', function (): voi
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('$this->allowedHallIdsForUser($user)')
->toContain("'aiTimeline'")
->toContain("'aiBatchSize'")
->toContain("'aiMaxCostUsd'")
->toContain('], 202);');
->toContain('(new xlvask_usage_logs_o())->summarizeUsageOrdersReadOnly(')
->not->toContain('xlvask_autopilot_service()->getSummary(');
});
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('Deprecated. Use /modules/xlvask/services/usage/autopilot-runs.')
->toContain('Use the server-generated automation decision preview and apply endpoints.')
->not->toContain("post('/modules/xlvask/services/usage/orders/automation/run', function () {\n global \$response;\n \$this->requirePermission('manage_xlvask_usage_automation');\n\n \$user")
->not->toContain('(new xlvask_automation_service())->runPending(')
->not->toContain('(new xlvask_automation_service())->evaluateUsageLogById(');
});
it('exposes permission-aware capabilities active run and preview-bound server policy routes', function (): void {
$route = (string)file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
expect($route)
->toContain("get('/modules/xlvask/services/usage/automation/capabilities'")
->toContain("get('/modules/xlvask/services/usage/autopilot-runs/active'")
->toContain("post('/modules/xlvask/services/usage/automation/admin/policy/previews'")
->toContain("post('/modules/xlvask/services/usage/automation/admin/policy/apply'")
->toContain("post('/modules/xlvask/services/usage/automation/admin/halt'")
->toContain("'can_manage_policy' => \$canManagePolicy")
->toContain("'can_halt' => \$canManagePolicy")
->toContain("'preview' => (new xlvask_automation_policy_service())->createPolicyPreview(")
->toContain("self::requireParameters(['target_stage', 'reason'])")
->toContain("(string)\$this->getParameter('reason')")
->toContain("['run' => (new xlvask_automation_policy_service())->activeRunReadOnly(");
});
it('allows all-only permission and uses every configured scanner hall for all scope', function (): void {
$route = (string)file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
expect($route)
->toContain("if (!\$this->hasPermission(\$permission_list_all))")
->toContain("if (\$this->hasPermission('list_xlvask_usage_orders_all'))")
->toContain("if (!\$this->hasPermission('list_xlvask_usage_orders_all'))")
->toContain('private function allowedHallIdsForUser(object $user): array')
->not->toContain('private static function allowedHallIdsForUser')
->toContain("'list_xlvask_usage_orders_all' => 'Read all XL Vask usage-log automation summaries'")
->toContain('SELECT DISTINCT HallId FROM plate_scanners');
});
it('makes direct ignore and deprecated automation routes non-mutating', function (): void {
$route = (string)file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
expect($route)
->toContain("patch('/modules/xlvask/services/usage/orders/{id}/ignore'")
->toContain("response->error('Use the server-generated automation decision preview and apply endpoints.', 409)")
->toContain("response->error('Deprecated. Use /modules/xlvask/services/usage/autopilot-runs.', 410)")
->not->toContain("SET ignored_at = NOW(),");
});
it('returns revision and resolution state on XL Vask usage order rows', function (): void {
it('routes legacy autopilot, calibration and policy transition paths through 410 Gone stubs', function (): void {
$route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
expect($route)->not->toBeFalse();
@@ -167,71 +118,35 @@ it('returns revision and resolution state on XL Vask usage order rows', function
$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");
->not->toContain("'/modules/xlvask/services/usage/autopilot-runs'")
->not->toContain("'/modules/xlvask/services/usage/autopilot-runs/active'")
->not->toContain("'/modules/xlvask/services/usage/automation/admin/policy/previews'")
->not->toContain("'/modules/xlvask/services/usage/automation/admin/policy/apply'")
->not->toContain("'/modules/xlvask/services/usage/automation/admin/halt'")
->not->toContain("'/modules/xlvask/services/usage/automation/admin/calibrations/backtest'")
->not->toContain("'/modules/xlvask/services/usage/automation/admin/calibrations/labels'")
->not->toContain("'/modules/xlvask/services/usage/automation/admin/calibrations/{id}/activate'")
->not->toContain("'/modules/xlvask/services/usage/automation/admin/wash-id-uniqueness/activate'")
->not->toContain("'/modules/xlvask/services/usage/automation/decisions/preview'")
->not->toContain("'/modules/xlvask/services/usage/automation/decisions/apply'")
->not->toContain("'/modules/xlvask/services/usage/automation/capabilities'")
->not->toContain("'/modules/xlvask/services/usage/automation/admin/readiness'");
});
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');
expect($openApi)
->toContain('operationId: getXlvaskAutomationCapabilities')
->toContain('effective_action_sources:')
->toContain('items: { type: string, enum: [openai] }')
->toContain('operationId: getActiveXlvaskUsageAutopilotRun')
->toContain('operationId: previewXlvaskAutomationPolicyTransition')
->toContain('operationId: applyXlvaskAutomationPolicyTransition')
->toContain('operationId: haltXlvaskAutomation')
->toContain('required: [target_stage, reason]');
});
it('wires preview-bound bulk decisions through the transactional autopilot service', function (): void {
it('allows all-only permission and uses every configured scanner hall for all scope', 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();
expect($route)->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');
->toContain("if (!\$this->hasPermission(\$permission_list_all))")
->toContain("if (\$this->hasPermission('list_xlvask_usage_orders_all'))")
->toContain("if (!\$this->hasPermission('list_xlvask_usage_orders_all'))")
->toContain('private function allowedHallIdsForUser(object $user): array')
->not->toContain('private static function allowedHallIdsForUser')
->toContain('SELECT DISTINCT HallId FROM plate_scanners');
});
it('does not let pending automation schema block ordinary invoice period operations', function (): void {
@@ -242,36 +157,74 @@ it('does not let pending automation schema block ordinary invoice period operati
->toContain('XL Vask automation migration is pending');
});
it('exposes a review_xlvask_usage_order permission on decision endpoints for the selvvask operator flow', function (): void {
$route = (string)file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
it('does not invoke the autopilot service anywhere on the usage-log listing path', function (): void {
$route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
$object = file_get_contents(WD . '/objects/xlvask_usage_logs_o.php');
expect($route)
->toContain("'review_xlvask_usage_order' => 'Preview an XL Vask automation decision as a reviewer'")
->toContain("'review_xlvask_usage_order' => 'Apply a previewed XL Vask automation decision as a reviewer'")
->toContain("'review_xlvask_usage_order' => 'Inspect XL Vask automation capabilities as a reviewer'")
// The decisions endpoints must check both manage_xlvask_usage_automation and review_xlvask_usage_order
->toContain("!\$this->hasPermission('manage_xlvask_usage_automation')")
->toContain("!\$this->hasPermission('review_xlvask_usage_order')")
// The capabilities endpoint must light can_review for both manage and review permissions
->toContain("'can_review' => \$canReview")
->toContain("'review_xlvask_usage_order'")
->toContain("'list_xlvask_usage_orders_all'")
->toContain("'list_xlvask_usage_orders_own'")
// Other admin-only capabilities must remain gated on manage_xlvask_usage_automation
->toContain("'can_dry_run' => \$canManage")
->toContain("'can_execute' => \$canManage");
->not->toBeFalse()
->and($object)->not->toBeFalse();
expect((string)$route)
->not->toContain('xlvask_autopilot_service')
->not->toContain('xlvask_automation_policy_service')
->not->toContain('readAutomationStateByUsageLogId')
->not->toContain('evaluateUsageLogRow');
expect((string)$object)
->toContain('summarizeUsageOrdersReadOnly')
->toContain('getAmountSummaryReadOnly');
});
it('still requires manage_xlvask_usage_automation for the AI autopilot run lifecycle', function (): void {
$route = (string)file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
it('removes the AI autopilot and policy service files entirely', function (): void {
expect(file_exists(WD . '/classes/xlvask_autopilot_service.php'))->toBeFalse();
expect(file_exists(WD . '/classes/xlvask_automation_service.php'))->toBeFalse();
expect(file_exists(WD . '/classes/xlvask_automation_policy_service.php'))->toBeFalse();
expect(file_exists(WD . '/classes/minimax.php'))->toBeFalse();
expect(is_dir(WD . '/modules/miniMax'))->toBeFalse();
expect(file_exists(WD . '/modules/xlvask/AUTOMATION_RUNBOOK.md'))->toBeFalse();
expect(file_exists(WD . '/cron/EnsureXLVaskAutomationSchema.php'))->toBeFalse();
});
// The autopilot-runs POST must remain manage-only — review_xlvask_usage_order must NOT unlock
// the AI-driven dry-run / execute pipeline.
it('removes the MiniMax config endpoints from moduleConfigRoute and the cli migrate command', function (): void {
$route = (string)file_get_contents(WD . '/routes/moduleConfigRoute.php');
expect($route)
->toContain("post('/modules/xlvask/services/usage/autopilot-runs'")
->toContain("\$this->requirePermission('manage_xlvask_usage_automation');")
// The autopilot-runs/{id} GET (used for status polling) must also remain manage-only.
->toContain("get('/modules/xlvask/services/usage/autopilot-runs/{id}'")
// The autopilot-runs/active GET (recovery) must also remain manage-only.
->toContain("get('/modules/xlvask/services/usage/autopilot-runs/active'");
->not->toContain("'/minimax/config'")
->not->toContain('modules_minimax_config')
->not->toContain('MiniMax config');
$cli = (string)file_get_contents(WD . '/cli.php');
expect($cli)
->not->toContain("'xlvask-automation-migrate'")
->not->toContain('EnsureXLVaskAutomationSchema.php');
});
it('exposes the simplified operator flow in OpenAPI and removes the AI autopilot surface', 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('/modules/xlvask/services/usage/orders/{id}/ignore:')
->toContain('/modules/xlvask/services/usage/orders/{id}/unignore:')
->toContain('/modules/xlvask/services/usage/orders/{id}/accept:')
->toContain('/modules/xlvask/services/usage/orders/{id}/reject:')
->not->toContain('/modules/xlvask/services/usage/autopilot-runs:')
->not->toContain('/modules/xlvask/services/usage/automation/decisions/preview:')
->not->toContain('/modules/xlvask/services/usage/automation/decisions/apply:')
->not->toContain('/modules/xlvask/services/usage/automation/admin/readiness:')
->not->toContain('/modules/xlvask/services/usage/automation/admin/policy/previews:')
->not->toContain('/modules/xlvask/services/usage/automation/admin/policy/apply:')
->not->toContain('/modules/xlvask/services/usage/automation/admin/halt:')
->not->toContain('/modules/xlvask/services/usage/automation/admin/calibrations/')
->not->toContain('/modules/xlvask/services/usage/automation/admin/wash-id-uniqueness/')
->not->toContain('/modules/xlvask/services/usage/automation/capabilities:')
->not->toContain('/modules/xlvask/services/usage/autopilot-runs/{id}:')
->not->toContain('/modules/xlvask/services/usage/autopilot-runs/active:')
->not->toContain('xlvaskAutomationPolicyService')
->not->toContain('xlvaskAutomationService')
->not->toContain('xlvaskAutopilotService');
});