Compare commits

..
Author SHA1 Message Date
MiniMax M3 Subagent 8972b8f2ae fix(api): apply e-conomic discount percentage at line level for customer 35131752
Bug #11: For customer 35131752 ('kd'), the 15% e-conomic discount was
configured on the customer but never applied to the draft invoice line
items. The customer discount was only used in the flag/preview service for
expected price calculations, not when actually building the draft invoice.

Changes:
- economicCustomers.php: log swallowed missing-currency-price errors so
  silently-missing discounts (like customer 35131752) become visible in
  the application log instead of vanishing.
- economic_invoice_draft.php: thread the customer discount percentage
  through addOrderItemLines/addOrderItemLine and apply it at the line
  level (e-conomic's draft invoice line API requires per-line
  discountPercentage; an aggregate TotDiscount line is ignored when the
  customer has a per-line discount configured).
- economic_invoices_draft_endpoint.php: forward the customer discount
  percentage to the draft builder.
- collected_order_invoices_o.php: resolve the customer discount via
  Redis cache + e-conomicCustomers, then pass it to add_orders.
- Tests: new EconomicInvoiceDraftCustomerDiscountTest covering the
  customer 35131752 15% case, plus updates to the existing wiring tests
  to account for the new parameter and the customer-discount guard on
  the aggregate TotDiscount line.
