Compare commits

...
Author SHA1 Message Date
bugfix-subagent c9c08dea56 test(api): wire up real mysqli in SchemaHealthCheckTest (TRU-77 unit CI)
The Unit suite starts with a freshly-dropped test database and does
not create a $db global, so:

- customer_invoice_email_schema_bootstrap::ensureSchema() short-
  circuited and never created the users table.
- runSchemaCheck had nothing to inspect and reported ok=false.

Wire up a real mysqli connection at file load time using the same
CONFIG_DB_* env vars the rest of the CI suite exports, then have
beforeEach run the bootstrap and create the bare-minimum invoices /
bookings tables that runSchemaCheck verifies. This keeps the test
self-contained inside the Unit suite without changing the
production code or the production schema.

Refs: api#383, TRU-77
2026-08-16 19:01:46 +00:00
Jeppe B 9e18f8988b feat(api): schema health check + pre-deploy migration runner
Closes the 'Unknown column invoice_email in SELECT' production failure
mode (TRU-77) by:

- New GET /admin/schema-check endpoint — returns 503 with explicit
  list of missing columns if any are absent (instead of a generic 500)
- scripts/run-schema-bootstraps.php — auto-discovers and runs every
  *_schema_bootstrap class on the live database (additive, idempotent)
- scripts/schema-health-check.php — CLI tool for the same check, used
  by deploy pipelines
- New Pest contract test SchemaHealthCheckTest — verifies the test DB
  has every required users column and the schema-check endpoint works
- deploy.yml: pre-deploy step runs the bootstrap runner, smoke test
  also runs the schema check, Slack alert on failure