2026-08-10 16:23:05 +02:00
65 changed files with 8395 additions and 1635 deletions
+2 -2
View File
@@ -68,7 +68,7 @@ jobs:
edge-agent:
name: Edge Agent (required)
runs-on: ubuntu-24.04
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || 'backend' }}
steps:
- name: Checkout
@@ -376,7 +376,7 @@ jobs:
release-manager-gate:
name: Release Manager gate
runs-on: ubuntu-24.04
runs-on: [self-hosted, Linux, X64, pleno, backend]
needs: [required-ci]
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.required-ci.result == 'success' }}
-4
View File
@@ -1,4 +0,0 @@
# AGENT MCP SMOKE
Generated 20260813-091957 by hermes agent to verify GitHub MCP wiring.
Safe to close.
-1
View File
@@ -7,5 +7,4 @@
<!-- AUTO-GENERATED, DO NOT EDIT -->
<p>Comprehensive API reference generated from the repository root <code>openapi.yaml</code>.</p>
<p>The edge broker's <code>/api/health</code> response additionally exposes a <code>lastActivityAt</code> field (ISO 8601 timestamp). It reports the most recent successful HTTP request handled by the broker container and defaults to the container's start time when no request has been processed yet.</p>
</topic>
-95
View File
@@ -1,95 +0,0 @@
# 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.
+2 -25
View File
@@ -18548,13 +18548,11 @@ components:
additionalProperties:
type: array
items:
oneOf:
- $ref: '#/components/schemas/InvoicingPeriodCustomer'
- $ref: '#/components/schemas/InvoicingPeriodCustomerMembership'
$ref: '#/components/schemas/InvoicingPeriodCustomer'
InvoicingPeriodCustomer:
type: object
required: [customer_number]
required: [customer_number, customer_name, transactions, invoice_collections]
additionalProperties: true
properties:
customer_number:
@@ -18570,27 +18568,6 @@ components:
items:
$ref: '#/components/schemas/InvoicingPeriodInvoiceCollection'
InvoicingPeriodCustomerMembership:
type: object
description: >-
Lightweight customer marker returned for every non-active view
bucket of the period response. Used by the front-end to render
category indicator chips (e.g. "Faktura pr. ordre") regardless of
which tab the user is currently looking at. Full customer-card
data (transactions, invoice collections, queue, draft, meta)
is intentionally omitted for non-active buckets; see
InvoicingPeriodCustomer for the shape returned for the active
bucket.
additionalProperties: false
required: [customer_number, membership_only]
properties:
customer_number:
type: integer
minimum: 1
membership_only:
type: boolean
enum: [true]
InvoicingPeriodTransaction:
type: object
required: [id, booked, invoice_state]
@@ -405,10 +405,6 @@ def render_api_reference_topic() -> str:
' title="API Reference" id="API-Reference">\n'
f"\n <!-- {AUTOGEN_NOTE} -->\n"
" <p>Comprehensive API reference generated from the repository root <code>openapi.yaml</code>.</p>\n"
" <p>The edge broker's <code>/api/health</code> response additionally exposes a "
"<code>lastActivityAt</code> field (ISO 8601 timestamp). It reports the most recent "
"successful HTTP request handled by the broker container and defaults to the container's "
"start time when no request has been processed yet.</p>\n"
"</topic>\n"
)
+54
View File
@@ -0,0 +1,54 @@
#!/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);
-8
View File
@@ -189,8 +189,6 @@ export function createBrokerServer(options = {}) {
const browserStreamSessions = new Map();
const gatewayStreamSessions = new Map();
const inflightGatewaySyncs = new Map();
const containerStartedAt = currentTimestamp();
let lastActivityAt = containerStartedAt;
const managerRequest = async (path, body = {}, method = "POST") => {
if (!managerUrl) {
@@ -482,7 +480,6 @@ export function createBrokerServer(options = {}) {
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url, "http://localhost");
lastActivityAt = currentTimestamp();
if (req.method === "GET" && url.pathname === "/api/health") {
jsonResponse(res, 200, {
ok: true,
@@ -491,7 +488,6 @@ export function createBrokerServer(options = {}) {
manager_url_configured: Boolean(managerUrl),
shared_secret_configured: Boolean(sharedSecret),
agents_connected: agents.size,
lastActivityAt,
});
return;
}
@@ -1087,10 +1083,6 @@ export function createBrokerServer(options = {}) {
pendingCommands,
managerUrl,
authMode,
containerStartedAt,
get lastActivityAt() {
return lastActivityAt;
},
},
};
}
-39
View File
@@ -219,10 +219,6 @@ test("broker exposes health and shared-secret diagnostics", async () => {
assert.equal(healthJson.auth_mode, "manager");
assert.equal(healthJson.manager_url_configured, true);
assert.equal(healthJson.shared_secret_configured, true);
assert.equal(typeof healthJson.lastActivityAt, "string");
assert.match(healthJson.lastActivityAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
assert.ok(healthJson.lastActivityAt >= broker.state.containerStartedAt);
assert.equal(healthJson.lastActivityAt, broker.state.lastActivityAt);
const invalidSecretResponse = await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
method: "POST",
@@ -251,41 +247,6 @@ test("broker exposes health and shared-secret diagnostics", async () => {
await broker.close();
});
test("broker updates lastActivityAt after each successful request", async () => {
const broker = createBrokerServer({ authMode: "manager", sharedSecret: "secret", managerUrl: "http://manager.test" });
const address = await broker.listen(0);
const port = address.port;
assert.equal(broker.state.lastActivityAt, broker.state.containerStartedAt);
const firstResponse = await fetch(`http://127.0.0.1:${port}/api/health`);
const firstJson = await firstResponse.json();
const firstActivityAt = broker.state.lastActivityAt;
assert.equal(typeof firstJson.lastActivityAt, "string");
assert.equal(firstJson.lastActivityAt, firstActivityAt);
assert.ok(firstActivityAt >= broker.state.containerStartedAt);
await new Promise((resolve) => setTimeout(resolve, 5));
await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
method: "POST",
headers: {
"x-edge-broker-secret": "secret",
},
});
assert.notEqual(broker.state.lastActivityAt, firstActivityAt);
assert.ok(broker.state.lastActivityAt > firstActivityAt);
const secondResponse = await fetch(`http://127.0.0.1:${port}/api/health`);
const secondJson = await secondResponse.json();
assert.equal(secondJson.lastActivityAt, broker.state.lastActivityAt);
await broker.close();
});
test("broker bridges browser shell sessions through the connected agent", async () => {
const closedSessions = [];
const broker = createBrokerServer({
@@ -7,14 +7,9 @@ use objects\passkeys_o;
use objects\subusers_o;
use objects\users_o;
use Throwable;
use traits\boolean_normalization_t;
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
class account_deletion_service
{
use boolean_normalization_t;
public const CONFIRMATION_PHRASE = 'SLET MIN KONTO';
public const POLICY_VERSION = '2026-07-20';
public const MAX_RETRIES = 5;
@@ -57,7 +52,7 @@ class account_deletion_service
$result = $db->query("SELECT value FROM module_config WHERE module = '$module' AND variable = '$variable' LIMIT 1");
if ($result === false || $result->num_rows === 0) return false;
$row = $result->fetch_assoc();
return self::normalizeBoolean((string)($row['value'] ?? ''));
return in_array(strtolower(trim((string)($row['value'] ?? ''))), ['1', 'true', 'yes', 'on'], true);
} catch (Throwable) {
return false;
}
+1 -6
View File
@@ -3,14 +3,9 @@
namespace classes;
use Throwable;
use traits\boolean_normalization_t;
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
class cron_worker
{
use boolean_normalization_t;
private cron_scheduler $scheduler;
private string $worker_id;
private string $name;
@@ -296,7 +291,7 @@ class cron_worker
return $default;
}
return self::normalizeBoolean($value);
return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true);
}
private function commitSha(): string
@@ -1297,34 +1297,7 @@ class invoice_period_flag_service
}
}
// Partition primary rows by whether reg_2 is empty. A single-tractor order (reg_2 = '')
// should only be compared against historical orders that ALSO had reg_2 empty, so we don't
// raise a "historical_primary_product_mismatch" flag that names the tractor-trailer
// (Forvogn med hænger) product as the expected one when the current order has no trailer.
$hasReg2 = [];
$emptyReg2 = [];
foreach ($primaryRows as $row) {
if (trim((string)($row['reg_2'] ?? '')) === '') {
$emptyReg2[] = $row;
} else {
$hasReg2[] = $row;
}
}
$history = [];
if (!empty($emptyReg2)) {
$history = $history + $this->getPrimaryProductHistory(
$dateFrom,
array_column($emptyReg2, 'reg_1'),
true
);
}
if (!empty($hasReg2)) {
$history = $history + $this->getPrimaryProductHistory(
$dateFrom,
array_column($hasReg2, 'reg_1'),
false
);
}
$history = $this->getPrimaryProductHistory($dateFrom, array_column($primaryRows, 'reg_1'));
foreach ($primaryRows as $row) {
$reg = strtoupper(trim((string)($row['reg_1'] ?? '')));
if ($reg === '' || !isset($history[$reg])) {
@@ -1495,7 +1468,6 @@ class invoice_period_flag_service
{
$product = (string)($params['product'] ?? 'Item');
$expectedProduct = (string)($params['expected_product'] ?? 'expected product');
$washId = (string)($params['wash_id'] ?? '');
return match ($definitionKey) {
'price_mismatch' => "{$product} product price differs from expected.",
'customer_rule_restrict_addon_services' => "{$product} violates restricted addon services.",
@@ -1515,9 +1487,7 @@ class invoice_period_flag_service
'duplicate_vehicle_subscription_charge_same_month' => "Duplicate vehicle subscription charges exist in the same month.",
'vehicle_subscription_type_mismatch' => "{$product} does not match the vehicle subscription type {$expectedProduct}.",
'historical_primary_product_mismatch' => "{$product} differs from the registration number's usual product {$expectedProduct}.",
'xlvask_missing_order_link' => $washId === ''
? 'XL Vask wash is neither ignored nor linked to an order in the selected period.'
: "XL Vask wash {$washId} is neither ignored nor linked to an order in the selected period.",
'xlvask_missing_order_link' => "XL Vask wash is neither ignored nor linked to an order in the selected period.",
default => "Automatically detected invoice-period issue.",
};
}
@@ -1544,8 +1514,7 @@ class invoice_period_flag_service
['type' => 'text', 'text' => ' is attached without a wash certificate item.'],
],
'xlvask_missing_order_link' => [
['type' => 'text', 'text' => 'XL Vask wash '],
['type' => 'xlvask_usage_log', 'text' => (string)($params['wash_id'] ?? '')],
['type' => 'xlvask_usage_log', 'text' => 'XL Vask wash'],
['type' => 'text', 'text' => ' is neither ignored nor linked to an order in the selected period.'],
],
default => [],
@@ -1805,7 +1774,7 @@ class invoice_period_flag_service
return $map;
}
private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers, ?bool $requireReg2Empty = null): array
private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers): array
{
global $db;
@@ -1826,16 +1795,6 @@ class invoice_period_flag_service
$registrationFilter = implode(',', array_map(static function (string $registrationNumber) use ($db): string {
return "'" . $db->escape_string($registrationNumber) . "'";
}, array_keys($registrations)));
// Restrict historical orders to those whose reg_2 status matches the current rows:
// - null → no filter (default behaviour, backwards compatible)
// - true → reg_2 empty (single-tractor orders only)
// - false → reg_2 non-empty (tractor-trailer combo orders only)
$reg2Filter = '';
if ($requireReg2Empty === true) {
$reg2Filter = "AND (COALESCE(o.reg_2, '') = '')";
} elseif ($requireReg2Empty === false) {
$reg2Filter = "AND (COALESCE(o.reg_2, '') <> '')";
}
$result = $db->query(
"SELECT UPPER(TRIM(o.reg_1)) AS reg, oi.product_id, p.name AS product_name, COUNT(*) AS usage_count
FROM orders o
@@ -1849,7 +1808,6 @@ class invoice_period_flag_service
AND COALESCE(oi.related_item_id, 0) = 0
AND COALESCE(o.reg_1, '') <> ''
AND o.reg_1 IN ({$registrationFilter})
{$reg2Filter}
GROUP BY UPPER(TRIM(o.reg_1)), oi.product_id, p.name
ORDER BY reg, usage_count DESC, oi.product_id ASC"
);
+196
View File
@@ -0,0 +1,196 @@
<?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;
}
}
@@ -5,14 +5,9 @@ namespace classes;
use Exception;
use mysqli_result;
use Throwable;
use traits\boolean_normalization_t;
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
class module_usage_service
{
use boolean_normalization_t;
private module_usage_registry $registry;
public function __construct(?module_usage_registry $registry = null)
@@ -973,7 +968,10 @@ class module_usage_service
private function toBool(mixed $value): bool
{
return self::normalizeBoolean($value);
if (is_bool($value)) {
return $value;
}
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
}
private function sqlString(string $value): string
@@ -7,7 +7,7 @@ use InvalidArgumentException;
class order_item_reason_policy
{
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
public const AFFECTED_PRODUCT_IDS = [21, 22, 25, 26, 27];
public const AFFECTED_PRODUCT_IDS = [21, 22, 24, 25, 26, 27];
public static function reasons(): array
{
@@ -5,15 +5,11 @@ namespace classes;
use customers\economicCustomers;
use RuntimeException;
use Throwable;
use traits\boolean_normalization_t;
require_once __DIR__ . '/cors_policy.php';
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
class release_manager
{
use boolean_normalization_t;
private const APPS = ['frontend', 'api'];
private const DEFAULT_BRANCH = 'master';
private const RELEASE_ROUTE_SLUGS = [
@@ -12669,7 +12665,10 @@ class release_manager
private function toBool(mixed $value): bool
{
return self::normalizeBoolean($value);
if (is_bool($value)) {
return $value;
}
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
}
private function requestTraceId(): string
@@ -2,14 +2,8 @@
namespace classes;
use traits\boolean_normalization_t;
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
class releasemanager
{
use boolean_normalization_t;
public function isEnabled(): bool
{
try {
@@ -17,7 +11,7 @@ class releasemanager
global $db;
$result = $db->query("SELECT value FROM module_config WHERE module = 'ReleaseManager' AND variable = 'enabled' LIMIT 1");
$row = $result ? $result->fetch_assoc() : null;
return self::normalizeBoolean((string)($row['value'] ?? 'true'));
return in_array(strtolower(trim((string)($row['value'] ?? 'true'))), ['1', 'true', 'yes', 'on'], true);
} catch (\Throwable) {
return true;
}
@@ -6,14 +6,9 @@ use Aws\S3\S3Client;
use mysqli;
use Predis\Client as PredisClient;
use Throwable;
use traits\boolean_normalization_t;
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
class replica_failover_manager
{
use boolean_normalization_t;
public const KIND_DATABASE = 'database';
public const KIND_REDIS = 'redis';
public const KIND_MINIO = 'minio';
@@ -507,7 +502,11 @@ class replica_failover_manager
private static function boolValue(mixed $value): bool
{
return self::normalizeBoolean($value);
if (is_bool($value)) {
return $value;
}
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
}
private static function jsonDecode(mixed $value): array
@@ -4,14 +4,9 @@ namespace classes;
use Aws\S3\S3Client;
use Throwable;
use traits\boolean_normalization_t;
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
class superuser_system_status_service
{
use boolean_normalization_t;
public const MODULE_PROBE_TTL_SECONDS = 60;
public const REFRESH_AFTER_SECONDS = 30;
private const MODULE_PROBE_CACHE_KEY_PREFIX = 'superuser_system_status:module_probe:';
@@ -852,7 +847,7 @@ class superuser_system_status_service
protected function parseModuleConfigValue(string $type, mixed $value): mixed
{
return match (strtolower($type)) {
'bool' => self::normalizeBoolean($value),
'bool' => in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true),
'int', 'integer' => is_numeric($value) ? (int)$value : null,
'float', 'double' => is_numeric($value) ? (float)$value : null,
'json' => is_string($value) ? json_decode($value, true) : null,
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,6 +106,10 @@ 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,6 +150,12 @@ $cron_tasks = [
'next_run' => 0,
'function' => 'SyncXLVaskModuleCron',
],
'ProcessXLVaskAutopilotQueueCron' => [
'interval' => 60,
'last_run' => 0,
'next_run' => 0,
'function' => 'ProcessXLVaskAutopilotQueueCron',
],
'SystemSearchCacheMaintenanceCron' => [
'interval' => 300, // 5 minutes
'last_run' => 0,
@@ -673,6 +679,15 @@ function SyncXLVaskModuleCron(): void
}
}
function ProcessXLVaskAutopilotQueueCron(): array
{
$xlvask = new xlvask();
if (!$xlvask->config->enabled->isTrue()) {
return [];
}
return $xlvask->getTasks()->processAutopilotQueue(3);
}
function EconomicTransferQueueCron(): void
{
try {
@@ -0,0 +1,71 @@
<?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.'));
}
@@ -76,9 +76,14 @@ class economicCustomers extends economic_m
// The discount is global, but e-conomic resolves it through a product-specific
// invoice-line template. For foreign-currency customers some templates can fail
// if that product has no price in the customer currency, so try a few products
// before falling back to zero.
// before falling back to zero. We log every swallowed currency-price failure so
// silently-missing discounts (e.g. bug #11 customer 35131752 "kd" 15%) become
// visible in the application log instead of vanishing into the void.
$products = $this->getCustomerProducts($customer_number, 10);
$attempted_products = 0;
$swallowed_errors = 0;
foreach ($this->extractCustomerProductNumbers($products) as $product_number) {
$attempted_products++;
try {
$discount = $this->getCustomerProductDiscount($customer_number, $product_number);
return (int)($discount->discountPercentage ?? 0);
@@ -86,9 +91,24 @@ class economicCustomers extends economic_m
if (!$this->isMissingCurrencyPriceLookupError($exception)) {
throw $exception;
}
$swallowed_errors++;
error_log(sprintf(
'[economicCustomers] Swallowed missing-currency-price error while resolving discount for customer %d product %d: %s',
$customer_number,
$product_number,
$exception->getMessage()
));
}
}
if ($attempted_products > 0 && $swallowed_errors === $attempted_products) {
error_log(sprintf(
'[economicCustomers] All %d invoice-line template probes failed with missing currency prices for customer %d; falling back to 0%% discount. Verify "economic_customer_discount_percentage" in e-conomic for this customer.',
$attempted_products,
$customer_number
));
}
return 0;
}
@@ -72,7 +72,7 @@ class economic_invoices_draft_endpoint
* @return array{order_count:int,orders_with_invoice_lines:int,line_count:int,batch_count:int,batch_sizes:array<int,int>}
* @throws Exception If the request fails
*/
public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500, bool $use_itemized_discounts = false): array
public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500, bool $use_itemized_discounts = false, int $customer_discount_percentage = 0): array
{
$draftInvoice = (new economic())->getInvoiceDraft($invoiceDraftId, strtoupper($currency), true);
$orders_with_invoice_lines = 0;
@@ -89,8 +89,8 @@ class economic_invoices_draft_endpoint
$orders_with_invoice_lines++;
// Add the transaction header (Timestamp, department, etc.)
$draftInvoice->addNewTransactionHeader($order);
// Add the order lines
$draftInvoice->addOrderItemLines($order, $use_itemized_discounts);
// Add the order lines (including the customer-level e-conomic discount, if any).
$draftInvoice->addOrderItemLines($order, $use_itemized_discounts, $customer_discount_percentage);
// Add an empty line, so the invoice is not empty
$draftInvoice->addTextLine('');
}
@@ -249,7 +249,7 @@ class economic_invoice_draft
* @throws Exception if the order is not found
* @throws Exception if the order is not valid
*/
public function addOrderItemLines(orders_o $order, bool $use_itemized_discounts = false): void
public function addOrderItemLines(orders_o $order, bool $use_itemized_discounts = false, int $customer_discount_percentage = 0): void
{
// Get the order items
$order_items = $order->getOrderItems($order->id);
@@ -268,18 +268,25 @@ class economic_invoice_draft
});
// Define the total discount applied to the order
$total_discount = 0;
// Normalize the customer discount percentage (clamp to 0..100)
$customer_discount_percentage = max(0, min(100, $customer_discount_percentage));
// Force itemized discount mode when the customer has a global e-conomic discount
// so the discount is applied at the line level (e-conomic line API requires per-line
// discountPercentage; an aggregate TotDiscount line would be ignored when the
// customer does not have a per-line discount configured for the customer).
$effective_itemized_discounts = $use_itemized_discounts || $customer_discount_percentage > 0;
// Loop through the order items
foreach ( $order_items as $order_item ) {
if ($this->shouldSkipOrderItemLine($order_item)) {
continue;
}
// Add the order item to the draft invoice
self::addOrderItemLine($order_item, $department, false, $use_itemized_discounts);
self::addOrderItemLine($order_item, $department, false, $effective_itemized_discounts, $customer_discount_percentage);
// Add the line discount to the total discount
$total_discount += ($order_item['product']['price'] - $order_item['price']) * $order_item['quantity'];
}
// If the total discount is greater than 0, add it to the invoice
if (!$use_itemized_discounts && $total_discount > 0) {
if (!$use_itemized_discounts && $customer_discount_percentage === 0 && $total_discount > 0) {
// Add the discount to the invoice
self::addProductDiscountLine($total_discount, $department['economic_department_id'] ?? 0, $department['dimension'] ?? 0);
}
@@ -295,7 +302,7 @@ class economic_invoice_draft
* @throws Exception if the order item is not found
* @throws Exception if the order item is not valid
*/
public function addOrderItemLine(array $order_item, array $department, bool $show_discount = false, bool $use_itemized_discount = false): void
public function addOrderItemLine(array $order_item, array $department, bool $show_discount = false, bool $use_itemized_discount = false, int $customer_discount_percentage = 0): void
{
// Check if the order item is valid
if (!isset($order_item['id'])) {
@@ -309,9 +316,18 @@ class economic_invoice_draft
// Get the dimension id
$economic_dimension_id = $department['economic_dimension_id'] ?? 0;
$pricing = self::resolveOrderItemInvoicePricing($order_item);
$discount_percentage = $use_itemized_discount
// The customer discount (e.g. kd customer 35131752 with 15% global e-conomic discount)
// is applied at the line level. Combined with per-item discounts using max() so the
// biggest discount wins, and so we never accidentally apply a 15% discount on top of
// an already-discounted per-item price.
$customer_discount_percentage = max(0, min(100, $customer_discount_percentage));
$itemized_discount_percentage = $use_itemized_discount
? $this->resolveItemizedDiscountPercentageForInvoiceCurrency($pricing)
: 0;
: 0.0;
$discount_percentage = (float)max(
$itemized_discount_percentage,
(float)$customer_discount_percentage
);
// Add the order item to the draft invoice
self::addProductLine(
(string)$order_item['product']['economic_product_id'],
@@ -0,0 +1,29 @@
<?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,
''
);
}
}
@@ -0,0 +1,29 @@
<?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'
);
}
}
@@ -0,0 +1,28 @@
<?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();
}
}
@@ -0,0 +1,86 @@
# 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.
@@ -0,0 +1,52 @@
<?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;
}
}
}
@@ -0,0 +1,52 @@
<?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;
}
}
}
@@ -0,0 +1,29 @@
<?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'
);
}
}
@@ -0,0 +1,29 @@
<?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'
);
}
}
@@ -0,0 +1,28 @@
<?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,6 +2,9 @@
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;
@@ -43,6 +46,9 @@ 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();
};
}
@@ -70,6 +76,47 @@ 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
{
@@ -281,8 +328,10 @@ 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)) . "'"
@@ -303,6 +352,8 @@ 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;
@@ -323,10 +374,25 @@ 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' => 0];
return ['linked' => $linked, 'orders_created' => $ordersCreated];
}
private static function formatUsageLogs(array $getUsageLog): array
@@ -436,5 +502,9 @@ 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();
}
}
@@ -0,0 +1,27 @@
<?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,11 +3,19 @@
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;
@@ -31,6 +39,22 @@ 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
@@ -53,11 +77,19 @@ 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();
}
@@ -725,6 +725,52 @@ class collected_order_invoices_o extends db
return false;
}
/**
* Resolve the e-conomic customer discount percentage that should be applied at the
* line level when building the invoice draft. Caches via Redis to avoid hammering
* the e-conomic templates endpoint on every draft sync.
*/
private static function resolveCustomerDiscountPercentageForDraft(int $customer_number): int
{
if ($customer_number <= 0) {
return 0;
}
$user = (new users_o())->getUserByCustomerNumber($customer_number);
$userId = (int)$user->id;
if ($userId > 0 && defined('redis')) {
try {
$cached = constant('redis')->get_economic_customer_discount_percentage($userId);
if ($cached !== null) {
return max(0, min(100, (int)$cached));
}
} catch (\Throwable $e) {
// Fall through to the live lookup.
}
}
try {
$discount = (int)(new \customers\economicCustomers())->getCustomerDiscountPercentage($customer_number);
} catch (\Throwable $e) {
error_log(sprintf(
'[collected_order_invoices_o] Failed to resolve e-conomic customer discount for customer %d: %s',
$customer_number,
$e->getMessage()
));
return 0;
}
if ($userId > 0 && defined('redis')) {
try {
constant('redis')->cache_economic_customer_discount_percentage($userId, $discount);
} catch (\Throwable $e) {
// Cache failures are non-fatal.
}
}
return max(0, min(100, $discount));
}
/**
* Require the invoice draft to not already exist
* @throws Exception If the request was not successful
@@ -964,7 +1010,18 @@ class collected_order_invoices_o extends db
break;
}
}
$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency, 500, $use_itemized_discounts);
// Look up the customer-level e-conomic discount (e.g. bug #11 customer 35131752
// "kd" 15%). This is applied at the line level so the draft invoice carries the
// discount percentage that e-conomic expects for the customer.
$customer_discount_percentage = self::resolveCustomerDiscountPercentageForDraft((int)$this->customer_number->value());
$metrics = (new economic())->invoices->draft->add_orders(
$draft_id,
$order_objects,
$currency,
500,
$use_itemized_discounts,
$customer_discount_percentage
);
$this->last_economic_transfer_metrics = [
'draft_invoice_id' => $draft_id,
'currency' => (string)$currency,
+2 -9
View File
@@ -615,13 +615,7 @@ class orders_o extends db
public function getOrderItems(int $order_id): array
{
global $db;
// Order primary items first (related_item_id IS NULL), then addons grouped by
// their parent (related_item_id ASC), and finally fall back to insertion order
// (id ASC). Without an explicit ORDER BY, MySQL is free to return rows in any
// order, which causes the FE tree-builder to render addons before their
// primary on the invoice and POS displays (Trækker + addons like Trailer/Dolly
// visually appearing as if only Trailer/Dolly were attached to the order).
$sql = "SELECT * FROM order_items WHERE order_id = $order_id ORDER BY (related_item_id IS NULL) DESC, related_item_id ASC, id ASC";
$sql = "SELECT * FROM order_items WHERE order_id = $order_id";
$result = $db->query($sql);
$order_items = [];
if ($result->num_rows > 0 && $result) {
@@ -2208,8 +2202,7 @@ class orders_o extends db
$sql = "SELECT id FROM $this->table
WHERE (UPPER(TRIM(reg_1)) = '$reg' OR UPPER(TRIM(reg_2)) = '$reg' OR UPPER(TRIM(reg_3)) = '$reg')
AND created_at BETWEEN '$from_date' AND '$to_date'
AND deleted_at IS NULL
ORDER BY id ASC";
AND deleted_at IS NULL";
$result = $db->query($sql);
if ($result->num_rows === 0) {
return []; // No orders found with the registration number in the date range
@@ -569,81 +569,4 @@ 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],
];
}
}
+369 -72
View File
@@ -10659,92 +10659,372 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/orders/{id}/ignore:
patch:
/modules/xlvask/services/usage/autopilot-runs:
post:
tags:
- Modules
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:
- name: id
in: path
required: true
schema: { type: integer }
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: [reason]
required: [mode]
properties:
reason:
dateFrom:
type: string
maxLength: 500
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:
'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/orders/{id}/unignore:
post:
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
'202':
description: XLVask usage autopilot run queued successfully
content:
application/json:
schema:
type: object
properties:
order_id: { type: integer }
usage_log_id: { type: integer }
'400': { $ref: '#/components/responses/BadRequest' }
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' }
'404': { $ref: '#/components/responses/NotFound' }
'422': { description: XL Vask customer is not linkable }
/modules/xlvask/services/usage/orders/{id}/reject:
/modules/xlvask/services/usage/autopilot-runs/{id}:
get:
tags:
- Modules
summary: Get an XLVask usage autopilot run
description: Returns status and summary metadata for a previously requested autopilot run.
operationId: getXlvaskUsageAutopilotRun
parameters:
- in: path
name: id
required: true
schema:
type: integer
responses:
'200':
description: XLVask usage autopilot run returned successfully
content:
application/json:
schema:
type: object
properties:
run:
type: object
'400':
description: Invalid XLVask autopilot run id
'404':
description: XLVask autopilot run not found
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/automation/decisions/preview:
post:
tags:
- Modules
summary: 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
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:
- name: id
in: path
- { 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
required: true
schema: { type: integer }
requestBody:
@@ -10753,18 +11033,35 @@ paths:
application/json:
schema:
type: object
required: [reason]
required: [artifact_hash, confirmation_text]
properties:
reason:
type: string
maxLength: 500
artifact_hash: { type: string }
confirmation_text: { type: string }
responses:
'200':
description: XL Vask usage log rejected
'400': { $ref: '#/components/responses/BadRequest' }
'200': { description: Calibration artifact activated successfully }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
/modules/xlvask/services/usage/automation/admin/wash-id-uniqueness/activate:
post:
tags: [Modules]
summary: Activate guarded wash-id uniqueness
description: Explicitly verifies duplicates, adds the normalized wash-id column and unique index, and fails closed on conflicts.
operationId: activateXlvaskWashIdUniqueness
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [confirmation_text]
properties:
confirmation_text: { type: string }
responses:
'200': { description: Wash-id uniqueness activated successfully }
'409': { description: Duplicate wash IDs or schema readiness blocked activation }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/action-logs:
get:
@@ -739,22 +739,6 @@ class InvoicingPeriodRoute
$pagedTypes[$periodView] = array_slice($types[$periodView], $offset, $perPage);
}
// Surface lightweight customer memberships for every non-active
// view bucket so the front-end can render category indicator
// chips (e.g. "Faktura pr. ordre") regardless of which tab the
// user is currently looking at. Filters, search, sort, flag tab
// and workflow filters have already been applied to `$types`
// above, so the membership set matches the active bucket's
// semantics for this request.
foreach ($types as $typeName => $customers) {
if ($typeName === $periodView) {
continue;
}
$pagedTypes[$typeName] = self::summarizePeriodCustomerMemberships(
is_array($customers) ? $customers : []
);
}
$period['types'] = $pagedTypes;
$period['type_counts'] = $typeCounts;
$period['type_totals'] = self::summarizePeriodTypeTotals($types);
@@ -1246,41 +1230,6 @@ class InvoicingPeriodRoute
return $counts;
}
/**
* Build a deduplicated list of lightweight `{customer_number}` markers
* for a single non-active view bucket. These entries let the front-end
* know which customers belong to a category without shipping the full
* card (transactions, invoice_collections, queue, draft, meta, ).
*
* Filters, search, sort, flag tab and workflow filters are expected to
* have been applied to `$customers` upstream we only de-duplicate and
* project the `customer_number` field here.
*
* @param array<int, array<string, mixed>> $customers
* @return array<int, array{customer_number: int, membership_only: true}>
*/
private static function summarizePeriodCustomerMemberships(array $customers): array
{
$memberships = [];
$seen = [];
foreach ($customers as $customer) {
if (!is_array($customer)) {
continue;
}
$customerNumber = (int)($customer['customer_number'] ?? 0);
if ($customerNumber < 1 || isset($seen[$customerNumber])) {
continue;
}
$seen[$customerNumber] = true;
$memberships[] = [
'customer_number' => $customerNumber,
'membership_only' => true,
];
}
return $memberships;
}
private static function summarizePeriodTypeTotals(array $types): array
{
$totals = [];
@@ -952,6 +952,44 @@ 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,11 +2,15 @@
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;
@@ -176,6 +180,10 @@ 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
@@ -191,6 +199,27 @@ 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');
@@ -222,5 +251,15 @@ 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'
]
);
}
}
+1 -1
View File
@@ -237,7 +237,7 @@ class ordersRoute
$order->setPendingHandheldIndicator();
}
// Log the incident
(new logs_o())->add('orders', $data['department_id'], 1, $user->id, 'ADD_ORDER', 'Successfully added an order (ID: ' . (int)$order->id . ')');
(new logs_o())->add('orders', $data['department_id'], 1, $user->id, 'ADD_ORDER', 'Successfully added an order (ID: ' . $data['department_id'] . ')');
// Return a success message, containing the orders array
$response->success($order->asArray());
} else {
+562 -300
View File
@@ -2,10 +2,24 @@
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;
@@ -16,110 +30,136 @@ class xlvaskUsageLogsRoute
public function run(): void
{
$this->get('/modules/xlvask/services/usage/orders', function () {
$permission_list_own = 'list_xlvask_usage_orders_own';
$permission_list_all = 'list_xlvask_usage_orders_all';
$response_includes_items = false;
// 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
global $response;
if (!$this->hasPermission($permission_list_all)) {
$this->requirePermission($permission_list_own);
}
// Get the user object
$user = (new authentication())->get_user();
if (!$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
$response->error('Invalid session', 400);
}
$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)',
]);
},
[
'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;
@@ -132,189 +172,375 @@ class xlvaskUsageLogsRoute
}
$dateFrom = $this->isParametersSet(['dateFrom']) ? (string)$this->getParameter('dateFrom') : null;
$dateTo = $this->isParametersSet(['dateTo']) ? (string)$this->getParameter('dateTo') : null;
$allowedHallIds = $this->allowedHallIdsForUser($user);
$summary = (new xlvask_usage_logs_o())->summarizeUsageOrdersReadOnly(
$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(
$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');
$canManagePolicy = $this->hasPermission('superuser_xlvask_automation_activate');
$response->success([
'can_view' => true,
'can_review' => $canManage,
'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',
]);
$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
);
$response->success(['summary' => $summary]);
$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),
]);
}, [
'list_xlvask_usage_orders_own' => 'Read XL Vask usage-log summary',
'list_xlvask_usage_orders_all' => 'Read all XL Vask usage-log summaries',
'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;
$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']);
$this->post('/modules/xlvask/services/usage/automation/decisions/apply', function () {
global $response;
$this->requirePermission('manage_xlvask_usage_automation');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$input = [];
foreach (['preview_id', 'selection_hash', 'confirmation_text'] as $key) {
if ($this->isParametersSet([$key])) {
$input[$key] = $this->getParameter($key);
}
}
$response->success(
(new xlvask_autopilot_service())->applyDecision(
$input,
(int)$user->id,
$this->allowedHallIdsForUser($user)
)
);
}, ['manage_xlvask_usage_automation' => 'Apply a previewed XL Vask automation decision']);
$this->patch('/modules/xlvask/services/usage/orders/{id}/ignore', 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($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',
]);
$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/{id}/unignore', function () {
$this->post('/modules/xlvask/services/usage/orders/automation/run', 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);
}
$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',
]);
$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}/accept', function () {
$this->post('/modules/xlvask/services/usage/orders/{id}/automation/evaluate', 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->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}/reject', function () {
$this->post('/modules/xlvask/services/usage/orders/{id}/automation/accept', function () {
global $response;
$this->requirePermission('review_xlvask_usage_order');
$this->requirePermission('manage_xlvask_usage_automation');
$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);
self::requireUsageLogInHallScope($id, $this->allowedHallIdsForUser($user));
$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 () {
global $response;
$this->requirePermission('manage_xlvask_usage_automation');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 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);
$id = (int)($this->fromRoute('id') ?? 0);
if ($id < 1) {
$response->error('Invalid XL Vask usage log id', 400);
}
$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',
]);
self::requireUsageLogInHallScope($id, $this->allowedHallIdsForUser($user));
$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->get('/modules/xlvask/services/usage/orders/fast-link', function () {
global $response;
@@ -323,55 +549,91 @@ class xlvaskUsageLogsRoute
if (!$user) {
$response->error('Invalid session', 400);
}
self::requireParameters(['fast_link_key']);
self::requireParameters([
'fast_link_key', // Example: 'temporary_cache_6878cf0603d77'
]);
// Get the fast link key from the request
$fast_link_key = (string)self::getParameter('fast_link_key');
self::requireType($fast_link_key, self::type_string());
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)) {
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
$response->error('Invalid fast link key format', 400);
}
$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',
]);
},
[
'fast_link_key' => 'string', // Example: 'temporary_cache_6878cf0603d77'
]
);
}
private function allowedHallIdsForUser(object $user): array
@@ -1,69 +0,0 @@
<?php
/**
* Standalone smoke test for the boolean_normalization_t trait.
*
* The composer autoloader is not always available locally (CI may install
* dependencies before this script runs); the inline require_once calls
* below let us verify the trait + all seven consumers in isolation.
*/
declare(strict_types=1);
require_once __DIR__ . '/../../traits/boolean_normalization_t.php';
require_once __DIR__ . '/../../classes/cron_worker.php';
require_once __DIR__ . '/../../classes/replica_failover_manager.php';
require_once __DIR__ . '/../../classes/superuser_system_status_service.php';
require_once __DIR__ . '/../../classes/module_usage_service.php';
require_once __DIR__ . '/../../classes/account_deletion_service.php';
require_once __DIR__ . '/../../classes/releasemanager.php';
require_once __DIR__ . '/../../classes/release_manager.php';
$classes = [
'classes\\cron_worker',
'classes\\replica_failover_manager',
'classes\\superuser_system_status_service',
'classes\\module_usage_service',
'classes\\account_deletion_service',
'classes\\releasemanager',
'classes\\release_manager',
];
foreach ($classes as $class) {
$rc = new ReflectionClass($class);
$ok = in_array('traits\\boolean_normalization_t', $rc->getTraitNames(), true);
echo str_pad($class, 55) . ' -> ' . ($ok ? 'YES' : 'NO') . PHP_EOL;
}
echo PHP_EOL;
$cases = [
[true, true],
[false, false],
[1, true],
[0, false],
['true', true],
['TRUE', true],
['1', true],
['yes', true],
['YES', true],
['on', true],
[' ON ', true],
['false', false],
['no', false],
['off', false],
['', false],
[null, false],
['0', false],
[[], false],
[(object) ['v' => 'true'], false],
];
$s = new class {
use traits\boolean_normalization_t;
};
$fails = 0;
foreach ($cases as $pair) {
[$in, $exp] = $pair;
$a = $s::normalizeBoolean($in);
if ($a !== $exp) {
echo 'FAIL ' . var_export($in, true) . ' expected ' . var_export($exp, true) . ' got ' . var_export($a, true) . PHP_EOL;
$fails++;
}
}
echo ($fails === 0 ? 'OK' : 'FAIL') . ' - ' . count($cases) . ' normalizeBoolean cases' . PHP_EOL;
@@ -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(22);
expect($definitions)->toHaveCount(24);
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();
@@ -14,7 +14,11 @@ it('routes collected invoice draft line uploads through the multi-order batch en
$methodBlock = substr($content, (int)$start, (int)$end - (int)$start);
expect($methodBlock)->toContain('$order_objects = [];')
->and($methodBlock)->toContain('$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency, 500, $use_itemized_discounts);')
// Bug #11 customer 35131752 — the customer discount is threaded through add_orders
// so the line-level discountPercentage is applied to each line item.
->and($methodBlock)->toContain('$customer_discount_percentage = self::resolveCustomerDiscountPercentageForDraft')
->and($methodBlock)->toContain('$metrics = (new economic())->invoices->draft->add_orders(')
->and($methodBlock)->toContain('$customer_discount_percentage')
->and($methodBlock)->toContain('...$metrics')
->and($methodBlock)->not->toContain('self::addInvoiceToDraft($order[\'id\'], true, $draft_id, $currency);');
});
@@ -36,7 +40,7 @@ it('keeps single-order draft uploads as a wrapper around the batch endpoint', fu
$batchBlock = substr($content, (int)$singleEnd);
expect($batchBlock)->toContain('$draftInvoice->flushLinesInBatches($line_batch_size);')
->and($batchBlock)->toContain('$draftInvoice->addOrderItemLines($order, $use_itemized_discounts);')
->and($batchBlock)->toContain('$draftInvoice->addOrderItemLines($order, $use_itemized_discounts, $customer_discount_percentage);')
->and($batchBlock)->toContain("'orders_with_invoice_lines' => \$orders_with_invoice_lines");
});
@@ -50,7 +54,7 @@ it('selects itemized discount mode for collected invoice batch transfers', funct
->and($content)->toContain('invoice_discount_layout')
->and($content)->toContain('hasDiscountedIncludedInvoiceItems')
->and($content)->toContain('orderItemHasBillableDiscount')
->and($content)->toContain('$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency, 500, $use_itemized_discounts);');
->and($content)->toContain('$customer_discount_percentage');
});
it('includes collected invoice batch transfer metrics in queue results when available', function (): void {
@@ -0,0 +1,182 @@
<?php
/**
* Tests for the e-conomic customer-level discount being applied at the line level.
*
* Regression coverage for bug #11 — E-conomic 15% discount not applied on
* customer 35131752 ("kd"). The customer has a 15% global discount configured in
* e-conomic, but the invoice was being sent without any discount on the line items.
*
* The fix threads the customer discount percentage through the draft builder so it
* is applied at the line level via the `discountPercentage` field that e-conomic
* expects on each line.
*/
app_require('modules/economic/helpers/economic_invoice_draft.php');
use helpers\economic_invoice_draft;
if (!class_exists('EconomicInvoiceDraftCustomerDiscountProbe')) {
class EconomicInvoiceDraftCustomerDiscountProbe extends economic_invoice_draft
{
public array $sentBatches = [];
public function __construct()
{
$this->draft_invoice_number = 35131752;
$this->currency = 'DKK';
$this->conversion_rate = 1.0;
$this->draft_invoice_data = (object)['draftInvoiceNumber' => 35131752];
}
protected function sendDraftLines(array $draft_lines): object
{
$this->sentBatches[] = $draft_lines;
return (object)['lines' => $draft_lines];
}
}
}
function economic_customer_discount_order_item(float $price, float $product_price, array $overrides = []): array
{
return array_replace_recursive([
'id' => 9001,
'quantity' => 1,
'price' => $price,
'reference' => '',
'notes' => '',
'include_in_invoice' => true,
'product' => [
'economic_product_id' => '5',
'name' => 'Wash',
'price' => $product_price,
],
], $overrides);
}
it('applies the 15% customer discount to a line item for customer 35131752 "kd"', function (): void {
$draft = new EconomicInvoiceDraftCustomerDiscountProbe();
// Order item is at full price (no per-item discount) — exactly the customer 35131752
// case where the 15% global e-conomic discount was silently dropped.
$draft->addOrderItemLine(
economic_customer_discount_order_item(100.0, 100.0),
[
'economic_department_id' => 75,
'economic_dimension_id' => 1,
],
false,
false,
15
);
$draft->flushLinesInBatches();
$line = $draft->sentBatches[0][0];
expect($line['product']['productNumber'])->toBe('5')
->and($line['description'])->toBe('Wash')
->and($line['quantity'])->toBe(1.0)
->and($line['unitNetPrice'])->toBe(100.0)
->and($line['discountPercentage'])->toBe(15.0);
});
it('uses the larger discount when both per-item and customer discounts are present', function (): void {
$draft = new EconomicInvoiceDraftCustomerDiscountProbe();
// Per-item discount = 10%, customer discount = 15% → max(15, 10) = 15.
$draft->addOrderItemLine(
economic_customer_discount_order_item(90.0, 100.0),
[
'economic_department_id' => 75,
'economic_dimension_id' => 1,
],
false,
true,
15
);
$draft->flushLinesInBatches();
$line = $draft->sentBatches[0][0];
expect($line['unitNetPrice'])->toBe(100.0)
->and($line['discountPercentage'])->toBe(15.0);
});
it('uses the per-item discount when it is larger than the customer discount', function (): void {
$draft = new EconomicInvoiceDraftCustomerDiscountProbe();
// Per-item discount = 25%, customer discount = 15% → max(25, 15) = 25.
$draft->addOrderItemLine(
economic_customer_discount_order_item(75.0, 100.0),
[
'economic_department_id' => 75,
'economic_dimension_id' => 1,
],
false,
true,
15
);
$draft->flushLinesInBatches();
$line = $draft->sentBatches[0][0];
expect($line['unitNetPrice'])->toBe(100.0)
->and($line['discountPercentage'])->toBe(25.0);
});
it('clamps the customer discount percentage to the 0..100 range', function (): void {
$draft = new EconomicInvoiceDraftCustomerDiscountProbe();
$draft->addOrderItemLine(
economic_customer_discount_order_item(100.0, 100.0),
[
'economic_department_id' => 75,
'economic_dimension_id' => 1,
],
false,
false,
150
);
$draft->flushLinesInBatches();
$line = $draft->sentBatches[0][0];
expect($line['discountPercentage'])->toBe(100.0);
});
it('emits no line discount when both per-item and customer discounts are zero', function (): void {
$draft = new EconomicInvoiceDraftCustomerDiscountProbe();
$draft->addOrderItemLine(
economic_customer_discount_order_item(100.0, 100.0),
[
'economic_department_id' => 75,
'economic_dimension_id' => 1,
],
false,
false,
0
);
$draft->flushLinesInBatches();
$line = $draft->sentBatches[0][0];
expect($line['unitNetPrice'])->toBe(100.0)
->and($line['discountPercentage'])->toBe(0.0);
});
it('keeps base behavior unchanged when the customer discount is zero', function (): void {
$draft = new EconomicInvoiceDraftCustomerDiscountProbe();
// No customer discount, no per-item discount — unit price should be the final price.
$draft->addOrderItemLine(
economic_customer_discount_order_item(100.0, 100.0),
[
'economic_department_id' => 75,
'economic_dimension_id' => 1,
],
false,
false,
0
);
$draft->flushLinesInBatches();
$line = $draft->sentBatches[0][0];
expect($line['unitNetPrice'])->toBe(100.0)
->and($line['discountPercentage'])->toBe(0.0);
});
@@ -1,6 +1,6 @@
<?php
it('only adds TotDiscount aggregate line when itemized discounts are disabled', function (): void {
it('only adds TotDiscount aggregate line when itemized discounts are disabled and no customer discount is set', function (): void {
$content = file_get_contents(app_path('modules/economic/helpers/economic_invoice_draft.php'));
expect($content)->not->toBeFalse();
@@ -13,7 +13,11 @@ it('only adds TotDiscount aggregate line when itemized discounts are disabled',
expect($end)->toBeGreaterThan($start);
$block = substr($content, (int)$start, (int)$end - (int)$start);
// The aggregate TotDiscount line is only added when neither itemized discounts
// nor a customer-level e-conomic discount is in effect. The customer discount
// (e.g. bug #11 customer 35131752 "kd" 15%) is applied at the line level instead.
expect($block)
->toContain('if (!$use_itemized_discounts && $total_discount > 0)')
->toContain('if (!$use_itemized_discounts && $customer_discount_percentage === 0 && $total_discount > 0)')
->toContain('self::addProductDiscountLine($total_discount');
});
@@ -167,8 +167,7 @@ it('builds interactive message parts for order and wash certificate warnings', f
['type' => 'text', 'text' => ' is present without a wash certificate.'],
]);
expect($xlVaskFlag['message_parts'])->toBe([
['type' => 'text', 'text' => 'XL Vask wash '],
['type' => 'xlvask_usage_log', 'text' => 'wash-55'],
['type' => 'xlvask_usage_log', 'text' => 'XL Vask wash'],
['type' => 'text', 'text' => ' is neither ignored nor linked to an order in the selected period.'],
]);
});
@@ -1167,151 +1166,8 @@ it('limits historical primary product lookup to current period registrations', f
expect($content)->not->toBeFalse();
$content = (string)$content;
expect($content)->toContain('private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers, ?bool $requireReg2Empty = null): array');
expect($content)->toContain('getPrimaryProductHistory($dateFrom, array_column($primaryRows, \'reg_1\'))');
expect($content)->toContain('private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers): array');
expect($content)->toContain('AND o.reg_1 IN ({$registrationFilter})');
expect($content)->not->toContain('$byReg = []');
});
it('does not flag single-tractor orders against historical tractor-trailer products', function (): void {
global $db;
$hadDb = array_key_exists('db', $GLOBALS);
$previousDb = $GLOBALS['db'] ?? null;
$db = new class {
public function escape_string(string $value): string
{
return addslashes($value);
}
public function query(string $sql): object|false
{
if (str_contains($sql, 'SHOW COLUMNS FROM `customer_vehicles`')) {
return $this->result([]);
}
if (str_contains($sql, 'FROM customer_vehicles')) {
return $this->result([]);
}
// History lookup that matches the reg_2-empty filter (single-tractor history)
if (str_contains($sql, 'FROM orders o')
&& str_contains($sql, "COALESCE(o.reg_2, '') = ''")) {
return $this->result([
[
'reg' => 'EC21233',
'product_id' => 3,
'product_name' => 'Forvogn',
'usage_count' => 8,
],
]);
}
// History lookup that matches the reg_2-non-empty filter (tractor-trailer history)
if (str_contains($sql, 'FROM orders o')
&& str_contains($sql, "COALESCE(o.reg_2, '') <> ''")) {
return $this->result([
[
'reg' => 'EC21233',
'product_id' => 7,
'product_name' => 'Forvogn med hænger',
'usage_count' => 12,
],
]);
}
return false;
}
private function result(array $rows): object
{
return new class($rows) {
public int $num_rows;
public array $rows;
public function __construct(array $rows)
{
$this->rows = $rows;
$this->num_rows = count($rows);
}
public function fetch_assoc(): ?array
{
return array_shift($this->rows);
}
};
}
};
try {
$baseRow = [
'customer_number' => 35131752,
'customer_name' => 'Single Tractor Customer',
'order_id' => 7001,
'order_item_id' => 8001,
'invoice_collection_id' => 901,
'department_id' => 7,
'reg_1' => 'EC21233',
'reg_2' => '',
'is_wash' => 1,
'related_item_id' => 0,
'order_created_at' => '2026-08-01 08:05:21',
];
// Single-tractor order (reg_2 = '') whose primary product is just "Forvogn" should NOT be
// flagged, even though historical tractor-trailer orders (reg_2 non-empty) have used the
// "Forvogn med hænger" product for the same registration.
$singleTractorFlags = invoice_period_flag_service_invoke('detectVehicleTypeMismatches', [
[
$baseRow + [
'product_id' => 3,
'product_name' => 'Forvogn',
],
],
'2026-08-01 00:00:00',
]);
expect(array_column($singleTractorFlags, 'definition_key'))->not->toContain('historical_primary_product_mismatch');
// Tractor-trailer order (reg_2 non-empty) using a non-matching product SHOULD still be flagged.
$trailerFlags = invoice_period_flag_service_invoke('detectVehicleTypeMismatches', [
[
[
'product_id' => 5,
'product_name' => 'Kassevogn',
'reg_2' => 'AB12345',
] + $baseRow,
],
'2026-08-01 00:00:00',
]);
$mismatchFlags = array_values(array_filter(
$trailerFlags,
static fn(array $flag): bool => ($flag['definition_key'] ?? null) === 'historical_primary_product_mismatch'
));
expect(count($mismatchFlags))->toBe(1);
expect($mismatchFlags[0]['message_params']['expected_product'] ?? null)->toBe('Forvogn med hænger');
} finally {
if ($hadDb) {
$db = $previousDb;
} else {
unset($GLOBALS['db']);
}
}
});
it('partitions historical primary product lookup by current rows reg_2 presence', function (): void {
$content = file_get_contents(app_path('classes/invoice_period_flag_service.php'));
expect($content)->not->toBeFalse();
$content = (string)$content;
// The detect() function must call getPrimaryProductHistory twice: once with requireReg2Empty=true
// for rows whose reg_2 is empty, and once with requireReg2Empty=false for rows that do have a
// trailer. This prevents the historical_primary_product_mismatch flag from naming the
// tractor-trailer (Forvogn med hænger) product as the expected product when the current order
// has no trailer.
expect($content)->toContain("getPrimaryProductHistory(\n \$dateFrom,\n array_column(\$emptyReg2, 'reg_1'),\n true\n )");
expect($content)->toContain("getPrimaryProductHistory(\n \$dateFrom,\n array_column(\$hasReg2, 'reg_1'),\n false\n )");
expect($content)->toContain("\$reg2Filter = \"AND (COALESCE(o.reg_2, '') = '')\"");
expect($content)->toContain("\$reg2Filter = \"AND (COALESCE(o.reg_2, '') <> '')\"");
});
@@ -289,9 +289,7 @@ it('slices only the active period view and keeps exact full-result type counts',
'po' => 'PO-BETA',
'reg_1' => 'BB22222',
]);
expect($result['period']['types']['fixed_pricing'])->toBe([
['customer_number' => 1002, 'membership_only' => true],
]);
expect($result['period']['types']['fixed_pricing'])->toBe([]);
expect($result['period']['type_counts']['all'])->toBe([
'requires_action' => 1,
'draft' => 1,
@@ -689,219 +687,3 @@ it('blocks review from aggregate manual counts when restricted flag details are
'next_action' => 'resolve_manual_flags',
]);
});
it('surfaces lightweight customer memberships on every non-active period view bucket', function (): void {
$period = [
'dateFrom' => '2026-04-01 00:00:00',
'dateTo' => '2026-04-30 23:59:59',
'types' => [
'all' => [
invoicing_period_customer_card(2001, 'Alpha Logistics', [
invoicing_period_transaction(['id' => 21, 'customer_number' => 2001, 'amount' => 100]),
]),
invoicing_period_customer_card(2002, 'Beta Logistics', [
invoicing_period_transaction(['id' => 22, 'customer_number' => 2002, 'amount' => 200]),
]),
invoicing_period_customer_card(2003, 'Gamma Logistics', [
invoicing_period_transaction(['id' => 23, 'customer_number' => 2003, 'amount' => 300]),
]),
],
'fixed_pricing' => [
invoicing_period_customer_card(2001, 'Alpha Logistics', [], false, [
'meta' => ['fixed_pricing' => ['price' => 600]],
]),
],
'invoice_per_order' => [
invoicing_period_customer_card(2002, 'Beta Logistics', [], true),
invoicing_period_customer_card(2002, 'Beta Logistics', [], true),
],
'tank_cleaning' => [
invoicing_period_customer_card(2003, 'Gamma Logistics', [], false),
],
'vehicle_subscriptions' => [],
'special_arrangements' => [],
'possible_duplicates' => [],
'self_wash' => [],
],
];
$result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
'periodView' => 'all',
'page' => 1,
'limit' => 25,
'includeRequiresAction' => true,
'includeBooked' => true,
]]);
// Active bucket still carries full customer cards.
expect($result['period']['types']['all'])->toHaveCount(3);
expect($result['period']['types']['all'][0])->toHaveKey('transactions');
expect($result['period']['types']['all'][0])->toHaveKey('customer_name');
// Non-active buckets expose only {customer_number, membership_only: true} entries.
expect($result['period']['types']['fixed_pricing'])->toBe([
['customer_number' => 2001, 'membership_only' => true],
]);
// Membership entries must de-duplicate by customer_number even when the
// source bucket contains the customer twice.
expect($result['period']['types']['invoice_per_order'])->toBe([
['customer_number' => 2002, 'membership_only' => true],
]);
expect($result['period']['types']['tank_cleaning'])->toBe([
['customer_number' => 2003, 'membership_only' => true],
]);
// Buckets with no matching customers stay as empty arrays.
expect($result['period']['types']['vehicle_subscriptions'])->toBe([]);
expect($result['period']['types']['special_arrangements'])->toBe([]);
expect($result['period']['types']['possible_duplicates'])->toBe([]);
expect($result['period']['types']['self_wash'])->toBe([]);
// Counts and totals remain authoritative and unaffected by pagination.
expect($result['period']['type_counts']['all']['total'])->toBe(3);
// summarizePeriodType counts raw array entries; the duplicate 2002 entry
// in invoice_per_order is therefore reflected in type_counts but our
// membership projector de-duplicates it (asserted above).
expect($result['period']['type_counts']['invoice_per_order']['total'])->toBe(2);
expect($result['period']['type_totals']['fixed_pricing']['total'])->toBe(600.0);
});
it('respects the search filter when emitting customer memberships on non-active buckets', function (): void {
$period = [
'dateFrom' => '2026-04-01 00:00:00',
'dateTo' => '2026-04-30 23:59:59',
'types' => [
'all' => [
invoicing_period_customer_card(3001, 'Alpha', [
invoicing_period_transaction(['id' => 31, 'customer_number' => 3001, 'amount' => 10]),
]),
invoicing_period_customer_card(3002, 'Beta', [
invoicing_period_transaction(['id' => 32, 'customer_number' => 3002, 'amount' => 20]),
]),
invoicing_period_customer_card(3003, 'Gamma', [
invoicing_period_transaction(['id' => 33, 'customer_number' => 3003, 'amount' => 30]),
]),
],
'invoice_per_order' => [
invoicing_period_customer_card(3001, 'Alpha', [], true),
invoicing_period_customer_card(3003, 'Gamma', [], true),
],
'fixed_pricing' => [
invoicing_period_customer_card(3002, 'Beta', [], false),
invoicing_period_customer_card(3003, 'Gamma', [], false),
],
],
];
$result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
'periodView' => 'all',
'page' => 1,
'limit' => 25,
'search' => 'Beta',
'includeRequiresAction' => true,
'includeBooked' => true,
]]);
expect(array_column($result['period']['types']['all'], 'customer_number'))->toBe([3002]);
expect($result['period']['types']['invoice_per_order'])->toBe([]);
expect($result['period']['types']['fixed_pricing'])->toBe([
['customer_number' => 3002, 'membership_only' => true],
]);
});
it('respects the flag-tab filter when emitting customer memberships on non-active buckets', function (): void {
$period = [
'dateFrom' => '2026-04-01 00:00:00',
'dateTo' => '2026-04-30 23:59:59',
'types' => [
'all' => [
invoicing_period_customer_card(4001, 'Red Flag Customer', [
invoicing_period_transaction(['id' => 41, 'customer_number' => 4001, 'amount' => 50]),
], false, [
'flags' => [
[
'source' => 'manual',
'status' => 'active',
'target_type' => 'customer',
],
],
]),
invoicing_period_customer_card(4002, 'Clean Customer', [
invoicing_period_transaction(['id' => 42, 'customer_number' => 4002, 'amount' => 60]),
], false),
],
'invoice_per_order' => [
invoicing_period_customer_card(4001, 'Red Flag Customer', [], true, [
'flags' => [
[
'source' => 'manual',
'status' => 'active',
'target_type' => 'customer',
],
],
]),
invoicing_period_customer_card(4002, 'Clean Customer', [], true),
],
'fixed_pricing' => [
invoicing_period_customer_card(4001, 'Red Flag Customer', [], true, [
'flags' => [
[
'source' => 'manual',
'status' => 'active',
'target_type' => 'customer',
],
],
]),
invoicing_period_customer_card(4002, 'Clean Customer', [], false),
],
],
];
$result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
'periodView' => 'all',
'page' => 1,
'limit' => 25,
'flagTab' => 'red',
'includeRequiresAction' => true,
'includeBooked' => true,
]]);
expect(array_column($result['period']['types']['all'], 'customer_number'))->toBe([4001]);
expect($result['period']['types']['invoice_per_order'])->toBe([
['customer_number' => 4001, 'membership_only' => true],
]);
expect($result['period']['types']['fixed_pricing'])->toBe([
['customer_number' => 4001, 'membership_only' => true],
]);
});
it('emits lightweight memberships on non-active buckets when the active bucket is a single customer', function (): void {
$period = [
'dateFrom' => '2026-04-01 00:00:00',
'dateTo' => '2026-04-30 23:59:59',
'types' => [
'all' => [
invoicing_period_customer_card(5001, 'Solo Customer', [
invoicing_period_transaction(['id' => 51, 'customer_number' => 5001, 'amount' => 80]),
]),
],
'invoice_per_order' => [
invoicing_period_customer_card(5001, 'Solo Customer', [], true),
],
],
];
$result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
'periodView' => 'invoice_per_order',
'page' => 1,
'limit' => 25,
'includeRequiresAction' => true,
'includeBooked' => true,
]]);
expect($result['period']['types']['invoice_per_order'])->toHaveCount(1);
expect($result['period']['types']['invoice_per_order'][0])->toHaveKey('transactions');
expect($result['period']['types']['all'])->toBe([
['customer_number' => 5001, 'membership_only' => true],
]);
});
@@ -1,104 +0,0 @@
<?php
declare(strict_types=1);
// Locate the worktree's `orders_o.php`. The default `app_path()` helper resolves
// symlinks and points at the primary checkout's source, which we are forbidden to
// mutate. We prefer (in order):
// 1. The TRUCKWASH_WORKTREE_ROOT env var when set and valid
// 2. Walking up from this test file's directory to a project root that owns
// the `services/nginx/app/objects/orders_o.php` file
// 3. Falling back to the primary checkout's path (only when neither of the
// above resolves; this is the production CI path)
$ordersObjectFileResolver = static function (): string {
$candidate = getenv('TRUCKWASH_WORKTREE_ROOT');
if (is_string($candidate) && $candidate !== '' && is_dir($candidate)) {
$path = $candidate . '/services/nginx/app/objects/orders_o.php';
if (is_file($path)) {
return $path;
}
}
// Walk up from this test file looking for the orders_o.php in the same
// services/nginx/app tree. The test lives in
// services/nginx/app/tests/Unit/Orders/, so the target lives 4 levels up
// from this file's directory. We still walk defensively so the test works
// even if the test is moved into a deeper or shallower location.
$directory = __DIR__;
for ($i = 0; $i < 10; $i++) {
$candidatePath = $directory . '/objects/orders_o.php';
if (is_file($candidatePath)) {
return $candidatePath;
}
$parent = dirname($directory);
if ($parent === $directory) {
break;
}
$directory = $parent;
}
$existing = app_path('objects/orders_o.php');
if (is_file($existing)) {
return $existing;
}
throw new RuntimeException('Unable to locate orders_o.php for the addon ordering test.');
};
it('orders the SELECT in getOrderItems so primary items precede their addons', function () use ($ordersObjectFileResolver): void {
$ordersObjectFile = $ordersObjectFileResolver();
$content = file_get_contents($ordersObjectFile);
expect($content)->not->toBeFalse();
// Locate the getOrderItems method body.
$start = strpos($content, 'public function getOrderItems(int $order_id): array');
expect($start)->not->toBeFalse();
// Bound the search so we don't accidentally match unrelated SQL further down
// the file (the helper around line 645 in orders_o.php also uses ORDER BY id DESC).
$end = strpos($content, "\n }\n", $start);
expect($end)->not->toBeFalse();
$methodBody = substr($content, (int)$start, (int)$end - (int)$start);
// The SELECT against order_items must include an explicit ORDER BY so MySQL
// does not return rows in undefined order (which has been observed to put
// primary order items after their addons, breaking the FE tree-builder and
// the invoice line listing).
expect($methodBody)
->toContain("FROM order_items WHERE order_id = \$order_id")
->and($methodBody)
->toContain('ORDER BY');
// The ORDER BY must place primary items first (related_item_id IS NULL DESC),
// group addons by their parent (related_item_id ASC), and fall back to
// insertion order (id ASC).
expect($methodBody)
->toContain('(related_item_id IS NULL) DESC')
->and($methodBody)
->toContain('related_item_id ASC')
->and($methodBody)
->toContain('id ASC');
});
it('does not leave the legacy unordered SELECT in getOrderItems', function () use ($ordersObjectFileResolver): void {
$ordersObjectFile = $ordersObjectFileResolver();
$content = file_get_contents($ordersObjectFile);
expect($content)->not->toBeFalse();
$start = strpos($content, 'public function getOrderItems(int $order_id): array');
$end = strpos($content, "\n }\n", $start);
expect($start)->not->toBeFalse()
->and($end)->not->toBeFalse();
$methodBody = substr($content, (int)$start, (int)$end - (int)$start);
// The buggy SQL must be gone: previously this returned rows in whatever
// order MySQL felt like, leading to addons being listed before their primary
// and the operator seeing "only Trailer and Dolly" attached to a Trækker order.
expect($methodBody)
->not->toContain("FROM order_items WHERE order_id = \$order_id\";\n \$result");
});
@@ -1,17 +0,0 @@
<?php
it('logs the newly created order id (not the department id) when POST /orders succeeds', function (): void {
$routeFile = app_path('routes/ordersRoute.php');
$content = file_get_contents($routeFile);
expect($content)->not->toBeFalse();
// The POST /orders handler must log the id of the order that was just
// persisted by addArray(), not the department id from the request body.
// Without this, the audit trail for the "check-in creates 0-orders" bug
// is useless — every successful create logs the wrong identifier.
expect($content)->toContain("'Successfully added an order (ID: ' . (int)\$order->id . ')'");
// Guard against the previous copy/paste regression reappearing.
expect($content)->not->toContain("'Successfully added an order (ID: ' . \$data['department_id'] . ')'");
});
@@ -126,26 +126,3 @@ it('returns early when registration number is blank', function (): void {
}
}
});
it('orders registration-matched orders by id ASC so invoice-collection reassignment is deterministic', function (): void {
$dbStub = new OrdersRegistrationDateRangeDbStub();
$hadDb = array_key_exists('db', $GLOBALS);
$previousDb = $hadDb ? $GLOBALS['db'] : null;
$GLOBALS['db'] = $dbStub;
try {
(new orders_o())->getOrdersWithRegistrationNumberInDateRange(
'EC21235',
'2025-03-01 00:00:00',
'2025-04-30 23:59:59'
);
expect($dbStub->lastQuery)->toContain('ORDER BY id ASC');
} finally {
if ($hadDb) {
$GLOBALS['db'] = $previousDb;
} else {
unset($GLOBALS['db']);
}
}
});
@@ -1,79 +0,0 @@
<?php
declare(strict_types=1);
use traits\boolean_normalization_t;
use traits\module_config_variable;
it('treats true and integer 1 as truthy, everything else as false', function (): void {
$subject = new class {
use boolean_normalization_t;
};
expect($subject::normalizeBoolean(true))->toBeTrue();
expect($subject::normalizeBoolean(1))->toBeTrue();
});
it('accepts the canonical truthy-string set with whitespace and case folded', function (): void {
$subject = new class {
use boolean_normalization_t;
};
foreach (['true', 'TRUE', 'True', '1', 'yes', 'YES', 'Yes', 'on', 'ON', 'On'] as $case) {
expect($subject::normalizeBoolean($case))->toBeTrue();
}
expect($subject::normalizeBoolean(' true '))->toBeTrue();
expect($subject::normalizeBoolean(" YES\t"))->toBeTrue();
});
it('treats integer 0 and the canonical falsy strings as false', function (): void {
$subject = new class {
use boolean_normalization_t;
};
expect($subject::normalizeBoolean(false))->toBeFalse();
expect($subject::normalizeBoolean(0))->toBeFalse();
expect($subject::normalizeBoolean(''))->toBeFalse();
expect($subject::normalizeBoolean('0'))->toBeFalse();
expect($subject::normalizeBoolean('false'))->toBeFalse();
expect($subject::normalizeBoolean('no'))->toBeFalse();
expect($subject::normalizeBoolean('off'))->toBeFalse();
});
it('treats null, arrays and objects as false', function (): void {
$subject = new class {
use boolean_normalization_t;
};
expect($subject::normalizeBoolean(null))->toBeFalse();
expect($subject::normalizeBoolean([]))->toBeFalse();
expect($subject::normalizeBoolean(['true']))->toBeFalse();
expect($subject::normalizeBoolean((object)['value' => 'true']))->toBeFalse();
});
it('keeps module_config_variable::inputToBool returning true for the existing truthy strings', function (): void {
app_require('traits/module_config_variable_t.php');
$subject = new class {
use module_config_variable;
};
expect($subject::inputToBool('true'))->toBeTrue();
expect($subject::inputToBool('1'))->toBeTrue();
expect($subject::inputToBool('false'))->toBeFalse();
});
it('now also accepts the wider truthy-string set through inputToBool (parity with the inline copies)', function (): void {
app_require('traits/module_config_variable_t.php');
$subject = new class {
use module_config_variable;
};
// These were accepted by the inline in_array(...) copies but rejected
// by the previous inputToBool implementation. They are now consistent.
expect($subject::inputToBool('yes'))->toBeTrue();
expect($subject::inputToBool('on'))->toBeTrue();
expect($subject::inputToBool(' YES '))->toBeTrue();
});
@@ -0,0 +1,65 @@
<?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');
});
@@ -0,0 +1,805 @@
<?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('limits the usage-logs endpoint to the reviewer permission set', function (): void {
it('does not execute XL Vask usage automation while listing usage order rows', function (): void {
$route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
expect($route)->not->toBeFalse();
@@ -23,21 +23,9 @@ it('limits the usage-logs endpoint to the reviewer permission set', function ():
$route = (string)$route;
expect($route)
->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');
->toContain('$automation = $automation_service->readAutomationStateByUsageLogId($id, $log);')
->and($route)->not->toContain('$automation = $automation_service->evaluateUsageLogRow($log, (int)$user->id, true);')
->and($route)->toContain("requirePermission('manage_xlvask_usage_automation')");
expect($route)
->toContain("if (\$allowedHallIds === [])")
->toContain("No XL Vask hall scope is available', 403")
@@ -53,21 +41,27 @@ 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 ignore metadata out of the strict legacy XL Vask helper payload', function (): void {
it('keeps automation metadata out of the strict legacy XL Vask helper payload', function (): void {
$route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
expect($route)->not->toBeFalse();
$route = (string)$route;
$payloadStart = strpos((string)$route, '$usage_log_payload = array_intersect_key(');
$payloadEnd = strpos((string)$route, '// Create a new xlvask usage log object', $payloadStart ?: 0);
expect($route)
expect($payloadStart)->not->toBeFalse()
->and($payloadEnd)->not->toBeFalse();
$payloadDefinition = substr((string)$route, (int)$payloadStart, (int)$payloadEnd - (int)$payloadStart);
expect($payloadDefinition)
->not->toContain("'source_hash'")
->not->toContain("'source_revision'")
->not->toContain("'import_state'")
@@ -78,26 +72,26 @@ it('keeps ignore metadata out of the strict legacy XL Vask helper payload', func
->not->toContain("'last_run_id'");
});
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();
$route = (string)$route;
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');
expect($route)
->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');
->not->toBeFalse()
->and($automation)->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')");
});
it('exposes a reviewer summary endpoint that does not invoke the autopilot service', function (): void {
it('exposes additive XL Vask autopilot run and summary routes', function (): void {
$route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
expect($route)->not->toBeFalse();
@@ -106,49 +100,140 @@ it('exposes a reviewer summary endpoint that does not invoke the autopilot servi
expect($route)
->toContain("get('/modules/xlvask/services/usage/orders/summary'")
->toContain('(new xlvask_usage_logs_o())->summarizeUsageOrdersReadOnly(')
->not->toContain('xlvask_autopilot_service()->getSummary(');
->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);');
});
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();
$route = (string)$route;
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)
->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'");
->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 = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
expect($route)->not->toBeFalse();
$route = (string)$route;
$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 {
$route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
expect($route)->not->toBeFalse();
$route = (string)$route;
expect($route)
->toContain("'source_hash' => \$log['source_hash'] ?? null")
->toContain("'source_revision' => \$log['source_revision'] ?? null")
->toContain("'import_state' => \$log['import_state'] ?? 'unchanged'")
->toContain("'resolution_state' => \$log['resolution_state'] ?? 'needs_review'")
->toContain("'certainty' => \$log['certainty'] ?? 'none'")
->toContain("'planned_action' => \$log['planned_action'] ?? 'none'")
->toContain("'expected_version' => isset(\$log['expected_version']) ? (int)\$log['expected_version'] : 1")
->toContain("'automation' => \$automation");
});
it('documents XL Vask autopilot summary and run APIs in OpenAPI', function (): void {
$openApi = file_get_contents(WD . '/openapi.yaml');
expect($openApi)->not->toBeFalse();
$openApi = (string)$openApi;
expect($openApi)
->toContain('/modules/xlvask/services/usage/orders/summary:')
->toContain('operationId: summarizeXlvaskUsageAutomation')
->toContain('/modules/xlvask/services/usage/autopilot-runs:')
->toContain('operationId: createXlvaskUsageAutopilotRun')
->toContain('/modules/xlvask/services/usage/autopilot-runs/{id}:')
->toContain('operationId: getXlvaskUsageAutopilotRun')
->toContain('/modules/xlvask/services/usage/automation/decisions/preview:')
->toContain('operationId: previewXlvaskUsageAutomationDecision')
->toContain('/modules/xlvask/services/usage/automation/decisions/apply:')
->toContain('operationId: applyXlvaskUsageAutomationDecision')
->toContain('/modules/xlvask/services/usage/automation/admin/readiness:')
->toContain('operationId: adjudicateXlvaskCalibrationLabel')
->toContain('operationId: generateXlvaskCalibrationArtifact')
->toContain('operationId: activateXlvaskCalibrationArtifact')
->toContain('operationId: activateXlvaskWashIdUniqueness');
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 {
$route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
$autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php');
expect($route)
->not->toBeFalse()
->and($autopilot)->not->toBeFalse();
$route = (string)$route;
$autopilot = (string)$autopilot;
expect($route)
->toContain("post('/modules/xlvask/services/usage/automation/decisions/preview'")
->toContain("post('/modules/xlvask/services/usage/automation/decisions/apply'")
->toContain('createDecisionPreview(')
->toContain('applyDecision(')
->and($autopilot)->toContain("SELECT * FROM xlvask_automation_decision_previews WHERE id = '")
->toContain('FOR UPDATE')
->toContain('applyBoundDecisionWithinTransaction(')
->toContain('expected_version')
->toContain('source_hash');
});
it('does not let pending automation schema block ordinary invoice period operations', function (): void {
$bootstrap = (string)file_get_contents(WD . '/classes/invoice_period_flag_schema_bootstrap.php');
@@ -156,75 +241,3 @@ it('does not let pending automation schema block ordinary invoice period operati
->not->toContain('xlvask_usage_logs_schema_bootstrap::ensureTables()')
->toContain('XL Vask automation migration is pending');
});
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)
->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('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();
});
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)
->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');
});
@@ -1,38 +0,0 @@
<?php
namespace traits;
/**
* Canonical boolean normalisation used by configuration and helper code.
*
* The same `in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true)`
* pattern was inlined in six places across classes/cron_worker.php,
* classes/replica_failover_manager.php, classes/release_manager.php,
* classes/superuser_system_status_service.php, classes/module_usage_service.php,
* and traits/module_config_variable_t.php::inputToBool(). Centralising it
* here means the truthy-string set lives in exactly one location.
*/
trait boolean_normalization_t
{
/**
* Coerce $value to a real bool. Truthy inputs: true, 1, '1', 'true',
* 'yes', 'on' (case-insensitive, surrounding whitespace ignored).
* Everything else is false (including null, false, 0, '', '0', 'false',
* 'no', 'off', arrays, objects).
*
* @param mixed $value
*/
public static function normalizeBoolean(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_int($value) || is_float($value)) {
return $value !== 0;
}
if (is_string($value)) {
return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true);
}
return false;
}
}
@@ -5,8 +5,6 @@ namespace traits;
use classes\system_search_cache;
use Exception;
require_once __DIR__ . '/boolean_normalization_t.php';
trait module_config_variable
{
public string $module_name; // The name of the module
@@ -235,10 +233,12 @@ trait module_config_variable
*/
static function inputToBool(string $value): bool
{
// Delegate to the shared boolean_normalization_t helper so the
// truthy-string set ('1', 'true', 'yes', 'on') lives in exactly one
// place across the codebase.
return boolean_normalization_t::normalizeBoolean($value);
// If the value is true, 1, or "true", return true
if ($value === 'true' || $value === '1') {
return true;
}
// If the value is false, 0, or "false", return false
return false;
}
/**