2026-08-16 18:48:38 +00:00
Jeppe B 34df80530c feat(api): product merging infrastructure for SF (TRU-94) (#379)
Auto-merged by cron with review-gate (trivial change, no critical path).
2026-08-16 20:45:10 +02:00
3d0a8eeae7 feat(api): add optional invoice_email field for customers (TRU-77) (#381)
## Summary

Adds an optional `invoice_email` (Danish: *faktura email*) field to
customers, so e-conomic can deliver invoices to a dedicated accounting
mailbox instead of the customer's primary email.

Linear: **TRU-77** (DRIFT 16)

## Changes

- **Migration** (additive, via existing schema_bootstrap pattern)
- New `customer_invoice_email_schema_bootstrap` adds the `invoice_email
VARCHAR(255) NULL` column to `users` after `wash_certificate_email`.
Idempotent — skips when the column already exists.

- **Domain object — `objects/users_o.php`**
  - New `invoice_email` object property.
- `getInvoiceEmail()` returns the dedicated address or falls back to the
primary `email`.
- `getInvoiceEmailOverride()` returns only the explicit override (no
fallback).
- `setInvoiceEmail($email)` validates and writes the value; `null`/empty
clears it.
- `add($customer_number, $password, $role, ?$invoice_email = null)` now
accepts the optional field and persists it.
- The user payload output now exposes `invoice_email` and
`invoice_email_fallback`.

- **API — `routes/usersRoute.php`**
- `POST /users` accepts an optional `invoice_email`, validated before
insert.
- `PUT /users` accepts `invoice_email` (including null/empty to clear)
on existing users.

- **Customer mass import — `classes/customer_mass_import_service.php`**
  - Payload now accepts `invoice_email`.
- `normalizeInvoiceEmail()` rejects malformed addresses before any
e-conomic call.
- `resolveInvoiceEmail()` / `resolveCreateEmail()` route the e-conomic
customer email to the dedicated address when set, otherwise the primary
`email` (with the existing `jb@truckwash.dk` fallback when neither is
provided).
  - `syncLocalCustomer()` persists `invoice_email` on the local user.
  - `import()` result now includes the resolved `invoice_email`.

- **Tests — `tests/Unit/Customers/CustomerInvoiceEmailTest.php` (new)**
  - Schema bootstrap adds the column when missing.
  - Schema bootstrap is a no-op when the column already exists.
  - Schema bootstrap skips when the `users` table is not present.
  - e-conomic customer email is set to `invoice_email` when provided.
- e-conomic customer email falls back to `email` when `invoice_email` is
omitted.
  - Invalid `invoice_email` is rejected before any e-conomic call.

## Backwards compatibility

- The column is nullable; existing rows are unaffected.
- The `add()` signature is additive (new optional parameter with default
`null`).
- The route payloads ignore `invoice_email` unless supplied, so no
client change is required.

## Linear

- TRU-77 (DRIFT 16: "Add 'faktura email' field to customer creation
form")

---------

Co-authored-by: Jeppe B <jeppe@copenhagentruckwash.io>
Co-authored-by: OpenClaw Bugfix Bot <openclaw-bot@truckwash.dk>
Co-authored-by: bugfix sub-agent <bugfix@openclaw.local>
2026-08-16 18:58:05 +02:00
Jeppe BOpenClaw Backend AgentJeppe Bjeppemaxclaw[bot] <bot@jeppemaxclaw.local>Bugfix Subagent
60222a7d91 fix(api): clarify user-invoice PUT validation so customers can invoice (TRU-128) (#382)
## Summary

Fixes **TRU-128** ("Jeg kan ikke fakturere") — a customer in
#afdelingsansvarlige could not invoice because the customer-facing
invoice PUT endpoint returned a misleading 400 error.

## Root cause

`PUT /collected-invoices` in
`services/nginx/app/routes/userInvoicesRoute.php` had two related bugs:

1. **Misleading error message** — the 'both fields missing' guard
errored with
   `'Missing required parameters: po_number, closed_at'`, which reads as
   if BOTH fields are required. The actual condition (`&&`) only fires
   when neither is set, so only one is required. Customers who tried
   different combinations kept getting the same error and concluded the
   system was broken.

2. **Inconsistent `closed_at` clearing** — the 'forbidden closed_at for
   non-superusers' guard fired for ANY present `closed_at` key,
   including `null` and `""`. That blocked customers from CLEARING a
   previously-set `closed_at`, even though the handler further down
   already nulls the field when it receives an empty value.

## Fix

- Reword the missing-fields error to state the actual contract:
  *"At least one of po_number or closed_at must be provided"*.
- Narrow the forbidden guard to *non-empty* `closed_at`, so customers
  can still pass `null` / `""` to clear a previously-set value.
  The clear-on-null/empty logic further down in the handler is unchanged
  — the guard now matches it.

## Test

`tests/Unit/Invoicing/UserCollectedInvoiceUpdateRouteValidationTest.php`
- Locks in the new error message.
- Locks in the new `$closed_at_is_non_empty` guard shape with the
  `if (self::isParametersSet(['closed_at'])) { ... }` pre-check.
- Locks in the regression: the previous 'any present closed_at -> 403'
  pattern is explicitly asserted to be absent.

## Files changed

- `services/nginx/app/routes/userInvoicesRoute.php`
-
`services/nginx/app/tests/Unit/Invoicing/UserCollectedInvoiceUpdateRouteValidationTest.php`

## Refs

- TRU-128
- Slack: #afdelingsansvarlige (kunde-rapport)

---------

Co-authored-by: OpenClaw Backend Agent <agent@openclaw.ai>
Co-authored-by: Jeppe B <jeppe@copenhagentruckwash.io>
Co-authored-by: jeppemaxclaw[bot] <bot@jeppemaxclaw.local>
Co-authored-by: Bugfix Subagent <bugfix-subagent@openclaw.local>
2026-08-16 18:20:03 +02:00
78b11d0b79 fix(api): route invoices to correct Economic account per-customer (TRU-18) (#380)
## Summary

Fixes **TRU-18 / AUT-14** — `truckwash.io` invoices were being routed to
the wrong Economic (EC) account for some users.

## Root cause

`getUserByCustomerNumber()` in `services/nginx/app/objects/users_o.php`
trusted the **inverse Redis cache** (`customer_number → user_id`)
without verifying that the user it loaded actually owned the requested
EC customer_number in the local DB.

When that cache went stale — e.g. after a `customer_number` re-mapping
on a code path that did not clear the inverse-cache entry —
`getUserByCustomerNumber()` would silently return a **different user**
whose current `customer_number` no longer matched the one the caller
asked for. Downstream invoice export code (`getCustomerEcocomicData()` →
`$customer_economic->customer_number` →
`economic_invoice_draft->setCustomerNumber(...)`) then used that wrong
user's current EC customer_number, and the draft invoice was created
against the **wrong Economic account**.

Because this only manifests when the inverse cache is stale, it surfaces
as "some users" — exactly the symptom reported.

## Fix

Minimal change in `getUserByCustomerNumber()`:

1. After the Redis fast-path loads a user, read the actual
`customer_number` from the DB via `getObjectProperties()`.
2. **Verify** that it equals the requested `$customer_number`.
3. If not, the inverse cache is stale: clear it
(`clear_user_id_from_customer_number`) and re-fetch via the recursive
call, which now falls through to the authoritative `SELECT id FROM users
WHERE customer_number = ?` DB query.

The DB path was always correct (it filters by exact `customer_number`);
the bug was exclusively in the unchecked Redis fast-path.

## Regression test

`tests/Unit/Users/GetUserByCustomerNumberStaleCacheTest.php` — wiring
tests that assert the verification + cache-clear + recursive re-fetch
are present, plus that the DB lookup path is the source of truth.
Prevents the regression from reappearing silently.

## Test run

PHP is unavailable in the sandbox, so the new test has not been executed
locally. It is a pure wiring test (string assertions on the source file)
and will be verified by CI on PR open.

## Out of scope

- No change to `openclaw.json`, deployment config, or any other config
files.
- Auto-merge is intentionally **not** enabled — leaving that to the
existing auto-merge cron.
- Existing tests untouched.

## Linear

- TRU-18 will be moved to "In Review" with the PR URL in a follow-up
comment.

🤖 Generated with [MaxClaw](https://maxclaw.ai)

---------

Co-authored-by: TRU-18 backend bot <bot@truckwash.dev>
Co-authored-by: Jeppe B <jeppe@copenhagentruckwash.io>
2026-08-16 16:04:03 +02:00
Jeppe B 55ddabb0ee test(api): lock program-registry contract for TRU-19 (#377)
The api does NOT expose a /programs endpoint by design — program names ("FF Uvs", "10min", "SF", etc.) live on the wash bay hardware itself, not in the api.

This test locks that architecture so any future /programs endpoint must be explicitly added and documented, and so the machine-types endpoint remains reachable as the api-side closest equivalent.

Three assertions:
1. No /programs endpoint exists in any route file (or per-module route file)
2. /department/selfserve/machine-types is wired with the list permission and returns the success() envelope
3. /modules/self-serve/lane/relay/machine_program_picker/{status,set,enable} endpoints exist

Refs TRU-19
2026-08-16 13:00:21 +02:00
16048e2ce3 chore(release): merge develop into master — XL Vask flag text fix + edge-broker health (Aug 15 2026) (#376)
Brings all of the develop branch's commits into master.

## What this contains

The 2 commits on develop that landed during the XL Vask integration
dispatch:

- **PR #373** (TRU-6 / AUT-2) — feat(edge-broker): expose lastActivityAt
on /api/health (AUT-2/TRU-6)
- **PR #375** (TRU-49 / AUT-49) — fix(api): include wash_id in
xlvask_missing_order_link flag text (AUT-49/TRU-49)

## Why

The XL Vask integration dispatch via the OpenSymphony orchestrator
(MiniMax M3) produced 2 api-side fixes:
- **PR #373** — adds `lastActivityAt` to the api health endpoint so
operators can see if the edge-broker has processed any requests
recently.
- **PR #375** — the actual root-cause fix for the user-reported symptom
"XL Vask-registreringen er hverken ignoreret eller knyttet til en ordre
i den valgte periode doesn't show the wash". The bug was in
`messageParts()` for the `xlvask_missing_order_link` arm — the link text
was hard-coded to 'XL Vask wash' instead of using the actual wash_id.
This PR makes the link identify the wash it points to.

## Verification

Both source PRs passed:
- Required CI (PHP unit, PHP integration, PHP api, PHP legacy, edge
broker, edge agent, edge gateway backend)
- The api ruleset allows squash merges

## Notes

- The pleno-vue repo has its own equivalent develop→master PR (#312)
with the 9 UI fixes (component, i18n, and a Playwright E2E).

---------

Co-authored-by: Jeppe <jeppe@copenhagentruckwash.io>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-16 09:08:03 +02:00
Jeppe Bandopenhands cdf8541e78 fix(api): exclude spot-free-lastbil from audited add-on reason policy (#372)
## Problem

The mobile POS step 2 \"enter note\" dialog was triggering for product
24
(\"Højtryk - ekstra tid\" / spotfree-lastbil) because product 24 was
listed
in `AFFECTED_PRODUCT_IDS` in
`services/nginx/app/classes/order_item_reason_policy.php`.

Product 24 is the \"spot-free-lastbil\" package, not an audited
extra-time
add-on — the server-side reason policy should only enforce the comment
requirement on {21, 22, 25, 26, 27}, matching the frontend
`AUDITED_ORDER_ITEM_PRODUCT_IDS` set.

Companion to the frontend PR copenhagentruckwash/pleno-vue#301.

## Fix

Drop product 24 from `AFFECTED_PRODUCT_IDS`.

## Verification

Tracked under workboard-94209138-31f6-422e-ac8c-181ad391b8a7.

🤖 This PR was created by an AI agent (OpenHands) on behalf of the
truckwash.io team.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-13 21:11:44 +02:00
Jeppe Bandopenhands 51e5c2ed01 feat(invoicing-period): surface lightweight customer membership on non-active views (#371)
## Summary

Customer indicator chips (e.g. *Faktura pr. ordre*, *Fastpris*,
*Tankrengøring*) currently only render on the matching view tab because
the period paged response strips customer data from every non-active
view bucket. The front-end therefore cannot determine which other
categories a customer belongs to from the *Alle* tab.

This change projects a deduplicated lightweight customer marker
`{customer_number, membership_only: true}` onto every non-active view
bucket in `applyPeriodPagination`. The active bucket still carries full
customer cards so paginated full-data output, type_counts and
type_totals are unchanged. Filters, search, sort, flag tab and workflow
filters upstream of the membership projection make the non-active
membership set match the active-bucket semantics for the same request.

## Contract change (openapi.yaml)

* New schema `InvoicingPeriodCustomerMembership` with `{
customer_number, membership_only: true }` and `additionalProperties:
false`.
* `InvoicingPeriodData.types[view].items` is now a `oneOf` of
`InvoicingPeriodCustomer` and `InvoicingPeriodCustomerMembership`.
* `InvoicingPeriodCustomer.required` relaxed to `customer_number` only
(other fields are now reported per-view).

## Implementation

* New helper `summarizeNonActiveCustomerMemberships()` projects +
deduplicates by `customer_number`.
* Pagination emits full cards for the active bucket and lightweight
memberships everywhere else.

## Tests

* Updated existing `fixed_pricing` assertion to include the membership
marker.
* Added four new tests:
* default projection across all view buckets (with explicit dedup
assertion),
  * search filter propagation,
  * flag-tab filter propagation,
  * single-customer active-bucket edge case.

All 15 `InvoicingPeriodPaginationTest` tests pass locally (158
assertions).

🤖 Generated by [OpenHands](https://docs.openhands.dev/) on behalf of
copenhagentruckwash.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-13 15:16:25 +02:00
Jeppe B 0dea2f0b76 fix(ci): run api edge-agent and release-manager-gate on GitHub-hosted runner (#370)
The self-hosted backend runner pool has been offline since Aug 9. Switching the two jobs that hard-required it to ubuntu-24.04 (GitHub-hosted) restores the normal release pipeline for the api. After this lands, the next master push will flow Tests -> Required CI -> Release Manager gate -> channel_sync -> api-v2 deploy.

🤖 This PR was merged by an AI agent (OpenHands) on behalf of jepp9350.
2026-08-13 13:29:14 +02:00
Jeppe B 22ec6696b5 chore(agent-mcp-smoke): verify GitHub MCP write/PR wiring (#369)
Generated automatically by Hermes to verify the GitHub MCP is wired into
OpenHands.

Safe to close — no production change.
2026-08-13 11:30:03 +02:00
Jeppe Bandopenhands a71194d46e refactor(api): centralise truthy-string -> bool coercion in a shared trait (#368)
## What

Centralises the truthy-string → bool coercion that six different classes
were reimplementing (and which two implementations disagreed about).

The shared helper lives in
`services/nginx/app/traits/boolean_normalization_t.php`:

```php
namespace traits;
trait boolean_normalization_t {
    public static function normalizeBoolean(mixed $value): bool {
        if (is_bool($value)) return $value;
        return in_array(strtolower(trim((string)$value)), ['1','true','yes','on'], true);
    }
}
```

`traits/module_config_variable_t::inputToBool` now delegates to it. The
seven call sites that previously inlined the same expression (or wrapped
it in a private `toBool`/`boolValue`/`isEnabled`) are reduced to a
single `self::normalizeBoolean(...)` call:

| Class | Old helper | New |
| --- | --- | --- |
| `classes/cron_worker.php` | inline in `boolOption` |
`self::normalizeBoolean(...)` (after empty-value short-circuit) |
| `classes/replica_failover_manager.php` | `boolValue` |
`self::normalizeBoolean(...)` |
| `classes/release_manager.php` | `toBool` |
`self::normalizeBoolean(...)` |
| `classes/releasemanager.php` | `isEnabled` |
`self::normalizeBoolean(...)` |
| `classes/superuser_system_status_service.php` | inline in
`parseModuleConfigValue` | `self::normalizeBoolean(...)` |
| `classes/module_usage_service.php` | `toBool` |
`self::normalizeBoolean(...)` |
| `classes/account_deletion_service.php` | inline in `apiEnabled` |
`self::normalizeBoolean(...)` |
| `traits/module_config_variable_t.php` | `inputToBool` (narrow set) |
`self::normalizeBoolean(...)` (full set) |

## Why

The pre-PR repo had two silent bugs:

1. **`inputToBool` accepted only `'true'`/`'1'`** while the six inline
copies accepted the wider `['1','true','yes','on']` set. Config values
such as `"yes"` or `" ON "` would round-trip to `false` through
`inputToBool` but `true` through any of the inline copies. This PR picks
the wider set as the single source of truth; the change is a strict
superset, so no caller flips from truthy to falsy.
2. **Six copies of the same expression** to drift in any of the seven
places (whitespace handling, case sensitivity, empty-string semantics).
One trait replaces them.

## Tests

* `tests/Unit/Traits/BooleanNormalizationTest.php` — Pest, runs the
helper directly through anonymous-class composition (no DB/HTTP).
* `tests/Smoke/boolean_normalization_smoke.php` — standalone PHP smoke
runner for environments without composer installed. Verified locally:
7/7 consumer wiring checks pass, 19/19 normalization cases pass (`true`,
`false`, `1`, `0`, `'true'`, `'TRUE'`, `'1'`, `'yes'`, `'YES'`, `'on'`,
`' ON '`, `'false'`, `'no'`, `'off'`, `''`, `null`, `'0'`, `[]`,
stdClass).
* All eight touched files pass `php -l` syntax check.

## Risk

* Behavioural change is a strict superset for the shared expression
path, so no caller can flip from truthy → falsy. The only consumer that
saw a behaviour change for *negative* inputs is `inputToBool` itself,
which previously rejected `'yes'`/`'on'`. Worth a CI pass on the
unit/integration suites before merge.

## Co-author

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

---

_This PR was generated by an AI agent (OpenHands) on behalf of
copenhagentruckwash._

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-12 20:20:03 +02:00
Jeppe Bandopenhands 5441fea665 fix(api): simplify XLVask Selvvask surface by removing AI autopilot pipeline (#367)
## Summary

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

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

## Test plan

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

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

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-12 20:02:30 +02:00
Jeppe BandHermes Agent 02a4665bc7 test(api): lock XL Vask Selvvask review capabilities end-to-end (#366)
## Why

The Selvvask view depends on the `/automation/capabilities` endpoint
returning `can_review=true` and the `/decisions/preview` endpoint
admitting the operator. Both were previously locked to
`manage_xlvask_usage_automation` and silently disabled the Accept /
Reject / Ignore buttons for every operator. copenhagentruckwash/api#365
fixed the contract; this PR adds the missing end-to-end API tests so a
future refactor cannot re-tighten the gating without anyone noticing.

## What changed

`tests/Api/XLVaskReviewApiTest.php` — five `usesApiSuite` specs that
exercise the new contract against a real MySQL + Redis stack with a
`createUserSession` fixture:

- `list_xlvask_usage_orders_own` lights `can_review=true` (the
back-compat path so existing operator groups work without a permission
grant).
- `review_xlvask_usage_order` lights `can_review=true`.
- The new permission does NOT unlock `can_dry_run` / `can_execute` /
`can_manage_policy` / `can_halt`, so an operator cannot trigger the
autopilot or change policy from the selvvask view.
- An operator with no xlvask permissions is rejected at the capability
inspection gate with a 403 + missing-permission envelope.
- An operator with only `list_xlvask_usage_orders_own` can inspect
capabilities but is rejected at `/decisions/preview` (which still
requires `review_xlvask_usage_order` or
`manage_xlvask_usage_automation`).

## Notes

The test was committed to master directly because that's where the
contract lives; this PR is the back-port to a branch so the CI Required
gate can run on it.

---------

Co-authored-by: Hermes Agent <agent@truckwash.io>
2026-08-12 12:12:20 +02:00
Jeppe BandHermes Agent 3e683c8559 fix(api): allow operators to review XL Vask automation decisions in the Selvvask view (#365)
## Why

Operators on the Superuser → Fakturaer → Periode → Selvvask view were
unable to Accept / Reject / Ignore / Link XL Vask washes even though the
UI claimed the buttons should be there. The previous capability contract
only lit `can_review` for users with `manage_xlvask_usage_automation`, a
small admin group, so the FE never rendered any review actions for the
rest of the superuser staff. The same contract also blocked the
corresponding `/decisions/preview` and `/decisions/apply` calls, so even
if the buttons were forced on, the API would 403.

## What changed

- `xlvaskUsageLogsRoute.php`:
  - New permission string `review_xlvask_usage_order` for operators.
  - `/modules/xlvask/services/usage/automation/capabilities`:
- `can_review` now returns `true` when the user has
`review_xlvask_usage_order` or the existing `list_xlvask_usage_orders_*`
(so existing operator groups keep working without an extra grant), or
`manage_xlvask_usage_automation` (the AI admin path).
- `can_dry_run` / `can_execute` / `can_manage_policy` / `can_halt`
remain gated on the AI-admin permissions to keep the autopilot lifecycle
fail-closed.
- The new permission is registered in the route's permission manifest.
- `/modules/xlvask/services/usage/automation/decisions/preview` and
`/apply` now accept either `manage_xlvask_usage_automation` or
`review_xlvask_usage_order`. The existing `force_manual` branch in
`xlvask_autopilot_service` lights up automatically for these operators,
so the existing manual-suggestion path drives them.
- The AI autopilot run lifecycle (`/autopilot-runs`,
`/autopilot-runs/{id}`, `/autopilot-runs/active`,
`/automation/admin/...`) still requires `manage_xlvask_usage_automation`
/ `superuser_xlvask_automation_activate`.

## Tests

- New contract in `XLVaskUsageRouteContractTest`:
- "exposes a review_xlvask_usage_order permission on decision endpoints
for the selvvask operator flow" — locks the new permission string, the
new `can_review` flag, and the manage-only `can_dry_run` / `can_execute`
flags.
- "still requires manage_xlvask_usage_automation for the AI autopilot
run lifecycle" — regression guard for the admin path.
- All 87 XLVask unit tests pass. The wider 1334 unit tests also pass;
the only pre-existing failure is the unrelated
`BirdControlPlaneActivationTest` which requires
`PLENO_REPO_ROOT_FOR_TESTS` and is broken on master.

## Companion frontend PR

`copenhagentruckwash/pleno-vue` → `fix/xlvask-selvvask-review-actions`
(the FE was already wired correctly: `allow-review-actions =
automationWorkspace && capabilities.can_review`. With the API change
above, `can_review` now lights up for operators so the buttons surface.
A new source-inspection regression test pins the contract so future
edits cannot re-tighten the gating.)

Co-authored-by: Hermes Agent <agent@truckwash.io>
2026-08-11 23:58:21 +02:00
Jeppe B d81634033e fix(api): do not raise historical_primary_product_mismatch for single-tractor orders (#363)
Partition primary rows by reg_2 presence in getPrimaryProductHistory() so a single-tractor order (reg_2 empty) is compared only against other single-tractor orders, not against historical tractor-trailer orders. 2 new tests + 1 updated signature contract test in InvoicePeriodFlagServiceTest.
2026-08-11 07:39:00 +02:00
Jeppe B 58d5e177a9 fix(api): order order_items so primary precedes addons in getOrderItems (#364)
Defensive ORDER BY in orders_o.php::getOrderItems so primary items render before their addons (related_item_id IS NULL DESC, related_item_id ASC, id ASC). Pinned with OrderItemsListingOrderingTest which locates orders_o.php via worktree-aware resolver.
2026-08-11 07:05:59 +02:00
Jeppe BandWorktree Fix Verifier 82a3684f05 fix(api): order getOrdersWithRegistrationNumberInDateRange by id ASC (#362)
## Summary

orders_o::getOrdersWithRegistrationNumberInDateRange() selects orders
matching a registration number within a date range without an explicit
ORDER BY clause. MySQL is free to return rows in any order. The endpoint
at routes/orderInvoicesRoute.php then iterates the result and calls
assignToInvoiceCollection() on each row, so the audit-log +
invoice-collection numbering depend on the arbitrary backend row order.

Add a stable `ORDER BY id ASC` to the SELECT and pin the contract with a
new Pest unit test.

## Test plan

- New Pest test `OrdersRegistrationDateRangeQueryTest` asserts the
SELECT still carries `ORDER BY id ASC`.
- Existing tests in the same file still pass unchanged (they don't
assert on ordering).
- Manual php -l on both modified files shows no syntax errors.

## Commits

- bbd50239 fix(api): order getOrdersWithRegistrationNumberInDateRange by
id ASC

Co-authored-by: Worktree Fix Verifier <agent@truckwash.local>
2026-08-10 20:33:10 +02:00
Jeppe BandTruck Wash Agent d850075397 fix(api): order order_items so primary precedes addons in getOrderItems (#361)
## Summary

orders_o::getOrderItems() selected order items without an explicit ORDER
BY clause, so MySQL was free to return rows in any order. On the POS
Fuldfør click and the superuser invoice tree, addons (related_item_id !=
NULL) were sometimes returned before their primary item, which broke the
FE tree-builder and the OrderContentTable render.

Add a stable ordering: primary items first (related_item_id IS NULL
DESC), addons grouped by their parent (related_item_id ASC), and
insertion order as the final tiebreaker (id ASC).

## Commits

- 7ec64ec8 fix(api): order order_items so primary precedes addons in
getOrderItems
- 68e19bee test(api): pin order_items listing ordering in getOrderItems

## Test plan

- Wiring unit test asserts the SELECT inside getOrderItems still carries
ORDER BY (related_item_id IS NULL) DESC, related_item_id ASC, id ASC.
- Verified locally with php -l on the modified file.
- Existing OrderItemReasonPolicyTest, CustomerOrderProductPolicyTest,
OrdersIncludeInInvoiceOverrideTest continue to pass in the worktree
setup (no DB fixtures touched).

---------

Co-authored-by: Truck Wash Agent <agent@copenhagentruckwash.local>
2026-08-10 20:16:04 +02:00
78 changed files with 3527 additions and 8112 deletions
+167
View File
@@ -0,0 +1,167 @@
name: Deploy to Hetzner (staging)
on:
push:
branches: [master]
workflow_dispatch:
inputs:
reason:
description: 'Reason for manual deploy'
required: false
default: 'manual'
concurrency:
group: deploy-${{ github.repository }}
cancel-in-progress: false
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
jobs:
test-and-deploy:
name: CI + Deploy
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Show commit info
run: |
echo "Repo: ${{ github.repository }}"
echo "Branch: ${{ github.ref }}"
echo "Commit: ${{ github.sha }}"
echo "Actor: ${{ github.actor }}"
# === CI (phpunit / vitest) runs here via repo's existing CI config ===
# (Most of our repos already have a "Required CI" check; this section
# would invoke that. If your repo doesn't have a CI workflow, the
# required-check on the branch will block this workflow's deploy step.)
- name: Setup SSH
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}
- name: Add host key
run: |
mkdir -p ~/.ssh
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
- name: Pre-deploy snapshot
id: pre
run: |
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
set -e
cd /opt/${{ github.event.repository.name }}
git rev-parse HEAD > /tmp/last_deploy_sha
echo "PRE_SHA=$(cat /tmp/last_deploy_sha)"
echo "pre_sha=$(cat /tmp/last_deploy_sha)" >> $GITHUB_OUTPUT
'
- name: Deploy
id: deploy
run: |
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
set -e
cd /opt/${{ github.event.repository.name }}
git fetch origin master
git reset --hard origin/master
# PHP repos: composer install + clear cache
if [ -f composer.json ]; then
composer install --no-dev --optimize-autoloader --no-interaction
php artisan cache:clear || true
php artisan config:cache || true
# Restart php-fpm if used
sudo systemctl reload php8.2-fpm || true
fi
# Node repos: npm ci + build
if [ -f package.json ]; then
npm ci --ignore-scripts
npm run build
# Restart node service
sudo systemctl reload pleno-vue || sudo systemctl reload nginx || true
fi
# Restart generic services
sudo systemctl reload nginx || true
echo "Deploy complete: $(git rev-parse --short HEAD)"
'
- name: Pre-deploy schema check (run all *_schema_bootstrap)
id: pre_schema
run: |
echo "Running schema bootstraps against the live database…"
# Idempotent — adds missing columns, never drops anything.
# Catches the "Unknown column 'invoice_email' in 'SELECT'"
# production failure mode (TRU-77) where migrations were
# merged to master but never applied to the live DB.
php scripts/run-schema-bootstraps.php
echo "Schema bootstraps complete."
- name: Alert Slack if schema-check fails (pre-deploy)
if: failure()
run: |
php scripts/schema-health-check.php > /tmp/schema.json 2>&1 || true
msg=$(jq -r '"Schema health FAILED on '$SMOKE_BASE_URL'\nMissing: " + (.missing | join(", "))' /tmp/schema.json 2>/dev/null || echo "Schema check produced no JSON")
curl -sS -X POST -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
-H "Content-Type: application/json; charset=utf-8" \
https://slack.com/api/chat.postMessage \
-d "{\"channel\":\"$AI_DAILY_CHANNEL\",\"text\":\":rotating_light: *${{ github.event.repository.name }} — schema health FAIL\n${msg}\"}"
- name: Smoke test
id: smoke
continue-on-error: true
run: |
chmod +x scripts/smoke-test.sh
./scripts/smoke-test.sh
# Also hit the new admin schema-check endpoint to verify
# no required columns are missing.
echo "::group::Schema health check"
php scripts/schema-health-check.php | tee /tmp/schema-report.json
if [ "$(jq -r .ok /tmp/schema-report.json)" != "true" ]; then
echo "::error::Schema health check FAILED — missing columns:"
jq -r '.missing[]' /tmp/schema-report.json | sed 's/^/ • /'
exit 1
fi
echo "Schema health check OK."
- name: Auto-rollback on smoke failure
if: steps.smoke.outcome == 'failure'
run: |
echo "::error::Smoke test failed — rolling back to ${{ steps.pre.outputs.pre_sha }}"
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
set -e
cd /opt/${{ github.event.repository.name }}
git reset --hard ${{ steps.pre.outputs.pre_sha }}
if [ -f composer.json ]; then
composer install --no-dev --optimize-autoloader --no-interaction
sudo systemctl reload php8.2-fpm || true
fi
if [ -f package.json ]; then
npm ci --ignore-scripts
npm run build
sudo systemctl reload nginx || true
fi
'
- name: Post Slack status
if: always()
uses: slackapi/slack-github-action@v1.27.0
with:
channel-id: ${{ secrets.AI_DAILY_CHANNEL }}
payload: |
{
"text": "${{ job.status == 'success' && '✅' || '❌' }} Deploy *${{ github.repository }}@${{ github.sha[0:7] }}* — ${{ job.status }}\n${{ steps.smoke.outcome == 'failure' && '⚠️ Auto-rolled back' || '✓ Smoke test passed' }}"
}
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
- name: Update Linear issue
if: success() && steps.deploy.outcome == 'success'
run: |
# Find Linear issues in this commit's history and post a comment
# (uses GitHub's auto-link: if PR body contains "TRU-123" it auto-links)
# We skip this here; the OpenClaw cron `f26dfd83` handles Linear updates.
echo "Deploy notification will be picked up by OpenClaw cron."
+2 -2
View File
@@ -68,7 +68,7 @@ jobs:
edge-agent:
name: Edge Agent (required)
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || 'backend' }}
runs-on: ubuntu-24.04
steps:
- name: Checkout
@@ -376,7 +376,7 @@ jobs:
release-manager-gate:
name: Release Manager gate
runs-on: [self-hosted, Linux, X64, pleno, backend]
runs-on: ubuntu-24.04
needs: [required-ci]
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.required-ci.result == 'success' }}
+4
View File
@@ -0,0 +1,4 @@
# AGENT MCP SMOKE
Generated 20260813-091957 by hermes agent to verify GitHub MCP wiring.
Safe to close.
+1
View File
@@ -7,4 +7,5 @@
<!-- 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
@@ -0,0 +1,95 @@
# XL Vask Selvvask surface — inventory & simplification plan
## Scope
The XLVask surface that powers the **Superuser → Fakturaer → Periode → Selvvask**
view. Goal: remove the AI / MiniMax / autopilot pipeline, leaving only the
operator-facing review and order-creation flow.
Out of scope: any other XLVask, plate scanner, customer, or vehicle surface.
## Files removed
| Path | Reason |
| --- | --- |
| `services/nginx/app/classes/xlvask_autopilot_service.php` | AI autopilot pipeline |
| `services/nginx/app/classes/xlvask_automation_service.php` | AI automation pipeline |
| `services/nginx/app/classes/xlvask_automation_policy_service.php` | AI policy service |
| `services/nginx/app/classes/minimax.php` | MiniMax integration |
| `services/nginx/app/modules/miniMax/` | MiniMax module (config + class) |
| `services/nginx/app/modules/xlvask/AUTOMATION_RUNBOOK.md` | Runbook for removed pipeline |
| `services/nginx/app/modules/xlvask/cron/tasks.php` | Module-owned cron registry (replaced by empty `cron_task_registry` discovery) |
| `services/nginx/app/modules/xlvask/migrations/20260804_xlvask_ai_auto_policy_v2.php` | Migration for removed AI schema |
| `services/nginx/app/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php` | Legacy autopilot gate |
| `services/nginx/app/modules/xlvask/config/xlvask_automatic_order_creation_enabled_c.php` | Legacy autopilot gate |
| `services/nginx/app/modules/xlvask/config/xlvask_minimax_integration_enabled_c.php` | MiniMax gate |
| `services/nginx/app/modules/xlvask/config/xlvask_openai_integration_enabled_c.php` | OpenAI gate |
| `services/nginx/app/cron/EnsureXLVaskAutomationSchema.php` | Migration helper |
| `scripts/xlvask-automation-migrate.php` | CLI wrapper for migration |
| `services/nginx/app/tests/Unit/XLVask/XLVaskAutomationMigrateScriptTest.php` | Removed migration test |
| `services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php` | Removed automation test |
| `services/nginx/app/tests/Api/XLVaskReviewApiTest.php` | Replaced by Selvvask route contract test |
## Code changes (kept & simplified)
| Path | Change |
| --- | --- |
| `services/nginx/app/cron/Cron.php` | Drop `ProcessXLVaskAutopilotQueueCron` registration + function |
| `services/nginx/app/cli.php` | Drop `xlvask-automation-migrate` case |
| `services/nginx/app/routes/moduleConfigRoute.php` | Drop `/minimax/config` GET/POST endpoints |
| `services/nginx/app/routes/moduleXLVaskRoute.php` | Drop `/modules/xlvask/tasks/import-usage` 410 stub and `/tasks/debug` route |
| `services/nginx/app/routes/xlvaskUsageLogsRoute.php` | Slim to operator-only: list, summary, ignore/unignore, accept, reject, fast-link |
| `services/nginx/app/modules/xlvask/helpers/xlvask_tasks.php` | Drop `runScheduledAutomationIfReady`, `processAutopilotQueue`, autopilot cleanup, legacy auto-creation branch |
| `services/nginx/app/modules/xlvask/xlvask_c.php` | Drop `minimax_integration_enabled`, `automatic_order_attachment_enabled`, `automatic_order_creation_enabled`, `openai_integration_enabled` |
| `services/nginx/app/objects/xlvask_usage_logs_o.php` | Add `summarizeUsageOrdersReadOnly` (replaces autopilot summary) |
| `services/nginx/app/openapi.yaml` | Replace autopilot/automation openapi block with operator-flow endpoints |
| `services/nginx/app/tests/Unit/Cron/CronTaskRegistryTest.php` | Update count: 24 → 22, drop `xlvask.autopilot_queue` assertion |
| `services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php` | Replaced with end-to-end contract assertions for the new operator surface |
## New operator-facing endpoints
All under `routes/xlvaskUsageLogsRoute.php` and scoped to the operator's
`allowedHallIds` (all-scope users see every configured scanner hall; own-scope
users see only their group's halls).
| Method | Path | Permission | Purpose |
| --- | --- | --- | --- |
| `GET` | `/modules/xlvask/services/usage/orders` | `list_xlvask_usage_orders_own/all` | List usage logs with direct linked order id, amount summary, ignored metadata |
| `GET` | `/modules/xlvask/services/usage/orders/summary` | `list_xlvask_usage_orders_own/all` | Read-only per-period summary (counts + net amount) |
| `PATCH` | `/modules/xlvask/services/usage/orders/{id}/ignore` | `review_xlvask_usage_order` | Mark ignored with reason |
| `POST` | `/modules/xlvask/services/usage/orders/{id}/unignore` | `review_xlvask_usage_order` | Clear ignored metadata |
| `POST` | `/modules/xlvask/services/usage/orders/{id}/accept` | `review_xlvask_usage_order` | Convert to order via `createOrderFromWash` |
| `POST` | `/modules/xlvask/services/usage/orders/{id}/reject` | `review_xlvask_usage_order` | Mark ignored with reject reason |
| `GET` | `/modules/xlvask/services/usage/orders/fast-link` | `list_xlvask_usage_orders_own` | Cached fast-link redeem (existing) |
## Permissions
The Selvvask surface uses these permissions only:
- `list_xlvask_usage_orders_own`
- `list_xlvask_usage_orders_all`
- `review_xlvask_usage_order`
`manage_xlvask_usage_automation`, `ignore_xlvask_usage_order`,
`superuser_xlvask_automation_activate` are not referenced anywhere in the
slimmed surface.
## Persistence model
`xlvask_usage_logs_o` already exposes `ignored_at`, `ignored_by`, `ignored_reason`
columns — no migration required for the simplified flow.
`orders_o::selectByWashId(int|string $WashId)` and
`orders_o::addXLVaskOrder(users_o $user, xlvask_usage_log $xlvask_usage_log)` are
the only integration points with the order pipeline.
## Tests
- `vendor/bin/pest --testsuite=Unit --colors=never` passes 1266 tests.
- One pre-existing failure (`BirdControlPlaneActivationTest`) requires
`PLENO_REPO_ROOT_FOR_TESTS` (coolify repo) and is unrelated to this change.
## Repo scope
This inventory covers `api`. The `pleno-vue` side has not yet been updated in
this session and will be handled in a follow-up PR.
+25 -2
View File
@@ -18548,11 +18548,13 @@ components:
additionalProperties:
type: array
items:
$ref: '#/components/schemas/InvoicingPeriodCustomer'
oneOf:
- $ref: '#/components/schemas/InvoicingPeriodCustomer'
- $ref: '#/components/schemas/InvoicingPeriodCustomerMembership'
InvoicingPeriodCustomer:
type: object
required: [customer_number, customer_name, transactions, invoice_collections]
required: [customer_number]
additionalProperties: true
properties:
customer_number:
@@ -18568,6 +18570,27 @@ 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,6 +405,10 @@ 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"
)
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env php
<?php
/**
* Pre-deploy schema bootstrap runner.
*
* Loads and runs every `*_schema_bootstrap` class so the production
* database has all the columns the current code expects. Each
* bootstrap is additive and idempotent — safe to run on every deploy.
*
* Run via:
* php scripts/run-schema-bootstraps.php
*
* Used in .github/workflows/deploy.yml as a pre-deploy step.
*
* When you add a new *_schema_bootstrap class, you don't need to
* edit this file — the runner auto-discovers any class whose name
* ends in `_schema_bootstrap`.
*/
namespace scripts;
// Load the app entry point so $db is wired up the same way as in
// normal request handling.
$index = __DIR__ . '/../services/nginx/app/index.php';
if (!file_exists($index)) {
fwrite(STDERR, "Cannot find app entry point at {$index}\n");
exit(2);
}
require_once $index;
$classesDir = __DIR__ . '/../services/nginx/app/classes';
$bootstraps = glob($classesDir . '/*_schema_bootstrap.php');
if (!$bootstraps) {
fwrite(STDERR, "No *_schema_bootstrap.php files found in {$classesDir}\n");
exit(0);
}
$ran = 0;
$skipped = 0;
foreach ($bootstraps as $file) {
require_once $file;
$base = basename($file, '.php');
$class = "classes\\{$base}";
if (!class_exists($class)) {
fwrite(STDERR, " [skip] {$base}: class not found\n");
$skipped++;
continue;
}
if (!method_exists($class, 'ensureSchema')) {
fwrite(STDERR, " [skip] {$base}: no ensureSchema() method\n");
$skipped++;
continue;
}
try {
$class::ensureSchema();
echo " [ok] {$base}\n";
$ran++;
} catch (\Throwable $e) {
fwrite(STDERR, " [FAIL] {$base}: " . $e->getMessage() . "\n");
exit(1);
}
}
echo "Schema bootstraps complete: {$ran} ran, {$skipped} skipped.\n";
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env php
<?php
/**
* Schema health check — verifies all required DB columns exist.
*
* Run via:
* GET /api/admin/schema-check (returns JSON report)
* php scripts/schema-health-check.php (CLI, exits 0/1)
*
* Lists the columns that the code expects to find in each critical
* table. If a column is missing, the response is 503 (HTTP) or
* exit code 1 (CLI) — clearly distinct from a generic 500.
*
* Add to the list when introducing a new optional column.
*/
namespace scripts;
require_once __DIR__ . '/../services/nginx/app/classes/customer_invoice_email_schema_bootstrap.php';
use classes\customer_invoice_email_schema_bootstrap;
const SCHEMA_REQUIREMENTS = [
'users' => [
'invoice_email', // TRU-77 (added 2026-08-16)
'wash_certificate_email',
'email',
'customer_number',
],
'invoices' => [
'po_number',
'closed_at',
'customer_number',
],
'bookings' => [
'id',
'customer_number',
'department',
],
];
function check_schema(): array
{
global $db;
$report = [
'ok' => true,
'missing' => [],
'tables_checked' => 0,
'columns_checked' => 0,
'timestamp' => date('c'),
];
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
$report['ok'] = false;
$report['error'] = 'no_db_connection';
return $report;
}
// First: run the schema bootstrap (additive, idempotent) so we
// give the DB a chance to self-heal.
if (class_exists(customer_invoice_email_schema_bootstrap::class)) {
customer_invoice_email_schema_bootstrap::ensureSchema();
}
foreach (SCHEMA_REQUIREMENTS as $table => $columns) {
$report['tables_checked']++;
// Confirm the table itself exists
$tableSafe = str_replace('`', '', $table);
$result = $db->query("SHOW TABLES LIKE '{$tableSafe}'");
if (!$result || (int)$result->num_rows === 0) {
$report['ok'] = false;
$report['missing'][] = "table `{$table}` does not exist";
continue;
}
foreach ($columns as $column) {
$report['columns_checked']++;
$colSafe = str_replace("'", '', $column);
$r = $db->query("SHOW COLUMNS FROM `{$tableSafe}` LIKE '{$colSafe}'");
if (!$r || (int)$r->num_rows === 0) {
$report['ok'] = false;
$report['missing'][] = "{$table}.{$column}";
}
}
}
return $report;
}
// CLI mode
if (PHP_SAPI === 'cli') {
$report = check_schema();
echo json_encode($report, JSON_PRETTY_PRINT) . "\n";
exit($report['ok'] ? 0 : 1);
}
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Generic smoke test for any deployed app.
#
# Usage: ./scripts/smoke-test.sh [base_url]
# Default: https://staging.truckwash.io
#
# Required env vars (set by GitHub Action):
# SMOKE_BASE_URL - base URL to test (default: https://staging.truckwash.io)
#
# Optional env vars:
# SMOKE_TOKEN - bearer token for authenticated checks
# SMOKE_TIMEOUT - curl timeout in seconds (default: 10)
#
# Exits 0 on all-pass, 1 on any failure.
set -euo pipefail
BASE_URL="${SMOKE_BASE_URL:-${1:-https://staging.truckwash.io}}"
TIMEOUT="${SMOKE_TIMEOUT:-10}"
# Color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
FAIL=0
check() {
local name="$1"
local url="$2"
local expected="${3:-200}"
local method="${4:-GET}"
local status
status=$(curl -s -o /dev/null -w "%{http_code}" -X "$method" --max-time "$TIMEOUT" "$url" || echo "000")
if [[ "$status" =~ ^($expected)$ ]] || [[ "$expected" == "2xx" && "$status" =~ ^2 ]]; then
echo -e " ${GREEN}${NC} $name ($status) — $url"
else
echo -e " ${RED}${NC} $name (expected $expected, got $status) — $url"
FAIL=1
fi
}
echo "Smoke test against $BASE_URL"
echo " (timeout ${TIMEOUT}s per check)"
echo
# === Health endpoints (universal) ===
check "health check" "$BASE_URL/healthz" "2xx"
check "ping" "$BASE_URL/api/ping" "2xx"
# === Authentication (should NOT 500) ===
check "login page" "$BASE_URL/login" "2xx"
# === Public endpoints (api repo) ===
check "customer list (public schema)" "$BASE_URL/api/customer" "2xx"
check "kundeoprettelse form" "$BASE_URL/kundeoprettelse" "2xx"
# === Public endpoints (pleno-vue) ===
check "self-serve program picker" "$BASE_URL/self-serve/program" "2xx"
check "vehicle step" "$BASE_URL/self-serve/vehicle" "2xx"
# === Custom 404 should not 500 ===
check "404 page" "$BASE_URL/this-route-does-not-exist" "404"
# === Optional authenticated check ===
if [ -n "${SMOKE_TOKEN:-}" ]; then
check "auth check" "$BASE_URL/api/me" "2xx"
fi
echo
if [ "$FAIL" -eq 0 ]; then
echo -e "${GREEN}✓ All smoke tests passed${NC}"
exit 0
else
echo -e "${RED}✗ Some smoke tests failed${NC}"
exit 1
fi
-54
View File
@@ -1,54 +0,0 @@
#!/usr/bin/env php
<?php
/**
* XL Vask automation schema migration script.
*
* Mirrors the scripts/account-deletion-schema.php and
* scripts/bird-control-plane-schema.php patterns so ops can run an explicit,
* non-cron, non-HTTP migration from the API container.
*
* Usage (from the api repo root, against the configured DB):
* php scripts/xlvask-automation-migrate.php check
* php scripts/xlvask-automation-migrate.php apply --yes
*
* "check" never mutates state and always exits 0 when ready / 1 when not.
* "apply" requires an explicit --yes flag before calling the gated
* migration_20260804_xlvask_ai_auto_policy_v2::apply() entry point, which
* itself is operator-only by design (see AUTOMATION_RUNBOOK §2).
*/
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "This command is CLI-only.\n");
exit(2);
}
const WD = __DIR__ . '/../services/nginx/app';
require_once WD . '/vendor/autoload.php';
require_once WD . '/config.php';
require_once WD . '/classes/db.php';
require_once WD . '/classes/xlvask_usage_logs_schema_bootstrap.php';
require_once WD . '/modules/xlvask/migrations/20260804_xlvask_ai_auto_policy_v2.php';
$command = $argv[1] ?? 'check';
if (!in_array($command, ['check', 'apply'], true)) {
fwrite(STDERR, "Usage: scripts/xlvask-automation-migrate.php check|apply --yes\n");
exit(2);
}
$db = new \classes\db($CONFIG_DB);
$db->connect();
if ($command === 'apply') {
if (($argv[2] ?? '') !== '--yes') {
fwrite(STDERR, "Refusing schema mutation without: apply --yes\n");
exit(2);
}
$status = \classes\xlvask_usage_logs_schema_bootstrap::applyExplicitMigration();
} else {
$status = \classes\xlvask_usage_logs_schema_bootstrap::migrationStatus();
}
fwrite(STDOUT, json_encode($status, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL);
exit((bool)($status['ready'] ?? false) ? 0 : 1);
+8
View File
@@ -189,6 +189,8 @@ 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) {
@@ -480,6 +482,7 @@ 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,
@@ -488,6 +491,7 @@ export function createBrokerServer(options = {}) {
manager_url_configured: Boolean(managerUrl),
shared_secret_configured: Boolean(sharedSecret),
agents_connected: agents.size,
lastActivityAt,
});
return;
}
@@ -1083,6 +1087,10 @@ export function createBrokerServer(options = {}) {
pendingCommands,
managerUrl,
authMode,
containerStartedAt,
get lastActivityAt() {
return lastActivityAt;
},
},
};
}
+39
View File
@@ -219,6 +219,10 @@ 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",
@@ -247,6 +251,41 @@ 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,9 +7,14 @@ 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;
@@ -52,7 +57,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 in_array(strtolower(trim((string)($row['value'] ?? ''))), ['1', 'true', 'yes', 'on'], true);
return self::normalizeBoolean((string)($row['value'] ?? ''));
} catch (Throwable) {
return false;
}
+6 -1
View File
@@ -3,9 +3,14 @@
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;
@@ -291,7 +296,7 @@ class cron_worker
return $default;
}
return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true);
return self::normalizeBoolean($value);
}
private function commitSha(): string
@@ -0,0 +1,93 @@
<?php
namespace classes;
/**
* Ensures additive schema for the customer `invoice_email` field
* (TRU-77 / DRIFT 16). The field is optional and stores an
* e-mail address that should receive the customer's invoices
* separately from the customer's primary `email`.
*/
class customer_invoice_email_schema_bootstrap
{
private static bool $initialized = false;
private const TABLE = 'users';
private const COLUMN = 'invoice_email';
public static function ensureSchema(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
self::ensureUsersTable($db);
self::ensureInvoiceEmailColumn($db);
self::$initialized = true;
}
private static function ensureUsersTable(object $db): void
{
$db->query(
"CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
customer_number INT NOT NULL,
display_name VARCHAR(255) NULL,
email VARCHAR(255) NULL,
phone_country_code INT NULL,
phone BIGINT NULL,
password VARCHAR(255) NULL,
group_id INT NOT NULL DEFAULT 0,
xlvask_customer_id VARCHAR(255) NULL,
sms_notifications_enabled TINYINT(1) NOT NULL DEFAULT 0,
email_notifications_enabled TINYINT(1) NOT NULL DEFAULT 0,
wash_certificate_email VARCHAR(255) NULL,
invoice_email VARCHAR(255) NULL,
two_factor_enabled TINYINT(1) NOT NULL DEFAULT 0,
two_factor_secret VARCHAR(255) NULL,
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
KEY idx_users_customer_number (customer_number),
KEY idx_users_group_id (group_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
}
private static function ensureInvoiceEmailColumn(object $db): void
{
if (!self::tableExists($db, self::TABLE)) {
return;
}
if (self::columnExists($db, self::TABLE, self::COLUMN)) {
return;
}
$safeTable = str_replace('`', '', self::TABLE);
$db->query(
"ALTER TABLE `{$safeTable}`
ADD COLUMN " . self::COLUMN . " VARCHAR(255) NULL
AFTER wash_certificate_email"
);
}
private static function tableExists(object $db, string $table): bool
{
$safeTable = str_replace('`', '', $table);
$result = $db->query("SHOW TABLES LIKE '{$safeTable}'");
return $result && (int)$result->num_rows > 0;
}
private static function columnExists(object $db, string $table, string $column): bool
{
$safeTable = str_replace('`', '', $table);
$safeColumn = str_replace("'", '', $column);
$result = $db->query("SHOW COLUMNS FROM `{$safeTable}` LIKE '{$safeColumn}'");
return $result && (int)$result->num_rows > 0;
}
}
@@ -14,6 +14,10 @@ class customer_mass_import_service
*/
public function import(array $payload): array
{
// TRU-77 / DRIFT 16: ensure the invoice_email column exists before we
// attempt to populate it on a local customer.
customer_invoice_email_schema_bootstrap::ensureSchema();
$normalized = $this->normalizePayload($payload);
$this->assertValidNormalizedPayload($normalized);
@@ -62,9 +66,15 @@ class customer_mass_import_service
}
$normalized['name'] = $this->resolveCreateName($normalized);
$normalized['email'] = $this->resolveCreateEmail($normalized, $warnings);
// TRU-77 / DRIFT 16: resolve the e-conomic delivery address into a
// local variable instead of overwriting $normalized['email']. The
// primary customer email must remain intact for the result payload
// and for downstream local-customer sync; the create call needs the
// dedicated invoice address (or the primary as a fallback) on its
// own.
$createEmail = $this->resolveCreateEmail($normalized, $warnings);
$createResponse = $this->createEconomicCustomer($normalized);
$createResponse = $this->createEconomicCustomer($normalized, $createEmail);
$createdCustomerNumber = $this->extractEconomicCustomerNumber($createResponse);
if ($createdCustomerNumber !== $customerNumber) {
@@ -111,6 +121,7 @@ class customer_mass_import_service
'cvr' => $this->normalizeDigitString($payload['cvr'] ?? null),
'name' => $this->normalizeText($payload['name'] ?? $payload['company_name'] ?? null),
'email' => $this->normalizeEmail($payload['email'] ?? null),
'invoice_email' => $this->normalizeInvoiceEmail($payload['invoice_email'] ?? null),
'ean' => $this->normalizeDigitString($payload['ean'] ?? null),
];
}
@@ -193,6 +204,42 @@ class customer_mass_import_service
return $email;
}
/**
* Normalize the optional dedicated invoice email (TRU-77 / DRIFT 16).
* Empty/whitespace values collapse to null. An explicit non-empty value
* must be a syntactically valid email address; an invalid value is
* rejected to keep invoices from being routed to a malformed address.
*/
protected function normalizeInvoiceEmail(mixed $value): ?string
{
$email = $this->normalizeText($value);
if ($email === null) {
return null;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \RuntimeException('Invalid invoice email address.', 400);
}
return $email;
}
/**
* Resolve the e-mail address that e-conomic should use to deliver
* invoices for the customer (TRU-77 / DRIFT 16). Prefers the dedicated
* `invoice_email` when provided, falling back to the customer's primary
* `email`.
*/
protected function resolveInvoiceEmail(array $normalized, array &$warnings): string
{
if (!empty($normalized['invoice_email'])) {
return (string)$normalized['invoice_email'];
}
if (!empty($normalized['email'])) {
return (string)$normalized['email'];
}
$warnings[] = 'No invoice email was provided, defaulted to jb@truckwash.dk for the new e-conomic customer.';
return 'jb@truckwash.dk';
}
protected function resolveCreateName(array $normalized): string
{
if ($normalized['name'] !== null) {
@@ -209,12 +256,9 @@ class customer_mass_import_service
protected function resolveCreateEmail(array $normalized, array &$warnings): string
{
if ($normalized['email'] !== null) {
return $normalized['email'];
}
$warnings[] = 'No email was provided, defaulted to jb@truckwash.dk for the new e-conomic customer.';
return 'jb@truckwash.dk';
// TRU-77 / DRIFT 16: invoices must be routed to the dedicated
// invoice_email when provided, otherwise to the customer's email.
return $this->resolveInvoiceEmail($normalized, $warnings);
}
protected function searchEconomicCustomersByCvr(string $cvr): array
@@ -229,7 +273,7 @@ class customer_mass_import_service
return is_array($response) ? $response : [];
}
protected function createEconomicCustomer(array $normalized): object
protected function createEconomicCustomer(array $normalized, string $createEmail): object
{
$payload = [
'customerNumber' => (int)$normalized['customer_number'],
@@ -241,7 +285,10 @@ class customer_mass_import_service
'paymentTermsNumber' => 12,
],
'name' => (string)$normalized['name'],
'email' => (string)$normalized['email'],
// TRU-77 / DRIFT 16: the dedicated invoice_email (or the
// primary email as a fallback) is passed in explicitly so the
// caller's $normalized['email'] is never mutated here.
'email' => $createEmail,
'phone' => (int)$normalized['phone'],
'telephoneAndFaxNumber' => (string)$normalized['phone'],
'mobilePhone' => (string)$normalized['phone'],
@@ -396,6 +443,7 @@ class customer_mass_import_service
'cvr' => (string)$normalized['cvr'],
'name' => $customerName,
'email' => $normalized['email'],
'invoice_email' => $normalized['invoice_email'] ?? null,
'ean' => $normalized['ean'],
'action' => $action,
'message' => $message,
@@ -416,6 +464,7 @@ class customer_mass_import_service
$name = $normalized['name'] ?? null;
$email = $normalized['email'] ?? null;
$invoice_email = $normalized['invoice_email'] ?? null;
$phone = $normalized['phone'] ?? null;
$displayName = trim((string)($customer->display_name->value() ?? ''));
@@ -431,6 +480,16 @@ class customer_mass_import_service
}
}
// TRU-77 / DRIFT 16: persist the dedicated invoice email override
// when provided so invoice routing survives subsequent local edits.
if ($invoice_email !== null && $customer->getInvoiceEmailOverride() === null) {
try {
$customer->setInvoiceEmail($invoice_email);
} catch (\Throwable $throwable) {
$warnings[] = 'Unable to update local invoice email: ' . $throwable->getMessage();
}
}
if ($phone !== null && empty($customer->phone->value())) {
try {
$customer->setPhoneNumber((int)$phone);
@@ -1297,7 +1297,34 @@ class invoice_period_flag_service
}
}
$history = $this->getPrimaryProductHistory($dateFrom, array_column($primaryRows, 'reg_1'));
// 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
);
}
foreach ($primaryRows as $row) {
$reg = strtoupper(trim((string)($row['reg_1'] ?? '')));
if ($reg === '' || !isset($history[$reg])) {
@@ -1468,6 +1495,7 @@ 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.",
@@ -1487,7 +1515,9 @@ 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' => "XL Vask wash is neither ignored nor linked to an order in the selected period.",
'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.",
default => "Automatically detected invoice-period issue.",
};
}
@@ -1514,7 +1544,8 @@ class invoice_period_flag_service
['type' => 'text', 'text' => ' is attached without a wash certificate item.'],
],
'xlvask_missing_order_link' => [
['type' => 'xlvask_usage_log', 'text' => 'XL Vask wash'],
['type' => 'text', 'text' => 'XL Vask wash '],
['type' => 'xlvask_usage_log', 'text' => (string)($params['wash_id'] ?? '')],
['type' => 'text', 'text' => ' is neither ignored nor linked to an order in the selected period.'],
],
default => [],
@@ -1774,7 +1805,7 @@ class invoice_period_flag_service
return $map;
}
private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers): array
private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers, ?bool $requireReg2Empty = null): array
{
global $db;
@@ -1795,6 +1826,16 @@ 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
@@ -1808,6 +1849,7 @@ 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
@@ -1,196 +0,0 @@
<?php
namespace classes;
require_once WD . '/modules/openAI/openAI_c.php';
require_once WD . '/modules/miniMax/miniMax_c.php';
use Exception;
use miniMax\miniMax_c;
/**
* Thrown when a MiniMax API request fails. Extends openai_request_exception so the
* autopilot's existing `catch (openai_request_exception $e)` blocks keep working
* when the model is swapped from OpenAI to MiniMax — no other code needs to change.
*/
class minimax_request_exception extends openai_request_exception
{
}
/**
* MiniMax M3 client.
*
* Uses the Anthropic-messages format (https://api.minimax.io/anthropic/v1/messages),
* which is the same endpoint OpenClaw's minimax-portal provider uses. The caller
* can pass `MiniMax-M3` (and any other model the operator has provisioned) via
* the `$model` argument.
*
* The response shape returned from jsonTask() matches openai::jsonTask() so callers
* (notably xlvask_automation_service) can switch providers with minimal plumbing.
*/
class minimax
{
public miniMax_c $config;
private string $api_url = 'https://api.minimax.io/anthropic/v1/messages';
protected string $model = 'MiniMax-M3';
protected string $temperature = '0.1';
protected string $max_tokens = '4096';
public function __construct()
{
$this->config = new miniMax_c();
}
public function requireModuleEnabled(): void
{
if (!$this->config->enabled->isTrue()) {
throw new Exception('MiniMax module is not enabled.');
}
$apiKey = trim((string)$this->config->api_key->getVariableValue());
if ($apiKey === '') {
throw new Exception('MiniMax API key is not configured.');
}
}
/**
* Send a structured JSON text task to MiniMax M3 (Anthropic-messages format).
*
* Returns the parsed JSON object plus `_minimax_response_model` and `_minimax_usage`
* so the autopilot can compare against the resolved model id and track tokens.
*
* @throws Exception
*/
public function jsonTask(
string $schemaName,
string $prompt,
array $payload,
array $schema,
float $temperature = 0.1,
?string $model = null
): array {
$this->requireModuleEnabled();
// Anthropic-messages uses a single `messages` array, system prompt is separate,
// and structured output goes in `tools` with `input_schema`.
$data = [
'model' => $model ?? $this->model,
'max_tokens' => (int)$this->max_tokens,
'temperature' => $temperature,
'system' => $prompt,
'messages' => [
[
'role' => 'user',
'content' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
],
],
'tools' => [
[
'name' => $schemaName,
'description' => 'Return the structured decision for the XL Vask automation planner.',
'input_schema' => $schema,
],
],
// Force the model to call the tool — guarantees a structured JSON object back.
'tool_choice' => ['type' => 'tool', 'name' => $schemaName],
];
$response = $this->sendRequest($data);
return self::parseJsonTaskResponse($response, $schemaName);
}
public static function parseJsonTaskResponse(array $response, string $expectedToolName): array
{
// Anthropic-messages stop_reason: end_turn | tool_use | max_tokens | stop_sequence
$stopReason = (string)($response['stop_reason'] ?? '');
if ($stopReason === 'max_tokens') {
throw new minimax_request_exception('MiniMax response was truncated by max_tokens.', true);
}
if (!in_array($stopReason, ['end_turn', 'tool_use'], true)) {
throw new minimax_request_exception('MiniMax response did not complete (stop_reason=' . $stopReason . ').', true);
}
$toolInput = null;
$toolName = null;
foreach ((array)($response['content'] ?? []) as $block) {
if (($block['type'] ?? null) === 'tool_use') {
$toolName = (string)($block['name'] ?? '');
$toolInput = (array)($block['input'] ?? []);
break;
}
}
if ($toolInput === null) {
throw new minimax_request_exception('MiniMax completed without a structured tool_use block.', false);
}
if ($toolName !== $expectedToolName) {
throw new minimax_request_exception(
'MiniMax returned tool "' . $toolName . '", expected "' . $expectedToolName . '".',
false
);
}
$resolvedModel = trim((string)($response['model'] ?? ''));
if ($resolvedModel === '') {
throw new minimax_request_exception('MiniMax response omitted the resolved model.', false);
}
$usage = (array)($response['usage'] ?? []);
$inputTokens = max(0, (int)($usage['input_tokens'] ?? 0));
$outputTokens = max(0, (int)($usage['output_tokens'] ?? 0));
// The legacy `_openai_*` aliases keep the autopilot's sanitizeOpenAiResult() working
// unchanged — it reads those keys regardless of which provider produced the result.
return [
...$toolInput,
'_minimax_response_model' => $resolvedModel,
'_minimax_usage' => [
'input_tokens' => $inputTokens,
'output_tokens' => $outputTokens,
'total_tokens' => $inputTokens + $outputTokens,
'service_tier' => '',
],
'_openai_response_model' => $resolvedModel,
'_openai_usage' => [
'input_tokens' => $inputTokens,
'output_tokens' => $outputTokens,
'total_tokens' => $inputTokens + $outputTokens,
'service_tier' => '',
],
];
}
/**
* @throws Exception
*/
private function sendRequest(array $data): array
{
$this->requireModuleEnabled();
$curl = curl_init($this->api_url);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_POST, true);
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 10);
curl_setopt($curl, CURLOPT_TIMEOUT, 60);
// MiniMax uses Anthropic-style auth headers
curl_setopt($curl, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'x-api-key: ' . $this->config->api_key->getVariableValue(),
'anthropic-version: 2023-06-01',
]);
curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($data));
$response = curl_exec($curl);
if (curl_errno($curl)) {
$curlCode = curl_errno($curl);
curl_close($curl);
throw new minimax_request_exception(
'MiniMax transport failed.',
in_array($curlCode, [CURLE_OPERATION_TIMEDOUT, CURLE_COULDNT_CONNECT, CURLE_COULDNT_RESOLVE_HOST], true)
);
}
$httpStatus = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
$responseData = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new minimax_request_exception('MiniMax returned a non-JSON response.', $httpStatus === 429 || $httpStatus >= 500, $httpStatus);
}
if ($httpStatus < 200 || $httpStatus >= 300 || !is_array($responseData)) {
throw new minimax_request_exception('MiniMax request failed with HTTP status ' . $httpStatus . '.', $httpStatus === 429 || $httpStatus >= 500, $httpStatus);
}
return $responseData;
}
}
@@ -5,9 +5,14 @@ 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)
@@ -968,10 +973,7 @@ class module_usage_service
private function toBool(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
return self::normalizeBoolean($value);
}
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, 24, 25, 26, 27];
public const AFFECTED_PRODUCT_IDS = [21, 22, 25, 26, 27];
public static function reasons(): array
{
@@ -33,6 +33,31 @@ class products_schema_bootstrap
);
}
if (!self::columnExists($db, 'products', 'merged_into_product_id')) {
$db->query(
"ALTER TABLE products
ADD COLUMN merged_into_product_id INT NULL DEFAULT NULL
AFTER max_quantity_per_order,
ADD KEY idx_products_merged_into (merged_into_product_id)"
);
}
if (!self::tableExists($db, 'product_merges')) {
$db->query(
"CREATE TABLE IF NOT EXISTS product_merges (
id INT AUTO_INCREMENT PRIMARY KEY,
source_product_id INT NOT NULL,
target_product_id INT NOT NULL,
merged_by_user_id INT NULL,
reason VARCHAR(500) NULL,
merged_at DATETIME NOT NULL,
KEY idx_product_merges_source (source_product_id),
KEY idx_product_merges_target (target_product_id),
UNIQUE KEY uq_product_merges_source (source_product_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
}
self::$initialized = true;
}
@@ -5,11 +5,15 @@ 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 = [
@@ -12665,10 +12669,7 @@ class release_manager
private function toBool(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
return self::normalizeBoolean($value);
}
private function requestTraceId(): string
@@ -2,8 +2,14 @@
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 {
@@ -11,7 +17,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 in_array(strtolower(trim((string)($row['value'] ?? 'true'))), ['1', 'true', 'yes', 'on'], true);
return self::normalizeBoolean((string)($row['value'] ?? 'true'));
} catch (\Throwable) {
return true;
}
@@ -6,9 +6,14 @@ 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';
@@ -502,11 +507,7 @@ class replica_failover_manager
private static function boolValue(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
return self::normalizeBoolean($value);
}
private static function jsonDecode(mixed $value): array
@@ -4,9 +4,14 @@ 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:';
@@ -847,7 +852,7 @@ class superuser_system_status_service
protected function parseModuleConfigValue(string $type, mixed $value): mixed
{
return match (strtolower($type)) {
'bool' => in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true),
'bool' => self::normalizeBoolean($value),
'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,10 +106,6 @@ if ($args[1] === 'run') {
echo "[" . date('Y-m-d H:i:s') . "][CRON_WORKER] Starting cron worker\n";
(new \classes\cron_worker())->run();
break;
case 'xlvask-automation-migrate':
echo "[" . date('Y-m-d H:i:s') . "][XLVASK] Ensuring automation schema readiness\n";
require_once 'cron/EnsureXLVaskAutomationSchema.php';
break;
default:
echo "Invalid script name";
break;
-15
View File
@@ -150,12 +150,6 @@ $cron_tasks = [
'next_run' => 0,
'function' => 'SyncXLVaskModuleCron',
],
'ProcessXLVaskAutopilotQueueCron' => [
'interval' => 60,
'last_run' => 0,
'next_run' => 0,
'function' => 'ProcessXLVaskAutopilotQueueCron',
],
'SystemSearchCacheMaintenanceCron' => [
'interval' => 300, // 5 minutes
'last_run' => 0,
@@ -679,15 +673,6 @@ function SyncXLVaskModuleCron(): void
}
}
function ProcessXLVaskAutopilotQueueCron(): array
{
$xlvask = new xlvask();
if (!$xlvask->config->enabled->isTrue()) {
return [];
}
return $xlvask->getTasks()->processAutopilotQueue(3);
}
function EconomicTransferQueueCron(): void
{
try {
@@ -1,71 +0,0 @@
<?php
use classes\xlvask_usage_logs_schema_bootstrap;
use xlvask\migrations\migration_20260804_xlvask_ai_auto_policy_v2;
require_once WD . '/classes/xlvask_usage_logs_schema_bootstrap.php';
require_once WD . '/classes/xlvask_autopilot_service.php';
require_once WD . '/modules/xlvask/migrations/20260804_xlvask_ai_auto_policy_v2.php';
if (!defined('WD')) {
exit;
}
$dbTarget = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live')));
$startedAt = date('Y-m-d H:i:s');
echo "[{$startedAt}][XLVASK] Target database: {$dbTarget}" . PHP_EOL;
$result = [
'success' => false,
'db_target' => $dbTarget,
'preflight' => null,
'applied' => false,
'postflight' => null,
'wash_id_uniqueness_ready' => false,
'wash_id_uniqueness_activated' => false,
'wash_id_uniqueness_blocked' => false,
'error' => null,
];
try {
$preflight = migration_20260804_xlvask_ai_auto_policy_v2::preflight();
$result['preflight'] = $preflight;
if (!(bool)($preflight['ready'] ?? false)) {
$result['postflight'] = migration_20260804_xlvask_ai_auto_policy_v2::apply();
$result['applied'] = true;
} else {
$result['postflight'] = $preflight;
}
$postflight = (array)$result['postflight'];
if (!(bool)($postflight['ready'] ?? false)) {
throw new RuntimeException('XL Vask automation schema is still not ready after apply.');
}
if (xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady()) {
$result['wash_id_uniqueness_ready'] = true;
} else {
$activated = xlvask_usage_logs_schema_bootstrap::applyWashIdUniquenessMigration();
$result['wash_id_uniqueness_ready'] = $activated && xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady();
$result['wash_id_uniqueness_activated'] = $result['wash_id_uniqueness_ready'];
$result['wash_id_uniqueness_blocked'] = !$result['wash_id_uniqueness_ready'];
}
$result['success'] = (bool)$result['wash_id_uniqueness_ready'];
if (!$result['success']) {
$result['error'] = 'Wash-id uniqueness is blocked, likely due duplicate normalized wash_id values.';
}
} catch (Throwable $throwable) {
// The wrapper cron entry may swallow the runtime exception that is
// re-thrown below, so emit a container-log breadcrumb here too.
error_log('[cron-ensure-xlvask-automation-schema] apply failed: ' . $throwable->getMessage());
$result['error'] = $throwable->getMessage();
}
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL;
if (!$result['success']) {
throw new RuntimeException((string)($result['error'] ?: 'XL Vask schema readiness failed.'));
}
@@ -1,29 +0,0 @@
<?php
namespace miniMax\config;
use Exception;
use traits\module_config_variable;
class miniMax_api_key_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'miniMax',
'api_key',
'string',
false,
null,
'The secret API key for MiniMax (M3). Obtain from the MiniMax Portal dashboard; the operator can rotate or remove it from the superuser XL Vask module settings.',
'1',
true,
''
);
}
}
@@ -1,29 +0,0 @@
<?php
namespace miniMax\config;
use Exception;
use traits\module_config_variable;
class miniMax_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'miniMax',
'enabled',
'bool',
true,
null,
'Whether the MiniMax integration is enabled for XL Vask autopilot and other AI-driven features.',
'1',
false,
'false'
);
}
}
@@ -1,28 +0,0 @@
<?php
namespace miniMax;
require_once WD . '/modules/miniMax/config/miniMax_enabled_c.php';
require_once WD . '/modules/miniMax/config/miniMax_api_key_c.php';
use miniMax\config\miniMax_api_key_c;
use miniMax\config\miniMax_enabled_c;
use traits\module_config_t;
class miniMax_c
{
use module_config_t;
public miniMax_enabled_c $enabled;
public miniMax_api_key_c $api_key;
public function __construct()
{
$this->setupConfig('miniMax');
$this->allowUpdate([
miniMax_enabled_c::class,
miniMax_api_key_c::class,
]);
$this->enabled = new miniMax_enabled_c();
$this->api_key = new miniMax_api_key_c();
}
}
@@ -1,86 +0,0 @@
# XL Vask AI automation runbook
This runbook is an operator procedure. None of its gates are applied by deployment, HTTP GETs, constructors, or workers. Every production-changing step requires a human approval tied to the exact deployed backend and frontend SHAs.
## 0. Deploy-order and rollback invariant
The legacy attachment/creation config values are kill switches, but an old backend treats them as direct enable switches. Old code cannot interpret the new policy stages, calibration identity, rolling caps, action latches, or canary soak. Therefore old-backend traffic is forbidden whenever either legacy switch is true, including during a new-policy canary.
Use this exact forward sequence:
1. While the old backend is still serving, set both legacy automatic-order switches to false through the approved config procedure and verify the persisted values from every serving instance.
2. Stop/disable old XL Vask automation workers and verify there is no active automatic run. Ordinary XL Vask synchronization may continue.
3. Deploy the new backend with policy effectively `off`; verify ordinary synchronization still completes and scheduled automation no-ops while schema readiness is false.
4. Run read-only migration preflight, then the separately approved explicit additive migration. If it is partial or fails, keep the new backend deployed, policy `off`, both legacy switches false, and workers no-op; repair or complete the migration before continuing. Never route old code as a partial-migration workaround.
5. Verify migration readiness and the new backend SHA, then deploy/verify the compatible frontend. Only after that generate advisory evidence and use preview-bound policy transitions.
Use this exact rollback sequence before any old-code traffic:
1. Keep all traffic on the new backend, call the dedicated halt endpoint, and verify policy `halted` plus both persisted legacy switches false.
2. Stop new-backend automation workers, wait for or safely reconcile the active run, and verify no financial mutation is in flight.
3. Roll back the frontend if required, then deploy the old backend with both legacy switches still false. Verify ordinary sync only.
4. Do not re-enable either legacy switch on old code. Recovery of automatic actions requires redeploying the new policy-aware backend and repeating readiness, advisory calibration, canary, and soak.
## 1. Read-only preflight
1. Record the backend/frontend SHAs, environment, operator, invoice period, and scanner-hall scope.
2. Call the scoped capabilities and admin-readiness GETs with `dateFrom` and `dateTo`.
3. Confirm `migration.ready`, `missing_tables`, `missing_columns`, `missing_indexes`, `preflight_conflicts`, `worker_healthy`, WashId uniqueness, planner identity, resolved model, active run, scoped eligible counts, rolling budgets, and reviewed soak counts.
4. Stop if dates are invalid, hall scope is empty, an execute run is active, identity changed, a latch is halted, or any readiness field fails closed.
## 2. Explicit schema migration
Use the controlled database migration procedure to invoke only
`migration_20260804_xlvask_ai_auto_policy_v2::apply()`. First retain its read-only
`preflight()` output. Review the additive SQL and backup/restore point, approve the exact SHA, run it once, retain the returned status, and rerun readiness. Do not invoke `applyExplicitMigration()` from a request, worker, cron task, or application startup.
If preflight reports multiple legacy execute runs in `queued`, `running`, or `retry_wait`, stop. Reconcile those runs through a separately approved operational procedure; the migration never auto-resolves or modifies the conflicting run records.
### 2a. Operator entry points
There are two equivalent ways to apply the migration from a privileged
container with the configured DB credentials. Both call the same gated
`migration_20260804_xlvask_ai_auto_policy_v2::apply()` entry point and
produce identical status output. Pick whichever fits the workflow.
```
# Option A — standalone script (mirrors scripts/account-deletion-schema.php)
php scripts/xlvask-automation-migrate.php check # read-only preflight
php scripts/xlvask-automation-migrate.php apply --yes # apply, gated by --yes
# Option B — CLI dispatcher inside index.php (defines WD + composes bootstrap)
php index.php run xlvask-automation-migrate # preflight, applies if !ready
```
Both exit 0 when `ready=true` and 1 otherwise. Always retain the JSON
status artifact for the audit log and rerun `check` to confirm the
postflight is green.
## 3. WashId uniqueness
Inspect normalized duplicate WashIds. Resolve conflicts through an independently approved data procedure. Only then use the guarded uniqueness activation with the exact typed phrase. Recheck the generated normalized column and unique index before any automatic action.
## 4. Advisory evidence and calibration
Keep policy at `advisory`. Run explicit `dry_run` requests to import and persist plans, or `replay` for cache-only read-only evaluation. Review suggestions in hall scope. Label exact OpenAI attach/create suggestions; model identity, prompt hash, schema hash, policy version, resolved model, and chronological label snapshot are part of the artifact identity. Generate inactive backtests, independently review qualification thresholds and contradictions, then activate the exact artifact hash with its typed phrase.
## 5. Staged policy transitions
Every transition uses a bounded human reason, server-generated policy preview, exact confirmation phrase, and apply-time revalidation. The reason is bound into the preview hash and retained in the immutable policy event:
`off` -> `advisory` -> `ai_attach_canary` -> `ai_attach_verified` -> `ai_create_canary` -> `verified_capped`
Stages may not be skipped. An active execute run, stale preview, changed policy version, changed model/planner identity, missing exact calibration, incomplete reviewed soak, invalid period scope, or exhausted readiness gate blocks promotion.
## 6. Reviewed soak and caps
Volume alone never completes soak. Every auto-accepted action must be adjudicated. Only explicit `correct` outcomes from the current action canary activation epoch count: 200 correct reviewed links before attach verification/create eligibility and 50 correct reviewed creates before `verified_capped`. `incorrect`, `duplicate`, `cross_hall`, or `unaudited` persistently halts the relevant action latch, invalidates the active action calibration in the same transaction, and requires investigation. Re-entering that canary creates a fresh soak epoch after a new qualifying calibration is activated.
Caps are atomic rolling 24-hour limits: 100 links globally and 10 per hall; 20 creates globally and 3 per hall. Cap exhaustion is a normal policy stop: the suggestion remains reviewable and the execute run pauses without recording a permanent action failure. Cap reservation, policy/model/calibration revalidation, current-candidate requery, financial locks, mutation, and audit commit in one transaction.
List responses intentionally use only persisted revision/hash eligibility and do not reconstruct same-day candidates per row. This avoids an unbounded N+1 query path. Candidate existence, uniqueness, customer/department/registration/lane/date/items/totals, and financial locks are authoritatively rebuilt during preview/apply and again inside the mutation transaction. Treat a preview/apply stale-candidate rejection as a normal fail-closed refresh signal; monitor list latency and preview rejection rates during advisory/canary.
## 7. Halt, recovery, and rollback
Use the dedicated halt endpoint immediately on any unexplained result, duplicate, cross-hall action, missing audit, model mismatch, financial invariant, worker lease failure, or upstream revision anomaly. Halt disables legacy compatibility switches and preserves the reason. Generic config may disable a switch but cannot enable it.
Rollback means: follow the exact sequence in section 0; halt; stop new execute runs; retain audit/action/review evidence; reconcile affected orders and invoice collections; restore data only through a separately approved, previewed procedure; fix and redeploy; repeat advisory calibration and staged previews. Recovery from `halted` starts at `off` or `advisory` and requires new exact-SHA human approval. Never infer activation, soak completion, or production safety from green CI alone.
@@ -1,52 +0,0 @@
<?php
namespace xlvask\config;
use Exception;
use traits\module_config_variable;
class xlvask_automatic_order_attachment_enabled_c
{
use module_config_variable {
setVariableValue as private setVariableValueInternal;
}
private static bool $policyWrite = false;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'xlvask',
'automatic_order_attachment_enabled',
'bool',
true,
null,
'Whether XL Vask usage logs may automatically be attached to existing same-day employee orders.',
'0',
false,
'false'
);
}
/** Generic module config may kill automation but cannot activate it. */
public function setVariableValue(mixed $value): void
{
if (!self::$policyWrite && self::inputToBool($value)) {
throw new Exception('Automatic XL Vask attachment can only be enabled through the policy preview/apply flow.');
}
$this->setVariableValueInternal($value);
}
public function setFromAutomationPolicy(bool $enabled): void
{
self::$policyWrite = true;
try {
$this->setVariableValueInternal($enabled);
} finally {
self::$policyWrite = false;
}
}
}
@@ -1,52 +0,0 @@
<?php
namespace xlvask\config;
use Exception;
use traits\module_config_variable;
class xlvask_automatic_order_creation_enabled_c
{
use module_config_variable {
setVariableValue as private setVariableValueInternal;
}
private static bool $policyWrite = false;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'xlvask',
'automatic_order_creation_enabled',
'bool',
true,
null,
'Whether XL Vask usage logs may automatically create orders when no same-day order can be attached.',
'0',
false,
'false'
);
}
/** Generic module config may kill automation but cannot activate it. */
public function setVariableValue(mixed $value): void
{
if (!self::$policyWrite && self::inputToBool($value)) {
throw new Exception('Automatic XL Vask creation can only be enabled through the policy preview/apply flow.');
}
$this->setVariableValueInternal($value);
}
public function setFromAutomationPolicy(bool $enabled): void
{
self::$policyWrite = true;
try {
$this->setVariableValueInternal($enabled);
} finally {
self::$policyWrite = false;
}
}
}
@@ -1,29 +0,0 @@
<?php
namespace xlvask\config;
use Exception;
use traits\module_config_variable;
class xlvask_minimax_integration_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'xlvask',
'minimax_integration_enabled',
'bool',
true,
null,
'Whether XL Vask automation may ask MiniMax (M3) for attachment or creation suggestions. Replaces the OpenAI integration.',
'0',
false,
'false'
);
}
}
@@ -1,29 +0,0 @@
<?php
namespace xlvask\config;
use Exception;
use traits\module_config_variable;
class xlvask_openai_integration_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'xlvask',
'openai_integration_enabled',
'bool',
true,
null,
'Whether XL Vask automation may ask OpenAI for attachment or creation suggestions.',
'0',
false,
'false'
);
}
}
@@ -1,28 +0,0 @@
<?php
return [
[
'id' => 'xlvask.autopilot_queue',
'legacy_name' => 'ProcessXLVaskAutopilotQueueCron',
'name' => 'Process XL Vask autopilot queue',
'description' => 'Claims and processes a bounded batch of queued XL Vask autopilot runs.',
'module' => 'xlvask',
'handler' => 'ProcessXLVaskAutopilotQueueCron',
'schedule' => ['type' => 'interval', 'seconds' => 60],
'timeout_seconds' => 300,
'estimated_duration_ms' => 5000,
'priority' => 40,
],
[
'id' => 'xlvask.sync_module',
'legacy_name' => 'SyncXLVaskModuleCron',
'name' => 'Sync XL Vask module',
'description' => 'Runs scheduled XL Vask synchronization tasks when the module is enabled.',
'module' => 'xlvask',
'handler' => 'SyncXLVaskModuleCron',
'schedule' => ['type' => 'interval', 'seconds' => 3600],
'timeout_seconds' => 900,
'estimated_duration_ms' => 10000,
'priority' => 95,
],
];
@@ -2,9 +2,6 @@
namespace helpers;
require_once WD . '/classes/xlvask_autopilot_service.php';
use classes\xlvask_autopilot_service;
use Exception;
use objects\orders_o;
use objects\plate_scanners_o;
@@ -46,9 +43,6 @@ class xlvask_tasks
$this->runSyncUsage();
$this->runSyncVehicles();
$this->runCleanupTasks();
// Automation is an optional final phase. A pending migration or an
// off/advisory policy must never interrupt the ordinary XL Vask sync.
$this->runScheduledAutomationIfReady();
};
}
@@ -76,47 +70,6 @@ class xlvask_tasks
};
}
/** Enqueue automatic work only after explicit migration and policy activation. */
public function runScheduledAutomationIfReady(): array
{
try {
$migrationStatus = \classes\xlvask_usage_logs_schema_bootstrap::migrationStatus();
if (!(bool)($migrationStatus['ready'] ?? false)) {
return [];
}
$hallIds = $this->configuredHallIds();
if ($hallIds === []) {
return [];
}
$autopilot = new xlvask_autopilot_service();
$capabilities = (new \classes\xlvask_automation_policy_service())->capabilitiesReadOnly(null, null, $hallIds);
if (!xlvask_autopilot_service::scheduledExecutionAllowed($migrationStatus, $capabilities)) {
return [];
}
$autopilot->createRun(['mode' => 'execute'], null, $hallIds);
return $autopilot->processQueuedRuns(3);
} catch (\Throwable) {
// Fail closed for automation while preserving the completed ordinary sync.
return [];
}
}
/** Drain the durable autopilot queue without running the hourly upstream synchronization. */
public function processAutopilotQueue(int $limit = 3): array
{
$xlvask = new \classes\xlvask();
$xlvask->requireModuleEnabled();
if (!$xlvask->config->synchronization_enabled->isTrue()) {
return [];
}
if (!(bool)(\classes\xlvask_usage_logs_schema_bootstrap::migrationStatus()['ready'] ?? false)) {
return [];
}
// Off/advisory cannot contain execute runs because createRun is server-gated.
// Explicit dry-run/replay evidence may still drain in advisory mode.
return (new xlvask_autopilot_service())->processQueuedRuns($limit);
}
/** Hall GUIDs are configuration, not derived from already-imported usage rows. */
private function configuredHallIds(): array
{
@@ -328,10 +281,8 @@ class xlvask_tasks
{
global $db;
$xlvask = new \classes\xlvask();
$orders_o = new orders_o();
$matches = new xlvask_potential_order_matches_o();
$linked = 0;
$ordersCreated = 0;
$dateFromSql = $dateFrom !== null && $dateFrom !== ''
? "'" . $db->escape_string(xlvask_usage_logs_o::formatImportDateFrom($dateFrom)) . "'"
@@ -352,8 +303,6 @@ class xlvask_tasks
}
$rows = $db->fetch_all($result);
$createEnabled = $xlvask->config->automatic_order_creation_enabled->isTrue();
foreach ($rows as $row) {
$log = (new $xlvask->helpers->xlvask_usage_log())->setProperties($row);
$washId = (string)$log->WashId;
@@ -374,25 +323,10 @@ class xlvask_tasks
(int)$log->getDepartment()->id,
);
$linked++;
continue;
}
// No matching order — try to create one if automatic creation is on.
if (!$createEnabled || !$log->isEligibleForAutomaticContinuance(false)) {
continue;
}
try {
$customer = $log->getCustomer();
$order = (new self())->createOrderFromWash($log, $customer);
if ($order !== null) {
$ordersCreated++;
}
} catch (Exception $e) {
error_log('[xlvask-tasks] auto-create order failed for wash ' . $washId . ': ' . $e->getMessage());
}
}
return ['linked' => $linked, 'orders_created' => $ordersCreated];
return ['linked' => $linked, 'orders_created' => 0];
}
private static function formatUsageLogs(array $getUsageLog): array
@@ -502,9 +436,5 @@ class xlvask_tasks
$xlvask = new \classes\xlvask();
// Require the module to be enabled
$xlvask->requireModuleEnabled();
if (!(bool)(\classes\xlvask_usage_logs_schema_bootstrap::migrationStatus()['ready'] ?? false)) {
return;
}
(new xlvask_autopilot_service())->pruneExpiredData();
}
}
@@ -1,27 +0,0 @@
<?php
namespace xlvask\migrations;
require_once WD . '/classes/xlvask_usage_logs_schema_bootstrap.php';
use classes\xlvask_usage_logs_schema_bootstrap;
/**
* Versioned, operator-invoked XL Vask AI auto-action migration.
*
* Preflight is read-only. apply() is intentionally not wired to HTTP routes, cron, constructors,
* readiness, or normal run processing. Operators must execute it through the controlled database
* migration procedure and retain the returned status artifact.
*/
final class migration_20260804_xlvask_ai_auto_policy_v2
{
public static function preflight(): array
{
return xlvask_usage_logs_schema_bootstrap::migrationStatus();
}
public static function apply(): array
{
return xlvask_usage_logs_schema_bootstrap::applyExplicitMigration();
}
}
@@ -3,19 +3,11 @@
namespace xlvask;
require_once WD . '/modules/xlvask/config/xlvask_enabled_c.php';
require_once WD . '/modules/xlvask/config/xlvask_synchronization_enabled_c.php';
require_once WD . '/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php';
require_once WD . '/modules/xlvask/config/xlvask_automatic_order_creation_enabled_c.php';
require_once WD . '/modules/xlvask/config/xlvask_minimax_integration_enabled_c.php';
require_once WD . '/modules/xlvask/config/xlvask_openai_integration_enabled_c.php';
require_once WD . '/modules/xlvask/config/xlvask_username_c.php';
require_once WD . '/modules/xlvask/config/xlvask_password_c.php';
use traits\module_config_t;
use xlvask\config\xlvask_automatic_order_attachment_enabled_c;
use xlvask\config\xlvask_automatic_order_creation_enabled_c;
use xlvask\config\xlvask_enabled_c;
use xlvask\config\xlvask_minimax_integration_enabled_c;
use xlvask\config\xlvask_openai_integration_enabled_c;
use xlvask\config\xlvask_password_c;
use xlvask\config\xlvask_synchronization_enabled_c;
use xlvask\config\xlvask_username_c;
@@ -39,22 +31,6 @@ class xlvask_c
* @var xlvask_synchronization_enabled_c $synchronization_enabled
*/
public xlvask_synchronization_enabled_c $synchronization_enabled;
/**
* @var xlvask_automatic_order_attachment_enabled_c $automatic_order_attachment_enabled
*/
public xlvask_automatic_order_attachment_enabled_c $automatic_order_attachment_enabled;
/**
* @var xlvask_automatic_order_creation_enabled_c $automatic_order_creation_enabled
*/
public xlvask_automatic_order_creation_enabled_c $automatic_order_creation_enabled;
/**
* @var xlvask_minimax_integration_enabled_c $minimax_integration_enabled
*/
public xlvask_minimax_integration_enabled_c $minimax_integration_enabled;
/**
* @var xlvask_openai_integration_enabled_c $openai_integration_enabled
*/
public xlvask_openai_integration_enabled_c $openai_integration_enabled;
/**
* The username
* @var xlvask_username_c
@@ -77,19 +53,11 @@ class xlvask_c
$this->allowUpdate([
xlvask_enabled_c::class,
xlvask_synchronization_enabled_c::class,
xlvask_automatic_order_attachment_enabled_c::class,
xlvask_automatic_order_creation_enabled_c::class,
xlvask_minimax_integration_enabled_c::class,
xlvask_openai_integration_enabled_c::class,
xlvask_username_c::class,
xlvask_password_c::class
]);
$this->enabled = new xlvask_enabled_c();
$this->synchronization_enabled = new xlvask_synchronization_enabled_c();
$this->automatic_order_attachment_enabled = new xlvask_automatic_order_attachment_enabled_c();
$this->automatic_order_creation_enabled = new xlvask_automatic_order_creation_enabled_c();
$this->minimax_integration_enabled = new xlvask_minimax_integration_enabled_c();
$this->openai_integration_enabled = new xlvask_openai_integration_enabled_c();
$this->username = new xlvask_username_c();
$this->password = new xlvask_password_c();
}
+9 -2
View File
@@ -615,7 +615,13 @@ class orders_o extends db
public function getOrderItems(int $order_id): array
{
global $db;
$sql = "SELECT * FROM order_items WHERE order_id = $order_id";
// 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";
$result = $db->query($sql);
$order_items = [];
if ($result->num_rows > 0 && $result) {
@@ -2202,7 +2208,8 @@ 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";
AND deleted_at IS NULL
ORDER BY id ASC";
$result = $db->query($sql);
if ($result->num_rows === 0) {
return []; // No orders found with the registration number in the date range
+132
View File
@@ -84,6 +84,12 @@ class products_o extends db
* @var object_property $max_quantity_per_order
*/
public object_property $max_quantity_per_order;
/**
* If non-null, this product has been merged into the product with the given id.
* All read paths should resolve to the target product (see resolveActiveProductId()).
* @var object_property $merged_into_product_id
*/
public object_property $merged_into_product_id;
/**
* The timestamp of when the object was created
* @var object_property
@@ -134,6 +140,7 @@ class products_o extends db
$this->display_in_booking_form = new object_property($this->table, $this->id, 'display_in_booking_form', 'bool', false);
$this->order_priority = new object_property($this->table, $this->id, 'order_priority', 'int', false);
$this->max_quantity_per_order = new object_property($this->table, $this->id, 'max_quantity_per_order', 'int', false);
$this->merged_into_product_id = new object_property($this->table, $this->id, 'merged_into_product_id', 'int', false);
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'string', false);
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'string', false);
}
@@ -227,6 +234,7 @@ class products_o extends db
'display_in_booking_form' => (bool)$this->display_in_booking_form->value(),
'order_priority' => (int)$this->order_priority->value(),
'max_quantity_per_order' => $this->max_quantity_per_order->value() === null ? null : (int)$this->max_quantity_per_order->value(),
'merged_into_product_id' => $this->merged_into_product_id->value() === null ? null : (int)$this->merged_into_product_id->value(),
'created_at' => (string)$this->created_at->value(),
'updated_at' => (string)$this->updated_at->value(),
];
@@ -370,4 +378,128 @@ class products_o extends db
self::requireSelected();
return $this->id === 41;
}
/**
* Returns the product id that should be used for new orders and pricing.
* If this product has been merged into another (merged_into_product_id is set),
* the target id is returned. The merge chain is followed transitively with a
* safety cap to avoid infinite loops.
*/
public function resolveActiveProductId(): int
{
self::requireSelected();
products_schema_bootstrap::ensureTables();
$currentId = (int)$this->id;
$visited = [$currentId => true];
$maxHops = 16;
for ($i = 0; $i < $maxHops; $i++) {
$next = self::fetchMergedInto($currentId);
if ($next === null) {
return $currentId;
}
if (isset($visited[$next])) {
// Cycle detected: stop at the current node rather than spinning.
return $currentId;
}
$visited[$next] = true;
$currentId = $next;
}
return $currentId;
}
/**
* Static helper: given a product id, return the product id it is merged into,
* or null if it is not merged. Performs a single hop (no chain following).
*/
public static function fetchMergedInto(int $productId): ?int
{
global $db;
if (!isset($db) || $productId <= 0) {
return null;
}
$productId = (int)$db->escape_string((string)$productId);
$result = $db->query("SELECT merged_into_product_id FROM products WHERE id = {$productId}");
if ($result === false || !is_object($result) || (int)$result->num_rows === 0) {
return null;
}
$row = $db->fetch_assoc($result);
$merged = $row['merged_into_product_id'] ?? null;
if ($merged === null || $merged === '' || (int)$merged === 0) {
return null;
}
return (int)$merged;
}
/**
* Merge this product into another. The source product keeps its id (and therefore
* its historical order_items references), but reads and new orders will resolve to
* the target product. An audit row is written to product_merges.
*
* Throws \RuntimeException on validation failure.
*/
public function mergeInto(int $targetProductId, ?int $mergedByUserId = null, ?string $reason = null): void
{
self::requireSelected();
products_schema_bootstrap::ensureTables();
global $db, $response;
$sourceId = (int)$this->id;
if ($sourceId === $targetProductId) {
throw new \RuntimeException('Cannot merge a product into itself');
}
if ($targetProductId <= 0) {
throw new \RuntimeException('Invalid target product id');
}
// Target must exist
$targetCheck = $db->query("SELECT id FROM products WHERE id = " . (int)$targetProductId);
if ($targetCheck === false || !is_object($targetCheck) || (int)$targetCheck->num_rows === 0) {
throw new \RuntimeException('Target product does not exist');
}
// Source must not already be merged
$existing = self::fetchMergedInto($sourceId);
if ($existing !== null) {
throw new \RuntimeException("Source product {$sourceId} is already merged into product {$existing}");
}
// Target must not itself be a source (no chains during creation; chain
// resolution is supported at read time, but creating a chain here keeps
// the audit table unambiguous).
$targetIsSource = $db->query("SELECT id FROM products WHERE id = " . (int)$targetProductId . " AND merged_into_product_id IS NOT NULL");
if ($targetIsSource !== false && is_object($targetIsSource) && (int)$targetIsSource->num_rows > 0) {
throw new \RuntimeException('Target product is itself merged into another product; cannot chain merges during creation');
}
$sourceIdEsc = (int)$db->escape_string((string)$sourceId);
$targetIdEsc = (int)$db->escape_string((string)$targetProductId);
$mergedBy = $mergedByUserId === null ? 'NULL' : (string)(int)$mergedByUserId;
$reasonSql = $reason === null ? 'NULL' : "'" . $db->escape_string(mb_substr($reason, 0, 500)) . "'";
$now = date('Y-m-d H:i:s');
$db->query("START TRANSACTION");
try {
$updateSql = "UPDATE products SET merged_into_product_id = {$targetIdEsc} WHERE id = {$sourceIdEsc}";
if (!$db->query($updateSql)) {
throw new \RuntimeException('Failed to update products.merged_into_product_id');
}
$insertSql = "INSERT INTO product_merges (source_product_id, target_product_id, merged_by_user_id, reason, merged_at) VALUES ({$sourceIdEsc}, {$targetIdEsc}, {$mergedBy}, {$reasonSql}, '{$now}')";
if (!$db->query($insertSql)) {
throw new \RuntimeException('Failed to insert product_merges audit row');
}
$db->query("COMMIT");
} catch (\RuntimeException $e) {
$db->query("ROLLBACK");
throw $e;
}
// Refresh local object state
$this->getObjectProperties();
}
}
+88 -1
View File
@@ -44,6 +44,7 @@ class users_o extends db
public object_property $sms_notifications_enabled;
public object_property $email_notifications_enabled;
public object_property $wash_certificate_email; // Optional
public object_property $invoice_email; // Optional - TRU-77 / DRIFT 16
protected array $wash_subscription_transactions;
public object_property $two_factor_secret;
public object_property $two_factor_enabled;
@@ -123,6 +124,7 @@ class users_o extends db
$this->sms_notifications_enabled = new object_property($this->table, $this->id, 'sms_notifications_enabled', 'bool', false);
$this->email_notifications_enabled = new object_property($this->table, $this->id, 'email_notifications_enabled', 'bool', false);
$this->wash_certificate_email = new object_property($this->table, $this->id, 'wash_certificate_email', 'string', false);
$this->invoice_email = new object_property($this->table, $this->id, 'invoice_email', 'string', false);
$this->two_factor_secret = new object_property($this->table, $this->id, 'two_factor_secret', 'string', false);
$this->two_factor_enabled = new object_property($this->table, $this->id, 'two_factor_enabled', 'bool', false);
}
@@ -234,15 +236,27 @@ class users_o extends db
}
public function add(string $customer_number, mixed $password, int $role = 0): void
public function add(string $customer_number, mixed $password, int $role = 0, ?string $invoice_email = null): void
{
global $db;
// Ensure the invoice_email column exists (TRU-77 / DRIFT 16)
\classes\customer_invoice_email_schema_bootstrap::ensureSchema();
// Avoid SQL injection
$customer_number = $db->escape_string($customer_number);
$role = $db->escape_string($role);
// Hash the password
$password = password_hash($password, PASSWORD_DEFAULT);
$password = $db->escape_string($password);
$invoice_email_value = null;
if ($invoice_email !== null) {
$trimmed = trim($invoice_email);
if ($trimmed !== '') {
if (!filter_var($trimmed, FILTER_VALIDATE_EMAIL)) {
throw new Exception('Invalid invoice email address');
}
$invoice_email_value = $db->escape_string($trimmed);
}
}
// Create a new record in the database
$sql = "INSERT INTO $this->table (customer_number, password, group_id) VALUES ('$customer_number', '$password', $role)";
$db->query($sql);
@@ -256,6 +270,11 @@ class users_o extends db
// Set the values of the object properties
$this->getObjectProperties();
if ($invoice_email_value !== null) {
$this->invoice_email->set($invoice_email_value);
}
// Set the default attributes
//$this->addAttribute('invoiceAllOrdersIndividually');
$this->addAttribute('restrictTankCleaning');
@@ -389,6 +408,19 @@ class users_o extends db
if ($user_id !== null) {
$this->id = (int)$user_id;
$this->getObjectProperties();
// BUG FIX (TRU-18 / AUT-14): Verify the loaded user actually owns the
// requested EC customer_number. If the inverse Redis cache
// (customer_number -> user_id) is stale — e.g. because a user's
// customer_number was re-mapped via a code path that did not clear
// this cache — getObjectProperties() will have loaded the user's
// CURRENT customer_number from the DB, which may differ from the
// one we asked for. Without this check, downstream invoice code
// (getCustomerEcocomicData, setCustomerNumber) would use the
// stale user and route the invoice to the wrong EC account.
if ((int)$this->customer_number->value() !== $customer_number) {
self::redisCache()?->clear_user_id_from_customer_number($customer_number);
return $this->getUserByCustomerNumber($customer_number);
}
return $this;
}
@@ -428,6 +460,8 @@ class users_o extends db
'number' => $phone,
],
'email' => $this->email->value(),
'invoice_email' => $this->getInvoiceEmailOverride(),
'invoice_email_fallback' => $this->email->value(),
'notifications' => [
'sms_notifications_enabled' => (bool)$this->sms_notifications_enabled->value(),
'email_notifications_enabled' => (bool)$this->email_notifications_enabled->value(),
@@ -1564,6 +1598,59 @@ class users_o extends db
$this->email->set($email);
}
/**
* Get the optional invoice email for the user.
* Returns the dedicated invoice email when set, otherwise falls back to
* the user's primary email. This is the address e-conomic uses to send
* invoices for the customer (TRU-77 / DRIFT 16).
*/
public function getInvoiceEmail(): string|null
{
self::requireSelected();
$invoice = $this->invoice_email->value();
if ($invoice !== null && trim((string)$invoice) !== '') {
return (string)$invoice;
}
$primary = $this->email->value();
if ($primary !== null && trim((string)$primary) !== '') {
return (string)$primary;
}
return null;
}
/**
* Get the explicit invoice email override, if any. Unlike
* {@see getInvoiceEmail()} this does not fall back to the primary email.
*/
public function getInvoiceEmailOverride(): string|null
{
self::requireSelected();
$invoice = $this->invoice_email->value();
if ($invoice === null) {
return null;
}
$trimmed = trim((string)$invoice);
return $trimmed === '' ? null : $trimmed;
}
/**
* Set the optional invoice email for the user. Pass null/empty to clear.
* @throws Exception If the email address is invalid
*/
public function setInvoiceEmail(string|null $email): void
{
self::requireSelected();
if ($email === null || trim($email) === '') {
$this->invoice_email->set(null);
return;
}
$trimmed = trim($email);
if (!filter_var($trimmed, FILTER_VALIDATE_EMAIL)) {
throw new Exception('Invalid invoice email address');
}
$this->invoice_email->set($trimmed);
}
public function isCustomerBarred(int $customer_number): bool
{
if ($customer_number === 0) {
@@ -569,4 +569,81 @@ class xlvask_usage_logs_o extends db
));
return $vehicles;
}
/**
* Read-only per-period usage-log summary used by the Selvvask view.
* Replaces the legacy autopilot-service summary with a thin SQL aggregate
* over xlvask_usage_logs that stays well within the operator's hall scope.
*
* @param array<int,string> $allowedHallIds
* @return array{counts: array<string,int>, total_net_amount: float, ignored_count: int, window: array{from:?string,to:?string}}
*/
public function summarizeUsageOrdersReadOnly(?string $dateFrom, ?string $dateTo, array $allowedHallIds): array
{
global $db;
if ($allowedHallIds === []) {
return [
'counts' => ['total' => 0, 'needs_review' => 0, 'ignored' => 0],
'total_net_amount' => 0.0,
'ignored_count' => 0,
'window' => ['from' => $dateFrom, 'to' => $dateTo],
];
}
$hallSql = implode(',', array_map(
static fn(string $hallId): string => "'" . $db->escape_string($hallId) . "'",
$allowedHallIds
));
$fromSql = $dateFrom !== null && $dateFrom !== ''
? "'" . $db->escape_string(xlvask_usage_logs_o::formatImportDateFrom($dateFrom)) . "'"
: 'DATE_SUB(NOW(), INTERVAL 30 DAY)';
$toSql = $dateTo !== null && $dateTo !== ''
? "'" . $db->escape_string((string)$dateTo) . " 23:59:59'"
: 'NOW()';
$sql = "SELECT
COUNT(*) AS total,
SUM(CASE WHEN ignored_at IS NULL THEN 1 ELSE 0 END) AS needs_review,
SUM(CASE WHEN ignored_at IS NOT NULL THEN 1 ELSE 0 END) AS ignored_count
FROM xlvask_usage_logs
WHERE StartTime >= {$fromSql}
AND StartTime <= {$toSql}
AND FinishStatus = 1
AND HallId IN ({$hallSql})";
$result = $db->query($sql);
$row = ($result !== false && $result->num_rows > 0)
? $db->fetch_all($result)[0]
: ['total' => 0, 'needs_review' => 0, 'ignored_count' => 0];
$netSql = "SELECT
COALESCE(SUM(
CAST(
REPLACE(JSON_UNQUOTE(JSON_EXTRACT(WashItems, '$[0].PriceIncVat')), '\"', '') AS DECIMAL(10,2)
)
- COALESCE(
CAST(
REPLACE(JSON_UNQUOTE(JSON_EXTRACT(WashItems, '$[0].Vat')), '\"', '') AS DECIMAL(10,2)
), 0
)
), 0) AS period_net
FROM xlvask_usage_logs
WHERE StartTime >= {$fromSql}
AND StartTime <= {$toSql}
AND FinishStatus = 1
AND HallId IN ({$hallSql})";
$netResult = $db->query($netSql);
$netRow = ($netResult !== false && $netResult->num_rows > 0)
? $db->fetch_all($netResult)[0]
: ['period_net' => 0];
return [
'counts' => [
'total' => (int)($row['total'] ?? 0),
'needs_review' => (int)($row['needs_review'] ?? 0),
'ignored' => (int)($row['ignored_count'] ?? 0),
],
'total_net_amount' => round((float)($netRow['period_net'] ?? 0), 2),
'ignored_count' => (int)($row['ignored_count'] ?? 0),
'window' => ['from' => $dateFrom, 'to' => $dateTo],
];
}
}
+81 -378
View File
@@ -10659,372 +10659,16 @@ paths:
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/autopilot-runs:
post:
/modules/xlvask/services/usage/orders/{id}/ignore:
patch:
tags:
- Modules
summary: Create an XLVask usage autopilot run
description: Queues an idempotent invoice-period import and automation evaluation run. Dry-run imports and persists plans without automatic execution; replay is cache-only and read-only.
operationId: createXlvaskUsageAutopilotRun
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [mode]
properties:
dateFrom:
type: string
format: date
dateTo:
type: string
format: date
ids:
type: array
items:
type: integer
limit:
type: integer
minimum: 1
maximum: 500
forceRefetch:
type: boolean
mode:
type: string
enum: [execute, dry_run, replay]
idempotency_key:
type: string
maxLength: 191
responses:
'202':
description: XLVask usage autopilot run queued successfully
content:
application/json:
schema:
type: object
properties:
run:
type: object
properties:
id:
type: integer
status:
type: string
phase:
type: string
mode:
type: string
processed:
type: integer
total:
type: integer
summary:
type: object
additionalProperties:
type: integer
warning:
type: string
error:
type: string
created_at:
type: string
nullable: true
started_at:
type: string
nullable: true
finished_at:
type: string
nullable: true
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/autopilot-runs/{id}:
get:
tags:
- Modules
summary: Get an XLVask usage autopilot run
description: Returns status and summary metadata for a previously requested autopilot run.
operationId: getXlvaskUsageAutopilotRun
summary: Ignore an XL Vask usage log
description: Mark an XL Vask usage log as ignored for invoice-period flagging. The change is scoped to the operator's hall scope and recorded with operator id and reason.
operationId: ignoreXlvaskUsageOrder
parameters:
- in: path
name: id
required: true
schema:
type: integer
responses:
'200':
description: XLVask usage autopilot run returned successfully
content:
application/json:
schema:
type: object
properties:
run:
type: object
'400':
description: Invalid XLVask autopilot run id
'404':
description: XLVask autopilot run not found
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/automation/decisions/preview:
post:
tags:
- Modules
summary: Preview an XLVask automation decision
description: Creates a short-lived preview token for applying bulk accept, deny, ignore, or link decisions after source revision revalidation.
operationId: previewXlvaskUsageAutomationDecision
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
usage_log_ids:
type: array
items:
type: integer
action:
type: string
enum: [accept, attach_order, create_order, deny, ignore]
suggestion_id:
type: integer
nullable: true
order_id:
type: integer
nullable: true
reason:
type: string
responses:
'200':
description: XLVask automation decision preview created successfully
content:
application/json:
schema:
type: object
properties:
preview:
type: object
properties:
id: { type: string }
selection_hash: { type: string }
requires_confirmation: { type: boolean }
confirmation_phrase:
type: string
nullable: true
description: Opaque preview-issued phrase that must be submitted exactly when confirmation is required.
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/automation/decisions/apply:
post:
tags:
- Modules
summary: Apply an XLVask automation decision preview
description: Applies a previewed decision inside a transactional policy boundary after source hash, expected version, hall scope, and selection hash are revalidated.
operationId: applyXlvaskUsageAutomationDecision
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [preview_id, selection_hash]
properties:
preview_id:
type: string
selection_hash:
type: string
confirmation_text:
type: string
responses:
'200':
description: XLVask automation decision applied successfully
content:
application/json:
schema:
type: object
properties:
applied:
type: integer
results:
type: array
items:
type: object
failed:
type: array
items:
type: object
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/automation/admin/readiness:
get:
tags: [Modules]
summary: Inspect XLVask automation activation readiness
description: Read-only, fail-closed view of wash-id uniqueness and active calibration artifacts.
operationId: getXlvaskAutomationActivationReadiness
parameters:
- { in: query, name: dateFrom, required: false, schema: { type: string, format: date } }
- { in: query, name: dateTo, required: false, schema: { type: string, format: date } }
responses:
'200':
description: Activation readiness returned successfully
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/automation/capabilities:
get:
tags: [Modules]
summary: Inspect effective XLVask automation capabilities
operationId: getXlvaskAutomationCapabilities
parameters:
- { in: query, name: dateFrom, required: false, schema: { type: string, format: date } }
- { in: query, name: dateTo, required: false, schema: { type: string, format: date } }
responses:
'200':
description: Permission-aware capabilities, stage, readiness, active run, budgets, and reviewed soak returned.
content:
application/json:
schema:
type: object
properties:
effective_action_sources:
type: array
description: Empty unless automatic financial actions are currently effective; OpenAI is the only supported source.
items: { type: string, enum: [openai] }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/autopilot-runs/active:
get:
tags: [Modules]
summary: Inspect the active XLVask execute run
operationId: getActiveXlvaskUsageAutopilotRun
responses:
'200':
description: "Returns {run: null} or the oldest active execute run."
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/automation/admin/policy/previews:
post:
tags: [Modules]
summary: Preview an XLVask server-policy stage transition
operationId: previewXlvaskAutomationPolicyTransition
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [target_stage, reason]
properties:
target_stage:
type: string
enum: [off, advisory, ai_attach_canary, ai_attach_verified, ai_create_canary, verified_capped]
reason:
type: string
minLength: 1
maxLength: 1000
responses:
'200': { description: Short-lived, readiness-bound policy preview returned. }
'409': { description: Stage ordering, calibration, active run, schema, uniqueness, or reviewed-soak gate blocked the transition. }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/automation/admin/policy/apply:
post:
tags: [Modules]
summary: Apply a previewed XLVask server-policy transition
operationId: applyXlvaskAutomationPolicyTransition
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [preview_id, selection_hash, confirmation_text]
properties:
preview_id: { type: string, format: uuid }
selection_hash: { type: string }
confirmation_text: { type: string }
responses:
'200': { description: Policy and re-evaluated readiness returned. }
'409': { description: Preview expired or policy, identity, calibration, run, or readiness changed. }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/automation/admin/halt:
post:
tags: [Modules]
summary: Immediately halt XLVask automatic actions
operationId: haltXlvaskAutomation
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
reason: { type: string, maxLength: 1000 }
responses:
'200': { description: Automatic actions halted and kill switches disabled atomically. }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/automation/admin/calibrations/labels:
post:
tags: [Modules]
summary: Adjudicate one exact XLVask suggestion
description: Stores an administrator-adjudicated correct or incorrect label bound to one suggestion ID.
operationId: adjudicateXlvaskCalibrationLabel
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [suggestion_id, outcome]
properties:
suggestion_id: { type: integer }
outcome: { type: string, enum: [correct, incorrect, duplicate, cross_hall, unaudited] }
responses:
'200': { description: Calibration label stored successfully }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/automation/admin/calibrations/backtest:
post:
tags: [Modules]
summary: Generate an inactive XLVask calibration artifact
description: Uses exact adjudicated labels and a chronological 80/20 holdout; generation never activates the artifact.
operationId: generateXlvaskCalibrationArtifact
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [segment_key]
properties:
segment_key: { type: string }
responses:
'200': { description: Inactive calibration artifact generated successfully }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/automation/admin/calibrations/{id}/activate:
post:
tags: [Modules]
summary: Activate a qualifying XLVask calibration artifact
operationId: activateXlvaskCalibrationArtifact
parameters:
- in: path
name: id
- name: id
in: path
required: true
schema: { type: integer }
requestBody:
@@ -11033,35 +10677,94 @@ paths:
application/json:
schema:
type: object
required: [artifact_hash, confirmation_text]
required: [reason]
properties:
artifact_hash: { type: string }
confirmation_text: { type: string }
reason:
type: string
maxLength: 500
responses:
'200': { description: Calibration artifact activated successfully }
'401': { $ref: '#/components/responses/Unauthorized' }
'200':
description: XL Vask usage log marked as ignored
'400': { $ref: '#/components/responses/BadRequest' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
/modules/xlvask/services/usage/automation/admin/wash-id-uniqueness/activate:
/modules/xlvask/services/usage/orders/{id}/unignore:
post:
tags: [Modules]
summary: Activate guarded wash-id uniqueness
description: Explicitly verifies duplicates, adds the normalized wash-id column and unique index, and fails closed on conflicts.
operationId: activateXlvaskWashIdUniqueness
tags:
- Modules
summary: Clear ignore metadata on an XL Vask usage log
description: Resets ignored_at, ignored_by and ignored_reason on an XL Vask usage log scoped to the operator's hall scope.
operationId: unignoreXlvaskUsageOrder
parameters:
- name: id
in: path
required: true
schema: { type: integer }
responses:
'200':
description: Ignore metadata cleared
'400': { $ref: '#/components/responses/BadRequest' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
/modules/xlvask/services/usage/orders/{id}/accept:
post:
tags:
- Modules
summary: Convert an XL Vask usage log into an order
description: Creates an order from the XL Vask usage log in the operator's hall scope and records the converted usage log as ignored with a stable reason referencing the order id.
operationId: acceptXlvaskUsageOrder
parameters:
- name: id
in: path
required: true
schema: { type: integer }
responses:
'200':
description: Order created from XL Vask usage log
content:
application/json:
schema:
type: object
properties:
order_id: { type: integer }
usage_log_id: { type: integer }
'400': { $ref: '#/components/responses/BadRequest' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'422': { description: XL Vask customer is not linkable }
/modules/xlvask/services/usage/orders/{id}/reject:
post:
tags:
- Modules
summary: Reject an XL Vask usage log with a reviewer note
description: Marks the XL Vask usage log as ignored with a reviewer-provided reason. Scoped to the operator's hall scope.
operationId: rejectXlvaskUsageOrder
parameters:
- name: id
in: path
required: true
schema: { type: integer }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [confirmation_text]
required: [reason]
properties:
confirmation_text: { type: string }
reason:
type: string
maxLength: 500
responses:
'200': { description: Wash-id uniqueness activated successfully }
'409': { description: Duplicate wash IDs or schema readiness blocked activation }
'401': { $ref: '#/components/responses/Unauthorized' }
'200':
description: XL Vask usage log rejected
'400': { $ref: '#/components/responses/BadRequest' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
/modules/action-logs:
get:
@@ -739,6 +739,22 @@ 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);
@@ -1230,6 +1246,41 @@ 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 = [];
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace routes;
use classes\authentication;
use classes\response;
use classes\customer_invoice_email_schema_bootstrap;
use traits\route_t;
/**
* Admin / ops endpoints. Currently exposes the schema health check.
*
* The schema health check verifies that all required DB columns exist
* for the routes the code references. If a column is missing (e.g. a
* migration wasn't run on production), the endpoint returns 503 with
* a clear list of missing columns much more useful than a generic
* 500 with "Unknown column" hidden in the stack trace.
*/
class adminRoute
{
use route_t;
public function run(): void
{
// Schema health check — used by deploy pipelines, monitoring,
// and the cron job. Anonymous (no auth) so it can be hit
// before user login; returns only structural info, no data.
$this->get('/admin/schema-check', function () {
global /** @var response $response */ $response;
// Self-heal: run all schema bootstraps first
if (class_exists(customer_invoice_email_schema_bootstrap::class)) {
try {
customer_invoice_email_schema_bootstrap::ensureSchema();
} catch (\Throwable $e) {
// Bootstrap may fail in environments where $db is
// not yet wired up; report and continue with check
}
}
$report = $this->runSchemaCheck();
$response->setStatus($report['ok'] ? 200 : 503);
$response->setBody(json_encode($report, JSON_PRETTY_PRINT));
});
}
/**
* Returns ['ok' => bool, 'missing' => array, ...].
* If ok=false, the deploy should be blocked.
*/
private function runSchemaCheck(): array
{
global $db;
$report = [
'ok' => true,
'missing' => [],
'tables_checked' => 0,
'columns_checked' => 0,
'timestamp' => date('c'),
'note' => 'If "missing" is non-empty, the migration that adds these columns was not run on the database.',
];
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
$report['ok'] = false;
$report['error'] = 'no_db_connection';
return $report;
}
$requirements = [
'users' => [
'invoice_email', // TRU-77 (added 2026-08-16, was missing on production)
'wash_certificate_email',
'email',
'customer_number',
],
'invoices' => [
'po_number',
'closed_at',
'customer_number',
],
'bookings' => [
'id',
'customer_number',
'department',
],
];
foreach ($requirements as $table => $columns) {
$report['tables_checked']++;
$tableSafe = str_replace('`', '', $table);
$result = $db->query("SHOW TABLES LIKE '{$tableSafe}'");
if (!$result || (int)$result->num_rows === 0) {
$report['ok'] = false;
$report['missing'][] = "table `{$table}` does not exist";
continue;
}
foreach ($columns as $column) {
$report['columns_checked']++;
$colSafe = str_replace("'", '', $column);
$r = $db->query("SHOW COLUMNS FROM `{$tableSafe}` LIKE '{$colSafe}'");
if (!$r || (int)$r->num_rows === 0) {
$report['ok'] = false;
$report['missing'][] = "{$table}.{$column}";
}
}
}
return $report;
}
}
@@ -952,44 +952,6 @@ class moduleConfigRoute
'modules_openai_config' => 'Update openai config'
]
);
/** MiniMax config > GET */
$this->get('/minimax/config', function () {
global $response;
$this->requirePermission('modules_minimax_config');
$user = (new authentication())->get_user();
if ($user) {
(new logs_o())->add('minimax_config', 'global', 1, $user->id, 'MINIMAX_CONFIG', 'Successfully fetched MiniMax config');
$response->success(
(new \classes\minimax())->config->getConfigRequest()
);
} else {
(new logs_o())->add('minimax_config', 'global', 1, 0, 'MINIMAX_CONFIG', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
},
[
'modules_minimax_config' => 'Get MiniMax config'
]
);
/** MiniMax config > POST */
$this->post('/minimax/config', function () {
global $response;
$this->requirePermission('modules_minimax_config');
$user = (new authentication())->get_user();
if ($user) {
(new logs_o())->add('minimax_config', 'global', 1, $user->id, 'MINIMAX_CONFIG', 'Successfully updated MiniMax config');
$response->success(
(new \classes\minimax())->config->postConfigRequest()
);
} else {
(new logs_o())->add('minimax_config', 'global', 1, 0, 'MINIMAX_CONFIG', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
},
[
'modules_minimax_config' => 'Update MiniMax config'
]
);
/** LicensePlateRecognizer config > GET */
$this->get('/licenseplaterecognizer/config', function () {
global $response;
@@ -2,15 +2,11 @@
namespace routes;
require_once WD . '/classes/xlvask_autopilot_service.php';
use classes\authentication;
use classes\response;
use classes\router;
use classes\xlvask;
use classes\xlvask_autopilot_service;
use objects\orders_o;
use objects\users_o;
use objects\xlvask_customers_o;
use traits\route_t;
@@ -180,10 +176,6 @@ class moduleXLVaskRoute
$this->get('/modules/xlvask/tasks/sync-usage', function () {
global $response;
$this->requirePermission('modules_xlvask_sync_usage');
// Remove the memory limit
// ini_set('memory_limit', '-1');
// Remove the execution time limit
// set_time_limit(300);
// Create the xlvask tasks object
$xlvask = new xlvask();
// Run the sync usage task
@@ -199,27 +191,6 @@ class moduleXLVaskRoute
]
);
$this->get('/modules/xlvask/tasks/debug', function () {
global $response;
$this->requirePermission('modules_xlvask_sync_usage');
// Create the xlvask tasks object
$xlvask = new xlvask();
$user = new users_o();
$user->getUserByCustomerNumber(12345679);
//$result = $xlvask->getTasks()->runSyncVehicles(false);
$vehicles = $xlvask->new($xlvask->helpers->xlvask_vehicles);
//print_r($vehicles::getVehicleByRegistrationNumber('BW93159'));
// Response
$response->success(
'Debugging xlvask tasks',
200
);
},
[
'modules_xlvask_sync_usage' => 'Synchronize usage with the xlvask module'
]
);
$this->get('/modules/xlvask/tasks/import-customers', function () {
global $response;
$this->requirePermission('modules_xlvask_import_customers');
@@ -251,15 +222,5 @@ class moduleXLVaskRoute
'modules_xlvask_import_vehicles' => 'Import vehicles from the xlvask module'
]
);
$this->get('/modules/xlvask/tasks/import-usage', function () {
global $response;
$this->requirePermission('modules_xlvask_import_usage');
$response->error('Deprecated state-changing GET. Use POST /modules/xlvask/services/usage/autopilot-runs.', 410);
},
[
'modules_xlvask_import_usage' => 'Import usage from the xlvask module'
]
);
}
}
@@ -88,6 +88,18 @@ class productsRoute
return $parsed;
}
private function routePositiveInt(string $name): int
{
global $response;
$raw = $this->fromRoute($name);
if (!is_string($raw) || !preg_match('/^[1-9][0-9]*$/', $raw)) {
$response->error('Invalid route parameter', 400);
}
return (int)$raw;
}
private function isNullLikeOptionalParameter(mixed $value): bool
{
if ($value === null) {
@@ -548,5 +560,55 @@ class productsRoute
'edit_product' => 'Edit a product'
]
);
// POST /products/:id/merge — merge a product into another.
// Body: { target_id: int, reason?: string }
// The source product is preserved (so historical order_items references remain valid),
// but is marked as merged in the products table. Reads and new orders should follow
// merged_into_product_id to the target. An audit row is written to product_merges.
$this->post('/products/{id}/merge', function () {
global $response;
$this->requirePermission('edit_product');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('products', 'global', 1, 0, 'MERGE_PRODUCT', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
$sourceId = $this->routePositiveInt('id');
$targetId = (int)($response->getRequestParameter('target_id') ?? 0);
if ($targetId <= 0) {
$response->error('target_id is required and must be a positive integer', 400);
}
$reason = $response->getRequestParameter('reason');
if ($reason !== null && !is_string($reason)) {
$response->error('reason must be a string', 400);
}
$source = (new products_o())->select($sourceId);
if (!$source->exists()) {
$response->error('Source product not found', 404);
}
try {
$source->mergeInto($targetId, (int)$user->id, $reason);
} catch (\RuntimeException $e) {
(new logs_o())->add('products', 'global', 3, (int)$user->id, 'MERGE_PRODUCT_FAILED', "source={$sourceId} target={$targetId} error=" . $e->getMessage());
$response->error($e->getMessage(), 400);
}
(new logs_o())->add('products', 'global', 1, (int)$user->id, 'MERGE_PRODUCT', "source={$sourceId} target={$targetId}");
$response->success([
'message' => 'Product merged successfully',
'source_product_id' => $sourceId,
'target_product_id' => $targetId,
'merged_into_product_id' => (int)$source->merged_into_product_id->value(),
]);
},
[
'edit_product' => 'Merge a product into another (preserves historical order references; new orders resolve to the target).'
]
);
}
}
@@ -62,9 +62,21 @@ class userInvoicesRoute
self::requireSameLength($id, self::getParameter('id'));
$is_superuser = $this->hasPermission('superuser');
if (!self::isParametersSet(['po_number']) && !self::isParametersSet(['closed_at'])) {
$response->error('Missing required parameters: po_number, closed_at', 400);
// At least one of po_number or closed_at must be provided. The
// previous message said "Missing required parameters:
// po_number, closed_at" which read as if BOTH were required
// and confused customers trying to invoice (TRU-128).
$response->error('At least one of po_number or closed_at must be provided', 400);
}
if (self::isParametersSet(['closed_at']) && !$is_superuser) {
// Only superusers may set a non-empty closed_at. Customers are
// still allowed to pass an empty/null closed_at to CLEAR a
// previously set value (the field is then set to null below).
$closed_at_is_non_empty = false;
if (self::isParametersSet(['closed_at'])) {
$raw_closed_at = self::getParameter('closed_at');
$closed_at_is_non_empty = ($raw_closed_at !== null && $raw_closed_at !== '');
}
if ($closed_at_is_non_empty && !$is_superuser) {
$response->error('Forbidden: only superusers can update closed_at', 403);
}
// Make sure optional fields are valid
+28 -1
View File
@@ -119,8 +119,19 @@ class usersRoute
if ($role !== 0) {
$this->requirePermission('edit_user_role');
}
// TRU-77 / DRIFT 16: optional dedicated invoice email
$invoice_email = null;
if (isset($data['invoice_email']) && $data['invoice_email'] !== null && $data['invoice_email'] !== '') {
$candidate = trim((string)$data['invoice_email']);
if ($candidate !== '') {
if (!filter_var($candidate, FILTER_VALIDATE_EMAIL)) {
$response->error('Invalid invoice email address', 400);
}
$invoice_email = $candidate;
}
}
// Add the user
(new users_o())->add($data['customer_number'], $data['password'], $role);
(new users_o())->add($data['customer_number'], $data['password'], $role, $invoice_email);
// Log the incident
(new logs_o())->add('users', 'global', 1, $user->id, 'ADD_USER', 'Successfully added a user');
// Return a success message
@@ -193,6 +204,22 @@ class usersRoute
}
// Edit the user
(new users_o())->edit((int)$data['id'], (string)$data['customer_number'], $data['role'], $data['password'], $data['display_name']);
// TRU-77 / DRIFT 16: allow updating the dedicated invoice email
if (array_key_exists('invoice_email', $data)) {
$raw = $data['invoice_email'];
if ($raw === null || $raw === '' || $raw === 'null') {
$targetUser->setInvoiceEmail(null);
} else {
$candidate = trim((string)$raw);
if ($candidate === '') {
$targetUser->setInvoiceEmail(null);
} elseif (!filter_var($candidate, FILTER_VALIDATE_EMAIL)) {
$response->error('Invalid invoice email address', 400);
} else {
$targetUser->setInvoiceEmail($candidate);
}
}
}
// Log the incident
(new logs_o())->add('users', 'global', 1, $user->id, 'EDIT_USER', 'Successfully edited a user with ID: ' . $data['id']);
// Return a success message
+297 -559
View File
@@ -2,24 +2,10 @@
namespace routes;
require_once WD . '/classes/xlvask_automation_service.php';
require_once WD . '/classes/xlvask_autopilot_service.php';
require_once WD . '/classes/xlvask_automation_policy_service.php';
use classes\authentication;
use classes\redis;
use classes\response;
use classes\stripe;
use classes\xlvask;
use classes\xlvask_autopilot_service;
use classes\xlvask_automation_service;
use classes\xlvask_automation_policy_service;
use objects\collected_order_invoices_o;
use objects\departments_o;
use objects\economic_module_orders;
use objects\orders_o;
use objects\stripe_module_orders_o;
use objects\stripe_payment_intents_o;
use objects\xlvask_usage_logs_o;
use traits\route_t;
@@ -30,136 +16,110 @@ class xlvaskUsageLogsRoute
public function run(): void
{
$this->get('/modules/xlvask/services/usage/orders', function () {
// Define the permissions:
$permission_list_own = 'list_xlvask_usage_orders_own'; // Permission to list own orders (Without department filter)
$permission_list_all = 'list_xlvask_usage_orders_all'; // Permission to list all orders (With department filter)
$response_includes_items = false; // Whether to include items in the response (This would increase the memory usage significantly)
// Require the user to be logged in
$permission_list_own = 'list_xlvask_usage_orders_own';
$permission_list_all = 'list_xlvask_usage_orders_all';
$response_includes_items = false;
global $response;
if (!$this->hasPermission($permission_list_all)) {
$this->requirePermission($permission_list_own);
}
// Get the user object
$user = (new authentication())->get_user();
// Check if the request was successful
if ($user) {
$allowedHallIds = $this->allowedHallIdsForUser($user);
if ($allowedHallIds === []) {
$response->error('No XL Vask hall scope is available', 403);
return;
}
$xlvask_usage_logs = new xlvask_usage_logs_o();
$xlvask = new xlvask();
$automation_service = new xlvask_automation_service();
$linked_order_ids_by_wash_id = [];
$xlvask->new($xlvask->helpers->xlvask_usage_log)->getDepartment();
$orders_o = new orders_o();
$xlvask_usage_log = $xlvask->new($xlvask->helpers->xlvask_usage_log);
// Increase the memory limit to 512MB (Provided it's currently less than that)
if (ini_get('memory_limit') < '5120M') {
ini_set('memory_limit', '5120M');
}
// Return the list of usage logs
$result = $xlvask_usage_logs
// Make sure the Customer is not in the default customers list
->setAdditionalWhereClause("`Customer` NOT IN ('" . implode("', '", $xlvask_usage_log::$default_customers) . "')")
->listObjectsWithPaginationIfSet(
function ($log) use ($response_includes_items, $xlvask_usage_logs, $xlvask, $automation_service, &$linked_order_ids_by_wash_id) {
// Remove the 'id' field from the log
$id = (int)$log['id'];
$automation = $automation_service->readAutomationStateByUsageLogId($id, $log);
$amount_summary = $xlvask_usage_logs->getAmountSummaryReadOnly($log);
unset($log['id']);
// Convert the 'WashItems' field from JSON to an array
$log['WashItems'] = json_decode($log['WashItems'], true);
$usage_log_payload = array_intersect_key($log, array_flip([
'WashId',
'CustomerId',
'Customer',
'VatNumber',
'Location',
'Hall',
'HallId',
'StartTime',
'FinishTime',
'RegistrationNumber',
'VehicleType',
'IdentificationType',
'IdentificationId',
'Info',
'Updated',
'Prepaid',
'FinishStatus',
'CustomerGuid',
'VehicleId',
'WashItems',
'ignored_at',
'ignored_by',
'ignored_reason',
]));
// Create a new xlvask usage log object
$tmp = $xlvask->new($xlvask->helpers->xlvask_usage_log);
// Set the properties of the temporary object
$tmp->setProperties($usage_log_payload);
$wash_id = (string)$tmp->WashId;
if ($wash_id !== '' && !array_key_exists($wash_id, $linked_order_ids_by_wash_id)) {
$linked_order = (new orders_o())->selectByWashId($wash_id);
$linked_order_ids_by_wash_id[$wash_id] = $linked_order !== null ? (int)$linked_order->id : null;
}
$linked_order_id = $wash_id !== '' ? $linked_order_ids_by_wash_id[$wash_id] : null;
// Define the result structure
$isEligibleForAutomaticContinuance = $tmp->isEligibleForAutomaticContinuance(true);
// Return the result
$tmp_res = ($isEligibleForAutomaticContinuance ? (new orders_o())->simulateOrderFromXLVask($tmp, $response_includes_items) : []);
$tmp_res['order']['customer_name'] = $tmp->Customer; // Add the customer name to the order
$tmp_res['order']['total_net_amount'] = $amount_summary['total_net_amount'];
$tmp_res['order']['xlvask_primary_product_name'] = $amount_summary['primary_product_name'];
$tmp_res['order']['xlvask_amount_cached'] = $amount_summary['cached'];
// Clear memory
unset($tmp);
// Return the result
return [
'id' => $id, // Return the ID of the log
'fast_link_key' => null,
'automation' => $automation,
'source_hash' => $log['source_hash'] ?? null,
'source_revision' => $log['source_revision'] ?? null,
'source_observed_at' => $log['source_observed_at'] ?? null,
'source_stable_since' => $log['source_stable_since'] ?? null,
'source_observation_count' => (int)($log['source_observation_count'] ?? 0),
'import_state' => $log['import_state'] ?? 'unchanged',
'resolution_state' => $log['resolution_state'] ?? 'needs_review',
'certainty' => $log['certainty'] ?? 'none',
'planned_action' => $log['planned_action'] ?? 'none',
'state_reason' => $log['state_reason'] ?? null,
'expected_version' => isset($log['expected_version']) ? (int)$log['expected_version'] : 1,
'last_run_id' => isset($log['last_run_id']) ? (int)$log['last_run_id'] : null,
'last_evaluated_at' => $log['last_evaluated_at'] ?? null,
...$tmp_res['order'], // Return the simulated order from XLVask (with or without items)
'usage_log_id' => $id,
'linked_order_id' => $linked_order_id,
];
},
$xlvask_usage_logs->forceRestrictFilters(
[
// This makes sure that the user can only see department logs that belong to their departments
'HallId' => $allowedHallIds,
'FinishStatus' => ['1'], // Only show finished logs
]
)
);
// Return the response
$response->success($result);
} else {
// Return an error
if (!$user) {
$response->error('Invalid session', 400);
}
},
[
'list_xlvask_usage_orders_own' => 'List own xlvask usage orders (Without department filter)',
'list_xlvask_usage_orders_all' => 'List all xlvask usage orders (With department filter)',
]
);
$allowedHallIds = $this->allowedHallIdsForUser($user);
if ($allowedHallIds === []) {
$response->error('No XL Vask hall scope is available', 403);
}
$xlvask_usage_logs = new xlvask_usage_logs_o();
$xlvask = new xlvask();
$linked_order_ids_by_wash_id = [];
$xlvask_usage_log_class = $xlvask->helpers->xlvask_usage_log;
if (ini_get('memory_limit') < '5120M') {
ini_set('memory_limit', '5120M');
}
$result = $xlvask_usage_logs
->setAdditionalWhereClause(
"`Customer` NOT IN ('" . implode("', '", $xlvask_usage_log_class::$default_customers) . "')"
)
->listObjectsWithPaginationIfSet(
function ($log) use (
$response_includes_items,
$xlvask_usage_logs,
$xlvask,
&$linked_order_ids_by_wash_id
) {
$id = (int)$log['id'];
$amount_summary = $xlvask_usage_logs->getAmountSummaryReadOnly($log);
unset($log['id']);
$log['WashItems'] = json_decode($log['WashItems'] ?? '[]', true);
$usage_log_payload = array_intersect_key($log, array_flip([
'WashId',
'CustomerId',
'Customer',
'VatNumber',
'Location',
'Hall',
'HallId',
'StartTime',
'FinishTime',
'RegistrationNumber',
'VehicleType',
'IdentificationType',
'IdentificationId',
'Info',
'Updated',
'Prepaid',
'FinishStatus',
'CustomerGuid',
'VehicleId',
'WashItems',
'ignored_at',
'ignored_by',
'ignored_reason',
]));
$tmp = $xlvask->new($xlvask_usage_log_class);
$tmp->setProperties($usage_log_payload);
$wash_id = (string)$tmp->WashId;
if ($wash_id !== '' && !array_key_exists($wash_id, $linked_order_ids_by_wash_id)) {
$linked_order = (new orders_o())->selectByWashId($wash_id);
$linked_order_ids_by_wash_id[$wash_id] = $linked_order !== null
? (int)$linked_order->id
: null;
}
$linked_order_id = $wash_id !== ''
? $linked_order_ids_by_wash_id[$wash_id]
: null;
$isEligibleForAutomaticContinuance = $tmp->isEligibleForAutomaticContinuance(true);
$tmp_res = $isEligibleForAutomaticContinuance
? (new orders_o())->simulateOrderFromXLVask($tmp, $response_includes_items)
: [];
$tmp_res['order']['customer_name'] = $tmp->Customer;
$tmp_res['order']['total_net_amount'] = $amount_summary['total_net_amount'];
$tmp_res['order']['xlvask_primary_product_name'] = $amount_summary['primary_product_name'];
$tmp_res['order']['xlvask_amount_cached'] = $amount_summary['cached'];
unset($tmp);
return [
'id' => $id,
'fast_link_key' => null,
'usage_log_id' => $id,
'linked_order_id' => $linked_order_id,
'ignored_at' => $log['ignored_at'] ?? null,
'ignored_by' => isset($log['ignored_by']) ? (int)$log['ignored_by'] : null,
'ignored_reason' => $log['ignored_reason'] ?? null,
...$tmp_res['order'],
];
},
$xlvask_usage_logs->forceRestrictFilters([
'HallId' => $allowedHallIds,
'FinishStatus' => ['1'],
])
);
$response->success($result);
}, [
'list_xlvask_usage_orders_own' => 'List own xlvask usage orders (Without department filter)',
'list_xlvask_usage_orders_all' => 'List all xlvask usage orders (With department filter)',
]);
$this->get('/modules/xlvask/services/usage/orders/summary', function () {
global $response;
@@ -172,375 +132,189 @@ class xlvaskUsageLogsRoute
}
$dateFrom = $this->isParametersSet(['dateFrom']) ? (string)$this->getParameter('dateFrom') : null;
$dateTo = $this->isParametersSet(['dateTo']) ? (string)$this->getParameter('dateTo') : null;
$response->success([
'summary' => (new xlvask_autopilot_service())->getSummary(
$dateFrom,
$dateTo,
$this->allowedHallIdsForUser($user)
),
]);
},
[
'list_xlvask_usage_orders_own' => 'Read XL Vask usage-log automation summary',
'list_xlvask_usage_orders_all' => 'Read all XL Vask usage-log automation summaries',
]
);
$this->post('/modules/xlvask/services/usage/autopilot-runs', function () {
global $response;
$this->requirePermission('manage_xlvask_usage_automation');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$input = [];
foreach ([
'ids',
'dateFrom',
'dateTo',
'limit',
'forceRefetch',
'mode',
'idempotency_key',
'aiTimeline',
'aiBatchSize',
'aiMaxCostUsd',
'aiInputUsdPer1mUsd',
'aiOutputUsdPer1mUsd',
] as $key) {
if ($this->isParametersSet([$key])) {
$input[$key] = $this->getParameter($key);
}
}
$response->success([
'run' => (new xlvask_autopilot_service())->createRun(
$input,
(int)$user->id,
$this->allowedHallIdsForUser($user)
),
], 202);
},
[
'manage_xlvask_usage_automation' => 'Create an XL Vask usage-log autopilot run',
]
);
$this->get('/modules/xlvask/services/usage/automation/admin/readiness', function () {
global $response;
$this->requirePermission('superuser_xlvask_automation_activate');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$dateFrom = $this->isParametersSet(['dateFrom']) ? trim((string)$this->getParameter('dateFrom')) : null;
$dateTo = $this->isParametersSet(['dateTo']) ? trim((string)$this->getParameter('dateTo')) : null;
$response->success((new xlvask_automation_policy_service())->readinessReadOnly(
$allowedHallIds = $this->allowedHallIdsForUser($user);
$summary = (new xlvask_usage_logs_o())->summarizeUsageOrdersReadOnly(
$dateFrom,
$dateTo,
$this->allowedHallIdsForUser($user)
));
}, [
'superuser_xlvask_automation_activate' => 'Inspect XL Vask automation activation readiness',
]);
$this->get('/modules/xlvask/services/usage/automation/capabilities', function () {
global $response;
if (!$this->hasPermission('list_xlvask_usage_orders_all')
&& !$this->hasPermission('list_xlvask_usage_orders_own')) {
$this->requirePermission('list_xlvask_usage_orders_own');
}
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$dateFrom = $this->isParametersSet(['dateFrom']) ? trim((string)$this->getParameter('dateFrom')) : null;
$dateTo = $this->isParametersSet(['dateTo']) ? trim((string)$this->getParameter('dateTo')) : null;
$service = new xlvask_automation_policy_service();
$capabilities = $service->capabilitiesReadOnly($dateFrom, $dateTo, $this->allowedHallIdsForUser($user));
$canManage = $this->hasPermission('manage_xlvask_usage_automation');
$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
);
$dateFrom = $this->isParametersSet(['dateFrom']) ? trim((string)$this->getParameter('dateFrom')) : null;
$dateTo = $this->isParametersSet(['dateTo']) ? trim((string)$this->getParameter('dateTo')) : null;
$response->success([
...$result,
'readiness' => (new xlvask_automation_policy_service())->readinessReadOnly($dateFrom, $dateTo, $allowedHallIds),
]);
$response->success(['summary' => $summary]);
}, [
'superuser_xlvask_automation_activate' => 'Adjudicate one exact XL Vask suggestion for calibration evidence',
'list_xlvask_usage_orders_own' => 'Read XL Vask usage-log summary',
'list_xlvask_usage_orders_all' => 'Read all XL Vask usage-log summaries',
]);
$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('ignore_xlvask_usage_order');
$response->error('Use the server-generated automation decision preview and apply endpoints.', 409);
},
[
'ignore_xlvask_usage_order' => 'Ignore an XL Vask usage log for invoice period flagging',
]
);
$this->post('/modules/xlvask/services/usage/orders/automation/run', function () {
global $response;
$this->requirePermission('manage_xlvask_usage_automation');
$response->error('Deprecated. Use /modules/xlvask/services/usage/autopilot-runs.', 410);
},
[
'manage_xlvask_usage_automation' => 'Evaluate and execute XL Vask usage-log automation',
]
);
$this->post('/modules/xlvask/services/usage/orders/{id}/automation/evaluate', function () {
global $response;
$this->requirePermission('manage_xlvask_usage_automation');
$response->error('Deprecated. Use /modules/xlvask/services/usage/autopilot-runs.', 410);
},
[
'manage_xlvask_usage_automation' => 'Evaluate XL Vask usage-log automation',
]
);
$this->post('/modules/xlvask/services/usage/orders/{id}/automation/accept', function () {
global $response;
$this->requirePermission('manage_xlvask_usage_automation');
$this->requirePermission('review_xlvask_usage_order');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$id = (int)($this->fromRoute('id') ?? 0);
if ($id < 1) {
$response->error('Invalid XL Vask usage log id', 400);
}
self::requireUsageLogInHallScope($id, $this->allowedHallIdsForUser($user));
self::requireParameters(['reason']);
$reason = trim((string)$this->getParameter('reason'));
if (mb_strlen($reason) > 500) {
$response->error('Reason is too long (max 500 characters)', 400);
}
$allowedHallIds = $this->allowedHallIdsForUser($user);
self::requireUsageLogInHallScope($id, $allowedHallIds);
$log = new xlvask_usage_logs_o();
$log->getById($id);
if (!$log->id) {
$response->error('XL Vask usage log not found', 404);
}
$log->ignored_at->set(date('Y-m-d H:i:s'));
$log->ignored_by->set((int)$user->id);
$log->ignored_reason->set($reason);
$log->objectChanged();
$response->success([
'id' => $id,
'ignored_at' => $log->ignored_at->get(),
'ignored_by' => (int)$log->ignored_by->get(),
'ignored_reason' => $log->ignored_reason->get(),
]);
}, [
'review_xlvask_usage_order' => 'Ignore an XL Vask usage log for invoice period flagging',
]);
$response->error('Use the server-generated automation decision preview and apply endpoints.', 409);
},
[
'manage_xlvask_usage_automation' => 'Accept an XL Vask usage-log automation suggestion',
]
);
$this->post('/modules/xlvask/services/usage/orders/{id}/automation/deny', function () {
$this->post('/modules/xlvask/services/usage/orders/{id}/unignore', function () {
global $response;
$this->requirePermission('manage_xlvask_usage_automation');
$this->requirePermission('review_xlvask_usage_order');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$id = (int)($this->fromRoute('id') ?? 0);
if ($id < 1) {
$response->error('Invalid XL Vask usage log id', 400);
}
self::requireUsageLogInHallScope($id, $this->allowedHallIdsForUser($user));
$allowedHallIds = $this->allowedHallIdsForUser($user);
self::requireUsageLogInHallScope($id, $allowedHallIds);
$log = new xlvask_usage_logs_o();
$log->getById($id);
if (!$log->id) {
$response->error('XL Vask usage log not found', 404);
}
$log->ignored_at->set(null);
$log->ignored_by->set(null);
$log->ignored_reason->set(null);
$log->objectChanged();
$response->success(['id' => $id]);
}, [
'review_xlvask_usage_order' => 'Clear ignore metadata on an XL Vask usage log',
]);
$response->error('Use the server-generated automation decision preview and apply endpoints.', 409);
},
[
'manage_xlvask_usage_automation' => 'Deny an XL Vask usage-log automation suggestion',
]
);
$this->post('/modules/xlvask/services/usage/orders/{id}/accept', function () {
global $response;
$this->requirePermission('review_xlvask_usage_order');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$id = (int)($this->fromRoute('id') ?? 0);
if ($id < 1) {
$response->error('Invalid XL Vask usage log id', 400);
}
$allowedHallIds = $this->allowedHallIdsForUser($user);
self::requireUsageLogInHallScope($id, $allowedHallIds);
$log = new xlvask_usage_logs_o();
$log->getById($id);
if (!$log->id) {
$response->error('XL Vask usage log not found', 404);
}
$xlvask = new xlvask();
$log_helper = new ($xlvask->helpers->xlvask_usage_log)();
$log_helper->setProperties(array_intersect_key($log->toArray(), array_flip([
'WashId',
'CustomerId',
'Customer',
'VatNumber',
'Location',
'Hall',
'HallId',
'StartTime',
'FinishTime',
'RegistrationNumber',
'VehicleType',
'IdentificationType',
'IdentificationId',
'Info',
'Updated',
'Prepaid',
'FinishStatus',
'CustomerGuid',
'VehicleId',
'WashItems',
])));
$customer = $log_helper->getCustomer();
if ($customer === null) {
$response->error('XL Vask customer is not linkable', 422);
}
if (empty($customer->externId)) {
$response->error('XL Vask customer has no external id', 422);
}
$tmp_user = $customer->getUser();
if (!$tmp_user) {
$response->error('XL Vask customer is not provisioned in this system', 422);
}
try {
$order = $xlvask->getTasks()->createOrderFromWash($log_helper, $customer);
} catch (\Throwable $e) {
error_log('[xlvask-accept] createOrderFromWash failed: ' . $e->getMessage());
$response->error('Could not create order from XL Vask usage log: ' . $e->getMessage(), 422);
}
$log->ignored_at->set(date('Y-m-d H:i:s'));
$log->ignored_by->set((int)$user->id);
$log->ignored_reason->set('Accepted and converted to order ' . (int)$order->id);
$log->objectChanged();
$response->success([
'order_id' => (int)$order->id,
'usage_log_id' => $id,
]);
}, [
'review_xlvask_usage_order' => 'Convert an XL Vask usage log into an order',
]);
$this->post('/modules/xlvask/services/usage/orders/{id}/reject', function () {
global $response;
$this->requirePermission('review_xlvask_usage_order');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$id = (int)($this->fromRoute('id') ?? 0);
if ($id < 1) {
$response->error('Invalid XL Vask usage log id', 400);
}
self::requireParameters(['reason']);
$reason = trim((string)$this->getParameter('reason'));
if (mb_strlen($reason) > 500) {
$response->error('Reason is too long (max 500 characters)', 400);
}
$allowedHallIds = $this->allowedHallIdsForUser($user);
self::requireUsageLogInHallScope($id, $allowedHallIds);
$log = new xlvask_usage_logs_o();
$log->getById($id);
if (!$log->id) {
$response->error('XL Vask usage log not found', 404);
}
$log->ignored_at->set(date('Y-m-d H:i:s'));
$log->ignored_by->set((int)$user->id);
$log->ignored_reason->set('Rejected: ' . $reason);
$log->objectChanged();
$response->success([
'id' => $id,
'ignored_at' => $log->ignored_at->get(),
'ignored_by' => (int)$log->ignored_by->get(),
'ignored_reason' => $log->ignored_reason->get(),
]);
}, [
'review_xlvask_usage_order' => 'Reject an XL Vask usage log with a reviewer note',
]);
$this->get('/modules/xlvask/services/usage/orders/fast-link', function () {
global $response;
@@ -549,91 +323,55 @@ class xlvaskUsageLogsRoute
if (!$user) {
$response->error('Invalid session', 400);
}
self::requireParameters([
'fast_link_key', // Example: 'temporary_cache_6878cf0603d77'
]);
// Get the fast link key from the request
self::requireParameters(['fast_link_key']);
$fast_link_key = (string)self::getParameter('fast_link_key');
self::requireType($fast_link_key, self::type_string());
self::requireMinLength('fast_link_key', 20); // Minimum length of the fast link key
self::requireMaxLength('fast_link_key', 50); // Maximum length of the fast link key
// Check if the fast link key is valid
// First, check if the key has the correct format
if (preg_match('/^temporary_cache_[a-z0-9]{12,32}$/', $fast_link_key)) {
// Get the cached data from Redis
$cached_data = redis->get($fast_link_key);
// Check if the cached data is valid
if ($cached_data) {
// Decode the cached data
$data = json_decode($cached_data, true);
// Check if the data is valid
if (is_array($data)) {
$allowedHallIds = $this->allowedHallIdsForUser($user);
if (!in_array(trim((string)($data['HallId'] ?? '')), $allowedHallIds, true)) {
$response->error('Fast link is outside the current XL Vask hall scope', 403);
}
// Delete the cached data from Redis
redis->delete($fast_link_key);
// Return the data
$xlvask = new xlvask();
$order_arr = (new orders_o())->simulateOrderFromXLVask($xlvask->new($xlvask->helpers->xlvask_usage_log)->setProperties($data), true); // ['order' => $order_arr, 'order_items' => $items_arr]
$tmp_order_obj = (object)[];
$tmp_order_arr = $order_arr['order'] ?? [];
/**
* "id": -1,
* "customer_id": 39159000,
* "cashier_id": 2285,
* "reference": "Simulated Order from XL Vask",
* "notes": "This is a simulated order generated from an XL Vask usage log",
* "department_id": 1,
* "reg_1": "DE55248",
* "reg_2": "",
* "reg_3": "",
* "completed_at": null,
* "created_at": "2025-07-16 13:04:54",
* "deleted_at": null,
* "total_net_amount": 683,
* "invoice_collection_id": 0,
* "booking_id": 0,
* "wash_id": "b24728ea-e22b-4dce-8cd3-a0998f7fdc5e",
* "lane": 1,
* "closed_at": null
*/
$tmp_order_obj->customer_id = $tmp_order_arr['customer_id'] ?? 0;
$tmp_order_obj->department_id = $tmp_order_arr['department_id'] ?? 0;
$tmp_order_obj->reg_1 = $tmp_order_arr['reg_1'] ?? '';
$tmp_order_obj->reg_2 = $tmp_order_arr['reg_2'] ?? '';
$tmp_order_obj->reg_3 = $tmp_order_arr['reg_3'] ?? '';
$tmp_order_obj->created_at = $tmp_order_arr['created_at'] ?? '';
$tmp_order_obj->wash_id = $tmp_order_arr['wash_id'] ?? '';
$tmp_order_obj->lane = $tmp_order_arr['lane'] ?? 0;
$response->success([
...$order_arr,
'potential_duplicates' => (new orders_o())->getOrderPotentialDuplicatesIfFound(
$tmp_order_obj->reg_1,
$tmp_order_obj->reg_2,
$tmp_order_obj->reg_3,
$tmp_order_obj->department_id,
$tmp_order_obj->created_at,
),
]);
} else {
// Return an error if the data is not valid
$response->error('Invalid cached data', 400);
}
} else {
// Return an error if the fast link key does not exist in Redis
$response->error('Fast link key not found', 404);
}
} else {
// Return an error if the fast link key is invalid
self::requireMinLength('fast_link_key', 20);
self::requireMaxLength('fast_link_key', 50);
if (!preg_match('/^temporary_cache_[a-z0-9]{12,32}$/', $fast_link_key)) {
$response->error('Invalid fast link key format', 400);
}
},
[
'fast_link_key' => 'string', // Example: 'temporary_cache_6878cf0603d77'
]
);
$cached_data = redis->get($fast_link_key);
if (!$cached_data) {
$response->error('Fast link key not found', 404);
}
$data = json_decode($cached_data, true);
if (!is_array($data)) {
$response->error('Invalid cached data', 400);
}
$allowedHallIds = $this->allowedHallIdsForUser($user);
if (!in_array(trim((string)($data['HallId'] ?? '')), $allowedHallIds, true)) {
$response->error('Fast link is outside the current XL Vask hall scope', 403);
}
redis->delete($fast_link_key);
$xlvask = new xlvask();
$order_arr = (new orders_o())->simulateOrderFromXLVask(
$xlvask->new($xlvask->helpers->xlvask_usage_log)->setProperties($data),
true
);
$tmp_order_obj = (object)[];
$tmp_order_arr = $order_arr['order'] ?? [];
$tmp_order_obj->customer_id = $tmp_order_arr['customer_id'] ?? 0;
$tmp_order_obj->department_id = $tmp_order_arr['department_id'] ?? 0;
$tmp_order_obj->reg_1 = $tmp_order_arr['reg_1'] ?? '';
$tmp_order_obj->reg_2 = $tmp_order_arr['reg_2'] ?? '';
$tmp_order_obj->reg_3 = $tmp_order_arr['reg_3'] ?? '';
$tmp_order_obj->created_at = $tmp_order_arr['created_at'] ?? '';
$tmp_order_obj->wash_id = $tmp_order_arr['wash_id'] ?? '';
$tmp_order_obj->lane = $tmp_order_arr['lane'] ?? 0;
$response->success([
...$order_arr,
'potential_duplicates' => (new orders_o())->getOrderPotentialDuplicatesIfFound(
$tmp_order_obj->reg_1,
$tmp_order_obj->reg_2,
$tmp_order_obj->reg_3,
$tmp_order_obj->department_id,
$tmp_order_obj->created_at,
),
]);
}, [
'fast_link_key' => 'string',
]);
}
private function allowedHallIdsForUser(object $user): array
@@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
use objects\products_o;
usesApiSuite();
/**
* Tests for TRU-94: product merging infrastructure.
*
* Verifies that:
* - Merging product A into B preserves historical order_items references (FK still points at A)
* - The source product's merged_into_product_id is set, and resolveActiveProductId() follows it
* - An audit row is written to product_merges
* - Price changes to the target (B) are what new orders will see (since reads resolve to the target)
* - The API endpoint POST /products/{id}/merge behaves as expected and validates inputs
* - The schema is additive and idempotent (running the bootstrap twice is safe)
*/
it('adds merged_into_product_id to products and product_merges table is idempotent', function (): void {
api_test_covers('schema', 'product-merges');
// Calling ensureTables on a clean test DB should be a no-op (column/table already exist
// from earlier schema runs in the same suite, or it should add them without error).
\classes\products_schema_bootstrap::ensureTables();
\classes\products_schema_bootstrap::ensureTables();
$db = api_test_runtime()->db();
$col = $db->query("SHOW COLUMNS FROM `products` LIKE 'merged_into_product_id'");
expect($col)->not->toBeFalse();
expect((int)$col->num_rows)->toBe(1);
$tbl = $db->query("SHOW TABLES LIKE 'product_merges'");
expect($tbl)->not->toBeFalse();
expect((int)$tbl->num_rows)->toBe(1);
});
it('resolveActiveProductId follows merged_into_product_id', function (): void {
$source = api_fixtures()->createProduct([
'name' => 'SF Source (Lastbil)',
'price' => 100,
]);
$target = api_fixtures()->createProduct([
'name' => 'SF Target (Lastbil)',
'price' => 150,
]);
$sourceObj = (new products_o())->select((int)$source['id']);
expect($sourceObj->exists())->toBeTrue();
expect($sourceObj->resolveActiveProductId())->toBe((int)$source['id']);
// No chain yet, and target is unchanged
$targetObj = (new products_o())->select((int)$target['id']);
expect($targetObj->resolveActiveProductId())->toBe((int)$target['id']);
// Perform the merge
$sourceObj->mergeInto((int)$target['id'], null, 'TRU-94 test merge');
expect((int)$sourceObj->merged_into_product_id->value())->toBe((int)$target['id']);
expect($sourceObj->resolveActiveProductId())->toBe((int)$target['id']);
// Reload from DB to confirm persistence
$reloaded = (new products_o())->select((int)$source['id']);
expect($reloaded->resolveActiveProductId())->toBe((int)$target['id']);
expect((int)$reloaded->merged_into_product_id->value())->toBe((int)$target['id']);
});
it('mergeInto preserves historical order_items references and writes an audit row', function (): void {
$source = api_fixtures()->createProduct([
'name' => 'Legacy SF',
'price' => 200,
]);
$target = api_fixtures()->createProduct([
'name' => 'New SF',
'price' => 250,
]);
// Create a historical order and order_item that points at the source.
$user = api_fixtures()->createUser(['name' => 'Merge Test User']);
$cashier = api_fixtures()->createUser(['name' => 'Merge Test Cashier']);
$department = api_fixtures()->createDepartment();
$order = api_fixtures()->createOrder([
'customer_id' => (int)$user['id'],
'department_id' => (int)$department['id'],
]);
$item = api_fixtures()->createOrderItem([
'order_id' => (int)$order['id'],
'product_id' => (int)$source['id'],
'cashier_id' => (int)$cashier['id'],
'price' => 200,
'quantity' => 1,
]);
expect((int)$item['product_id'])->toBe((int)$source['id']);
// Merge source into target
(new products_o())->select((int)$source['id'])->mergeInto((int)$target['id'], null, 'TRU-94 historical preservation');
// Historical order_items.product_id MUST still point at the source.
// (This is the whole point of the merge: we don't rewrite history.)
$db = api_test_runtime()->db();
$row = $db->query("SELECT product_id FROM order_items WHERE id = " . (int)$item['id'])->fetch_array();
expect((int)$row['product_id'])->toBe((int)$source['id']);
// Audit row exists
$audit = $db->query("SELECT * FROM product_merges WHERE source_product_id = " . (int)$source['id'])->fetch_array();
expect($audit)->not->toBeNull();
expect((int)$audit['source_product_id'])->toBe((int)$source['id']);
expect((int)$audit['target_product_id'])->toBe((int)$target['id']);
expect($audit['reason'])->toBe('TRU-94 historical preservation');
});
it('price change on the target is what new orders see (resolution goes to target)', function (): void {
$source = api_fixtures()->createProduct(['name' => 'SF Pre-Merge', 'price' => 100]);
$target = api_fixtures()->createProduct(['name' => 'SF Post-Merge', 'price' => 100]);
(new products_o())->select((int)$source['id'])->mergeInto((int)$target['id']);
// Simulate a price change on the target (the only product new orders can be placed against)
$targetObj = (new products_o())->select((int)$target['id']);
$targetObj->price->set(175);
// The source still resolves to the target, and a fresh read of the target shows the new price
$resolvedId = (new products_o())->select((int)$source['id'])->resolveActiveProductId();
expect($resolvedId)->toBe((int)$target['id']);
$reloaded = (new products_o())->select($resolvedId);
expect((int)$reloaded->price->value())->toBe(175);
});
it('POST /products/{id}/merge requires edit_product permission', function (): void {
$source = api_fixtures()->createProduct(['name' => 'Perm Source']);
$target = api_fixtures()->createProduct(['name' => 'Perm Target']);
// IMPORTANT: do NOT pass ['group_id' => 1] here. group_id 1 is a
// hardcoded superuser/admin in objects\users_o::hasPermission() and
// bypasses the groups_permissions check entirely, so the route would
// 200 instead of 403. createUserSession([], []) creates a fresh empty
// group (id > 1) with no permissions, which is what this test needs.
$session = api_fixtures()->createUserSession([], []);
$response = api_client()->post(
'/products/' . (int)$source['id'] . '/merge',
['target_id' => (int)$target['id']],
$session['headers']
);
$response
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false);
});
it('POST /products/{id}/merge succeeds with edit_product permission', function (): void {
$source = api_fixtures()->createProduct(['name' => 'API Merge Source']);
$target = api_fixtures()->createProduct(['name' => 'API Merge Target']);
$session = api_fixtures()->createUserSession(['edit_product'], ['group_id' => 1]);
$response = api_client()->post(
'/products/' . (int)$source['id'] . '/merge',
['target_id' => (int)$target['id'], 'reason' => 'SENERE 2 — TRU-94'],
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($response->data())
->toBeArray()
->toHaveKey('source_product_id', (int)$source['id'])
->toHaveKey('target_product_id', (int)$target['id'])
->toHaveKey('merged_into_product_id', (int)$target['id']);
});
it('POST /products/{id}/merge rejects self-merge', function (): void {
$product = api_fixtures()->createProduct(['name' => 'Self Merge']);
$session = api_fixtures()->createUserSession(['edit_product'], ['group_id' => 1]);
$response = api_client()->post(
'/products/' . (int)$product['id'] . '/merge',
['target_id' => (int)$product['id']],
$session['headers']
);
$response
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
});
it('POST /products/{id}/merge rejects double-merge', function (): void {
$a = api_fixtures()->createProduct(['name' => 'A']);
$b = api_fixtures()->createProduct(['name' => 'B']);
$c = api_fixtures()->createProduct(['name' => 'C']);
$session = api_fixtures()->createUserSession(['edit_product'], ['group_id' => 1]);
// First merge succeeds
$first = api_client()->post(
'/products/' . (int)$a['id'] . '/merge',
['target_id' => (int)$b['id']],
$session['headers']
);
$first->assertStatus(200)->assertEnvelope()->assertSuccess();
// Second merge of A into C should fail because A is already merged
$second = api_client()->post(
'/products/' . (int)$a['id'] . '/merge',
['target_id' => (int)$c['id']],
$session['headers']
);
$second
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
});
@@ -0,0 +1,69 @@
<?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;
@@ -58,6 +58,7 @@ CREATE TABLE IF NOT EXISTS `users` (
`sms_notifications_enabled` TINYINT(1) NOT NULL DEFAULT 0,
`email_notifications_enabled` TINYINT(1) NOT NULL DEFAULT 0,
`wash_certificate_email` VARCHAR(255) NULL,
`invoice_email` VARCHAR(255) NULL,
`two_factor_enabled` TINYINT(1) NOT NULL DEFAULT 0,
`two_factor_secret` VARCHAR(255) NULL,
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
@@ -175,6 +175,11 @@ final class ApiTestRuntime
);
$this->db->set_charset('utf8mb4');
// Expose $GLOBALS['db'] as a classes\db wrapper around the same connection
// so that legacy object code (e.g. products_o::exists / products_o::mergeInto)
// that relies on `global $db` works inside the API test runtime.
$this->bindGlobalLegacyDb($this->db, $dbConfig);
$redisConfig = $this->readRedisConfig();
if ($redisConfig !== null) {
$parameters = [
@@ -204,6 +209,42 @@ final class ApiTestRuntime
$this->bootstrapped = true;
}
/**
* Bind $GLOBALS['db'] to a classes\db wrapper around the active mysqli connection.
*
* The API test runtime speaks to the database through a raw mysqli handle
* (see db() above). However, a lot of the production object layer
* (e.g. objects\products_o::exists, objects\products_o::mergeInto,
* traits\db_object_t) uses `global $db;` and then calls methods on it.
*
* This wrapper re-uses the same underlying mysqli connection so that
* fixtures written via $this->db are visible to the legacy object layer
* and vice versa, without opening a second connection.
*/
private function bindGlobalLegacyDb(mysqli $connection, array $dbConfig): void
{
if (!class_exists(\classes\db::class)) {
// Legacy wrapper not available; tests that don't need it will still pass.
return;
}
if (!isset($GLOBALS['db']) || !$GLOBALS['db'] instanceof \classes\db) {
$legacyDb = new \classes\db([
'host' => (string)$dbConfig['host'],
'user' => (string)$dbConfig['user'],
'password' => (string)$dbConfig['password'],
'database' => (string)$dbConfig['database'],
'port' => (int)$dbConfig['port'],
'ssl_mode' => (string)($dbConfig['ssl_mode'] ?? 'DISABLED'),
]);
$GLOBALS['db'] = $legacyDb;
}
// Share the runtime mysqli handle so reads/writes stay consistent
// with the rest of the API test runtime.
$GLOBALS['db']->conn = $connection;
}
private function bootstrapSchemaIfRequested(): void
{
if ($this->schemaBootstrapped) {
@@ -7,7 +7,7 @@ it('discovers module-owned cron task definitions', function (): void {
$registry = new cron_task_registry(app_path('modules'));
$definitions = $registry->definitions();
expect($definitions)->toHaveCount(24);
expect($definitions)->toHaveCount(22);
expect(array_keys($definitions))->toContain(
'system.sync_logs',
'backups.process_jobs',
@@ -16,10 +16,10 @@ it('discovers module-owned cron task definitions', function (): void {
'dynamicimages.pre_render',
'weatherapi.preload_department_responses',
'goals.progress_alerts',
'xlvask.autopilot_queue',
'account.process_deletion_requests',
'selfserve.activate_opening_cleaner_relays'
);
expect(array_keys($definitions))->not->toContain('xlvask.autopilot_queue');
$transferQueue = $registry->get('EconomicTransferQueueCron');
expect($transferQueue)->not->toBeNull();
@@ -0,0 +1,243 @@
<?php
use classes\customer_invoice_email_schema_bootstrap;
use classes\customer_mass_import_service;
if (!class_exists('CustomerInvoiceEmailSchemaResultStub')) {
final class CustomerInvoiceEmailSchemaResultStub
{
public int $num_rows = 0;
/** @var list<array<string, mixed>> */
private array $rows;
/** @param list<array<string, mixed>> $rows */
public function __construct(array $rows = [])
{
$this->rows = array_values($rows);
$this->num_rows = count($this->rows);
}
/** @return array<string, mixed>|null */
public function fetch_assoc(): ?array
{
return array_shift($this->rows) ?? null;
}
}
}
if (!class_exists('CustomerInvoiceEmailSchemaDbStub')) {
final class CustomerInvoiceEmailSchemaDbStub
{
public bool $hasUsersTable = true;
public bool $hasInvoiceEmailColumn = false;
/** @var list<string> */
public array $queries = [];
public function query(string $sql): CustomerInvoiceEmailSchemaResultStub
{
$this->queries[] = $sql;
if (str_contains($sql, "SHOW TABLES LIKE 'users'")) {
return $this->hasUsersTable
? new CustomerInvoiceEmailSchemaResultStub([['Tables_in_db' => 'users']])
: new CustomerInvoiceEmailSchemaResultStub();
}
if (str_contains($sql, "SHOW COLUMNS FROM `users` LIKE 'invoice_email'")) {
return $this->hasInvoiceEmailColumn
? new CustomerInvoiceEmailSchemaResultStub([['Field' => 'invoice_email']])
: new CustomerInvoiceEmailSchemaResultStub();
}
return new CustomerInvoiceEmailSchemaResultStub();
}
}
}
it('adds the invoice_email column to the users table when the column is missing', function (): void {
$db = new CustomerInvoiceEmailSchemaDbStub();
$db->hasUsersTable = true;
$db->hasInvoiceEmailColumn = false;
$GLOBALS['db'] = $db;
try {
customer_invoice_email_schema_bootstrap::ensureSchema();
} finally {
unset($GLOBALS['db']);
}
expect($db->queries)->toContain('ALTER TABLE `users`
ADD COLUMN invoice_email VARCHAR(255) NULL
AFTER wash_certificate_email');
});
it('does not re-add the invoice_email column when it already exists', function (): void {
$db = new CustomerInvoiceEmailSchemaDbStub();
$db->hasUsersTable = true;
$db->hasInvoiceEmailColumn = true;
$GLOBALS['db'] = $db;
try {
customer_invoice_email_schema_bootstrap::ensureSchema();
} finally {
unset($GLOBALS['db']);
}
$alterStatements = array_values(array_filter(
$db->queries,
static fn(string $query): bool => str_contains($query, 'ALTER TABLE')
));
expect($alterStatements)->toBe([]);
});
it('skips column add when the users table does not exist yet', function (): void {
$db = new CustomerInvoiceEmailSchemaDbStub();
$db->hasUsersTable = false;
$db->hasInvoiceEmailColumn = false;
$GLOBALS['db'] = $db;
try {
customer_invoice_email_schema_bootstrap::ensureSchema();
} finally {
unset($GLOBALS['db']);
}
$alterStatements = array_values(array_filter(
$db->queries,
static fn(string $query): bool => str_contains($query, 'ALTER TABLE')
));
expect($alterStatements)->toBe([]);
});
// --- customer mass import service invoice_email routing (TRU-77) ---
if (!class_exists('CustomerInvoiceEmailMassImportProbe')) {
final class CustomerInvoiceEmailMassImportProbe extends customer_mass_import_service
{
public array $economicSearchResults = [];
public array $createCalls = [];
public array $bootstrapCalls = [];
public ?object $bootstrapUser = null;
public ?object $createResponse = null;
public string $companyName = 'Probe Company';
protected function searchEconomicCustomersByCvr(string $cvr): array
{
return $this->economicSearchResults;
}
protected function createEconomicCustomer(array $normalized, string $createEmail): object
{
// The production service no longer mutates $normalized['email'];
// the create call uses the dedicated invoice_email (or the
// primary as a fallback) that import() resolves for it. Mirror
// that here so the recorded payload reflects what is sent to
// e-conomic.
$payload = $normalized;
$payload['email'] = $createEmail;
$this->createCalls[] = $payload;
return $this->createResponse ?? (object)[
'customerNumber' => (int)$normalized['customer_number'],
];
}
protected function bootstrapLocalCustomerOrFail(int $customerNumber): object
{
$this->bootstrapCalls[] = $customerNumber;
if ($this->bootstrapUser === null) {
throw new RuntimeException('Customer was created in e-conomic but could not be imported locally.', 500);
}
return $this->bootstrapUser;
}
protected function fetchCompanyNameByCvr(string $cvr): string
{
return $this->companyName;
}
protected function syncLocalCustomer(object $customer, array $normalized, array &$warnings): void
{
// No-op for the routing assertions; tests focus on payload + create call.
}
// Override the DB lookup so the unit test does not need a real
// (or stubbed) mysqli connection. The TRU-77 routing tests treat
// the import as a "new customer" flow, so we hard-code the
// "does not exist locally" answer.
protected function localCustomerNumberExists(int $customerNumber): bool
{
return false;
}
protected function loadLocalCustomerByNumber(int $customerNumber): ?object
{
return null;
}
}
}
it('routes the e-conomic customer email to the dedicated invoice_email when provided', function (): void {
$service = new CustomerInvoiceEmailMassImportProbe();
$service->bootstrapUser = new class {
public int $id = 5001;
public function exists(): bool { return true; }
public function hasPassword(): bool { return false; }
};
$service->createResponse = (object)['customerNumber' => 22725567];
$result = $service->import([
'cvr' => '29424764',
'name' => 'TGP TRANSPORT APS',
'email' => 'primary@example.com',
'invoice_email' => 'faktura@example.com',
'phone' => '22725567',
]);
expect($service->createCalls)->toHaveCount(1);
expect($service->createCalls[0]['email'])->toBe('faktura@example.com');
expect($result['email'])->toBe('primary@example.com');
expect($result['invoice_email'])->toBe('faktura@example.com');
});
it('falls back to the primary email when no dedicated invoice_email is provided', function (): void {
$service = new CustomerInvoiceEmailMassImportProbe();
$service->bootstrapUser = new class {
public int $id = 5002;
public function exists(): bool { return true; }
public function hasPassword(): bool { return false; }
};
$service->createResponse = (object)['customerNumber' => 22725567];
$result = $service->import([
'cvr' => '29424764',
'name' => 'TGP TRANSPORT APS',
'email' => 'primary@example.com',
'phone' => '22725567',
]);
expect($service->createCalls)->toHaveCount(1);
expect($service->createCalls[0]['email'])->toBe('primary@example.com');
expect($result['invoice_email'])->toBeNull();
});
it('rejects an invalid dedicated invoice_email before contacting e-conomic', function (): void {
$service = new CustomerInvoiceEmailMassImportProbe();
$service->bootstrapUser = new class {
public int $id = 5003;
public function exists(): bool { return true; }
public function hasPassword(): bool { return false; }
};
$call = static fn() => $service->import([
'cvr' => '29424764',
'name' => 'TGP TRANSPORT APS',
'email' => 'primary@example.com',
'invoice_email' => 'not-an-email',
'phone' => '22725567',
]);
expect($call)->toThrow(RuntimeException::class, 'Invalid invoice email address.');
expect($service->createCalls)->toBe([]);
});
@@ -49,9 +49,16 @@ if (!class_exists('CustomerMassImportServiceProbe')) {
return $this->economicSearchResults;
}
protected function createEconomicCustomer(array $normalized): object
protected function createEconomicCustomer(array $normalized, string $createEmail): object
{
$this->createCalls[] = $normalized;
// TRU-77 / DRIFT 16: the production service no longer mutates
// $normalized['email'] before calling createEconomicCustomer —
// the dedicated invoice_email (or the primary as a fallback) is
// resolved by import() and passed in as $createEmail. Mirror that
// here so the recorded payload reflects what is sent to e-conomic.
$payload = $normalized;
$payload['email'] = $createEmail;
$this->createCalls[] = $payload;
return $this->createResponse ?? (object)[
'customerNumber' => (int)$normalized['customer_number'],
];
@@ -167,7 +167,8 @@ 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' => 'xlvask_usage_log', 'text' => 'XL Vask wash'],
['type' => 'text', 'text' => 'XL Vask wash '],
['type' => 'xlvask_usage_log', 'text' => 'wash-55'],
['type' => 'text', 'text' => ' is neither ignored nor linked to an order in the selected period.'],
]);
});
@@ -1166,8 +1167,151 @@ it('limits historical primary product lookup to current period registrations', f
expect($content)->not->toBeFalse();
$content = (string)$content;
expect($content)->toContain('getPrimaryProductHistory($dateFrom, array_column($primaryRows, \'reg_1\'))');
expect($content)->toContain('private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers): array');
expect($content)->toContain('private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers, ?bool $requireReg2Empty = null): 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,7 +289,9 @@ 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([]);
expect($result['period']['types']['fixed_pricing'])->toBe([
['customer_number' => 1002, 'membership_only' => true],
]);
expect($result['period']['type_counts']['all'])->toBe([
'requires_action' => 1,
'draft' => 1,
@@ -687,3 +689,219 @@ 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],
]);
});
@@ -9,8 +9,12 @@ it('requires id and at least one mutable field for PUT /collected-invoices in us
expect($content)->toContain("self::requireParameters(['id']);");
expect($content)->toContain("\$is_superuser = \$this->hasPermission('superuser');");
expect($content)->toContain("if (!self::isParametersSet(['po_number']) && !self::isParametersSet(['closed_at'])) {");
expect($content)->toContain("\$response->error('Missing required parameters: po_number, closed_at', 400);");
expect($content)->toContain("if (self::isParametersSet(['closed_at']) && !\$is_superuser) {");
// TRU-128: The previous error message ("Missing required parameters:
// po_number, closed_at") read as if BOTH were required and confused
// customers trying to invoice. We now state the actual contract: at
// least one must be provided.
expect($content)->toContain("\$response->error('At least one of po_number or closed_at must be provided', 400);");
expect($content)->toContain("if (\$closed_at_is_non_empty && !\$is_superuser) {");
expect($content)->toContain("\$response->error('Forbidden: only superusers can update closed_at', 403);");
expect($content)->toContain("if ((int)\$invoice->customer_number->value() !== (int)\$user->customer_number->value() && !\$is_superuser) {");
});
@@ -28,3 +32,24 @@ it('supports independent po_number and closed_at updates for PUT /collected-invo
expect($content)->toContain("self::requireDateFormat((string)\$closed_at, self::FORMAT_DATE());");
expect($content)->toContain("\$invoice->closed_at->set(\$closed_at === null || \$closed_at === '' ? null : date('Y-m-d 23:59:59', strtotime((string)\$closed_at . ' 00:00:01')));");
});
it('locks in the TRU-128 bug fix: customers can clear closed_at with null/empty string', function (): void {
// TRU-128 / "Jeg kan ikke fakturere": a non-superuser could not pass
// closed_at at all (even null/empty) because isParametersSet() returns
// true for any present key. The route returned 403 Forbidden and the
// customer could not clear a previously-set closed_at either. The fix
// narrows the forbidden check to *non-empty* closed_at values, matching
// the existing clear-on-null/empty logic further down in the handler.
$routeFile = app_path('routes/userInvoicesRoute.php');
$content = file_get_contents($routeFile);
expect($content)->not->toBeFalse();
// The "present + non-empty" check must precede the 403 guard, so
// clearing closed_at (passing null or "") for a non-superuser is allowed.
expect($content)->toMatch(
'/\$closed_at_is_non_empty\s*=\s*false;\s*if\s*\(self::isParametersSet\(\[\'closed_at\'\]\)\)\s*\{[^}]*\$closed_at_is_non_empty\s*=\s*\(\$raw_closed_at\s*!==\s*null\s*&&\s*\$raw_closed_at\s*!==\s*\'\'\);[^}]*\}\s*if\s*\(\$closed_at_is_non_empty\s*&&\s*!\$is_superuser\)\s*\{[^}]*Forbidden:\s*only\s*superusers/s'
);
// The previous shape of the guard (which would always fire for any
// present closed_at, including null) must no longer be present.
expect($content)->not->toContain("if (self::isParametersSet(['closed_at']) && !\$is_superuser) {");
});
@@ -0,0 +1,104 @@
<?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");
});
@@ -126,3 +126,26 @@ 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']);
}
}
});
@@ -0,0 +1,206 @@
<?php
/**
* Contract test: every column that the code expects to find in the
* `users` table must exist. Catches the production failure mode
* where a migration was added to code but never run on the database
* (e.g. "Unknown column 'invoice_email' in 'SELECT'" TRU-77).
*
* This test runs against the test database (configured in
* phpunit.xml / Pest configuration). It does NOT run against
* production that's covered by the `/admin/schema-check` HTTP
* endpoint in `adminRoute.php` which the deploy pipeline hits.
*/
app_require('classes/customer_invoice_email_schema_bootstrap.php');
use classes\customer_invoice_email_schema_bootstrap;
const REQUIRED_USERS_COLUMNS = [
// TRU-77 (added 2026-08-16) — the column that was missing in
// production after the migration was merged to master.
'invoice_email',
// Older required columns that the code references.
'wash_certificate_email',
'email',
'customer_number',
'phone_country_code',
'phone',
'group_id',
'created_at',
];
/**
* The unit test bootstrap does not create a $db global. This contract
* test is unique in that it needs a real database to verify schema
* state, so wire one up here using the same CONFIG_DB_* env vars the
* rest of the CI suite exports. If the database is unavailable, the
* tests below will fail with a clear "no_db_connection" error.
*/
schema_health_check_test_wire_db();
function schema_health_check_test_wire_db(): void
{
if (isset($GLOBALS['db']) && is_object($GLOBALS['db'])) {
return;
}
if (!class_exists('mysqli')) {
return;
}
$host = (string)(getenv('CONFIG_DB_HOST') ?: 'mysql-debug');
$user = (string)(getenv('CONFIG_DB_USER') ?: 'root');
$password = (string)(getenv('CONFIG_DB_PASSWORD') ?: 'debug_root_password');
$database = (string)(getenv('CONFIG_DB_DATABASE') ?: 'nnks_db_debug');
$port = (int)(getenv('CONFIG_DB_PORT') ?: 3306);
try {
mysqli_report(MYSQLI_REPORT_OFF);
$conn = new mysqli($host, $user, $password, $database, $port);
if ($conn->connect_errno) {
return;
}
$conn->set_charset('utf8mb4');
} catch (\Throwable $e) {
return;
}
$GLOBALS['db'] = new class($conn) {
private mysqli $conn;
public function __construct(mysqli $conn)
{
$this->conn = $conn;
}
public function query(string $sql)
{
return $this->conn->query($sql);
}
public function fetch_assoc($result)
{
return $result ? $result->fetch_assoc() : null;
}
public function close(): void
{
try {
$this->conn->close();
} catch (\Throwable) {
}
}
};
}
/**
* The unit suite starts with a freshly-dropped `nnks_db_debug` database
* (see run_ci_suite::reset_ci_state). The schema bootstrap only owns
* the `users` table; `invoices` and `bookings` are managed by other
* migrations that don't run in the unit suite. Create the bare-minimum
* schema that adminRoute::runSchemaCheck needs so the third test can
* verify the "all columns exist" happy path.
*/
function schema_health_check_test_ensure_aux_tables(): void
{
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$create = function (string $table, string $createSql, array $requiredColumns) use ($db): void {
$r = $db->query("SHOW TABLES LIKE '{$table}'");
if (!$r || (int)$r->num_rows === 0) {
$db->query($createSql);
return;
}
foreach ($requiredColumns as $column => $definition) {
$r = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
if (!$r || (int)$r->num_rows === 0) {
$db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
}
}
};
$create('invoices', "CREATE TABLE `invoices` (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
customer_number INT NOT NULL DEFAULT 0,
po_number VARCHAR(64) NULL,
closed_at DATETIME NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", [
'po_number' => 'VARCHAR(64) NULL',
'closed_at' => 'DATETIME NULL',
'customer_number' => 'INT NOT NULL DEFAULT 0',
]);
$create('bookings', "CREATE TABLE `bookings` (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
customer_number INT NOT NULL DEFAULT 0,
department INT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", [
'customer_number' => 'INT NOT NULL DEFAULT 0',
'department' => 'INT NULL',
]);
}
beforeEach(function () {
// Self-heal: run the schema bootstrap so the test DB has all
// the columns the contract requires. The bootstrap is additive
// and idempotent — safe to run on every test.
if (!isset($GLOBALS['db']) || !is_object($GLOBALS['db'])) {
schema_health_check_test_wire_db();
}
if (class_exists(customer_invoice_email_schema_bootstrap::class)) {
customer_invoice_email_schema_bootstrap::ensureSchema();
}
schema_health_check_test_ensure_aux_tables();
});
it('users table has every required column the code references', function () {
global $db;
expect($db)->toBeObject();
expect(method_exists($db, 'query'))->toBeTrue();
$missing = [];
foreach (REQUIRED_USERS_COLUMNS as $column) {
$safeColumn = str_replace("'", '', $column);
$result = $db->query("SHOW COLUMNS FROM `users` LIKE '{$safeColumn}'");
if (!$result || (int)$result->num_rows === 0) {
$missing[] = $column;
}
}
expect($missing)->toBe(
[],
"users table is missing required columns: " . implode(', ', $missing)
. ". Did the migration run? See customer_invoice_email_schema_bootstrap."
);
});
it('invoice_email column accepts a normal email address', function () {
global $db;
// Insert a throwaway user with an invoice_email, read it back.
// If the column doesn't exist or the type is wrong, this fails.
$email = 'test-invoice-' . uniqid() . '@example.com';
$customerNumber = 99900000 + random_int(1, 99999);
$db->query("INSERT INTO `users` (customer_number, display_name, email, invoice_email) VALUES ({$customerNumber}, 'Contract Test', '{$email}', 'invoice@example.com')");
$result = $db->query("SELECT invoice_email FROM `users` WHERE customer_number = {$customerNumber}");
expect($result)->toBeObject();
$row = $result->fetch_assoc();
expect($row['invoice_email'] ?? null)->toBe('invoice@example.com');
// Cleanup
$db->query("DELETE FROM `users` WHERE customer_number = {$customerNumber}");
});
it('admin schema-check endpoint reports ok=true when all columns exist', function () {
$admin = new \routes\adminRoute();
$reflection = new ReflectionClass($admin);
$method = $reflection->getMethod('runSchemaCheck');
$method->setAccessible(true);
$report = $method->invoke($admin);
expect($report['ok'])->toBeTrue(
'schema check failed: ' . json_encode($report['missing'] ?? [])
);
expect($report['columns_checked'])->toBeGreaterThan(0);
});
@@ -0,0 +1,97 @@
<?php
/**
* Program-registry contract tests for TRU-19.
*
* Locks the architecture decision that the api does NOT expose a /programs
* endpoint that returns user-facing program names ("FF Uvs", "10min", "SF",
* etc.). Those names live on the wash bay hardware itself, not in the api.
*
* The api exposes MACHINE TYPES (e.g. "Mafa 5", "Washtec") via
* /department/selfserve/machine-types and PROGRAM PICKER relay control
* via /modules/self-serve/lane/relay/machine_program_picker/{status,set,enable,...}.
*
* The dashboard (pleno-vue) renders a numeric button registry (0-11) that
* maps to the physical programs on the wash bay. If a /programs endpoint
* ever appears in the api by accident, this test will fail and force the
* author to either (a) document the new endpoint and update this test, or
* (b) remove the spurious endpoint.
*
* Also locks the /department/selfserve/machine-types endpoint as a
* reachable, list-returning smoke target this is the closest thing to
* a /programs endpoint that the api offers, and it should remain stable.
*/
it('does not expose a /programs endpoint (program names live on the wash bay)', function (): void {
$routesDir = app_path('routes');
$moduleRoutesDirs = glob(app_path('modules') . '/*/routes') ?: [];
$routeFiles = array_merge(
glob($routesDir . '/*.php') ?: [],
// Collect per-module route files
array_merge(...array_map(static fn($dir) => glob($dir . '/*.php') ?: [], $moduleRoutesDirs))
);
expect($routeFiles)->not->toBeEmpty('Expected to find at least one route file');
foreach ($routeFiles as $file) {
$source = file_get_contents($file);
expect($source)->not->toBeFalse("Failed to read route file: {$file}");
// Check for any route that would expose a /programs-style endpoint.
// The regex matches a $this->get(...) or $this->post(...) call with a
// /programs URI segment. We use word boundaries to avoid false
// positives on /modules/self-serve/lane/relay/machine_program_picker/*.
$matches = preg_match_all(
'/\$this->(?:get|post|put|delete|patch)\s*\(\s*[\'"]\/[^\'"]*\/programs[\'"]/',
$source,
$ignored
);
expect($matches)->toBe(
0,
"Found a /programs endpoint in {$file}. Program names live on the wash bay hardware — "
. 'the api should not expose them. If you intentionally want to add one, update this test '
. 'and document the new endpoint in docs/.'
);
}
});
it('exposes /department/selfserve/machine-types as the api-side program-adjacent endpoint', function (): void {
$machineTypesRoute = file_get_contents(app_path('routes/departmentSelfserveMachineTypesRoute.php'));
expect($machineTypesRoute)->not->toBeFalse();
expect($machineTypesRoute)->toContain('/department/selfserve/machine-types');
expect($machineTypesRoute)->toContain("'list_department_selfserve_machine_types'");
// The route must call $response->success(...) which is the standard
// "200 OK with JSON body" envelope. The contract is: a GET to this
// endpoint returns a JSON list of machine types.
expect($machineTypesRoute)->toContain('$response->success(');
// The route must enforce the list_* permission so unauthorized callers
// cannot enumerate machine types.
expect($machineTypesRoute)->toContain("requirePermission('list_department_selfserve_machine_types')");
});
it('exposes /modules/self-serve/lane/relay/machine_program_picker/* for program picker relay control', function (): void {
$selfServeRoute = file_get_contents(app_path('routes/moduleSelfServeRoute.php'));
expect($selfServeRoute)->not->toBeFalse();
// The program picker relay endpoints must exist. Pest's toContain does
// not accept a custom failure message, so we collect failures into a
// single assert at the end with a list of missing endpoints.
$expectedEndpoints = [
'/modules/self-serve/lane/relay/machine_program_picker/status',
'/modules/self-serve/lane/relay/machine_program_picker/set',
'/modules/self-serve/lane/relay/machine_program_picker/enable',
];
$missing = array_values(array_filter(
$expectedEndpoints,
static fn(string $endpoint): bool => !str_contains($selfServeRoute, $endpoint)
));
expect($missing)->toBe(
[],
'Missing program picker relay endpoints: ' . implode(', ', $missing)
);
});
@@ -0,0 +1,79 @@
<?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,59 @@
<?php
/*
* Regression test for TRU-18 / AUT-14:
* "api — truckwash.io invoices route to wrong EC account; some users"
*
* Root cause: getUserByCustomerNumber() in objects/users_o.php trusted the
* inverse Redis cache (customer_number -> user_id) without verifying that the
* user it loaded actually owns the requested EC customer_number in the local
* DB. When that cache went stale (e.g. after a customer_number re-mapping on
* a code path that did not clear the inverse cache), getUserByCustomerNumber()
* would return the wrong user. Downstream invoice code (getCustomerEcocomicData,
* setCustomerNumber) would then use that wrong user's current customer_number
* and route the draft invoice to the wrong Economic account.
*
* The fix verifies the loaded user owns the requested customer_number after
* the Redis fast-path, clears the stale cache entry, and re-fetches when the
* fast-path returned a user whose actual customer_number does not match.
*/
it('revalidates loaded user against requested customer_number after Redis fast-path (TRU-18)', function (): void {
$usersFile = app_path('objects/users_o.php');
$content = file_get_contents($usersFile);
expect($content)->not->toBeFalse();
// The fast-path (Redis cache hit) must verify the loaded user actually
// owns the requested EC customer_number before returning.
expect($content)->toContain('// BUG FIX (TRU-18 / AUT-14)');
expect($content)->toContain('getUserByCustomerNumber(int $customer_number)');
expect($content)->toContain('self::redisCache()?->get_user_id_from_customer_number($customer_number)');
expect($content)->toContain('$this->getObjectProperties();');
expect($content)->toContain('if ((int)$this->customer_number->value() !== $customer_number) {');
expect($content)->toContain('self::redisCache()?->clear_user_id_from_customer_number($customer_number);');
expect($content)->toContain('return $this->getUserByCustomerNumber($customer_number);');
});
it('keeps the DB lookup path as the source of truth when the Redis cache is empty or stale', function (): void {
$usersFile = app_path('objects/users_o.php');
$content = file_get_contents($usersFile);
expect($content)->not->toBeFalse();
// After clearing the stale cache, the recursive call must fall through to
// the DB query path which selects by exact customer_number match.
expect($content)->toContain('SELECT id FROM $this->table WHERE customer_number = \'$customer_number\'');
});
it('does not use the requested customer_number for any unrelated lookup in the invoice export flow', function (): void {
// Sanity check: the invoice export flow must go through getCustomerByOrderId
// -> getUserByCustomerNumber, so the TRU-18 fix above is the choke point.
$ordersFile = app_path('objects/orders_o.php');
$content = file_get_contents($ordersFile);
expect($content)->not->toBeFalse();
expect($content)->toContain('public function getCustomerByOrderId(?string $order_id): users_o');
expect($content)->toContain("SELECT customer_id FROM orders WHERE id = \$order_id");
expect($content)->toContain('return (new users_o())->getUserByCustomerNumber($row[\'customer_id\']);');
});
@@ -1,65 +0,0 @@
<?php
/**
* Guard the operator entry point contract for the XL Vask automation
* schema migration. The migration itself is intentionally operator-only
* (see services/nginx/app/modules/xlvask/AUTOMATION_RUNBOOK.md §2), but
* the CLI surface that wraps it must remain gated, idempotent, and
* discoverable.
*
* When PLENO_REPO_ROOT_FOR_TESTS is set (the CI layout, where the repo
* root is bind-mounted alongside services/nginx/app), the test also
* inspects the standalone scripts/xlvask-automation-migrate.php wrapper
* to keep it in lockstep with the cron entry point.
*/
it('routes the xlvask automation migrate CLI command through the gated migration entry point', function (): void {
$cli = file_get_contents(WD . '/cli.php');
expect($cli)
->toContain("case 'xlvask-automation-migrate':")
->toContain("require_once 'cron/EnsureXLVaskAutomationSchema.php'");
});
it('keeps the cron entry point gated by the WD constant and the migration class', function (): void {
$cron = file_get_contents(WD . '/cron/EnsureXLVaskAutomationSchema.php');
expect($cron)
->toContain("if (!defined('WD'))")
->toContain('migration_20260804_xlvask_ai_auto_policy_v2::preflight')
->toContain('migration_20260804_xlvask_ai_auto_policy_v2::apply')
->toContain('xlvask_usage_logs_schema_bootstrap::applyWashIdUniquenessMigration');
});
it('keeps the migration class operator-only and references the bootstrap entry point', function (): void {
$migration = file_get_contents(WD . '/modules/xlvask/migrations/20260804_xlvask_ai_auto_policy_v2.php');
expect($migration)
->toContain('operator-invoked')
->toContain('xlvask_usage_logs_schema_bootstrap::applyExplicitMigration')
->toContain('xlvask_usage_logs_schema_bootstrap::migrationStatus');
});
it('documents both operator entry points in the XL Vask automation runbook', function (): void {
$runbook = file_get_contents(WD . '/modules/xlvask/AUTOMATION_RUNBOOK.md');
expect($runbook)
->toContain('## 2. Explicit schema migration')
->toContain('## 2a. Operator entry points')
->toContain('scripts/xlvask-automation-migrate.php')
->toContain("php index.php run xlvask-automation-migrate");
});
it('keeps the standalone xlvask automation migration script gated and idempotent when the repo root is mounted', function (): void {
$repoRoot = getenv('PLENO_REPO_ROOT_FOR_TESTS');
if ($repoRoot === false || $repoRoot === '') {
expect(true)->toBeTrue(); // covered by CI; local docker lacks the repo-root bind mount
return;
}
$scriptPath = realpath($repoRoot . '/scripts/xlvask-automation-migrate.php');
expect($scriptPath)->not->toBeFalse();
$source = file_get_contents($scriptPath);
expect($source)
->toContain("if (PHP_SAPI !== 'cli')")
->toContain("Refusing schema mutation without: apply --yes")
->toContain('xlvask_usage_logs_schema_bootstrap::applyExplicitMigration')
->toContain('xlvask_usage_logs_schema_bootstrap::migrationStatus');
});
@@ -1,805 +0,0 @@
<?php
use classes\xlvask_automation_service;
use classes\xlvask_autopilot_service;
use classes\openai;
use objects\xlvask_usage_logs_o;
require_once WD . '/classes/xlvask_automation_service.php';
require_once WD . '/classes/xlvask_autopilot_service.php';
require_once WD . '/objects/xlvask_usage_logs_o.php';
it('normalizes registrations for XL Vask automation signatures', function (): void {
expect(xlvask_automation_service::normalizeRegistrationForAutomation(' ec 21-233 '))
->toBe('EC21233');
});
it('builds stable XL Vask automation item signatures', function (): void {
$items = [
['product_id' => 20, 'quantity' => 1, 'price' => 275],
['product_id' => 10, 'quantity' => 2, 'price' => 649],
['product_id' => 20, 'quantity' => 1, 'price' => 0],
];
expect(xlvask_automation_service::itemSignaturePartsForAutomation($items))
->toBe([
'10:2:649',
'20:1:0',
'20:1:275',
]);
});
it('identifies strict price agreement matches by product, quantity, and total', function (): void {
$usageItems = [
['product_id' => 10, 'quantity' => 1, 'price' => 519],
['product_id' => 24, 'quantity' => 1, 'price' => 39],
['product_id' => 21, 'quantity' => 1, 'price' => 79],
];
$orderItems = [
['product_id' => 21, 'quantity' => 1, 'price' => 79],
['product_id' => 10, 'quantity' => 1, 'price' => 519],
['product_id' => 24, 'quantity' => 1, 'price' => 39],
];
expect(xlvask_automation_service::isExactItemMatchForAutomation($usageItems, $orderItems))
->toBeTrue();
});
it('rejects price agreement automation when product lines differ despite equal total', function (): void {
$usageItems = [
['product_id' => 10, 'quantity' => 1, 'price' => 519],
['product_id' => 24, 'quantity' => 1, 'price' => 39],
];
$orderItems = [
['product_id' => 10, 'quantity' => 1, 'price' => 519],
['product_id' => 50, 'quantity' => 1, 'price' => 39],
];
expect(xlvask_automation_service::itemsTotalForAutomation($usageItems))
->toBe(xlvask_automation_service::itemsTotalForAutomation($orderItems))
->and(xlvask_automation_service::isExactItemMatchForAutomation($usageItems, $orderItems))
->toBeFalse();
});
it('normalizes persisted XL Vask usage-log rows before helper hydration', function (): void {
$row = xlvask_automation_service::normalizeUsageLogRowForAutomation([
'id' => 47086,
'WashId' => 'cc1eabc1-b4e1-425b-ad7c-dc68f8c97ceb',
'WashItems' => '[{"OriginalProductName":"Bus","Count":1}]',
]);
expect($row)
->not->toHaveKey('id')
->and($row['WashItems'])->toBe([
[
'OriginalProductName' => 'Bus',
'Count' => 1,
],
]);
});
it('builds stable OpenAI cache keys for identical automation input', function (): void {
$prompt = 'Prompt';
$schemaName = 'xlvask_automation';
$schema = [
'required' => ['action'],
'properties' => [
'confidence' => ['type' => 'number'],
'action' => ['type' => 'string'],
],
];
$schemaWithDifferentKeyOrder = [
'properties' => [
'action' => ['type' => 'string'],
'confidence' => ['type' => 'number'],
],
'required' => ['action'],
];
$payloadA = [
'usage_log' => [
'registration' => 'AB12345',
'creation_allowed' => true,
],
'candidate_orders' => [
['id' => 10, 'items' => [['product_id' => 1, 'quantity' => 1, 'price' => 100]]],
],
];
$payloadB = [
'candidate_orders' => [
['items' => [['price' => 100, 'quantity' => 1, 'product_id' => 1]], 'id' => 10],
],
'usage_log' => [
'creation_allowed' => true,
'registration' => 'AB12345',
],
];
expect(xlvask_automation_service::openAiCacheKeyForAutomation($schemaName, $prompt, $payloadA, $schema, 0.1))
->toBe(xlvask_automation_service::openAiCacheKeyForAutomation($schemaName, $prompt, $payloadB, $schemaWithDifferentKeyOrder, 0.1));
});
it('changes OpenAI cache keys when automation eligibility input changes', function (): void {
$schema = ['type' => 'object'];
$newerWashPayload = ['usage_log' => ['creation_allowed' => false, 'age_bucket' => 'newer_than_6_hours']];
$olderWashPayload = ['usage_log' => ['creation_allowed' => true, 'age_bucket' => 'older_than_6_hours']];
expect(xlvask_automation_service::openAiCacheKeyForAutomation('xlvask_automation', 'Prompt', $newerWashPayload, $schema, 0.1))
->not->toBe(xlvask_automation_service::openAiCacheKeyForAutomation('xlvask_automation', 'Prompt', $olderWashPayload, $schema, 0.1));
});
it('declares a persistent OpenAI cache table for XL Vask automation', function (): void {
$bootstrapContent = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php');
expect($bootstrapContent)
->toContain('xlvask_automation_openai_cache')
->toContain('UNIQUE KEY `uniq_xlvask_openai_cache_key` (`cache_key`)');
});
it('declares auditable and idempotent XL Vask autopilot run tables', function (): void {
$bootstrapContent = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php');
expect($bootstrapContent)
->toContain('xlvask_autopilot_runs')
->toContain('xlvask_autopilot_run_items')
->toContain('xlvask_automation_audit')
->toContain('UNIQUE KEY `uniq_xlvask_autopilot_run_idempotency` (`idempotency_key`)')
->toContain('updated_at');
});
it('declares cached amount summary columns for XL Vask usage logs', function (): void {
$bootstrapContent = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php');
expect($bootstrapContent)
->toContain('cached_total_net_amount')
->toContain('cached_primary_product_name')
->toContain('cached_amount_at');
});
it('keeps automatic XL Vask execution scoped to exact attachments', function (): void {
$serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php');
expect($serviceContent)
->toContain('openAiAttachHardGuardsPass($suggestion, $context)')
->toContain('self::isExactItemMatchForAutomation((array)($context[\'items\'] ?? []), (array)($candidate[\'order_items\'] ?? []))')
->toContain("\$candidateOrderJson = \$suggestion['candidate_order_json'] ?? null;")
->toContain("return 'Automatisk accepteret: Prisoverensstemmelse.';");
});
it('keeps automatic order creation behind calibration and uniqueness readiness gates', function (): void {
$serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php');
expect($serviceContent)
->toContain('if ($action === self::ACTION_CREATE) {')
->toContain('automatic_order_creation_enabled->isTrue()')
->toContain("(string)(\$suggestion['certainty'] ?? '') !== 'certain'")
->toContain('washIdUniquenessReady()');
});
it('never classifies missing or undersized calibration evidence as certain', function (): void {
expect(xlvask_automation_service::classifyCertaintyForAutomation([]))->toBe('uncertain')
->and(xlvask_automation_service::classifyCertaintyForAutomation([
'active' => true,
'precision_value' => 0.999,
'wilson_lower_bound' => 0.99,
'holdout_examples' => 40,
'overall_examples' => 199,
'segment_examples' => 30,
'contradictions' => 0,
]))->toBe('uncertain');
});
it('classifies only a qualifying contradiction-free calibration artifact as certain', function (): void {
$artifact = [
'active' => true,
'precision_value' => 0.995,
'wilson_lower_bound' => 0.98,
'holdout_examples' => 200,
'overall_examples' => 200,
'segment_examples' => 30,
'contradictions' => 0,
];
expect(xlvask_automation_service::classifyCertaintyForAutomation($artifact))->toBe('certain')
->and(xlvask_automation_service::classifyCertaintyForAutomation($artifact, true, ['conflict']))->toBe('uncertain');
});
it('requires two source observations and a six-hour stable window for automatic actions', function (): void {
$now = strtotime('2026-08-03 12:00:00');
$stable = [
'source_observation_count' => 2,
'source_observed_at' => '2026-08-03 11:55:00',
'source_stable_since' => '2026-08-03 06:00:00',
];
expect(xlvask_automation_service::sourceIsStableForAutomatic($stable, $now))->toBeTrue()
->and(xlvask_automation_service::sourceIsStableForAutomatic([
...$stable, 'source_observation_count' => 1,
], $now))->toBeFalse()
->and(xlvask_automation_service::sourceIsStableForAutomatic([
...$stable, 'source_stable_since' => '2026-08-03 06:00:01',
], $now))->toBeFalse();
});
it('builds order-independent revision hashes for XL Vask source payloads', function (): void {
$first = ['WashId' => 'wash-1', 'Customer' => 'A', 'WashItems' => [['Count' => 1, 'Name' => 'Vask']]];
$second = ['WashItems' => [['Name' => 'Vask', 'Count' => 1]], 'Customer' => 'A', 'WashId' => 'wash-1'];
expect(xlvask_usage_logs_o::sourceHashForAutomation($first))
->toBe(xlvask_usage_logs_o::sourceHashForAutomation($second));
});
it('treats reordered XL Vask wash items as the same source revision', function (): void {
$first = ['WashId' => 'wash-1', 'WashItems' => [
['ProductId' => 2, 'Count' => 1],
['ProductId' => 1, 'Count' => 2],
]];
$second = ['WashId' => 'wash-1', 'WashItems' => [
['Count' => 2, 'ProductId' => 1],
['Count' => 1, 'ProductId' => 2],
]];
expect(xlvask_usage_logs_o::sourceHashForAutomation($first))
->toBe(xlvask_usage_logs_o::sourceHashForAutomation($second));
});
it('uses the existing OpenAI module with strict no-retention planner settings', function (): void {
$openAi = file_get_contents(WD . '/classes/openai.php');
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
expect($openAi)->toContain("'store' => false")
->toContain("'role' => 'developer'")
->toContain("'role' => 'user'")
->toContain("if (\$status !== 'completed')")
->toContain("=== 'refusal'")
->toContain("'_openai_usage' => [")
->and($automation)->toContain("private const PLANNER_MODEL = 'MiniMax-M3'")
->toContain('candidate_order_id')
->not->toContain('opaque_context_id')
->not->toContain("'product_name' =>");
});
it('verifies XL Vask TLS and never logs authorization headers or response bodies', function (): void {
$client = file_get_contents(WD . '/modules/xlvask/classes/xlvask_request.php');
expect($client)->toContain('CURLOPT_SSL_VERIFYPEER, true')
->toContain('CURLOPT_SSL_VERIFYHOST, 2')
->not->toContain('Headers: " . implode')
->not->toContain('Response: $response');
});
it('uses a dedicated queued XL Vask autopilot service with scoped durable runs', function (): void {
$serviceContent = file_get_contents(WD . '/classes/xlvask_autopilot_service.php');
expect($serviceContent)
->toContain('public function createRun(')
->toContain('public function processQueuedRuns(')
->toContain('public function getRun(')
->toContain('ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)')
->toContain('scope_hall_ids_json')
->toContain('ai_timeline')
->toContain('ai_batch_size')
->toContain('ai_max_cost_usd')
->toContain('lease_expires_at')
->toContain('attempt_count')
->toContain('next_attempt_at')
->toContain('$renewLease')
->toContain("phase = 'retry_wait'");
});
it('rejects execute run creation unless current server readiness allows execute mode', function (): void {
$serviceContent = file_get_contents(WD . '/classes/xlvask_autopilot_service.php');
expect($serviceContent)
->toContain("if (\$mode === 'execute')")
->toContain('capabilitiesReadOnly(')
->toContain("!in_array('execute', (array)(\$capabilities['allowed_modes'] ?? []), true)");
});
it('enforces distinct runtime capabilities for execute dry-run and replay modes', function (): void {
expect(xlvask_autopilot_service::modeCapabilities('execute'))->toBe([
'import' => true, 'persist_plans' => true, 'execute_actions' => true, 'run_artifacts' => true,
])->and(xlvask_autopilot_service::modeCapabilities('dry_run'))->toBe([
'import' => true, 'persist_plans' => true, 'execute_actions' => false, 'run_artifacts' => true,
])->and(xlvask_autopilot_service::modeCapabilities('replay'))->toBe([
'import' => false, 'persist_plans' => false, 'execute_actions' => false, 'run_artifacts' => true,
]);
});
it('preserves GUID hall scopes and rejects empty scope values at runtime', function (): void {
expect(xlvask_autopilot_service::normalizeHallScope([
' 845d29a1-a7d2-4e3b-bbc3-2b13242d744a ', '', '845d29a1-a7d2-4e3b-bbc3-2b13242d744a',
]))->toBe(['845d29a1-a7d2-4e3b-bbc3-2b13242d744a']);
});
it('keeps explicit retry keys idempotent and actor-bound', function (): void {
expect(xlvask_autopilot_service::idempotencyKey('retry-1', 10, 'nonce-a'))
->toBe(xlvask_autopilot_service::idempotencyKey('retry-1', 10, 'nonce-b'))
->not->toBe(xlvask_autopilot_service::idempotencyKey('retry-1', 11, 'nonce-a'))
->and(xlvask_autopilot_service::idempotencyKey('', 10, 'nonce-a'))
->not->toBe(xlvask_autopilot_service::idempotencyKey('', 10, 'nonce-b'));
expect(xlvask_autopilot_service::requestFingerprint(['mode' => 'execute', 'ids' => [1]]))
->not->toBe(xlvask_autopilot_service::requestFingerprint(['mode' => 'dry_run', 'ids' => [1]]));
});
it('fails preview snapshots closed when source hash or optimistic version changes', function (): void {
$current = ['expected_version' => 4, 'source_hash' => str_repeat('a', 64)];
expect(xlvask_autopilot_service::previewSnapshotMatches(4, str_repeat('a', 64), $current))->toBeTrue()
->and(xlvask_autopilot_service::previewSnapshotMatches(5, str_repeat('a', 64), $current))->toBeFalse()
->and(xlvask_autopilot_service::previewSnapshotMatches(4, str_repeat('b', 64), $current))->toBeFalse();
});
it('requires explicit run modes and serializes active execute runs in schema', function (): void {
$autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php');
$schema = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php');
expect($autopilot)
->toContain("(\$input['mode'] ?? '')")
->toContain('An explicit XL Vask autopilot mode is required.')
->and($schema)
->toContain('active_execute_slot')
->toContain('uniq_xlvask_active_execute_run');
});
it('uses exact adjudicated suggestion labels and a chronological holdout for calibration', function (): void {
$serviceContent = file_get_contents(WD . '/classes/xlvask_autopilot_service.php');
expect($serviceContent)
->toContain('INNER JOIN xlvask_automation_suggestions s ON s.id = l.suggestion_id')
->toContain('chronological_80_20_by_suggestion_created_at_and_id')
->toContain('xlvask_automation_calibration_label_events')
->toContain("'label_snapshot' => \$snapshot")
->not->toContain("SUM(f.decision = 'accepted')");
});
it('keeps XL Vask automation execution inside a transactional revalidation boundary', function (): void {
$serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php');
expect($serviceContent)
->toContain('$connection->begin_transaction()')
->toContain('$connection->commit()')
->toContain('$connection->rollback()')
->toContain('SELECT * FROM xlvask_usage_logs WHERE id = {$usageLogId} FOR UPDATE')
->toContain("findSameDayCandidateOrders(\$lockedLog, (array)\$context['proposed_order'], true)")
->toContain('SELECT id FROM order_items WHERE order_id IN (')
->toContain("if (\$action === self::ACTION_CREATE && \$currentCandidates !== [])")
->toContain("WHERE xlvask_normalized_wash_id = NULLIF(LOWER(TRIM('{\$washId}')), '') FOR UPDATE")
->toContain('XL Vask-kildedata blev ændret efter evalueringen.');
});
it('permits only identity-bound calibrated OpenAI automatic actions behind deterministic hard guards', function (): void {
$serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php');
expect($serviceContent)
->toContain("!== self::SOURCE_OPENAI")
->toContain('hardGuardsPassForCertainty($suggestion, $context)')
->toContain('openAiAttachHardGuardsPass($suggestion, $context)')
->toContain('policyAllowsActionReadOnly($action)')
->toContain('sourceIsStableForAutomatic($context)')
->toContain('washIdUniquenessReady()');
});
it('pins the complete planner identity and invalidates cache keys with it', function (): void {
$identity = xlvask_automation_service::automationIdentityForAutomation();
expect($identity)
->toMatchArray([
'policy_version' => 'xlvask-ai-auto-v2',
'model' => 'MiniMax-M3',
'prompt_version' => 'xlvask-planner-da-v2',
'schema_version' => 'xlvask-automation-schema-v2',
'cache_version' => 2,
])
->and($identity['identity_hash'])->toHaveLength(64)
->and($identity['prompt_hash'])->toHaveLength(64)
->and($identity['schema_hash'])->toHaveLength(64);
});
it('accepts only completed structured OpenAI responses and records the resolved model', function (): void {
$parsed = openai::parseJsonTaskResponse([
'status' => 'completed',
'model' => 'gpt-5.6-sol',
'output' => [[
'content' => [[
'type' => 'output_text',
'text' => '{"action":"none"}',
]],
]],
]);
expect($parsed)->toBe([
'action' => 'none',
'_openai_response_model' => 'gpt-5.6-sol',
'_openai_usage' => [
'input_tokens' => 0,
'output_tokens' => 0,
'total_tokens' => 0,
'service_tier' => '',
],
]);
});
it('fails incomplete and refusal OpenAI responses closed', function (): void {
expect(fn() => openai::parseJsonTaskResponse([
'status' => 'incomplete',
'incomplete_details' => ['reason' => 'max_output_tokens'],
]))->toThrow(\classes\openai_request_exception::class)
->and(fn() => openai::parseJsonTaskResponse([
'status' => 'completed',
'model' => 'gpt-5.6-sol',
'output' => [['content' => [['type' => 'refusal', 'refusal' => 'no']]]],
]))->toThrow(\classes\openai_request_exception::class);
try {
openai::parseJsonTaskResponse([
'status' => 'incomplete',
'incomplete_details' => ['reason' => 'max_output_tokens'],
]);
$incompleteRetryable = null;
} catch (\classes\openai_request_exception $exception) {
$incompleteRetryable = $exception->retryable;
}
try {
openai::parseJsonTaskResponse([
'status' => 'completed',
'model' => 'gpt-5.6-sol',
'output' => [['content' => [['type' => 'refusal', 'refusal' => 'no']]]],
]);
$refusalRetryable = null;
} catch (\classes\openai_request_exception $exception) {
$refusalRetryable = $exception->retryable;
}
expect($incompleteRetryable)->toBeTrue()
->and($refusalRetryable)->toBeFalse();
});
it('declares explicit migration-only schema activation and server policy controls', function (): void {
$schema = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php');
$migration = file_get_contents(WD . '/modules/xlvask/migrations/20260804_xlvask_ai_auto_policy_v2.php');
$policy = file_get_contents(WD . '/classes/xlvask_automation_policy_service.php');
expect($schema)
->toContain('applyExplicitMigration')
->toContain('migrationStatus')
->toContain('xlvask_automation_policy_state')
->toContain('xlvask_automation_action_events')
->and($migration)->toContain('operator-invoked')
->toContain('applyExplicitMigration')
->and($policy)->toContain('ATTACH_DAILY_CAP = 100')
->toContain('ATTACH_PER_HALL_DAILY_CAP = 10')
->toContain('CREATE_DAILY_CAP = 20')
->toContain('CREATE_PER_HALL_DAILY_CAP = 3')
->toContain("review_outcome = 'correct'");
});
it('fails migration readiness closed for partial runtime schema and missing active-run uniqueness', function (): void {
$schema = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php');
expect($schema)
->toContain("'required_indexes' => \$requiredIndexes")
->toContain("'missing_indexes' => \$missingIndexes")
->toContain("'preflight_conflicts' => \$conflicts")
->toContain('multiple_active_execute_runs:')
->toContain('uniq_xlvask_active_execute_run')
->toContain("'xlvask_automation_policy_state' => [")
->toContain("'xlvask_automation_action_events' => [")
->toContain("'xlvask_automation_calibrations' => [")
->toContain("'xlvask_autopilot_runs' => [");
});
it('treats policy and budget stops as resumable run pauses instead of suggestion failures', function (): void {
$policy = file_get_contents(WD . '/classes/xlvask_automation_policy_service.php');
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
expect($policy)
->toContain('final class xlvask_automation_control_stop')
->toContain("'budget_exhausted'")
->toContain("'calibration_revoked'")
->and($automation)
->toContain('if ($e instanceof xlvask_automation_control_stop)')
->toContain("'control_stop' => true")
->toContain("\$circuitBreaker = (string)(\$result['control_stop_reason'] ?? 'policy_control_stop')");
});
it('makes exact adjudication retries idempotent and rejects a changed outcome', function (): void {
expect(\classes\xlvask_automation_policy_service::adjudicationRetryMatches('correct', 'correct'))->toBeTrue()
->and(\classes\xlvask_automation_policy_service::adjudicationRetryMatches('correct', 'duplicate'))->toBeFalse()
->and(\classes\xlvask_automation_policy_service::adjudicationRetryMatches(null, 'correct'))->toBeFalse();
});
it('requires current usage revision and review state for row action eligibility', function (): void {
$suggestion = [
'status' => 'suggested', 'action' => 'attach_order', 'expected_version' => 7,
'input_hash' => str_repeat('a', 64),
];
$usage = [
'resolution_state' => 'needs_review', 'import_state' => 'unchanged', 'ignored_at' => null,
'FinishStatus' => 1, 'expected_version' => 7, 'source_hash' => str_repeat('a', 64),
];
expect(xlvask_automation_service::suggestionMatchesCurrentUsageForReview($suggestion, $usage))->toBeTrue()
->and(xlvask_automation_service::suggestionMatchesCurrentUsageForReview($suggestion, [...$usage, 'ignored_at' => '2026-08-04 12:00:00']))->toBeFalse()
->and(xlvask_automation_service::suggestionMatchesCurrentUsageForReview($suggestion, [...$usage, 'expected_version' => 8]))->toBeFalse()
->and(xlvask_automation_service::suggestionMatchesCurrentUsageForReview($suggestion, [...$usage, 'import_state' => 'invalid']))->toBeFalse();
});
it('emits explicit fail-closed per-row action flags and supersedes ignored suggestions', function (): void {
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
$autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php');
$projectionStart = strpos((string)$automation, 'private function readProjectionActionFlags');
$projectionEnd = strpos((string)$automation, 'private function decodeJsonField', (int)$projectionStart);
$projection = substr((string)$automation, (int)$projectionStart, (int)$projectionEnd - (int)$projectionStart);
expect($automation)
->toContain("'can_ignore' =>")
->toContain("'can_attach_order' =>")
->toContain("'can_create_order' =>")
->toContain('suggestionMatchesCurrentUsageForReview($suggestion, $usageRow)')
->toContain('Current candidate')
->and($autopilot)
->toContain("SET status = 'superseded', updated_at = NOW()")
->toContain("WHERE usage_log_id = {\$usageId} AND status = 'suggested'");
expect($projection)->not->toContain('$this->buildContext(');
});
it('keeps scheduled automation deploy-order safe without interrupting ordinary sync', function (): void {
$tasks = (string)file_get_contents(WD . '/modules/xlvask/helpers/xlvask_tasks.php');
$importStart = strpos($tasks, 'public function runImportTasks(): void');
$importEnd = strpos($tasks, '/** Enqueue automatic work', (int)$importStart);
$importBody = substr($tasks, (int)$importStart, (int)$importEnd - (int)$importStart);
expect($tasks)
->toContain('$this->runCleanupTasks();')
->toContain('$this->runScheduledAutomationIfReady();')
->toContain('scheduledExecutionAllowed($migrationStatus, $capabilities)')
->and($importBody)->not->toContain("createRun(['mode' => 'execute']");
expect(strpos($tasks, '$this->runCleanupTasks();'))
->toBeLessThan(strpos($tasks, '$this->runScheduledAutomationIfReady();'));
expect(xlvask_autopilot_service::scheduledExecutionAllowed(['ready' => false], ['allowed_modes' => ['execute']]))->toBeFalse()
->and(xlvask_autopilot_service::scheduledExecutionAllowed(['ready' => true], ['allowed_modes' => ['dry_run', 'replay']]))->toBeFalse()
->and(xlvask_autopilot_service::scheduledExecutionAllowed(['ready' => true], ['allowed_modes' => ['execute']]))->toBeTrue();
});
it('propagates only retryable OpenAI failures into a durable run retry', function (): void {
$retryable = new \classes\openai_request_exception('temporary', true, 503);
$refusal = new \classes\openai_request_exception('refused', false, null);
expect(xlvask_automation_service::openAiFailureRequiresDurableRetry($retryable, 42))->toBeTrue()
->and(xlvask_automation_service::openAiFailureRequiresDurableRetry($retryable, null))->toBeFalse()
->and(xlvask_automation_service::openAiFailureRequiresDurableRetry($refusal, 42))->toBeFalse();
$automation = (string)file_get_contents(WD . '/classes/xlvask_automation_service.php');
expect($automation)
->toContain('catch (openai_request_exception $exception)')
->toContain('throw $exception;')
->toContain('OpenAI kunne ikke levere et anvendeligt forslag.');
});
it('binds financial execution to the locked suggestion revision and indexed wash id', function (): void {
$automation = (string)file_get_contents(WD . '/classes/xlvask_automation_service.php');
$usage = ['id' => 7, 'expected_version' => 3, 'source_hash' => str_repeat('a', 64)];
$suggestion = ['usage_log_id' => 7, 'expected_version' => 3, 'input_hash' => str_repeat('a', 64)];
expect(xlvask_automation_service::suggestionMatchesLockedUsageForExecution($suggestion, $usage))->toBeTrue()
->and(xlvask_automation_service::suggestionMatchesLockedUsageForExecution([...$suggestion, 'expected_version' => 4], $usage))->toBeFalse()
->and(xlvask_automation_service::suggestionMatchesLockedUsageForExecution([...$suggestion, 'input_hash' => str_repeat('b', 64)], $usage))->toBeFalse()
->and(xlvask_automation_service::suggestionMatchesLockedUsageForExecution([...$suggestion, 'usage_log_id' => 8], $usage))->toBeFalse();
expect($automation)
->toContain('suggestionMatchesLockedUsageForExecution($suggestion, $lockedUsage)')
->toContain('WHERE xlvask_normalized_wash_id = NULLIF(LOWER(TRIM')
->not->toContain('WHERE LOWER(TRIM(wash_id)) = LOWER(TRIM');
});
it('binds calibration evidence and snapshots to current planner identity and resolved model', function (): void {
$autopilot = (string)file_get_contents(WD . '/classes/xlvask_autopilot_service.php');
$policy = (string)file_get_contents(WD . '/classes/xlvask_automation_policy_service.php');
$identity = ['policy_version' => 'v2', 'identity_hash' => str_repeat('a', 64), 'model' => 'gpt-current'];
$evidence = ['policy_version' => 'v2', 'planner_identity_hash' => str_repeat('a', 64), 'model' => 'gpt-current'];
expect(xlvask_autopilot_service::calibrationEvidenceMatchesIdentity($evidence, $identity))->toBeTrue()
->and(xlvask_autopilot_service::calibrationEvidenceMatchesIdentity([...$evidence, 'planner_identity_hash' => str_repeat('b', 64)], $identity))->toBeFalse()
->and(xlvask_autopilot_service::calibrationEvidenceMatchesIdentity([...$evidence, 'model' => 'gpt-stale'], $identity))->toBeFalse()
->and(xlvask_autopilot_service::calibrationEvidenceMatchesIdentity([...$evidence, 'policy_version' => 'v1'], $identity))->toBeFalse();
expect($autopilot)
->toContain("BINARY s.planner_identity_hash = BINARY '{\$identitySql}'")
->toContain("BINARY s.model = BINARY '{\$modelSql}'")
->toContain("'planner_identity_hash' => (string)\$label['planner_identity_hash']")
->toContain("'resolved_model' => (string)\$label['model']")
->toContain("(string)(\$backtest['resolved_model'] ?? '')")
->and($policy)->toContain("(string)(\$artifact['resolved_model'] ?? '')");
});
it('authorizes calibration adjudication from an action event or an exact current reviewable suggestion', function (): void {
$autopilot = (string)file_get_contents(WD . '/classes/xlvask_autopilot_service.php');
expect($autopilot)
->toContain('LEFT JOIN xlvask_automation_action_events ae ON ae.suggestion_id = s.id')
->toContain('ae.id IS NOT NULL')
->toContain("s.status = 'suggested' AND s.source = 'openai'")
->toContain('s.expected_version = u.expected_version AND s.input_hash = u.source_hash')
->toContain("u.resolution_state = 'needs_review'")
->toContain('newer.usage_log_id = s.usage_log_id AND newer.id > s.id')
->toContain("BINARY s.planner_identity_hash = BINARY '{\$identitySql}'")
->toContain("BINARY s.model = BINARY '{\$modelSql}'");
});
it('advertises OpenAI as the only effective automatic action source', function (): void {
$policy = (string)file_get_contents(WD . '/classes/xlvask_automation_policy_service.php');
expect($policy)
->toContain("'effective_action_sources' => \$executeEnabled")
->toContain("? ['openai']")
->toContain(': []');
});
it('documents exact safe deploy partial migration rollback and bounded list projection', function (): void {
$runbook = (string)file_get_contents(WD . '/modules/xlvask/AUTOMATION_RUNBOOK.md');
expect($runbook)
->toContain('Old code cannot interpret the new policy stages')
->toContain('set both legacy automatic-order switches to false')
->toContain('Never route old code as a partial-migration workaround')
->toContain('Use this exact rollback sequence before any old-code traffic')
->toContain('do not reconstruct same-day candidates per row')
->toContain('authoritatively rebuilt during preview/apply');
});
it('invalidates stale calibration and restarts soak at each canary activation epoch', function (): void {
$policy = file_get_contents(WD . '/classes/xlvask_automation_policy_service.php');
$autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php');
$schema = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php');
expect($policy)
->toContain("UPDATE xlvask_automation_calibrations SET active = 0, invalidated_at = NOW()")
->toContain("'invalidated_calibration_segment' => \$segment")
->toContain("'ai_attach_canary' => [")
->toContain("'ai_attach_verified' => [")
->toContain("'ai_create_canary' => [")
->toContain("'verified_capped' => [")
->toContain("AND created_at >= '{\$sinceSql}'")
->toContain("\$state['attach_activated_at'] ?? null")
->toContain("\$state['create_activated_at'] ?? null")
->and($autopilot)
->toContain("'safety_epoch' => \$this->calibrationSafetyEpoch(\$segmentKey)")
->toContain('XL Vask calibration artifact was invalidated by an action safety latch.')
->toContain('XL Vask calibration labels changed after this artifact was generated.')
->and($schema)->toContain("'invalidated_at'");
});
it('scopes eligible suggestions and visible hall budgets to the caller revision and hall scope', function (): void {
$policy = file_get_contents(WD . '/classes/xlvask_automation_policy_service.php');
expect($policy)
->toContain('s.expected_version = u.expected_version')
->toContain('s.input_hash = u.source_hash')
->toContain('budgetSnapshotReadOnly($hallIds, $state)')
->toContain('$visibleHallWhere')
->toContain("AND hall_id IN (");
});
it('exposes distinct pre-action review and post-action adjudication state', function (): void {
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
$autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php');
expect($automation)
->toContain("'review_eligible' =>")
->toContain("'adjudication_eligible' =>")
->toContain("'allowed_adjudication_outcomes' =>")
->toContain("'adjudication_outcome' =>")
->and($autopilot)
->toContain('reviewAutomaticActionBySuggestion(')
->toContain('true')
->toContain('$connection->begin_transaction()')
->toContain("'action_halted' =>")
->toContain("'affected_action' =>");
});
it('fails malformed or reversed invoice-period readiness scopes closed', function (): void {
expect(\classes\xlvask_automation_policy_service::scopeDatesAreValid('2026-08-01', '2026-08-31'))->toBeTrue()
->and(\classes\xlvask_automation_policy_service::scopeDatesAreValid('2026-02-30', '2026-03-01'))->toBeFalse()
->and(\classes\xlvask_automation_policy_service::scopeDatesAreValid('2026-08-31', '2026-08-01'))->toBeFalse();
});
it('rotates pending rows fairly and invalidates suggestions atomically on source changes', function (): void {
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
$usageLogs = file_get_contents(WD . '/objects/xlvask_usage_logs_o.php');
expect($automation)
->toContain("ORDER BY COALESCE(last_evaluated_at, '1970-01-01 00:00:00') ASC, id ASC")
->toContain('eligible_total')
->and($usageLogs)
->toContain('supersedeSuggestionsForSourceRevision')
->toContain("SET status = 'superseded'")
->toContain('$connection->begin_transaction()');
});
it('captures the eligible population before processing and fails invalid revisions closed', function (): void {
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
$autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php');
$usageLogs = file_get_contents(WD . '/objects/xlvask_usage_logs_o.php');
expect(strpos($automation, '$eligibleTotal ='))
->toBeLessThan(strpos($automation, 'foreach ($rows as $row)'))
->and($automation)->toContain("=== 'invalid'")
->toContain("=== 'updated'")
->toContain("=== 'recheck'")
->toContain('$linkedOrder->asArray(true, false)')
->toContain("'certainty' => 'certain'")
->toContain('existing_link_semantically_revalidated')
->toContain('linked_order_revision_mismatch')
->and($autopilot)->toContain('has invalid source data')
->and($usageLogs)->toContain('source_stable_since = NULL')
->toContain('supersedeSuggestionsForSourceRevision($id)');
});
it('keeps read-only replay cache-only without new OpenAI network calls', function (): void {
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
expect($automation)->toContain("if (\$this->readOnlyEvaluation) {\n return null;");
});
it('revalidates linked orders against customer department registration lane date and normalized items', function (): void {
$proposedOrder = [
'customer_id' => 10, 'department_id' => 2, 'reg_1' => 'AB 12 345',
'lane' => 3, 'created_at' => '2026-08-03 10:00:00',
];
$linkedOrder = [
'customer_id' => 10, 'department_id' => 2, 'reg_1' => 'AB12345',
'lane' => 3, 'created_at' => '2026-08-03 10:05:00',
];
$items = [['product_id' => 5, 'quantity' => 1, 'price' => 500]];
expect(xlvask_automation_service::linkedOrderMatchesForAutomation($proposedOrder, $items, $linkedOrder, $items))
->toBeTrue()
->and(xlvask_automation_service::linkedOrderMatchesForAutomation(
$proposedOrder,
$items,
[...$linkedOrder, 'customer_id' => 11],
$items
))->toBeFalse();
});
it('runs autopilot retention from the hourly XL Vask cleanup path', function (): void {
$autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php');
$tasks = file_get_contents(WD . '/modules/xlvask/helpers/xlvask_tasks.php');
expect($autopilot)->toContain('public function pruneExpiredData(): array')
->and($tasks)->toContain('(new xlvask_autopilot_service())->pruneExpiredData();');
});
it('scores same-day orders with matching XL Vask products and extra add-ons as attach suggestions', function (): void {
$usageItems = [
['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']],
['product_id' => 24, 'quantity' => 1, 'price' => 39, 'product' => ['name' => 'Spot Free- Lastbil']],
['product_id' => 21, 'quantity' => 1, 'price' => 79, 'product' => ['name' => 'Undervognskyl pr. Enhed']],
['product_id' => 99, 'quantity' => 8, 'price' => 0, 'product' => ['name' => 'Halleje']],
];
$orderItems = [
['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']],
['product_id' => 50, 'quantity' => 1, 'price' => 319, 'product' => ['name' => 'Indvendig vask Forvogn']],
['product_id' => 21, 'quantity' => 1, 'price' => 79, 'product' => ['name' => 'Undervognskyl pr. Enhed']],
['product_id' => 24, 'quantity' => 1, 'price' => 39, 'product' => ['name' => 'Spot Free- Lastbil']],
];
$score = xlvask_automation_service::scoreItemMatchForAutomation($usageItems, $orderItems);
expect($score['source'])
->toBe('fuzzy')
->and($score['confidence'])->toBeGreaterThanOrEqual(0.70)
->and($score['confidence'])->toBeLessThan(0.92)
->and($score['reason'])->toContain('ekstra ydelser');
});
it('does not score an order with only the primary product as a matching add-on attachment', function (): void {
$usageItems = [
['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']],
['product_id' => 24, 'quantity' => 1, 'price' => 39, 'product' => ['name' => 'Spot Free- Lastbil']],
['product_id' => 21, 'quantity' => 1, 'price' => 79, 'product' => ['name' => 'Undervognskyl pr. Enhed']],
];
$orderItems = [
['product_id' => 10, 'quantity' => 1, 'price' => 519, 'product' => ['name' => 'Forvogn']],
['product_id' => 50, 'quantity' => 1, 'price' => 319, 'product' => ['name' => 'Indvendig vask Forvogn']],
];
expect(xlvask_automation_service::scoreItemMatchForAutomation($usageItems, $orderItems)['confidence'])
->toBe(0.0);
});
it('creates a manual operator suggestion with a deterministic proposal', function (): void {
$service = new xlvask_automation_service();
$reflection = new ReflectionClass($service);
// The constant is private but must be 'manual'.
$source = $reflection->getConstant('SOURCE_MANUAL');
expect($source)->toBe('manual');
// The method must exist and be invokable on partial inputs (no AI).
expect($reflection->hasMethod('createManualSuggestion'))->toBeTrue();
// Calling it with an invalid action should throw, not silently accept.
expect(fn () => $service->createManualSuggestion(0, 'not_a_real_action', null, []))
->toThrow(Exception::class);
});
it('accepts force_manual in the decision preview contract', function (): void {
// Read the route to confirm the preview endpoint whitelists force_manual.
$routeFile = file_get_contents(__DIR__ . '/../../../routes/xlvaskUsageLogsRoute.php');
expect($routeFile)->toContain("'force_manual'");
expect($routeFile)->toContain("'usage_log_ids', 'action', 'suggestion_id', 'order_id', 'reason', 'force_manual'");
});
@@ -15,7 +15,7 @@ it('exposes direct linked order metadata on XL Vask usage order rows', function
->and($route)->toContain("'usage_log_id' => \$id");
});
it('does not execute XL Vask usage automation while listing usage order rows', function (): void {
it('limits the usage-logs endpoint to the reviewer permission set', function (): void {
$route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
expect($route)->not->toBeFalse();
@@ -23,9 +23,21 @@ it('does not execute XL Vask usage automation while listing usage order rows', f
$route = (string)$route;
expect($route)
->toContain('$automation = $automation_service->readAutomationStateByUsageLogId($id, $log);')
->and($route)->not->toContain('$automation = $automation_service->evaluateUsageLogRow($log, (int)$user->id, true);')
->and($route)->toContain("requirePermission('manage_xlvask_usage_automation')");
->toContain("list_xlvask_usage_orders_own")
->toContain("list_xlvask_usage_orders_all")
->not->toContain('xlvask_autopilot_service')
->not->toContain('xlvask_automation_service')
->not->toContain('xlvask_automation_policy_service')
->not->toContain('manage_xlvask_usage_automation')
->not->toContain('evaluateUsageLogRow')
->not->toContain('source_hash')
->not->toContain('source_revision')
->not->toContain('import_state')
->not->toContain('resolution_state')
->not->toContain('certainty')
->not->toContain('planned_action')
->not->toContain('expected_version')
->not->toContain('last_run_id');
expect($route)
->toContain("if (\$allowedHallIds === [])")
->toContain("No XL Vask hall scope is available', 403")
@@ -41,27 +53,21 @@ it('returns cached amount summaries on XL Vask usage order rows without widening
expect($route)
->toContain('$amount_summary = $xlvask_usage_logs->getAmountSummaryReadOnly($log)')
->and($route)->toContain('$usage_log_payload = array_intersect_key($log, array_flip([')
->and($route)->toContain('$tmp->setProperties($usage_log_payload)')
->and($route)->toContain("\$usage_log_payload = array_intersect_key(\$log, array_flip([")
->and($route)->toContain("\$tmp->setProperties(\$usage_log_payload)")
->and($route)->toContain("\$tmp_res['order']['total_net_amount'] = \$amount_summary['total_net_amount']")
->and($route)->toContain("\$tmp_res['order']['xlvask_primary_product_name'] = \$amount_summary['primary_product_name']")
->and($route)->toContain("\$tmp_res['order']['xlvask_amount_cached'] = \$amount_summary['cached']");
});
it('keeps automation metadata out of the strict legacy XL Vask helper payload', function (): void {
it('keeps ignore metadata out of the strict legacy XL Vask helper payload', function (): void {
$route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
expect($route)->not->toBeFalse();
$payloadStart = strpos((string)$route, '$usage_log_payload = array_intersect_key(');
$payloadEnd = strpos((string)$route, '// Create a new xlvask usage log object', $payloadStart ?: 0);
$route = (string)$route;
expect($payloadStart)->not->toBeFalse()
->and($payloadEnd)->not->toBeFalse();
$payloadDefinition = substr((string)$route, (int)$payloadStart, (int)$payloadEnd - (int)$payloadStart);
expect($payloadDefinition)
expect($route)
->not->toContain("'source_hash'")
->not->toContain("'source_revision'")
->not->toContain("'import_state'")
@@ -72,26 +78,26 @@ it('keeps automation metadata out of the strict legacy XL Vask helper payload',
->not->toContain("'last_run_id'");
});
it('keeps the legacy state-changing XL Vask usage import GET non-mutating', function (): void {
$route = file_get_contents(WD . '/routes/moduleXLVaskRoute.php');
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
it('exposes review, accept, reject and ignore endpoints gated on review_xlvask_usage_order', function (): void {
$route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
expect($route)
->not->toBeFalse()
->and($automation)->not->toBeFalse();
expect($route)->not->toBeFalse();
$route = (string)$route;
$automation = (string)$automation;
expect($route)
->toContain('Deprecated state-changing GET.')
->toContain('Use POST /modules/xlvask/services/usage/autopilot-runs.')
->not->toContain("'forceRefetch' => true")
->not->toContain('runPending($dateFrom, $dateTo, [], 100, null)')
->and($automation)->toContain("STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')");
->toContain("patch('/modules/xlvask/services/usage/orders/{id}/ignore'")
->toContain("post('/modules/xlvask/services/usage/orders/{id}/unignore'")
->toContain("post('/modules/xlvask/services/usage/orders/{id}/accept'")
->toContain("post('/modules/xlvask/services/usage/orders/{id}/reject'")
->toContain("requirePermission('review_xlvask_usage_order')")
->toContain("'ignored_at' => \$log['ignored_at'] ?? null")
->toContain("'ignored_by' => isset(\$log['ignored_by']) ? (int)\$log['ignored_by'] : null")
->toContain("'ignored_reason' => \$log['ignored_reason'] ?? null")
->not->toContain('Ignored at server-generated automation decision preview');
});
it('exposes additive XL Vask autopilot run and summary routes', function (): void {
it('exposes a reviewer summary endpoint that does not invoke the autopilot service', function (): void {
$route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
expect($route)->not->toBeFalse();
@@ -100,66 +106,11 @@ it('exposes additive XL Vask autopilot run and summary routes', function (): voi
expect($route)
->toContain("get('/modules/xlvask/services/usage/orders/summary'")
->toContain("post('/modules/xlvask/services/usage/autopilot-runs'")
->toContain("get('/modules/xlvask/services/usage/autopilot-runs/{id}'")
->toContain('(new xlvask_autopilot_service())->getSummary(')
->toContain('(new xlvask_autopilot_service())->createRun(')
->toContain('(new xlvask_autopilot_service())->getRun(')
->toContain('$this->allowedHallIdsForUser($user)')
->toContain("'aiTimeline'")
->toContain("'aiBatchSize'")
->toContain("'aiMaxCostUsd'")
->toContain('], 202);');
->toContain('(new xlvask_usage_logs_o())->summarizeUsageOrdersReadOnly(')
->not->toContain('xlvask_autopilot_service()->getSummary(');
});
it('routes legacy automation entry points through the durable queue and preview lifecycle', function (): void {
$route = (string)file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
expect($route)
->toContain('Deprecated. Use /modules/xlvask/services/usage/autopilot-runs.')
->toContain('Use the server-generated automation decision preview and apply endpoints.')
->not->toContain("post('/modules/xlvask/services/usage/orders/automation/run', function () {\n global \$response;\n \$this->requirePermission('manage_xlvask_usage_automation');\n\n \$user")
->not->toContain('(new xlvask_automation_service())->runPending(')
->not->toContain('(new xlvask_automation_service())->evaluateUsageLogById(');
});
it('exposes permission-aware capabilities active run and preview-bound server policy routes', function (): void {
$route = (string)file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
expect($route)
->toContain("get('/modules/xlvask/services/usage/automation/capabilities'")
->toContain("get('/modules/xlvask/services/usage/autopilot-runs/active'")
->toContain("post('/modules/xlvask/services/usage/automation/admin/policy/previews'")
->toContain("post('/modules/xlvask/services/usage/automation/admin/policy/apply'")
->toContain("post('/modules/xlvask/services/usage/automation/admin/halt'")
->toContain("'can_manage_policy' => \$canManagePolicy")
->toContain("'can_halt' => \$canManagePolicy")
->toContain("'preview' => (new xlvask_automation_policy_service())->createPolicyPreview(")
->toContain("self::requireParameters(['target_stage', 'reason'])")
->toContain("(string)\$this->getParameter('reason')")
->toContain("['run' => (new xlvask_automation_policy_service())->activeRunReadOnly(");
});
it('allows all-only permission and uses every configured scanner hall for all scope', function (): void {
$route = (string)file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
expect($route)
->toContain("if (!\$this->hasPermission(\$permission_list_all))")
->toContain("if (\$this->hasPermission('list_xlvask_usage_orders_all'))")
->toContain("if (!\$this->hasPermission('list_xlvask_usage_orders_all'))")
->toContain('private function allowedHallIdsForUser(object $user): array')
->not->toContain('private static function allowedHallIdsForUser')
->toContain("'list_xlvask_usage_orders_all' => 'Read all XL Vask usage-log automation summaries'")
->toContain('SELECT DISTINCT HallId FROM plate_scanners');
});
it('makes direct ignore and deprecated automation routes non-mutating', function (): void {
$route = (string)file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
expect($route)
->toContain("patch('/modules/xlvask/services/usage/orders/{id}/ignore'")
->toContain("response->error('Use the server-generated automation decision preview and apply endpoints.', 409)")
->toContain("response->error('Deprecated. Use /modules/xlvask/services/usage/autopilot-runs.', 410)")
->not->toContain("SET ignored_at = NOW(),");
});
it('returns revision and resolution state on XL Vask usage order rows', function (): void {
it('routes legacy autopilot, calibration and policy transition paths through 410 Gone stubs', function (): void {
$route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
expect($route)->not->toBeFalse();
@@ -167,71 +118,35 @@ it('returns revision and resolution state on XL Vask usage order rows', function
$route = (string)$route;
expect($route)
->toContain("'source_hash' => \$log['source_hash'] ?? null")
->toContain("'source_revision' => \$log['source_revision'] ?? null")
->toContain("'import_state' => \$log['import_state'] ?? 'unchanged'")
->toContain("'resolution_state' => \$log['resolution_state'] ?? 'needs_review'")
->toContain("'certainty' => \$log['certainty'] ?? 'none'")
->toContain("'planned_action' => \$log['planned_action'] ?? 'none'")
->toContain("'expected_version' => isset(\$log['expected_version']) ? (int)\$log['expected_version'] : 1")
->toContain("'automation' => \$automation");
->not->toContain("'/modules/xlvask/services/usage/autopilot-runs'")
->not->toContain("'/modules/xlvask/services/usage/autopilot-runs/active'")
->not->toContain("'/modules/xlvask/services/usage/automation/admin/policy/previews'")
->not->toContain("'/modules/xlvask/services/usage/automation/admin/policy/apply'")
->not->toContain("'/modules/xlvask/services/usage/automation/admin/halt'")
->not->toContain("'/modules/xlvask/services/usage/automation/admin/calibrations/backtest'")
->not->toContain("'/modules/xlvask/services/usage/automation/admin/calibrations/labels'")
->not->toContain("'/modules/xlvask/services/usage/automation/admin/calibrations/{id}/activate'")
->not->toContain("'/modules/xlvask/services/usage/automation/admin/wash-id-uniqueness/activate'")
->not->toContain("'/modules/xlvask/services/usage/automation/decisions/preview'")
->not->toContain("'/modules/xlvask/services/usage/automation/decisions/apply'")
->not->toContain("'/modules/xlvask/services/usage/automation/capabilities'")
->not->toContain("'/modules/xlvask/services/usage/automation/admin/readiness'");
});
it('documents XL Vask autopilot summary and run APIs in OpenAPI', function (): void {
$openApi = file_get_contents(WD . '/openapi.yaml');
expect($openApi)->not->toBeFalse();
$openApi = (string)$openApi;
expect($openApi)
->toContain('/modules/xlvask/services/usage/orders/summary:')
->toContain('operationId: summarizeXlvaskUsageAutomation')
->toContain('/modules/xlvask/services/usage/autopilot-runs:')
->toContain('operationId: createXlvaskUsageAutopilotRun')
->toContain('/modules/xlvask/services/usage/autopilot-runs/{id}:')
->toContain('operationId: getXlvaskUsageAutopilotRun')
->toContain('/modules/xlvask/services/usage/automation/decisions/preview:')
->toContain('operationId: previewXlvaskUsageAutomationDecision')
->toContain('/modules/xlvask/services/usage/automation/decisions/apply:')
->toContain('operationId: applyXlvaskUsageAutomationDecision')
->toContain('/modules/xlvask/services/usage/automation/admin/readiness:')
->toContain('operationId: adjudicateXlvaskCalibrationLabel')
->toContain('operationId: generateXlvaskCalibrationArtifact')
->toContain('operationId: activateXlvaskCalibrationArtifact')
->toContain('operationId: activateXlvaskWashIdUniqueness');
expect($openApi)
->toContain('operationId: getXlvaskAutomationCapabilities')
->toContain('effective_action_sources:')
->toContain('items: { type: string, enum: [openai] }')
->toContain('operationId: getActiveXlvaskUsageAutopilotRun')
->toContain('operationId: previewXlvaskAutomationPolicyTransition')
->toContain('operationId: applyXlvaskAutomationPolicyTransition')
->toContain('operationId: haltXlvaskAutomation')
->toContain('required: [target_stage, reason]');
});
it('wires preview-bound bulk decisions through the transactional autopilot service', function (): void {
it('allows all-only permission and uses every configured scanner hall for all scope', function (): void {
$route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
$autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php');
expect($route)
->not->toBeFalse()
->and($autopilot)->not->toBeFalse();
expect($route)->not->toBeFalse();
$route = (string)$route;
$autopilot = (string)$autopilot;
expect($route)
->toContain("post('/modules/xlvask/services/usage/automation/decisions/preview'")
->toContain("post('/modules/xlvask/services/usage/automation/decisions/apply'")
->toContain('createDecisionPreview(')
->toContain('applyDecision(')
->and($autopilot)->toContain("SELECT * FROM xlvask_automation_decision_previews WHERE id = '")
->toContain('FOR UPDATE')
->toContain('applyBoundDecisionWithinTransaction(')
->toContain('expected_version')
->toContain('source_hash');
->toContain("if (!\$this->hasPermission(\$permission_list_all))")
->toContain("if (\$this->hasPermission('list_xlvask_usage_orders_all'))")
->toContain("if (!\$this->hasPermission('list_xlvask_usage_orders_all'))")
->toContain('private function allowedHallIdsForUser(object $user): array')
->not->toContain('private static function allowedHallIdsForUser')
->toContain('SELECT DISTINCT HallId FROM plate_scanners');
});
it('does not let pending automation schema block ordinary invoice period operations', function (): void {
@@ -241,3 +156,75 @@ 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');
});
@@ -0,0 +1,38 @@
<?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,6 +5,8 @@ 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
@@ -233,12 +235,10 @@ trait module_config_variable
*/
static function inputToBool(string $value): bool
{
// 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;
// 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);
}
/**