Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4cf5524a6 | ||
|
|
80dca6b5f0 | ||
|
|
7c4acc636c | ||
|
|
34df80530c | ||
|
|
3d0a8eeae7 | ||
|
|
60222a7d91 | ||
|
|
78b11d0b79 | ||
|
|
55ddabb0ee | ||
|
|
16048e2ce3 | ||
|
|
cdf8541e78 | ||
|
|
51e5c2ed01 | ||
|
|
0dea2f0b76 | ||
|
|
22ec6696b5 | ||
|
|
a71194d46e | ||
|
|
5441fea665 | ||
|
|
02a4665bc7 | ||
|
|
3e683c8559 | ||
|
|
d81634033e | ||
|
|
58d5e177a9 | ||
|
|
82a3684f05 | ||
|
|
d850075397 |
@@ -0,0 +1,136 @@
|
||||
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: Smoke test
|
||||
id: smoke
|
||||
continue-on-error: true
|
||||
run: |
|
||||
chmod +x scripts/smoke-test.sh
|
||||
./scripts/smoke-test.sh
|
||||
|
||||
- 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."
|
||||
@@ -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' }}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# AGENT MCP SMOKE
|
||||
|
||||
Generated 20260813-091957 by hermes agent to verify GitHub MCP wiring.
|
||||
Safe to close.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Security documentation
|
||||
|
||||
This folder holds security-related planning, post-mortems, and pen-test
|
||||
artefacts for the Truck Wash ApS platform.
|
||||
|
||||
| Doc | Purpose | Status |
|
||||
| --- | --- | --- |
|
||||
| [`pen-test-plan.md`](./pen-test-plan.md) | TRU-80: scope, methodology, schedule and budget for the next white-hat pen test. | Draft v1, awaiting management sign-off. |
|
||||
|
||||
Conventions:
|
||||
|
||||
- Pen-test reports and any raw findings live in date-stamped subfolders
|
||||
(e.g. `2026-q4-pentest/`) and are **never** committed to the public
|
||||
repository — only the planning docs and re-test acceptance letters are.
|
||||
- All security work is tracked under the Linear project
|
||||
*UI Library & Pen Testing*.
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -33,17 +33,17 @@ class slack implements notification_i
|
||||
public function send_department_booking_notification(int $department_id, $message): self
|
||||
{
|
||||
// Get the departments webhook
|
||||
$webhook = self::get_department_webhook($department_id);
|
||||
$webhook = static::get_department_webhook($department_id);
|
||||
// Check if the webhook is empty
|
||||
if (empty($webhook)) {
|
||||
throw new \Exception('Department webhook is empty');
|
||||
}
|
||||
// Send the notification to the department
|
||||
self::add_log(self::send_webhook_message($message, $webhook));
|
||||
self::add_log(static::send_webhook_message($message, $webhook));
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function get_department_webhook(int $department_id): string|null
|
||||
protected function get_department_webhook(int $department_id): string|null
|
||||
{
|
||||
// Check if the department webhook is cached
|
||||
$webhook = redis->get_department_webhook($department_id);
|
||||
@@ -134,6 +134,68 @@ class slack implements notification_i
|
||||
. "Status: $status";
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a new-booking notification to the department's Slack webhook.
|
||||
*
|
||||
* Filter: only PICKUP bookings trigger a Slack notification. Drop-off
|
||||
* bookings (pickup_bool === false) are intentionally silenced per
|
||||
* Mikkel's SENERE 14 / TRU-106 request — drop-offs are noise in the
|
||||
* channel. Other delivery channels (SMS, email) are unaffected.
|
||||
*
|
||||
* Returns true if a Slack message was sent, false if it was filtered
|
||||
* out (drop-off) or the department has no Slack webhook configured.
|
||||
*
|
||||
* @throws \Exception If the department lookup or webhook send fails.
|
||||
*/
|
||||
public function send_new_booking_notification(
|
||||
$id,
|
||||
$customer_number,
|
||||
string $wash_type,
|
||||
string $contact_email,
|
||||
string $reference_number,
|
||||
string $regNrTraekker,
|
||||
string $regNrTrailer,
|
||||
string $washCertificateEmail,
|
||||
string $date,
|
||||
int $department,
|
||||
bool $pickup_bool,
|
||||
string $notes,
|
||||
string $washCertificateStatus,
|
||||
string $washCertificateUrl,
|
||||
string $status
|
||||
): bool {
|
||||
// TRU-106: drop-off bookings must not post to Slack.
|
||||
if (!$pickup_bool) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$webhook = static::get_department_webhook($department);
|
||||
if (empty($webhook)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$message = static::format_new_booking(
|
||||
$id,
|
||||
$customer_number,
|
||||
$wash_type,
|
||||
$contact_email,
|
||||
$reference_number,
|
||||
$regNrTraekker,
|
||||
$regNrTrailer,
|
||||
$washCertificateEmail,
|
||||
$date,
|
||||
$department,
|
||||
$pickup_bool,
|
||||
$notes,
|
||||
$washCertificateStatus,
|
||||
$washCertificateUrl,
|
||||
$status
|
||||
);
|
||||
|
||||
self::add_log(static::send_webhook_message($message, $webhook));
|
||||
return true;
|
||||
}
|
||||
|
||||
public function send_message(string $string, ?string $module = null): void
|
||||
{
|
||||
global $SLACK_DEFAULT_WEBHOOK;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
-52
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-52
@@ -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();
|
||||
}
|
||||
|
||||
@@ -205,10 +205,12 @@ class bookings_o extends db
|
||||
$sql = "SELECT * FROM $this->table WHERE id = $id";
|
||||
$result = $db->query($sql);
|
||||
if ($db->num_rows($result) === 0) {
|
||||
// Send a department webhook if the booking is new
|
||||
// Send a department webhook if the booking is new.
|
||||
// TRU-106: send_new_booking_notification() filters out drop-offs
|
||||
// (pickup_bool = 0) so only pickup bookings post to Slack.
|
||||
$slack = new slack();
|
||||
try {
|
||||
$slack->send_department_booking_notification($department, $slack->format_new_booking(
|
||||
$slack->send_new_booking_notification(
|
||||
$id,
|
||||
$customer_number,
|
||||
$wash_type,
|
||||
@@ -219,12 +221,12 @@ class bookings_o extends db
|
||||
$washCertificateEmail,
|
||||
$date,
|
||||
$department,
|
||||
$pickup_bool,
|
||||
(bool)$pickup_bool,
|
||||
$notes,
|
||||
$washCertificateStatus,
|
||||
$washCertificateUrl,
|
||||
$status
|
||||
));
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
// Log the error
|
||||
$logs = new logs_o();
|
||||
@@ -313,11 +315,13 @@ class bookings_o extends db
|
||||
!$deliverSlack // Only send email if slack is not available
|
||||
);
|
||||
// Check if the department has a slack webhook
|
||||
// TRU-106: send_new_booking_notification() filters out drop-offs
|
||||
// (pickup_bool = false) so only pickup bookings post to Slack.
|
||||
if ($deliverSlack) {
|
||||
// Send a notification to the department
|
||||
$slack = new slack();
|
||||
try {
|
||||
$slack->send_department_booking_notification($department->id, $slack->format_new_booking(
|
||||
$slack->send_new_booking_notification(
|
||||
$this->id,
|
||||
$customer_array['customer_number'],
|
||||
self::formatWashTypeFromServices(json_decode($this->data->value(), true)),
|
||||
@@ -333,7 +337,7 @@ class bookings_o extends db
|
||||
$this->washCertificateStatus->value(),
|
||||
$this->washCertificateUrl->value(),
|
||||
$this->status->value()
|
||||
));
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
// Previously this bare call would crash the entire
|
||||
// notifyNewBooking() flow if Slack returned non-2xx, so
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 = [];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
|
||||
+27
-2
@@ -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,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,157 @@
|
||||
<?php
|
||||
|
||||
app_require('classes/slack.php');
|
||||
|
||||
use classes\slack;
|
||||
|
||||
/**
|
||||
* Fake slack subclass that captures webhook messages without doing I/O.
|
||||
* Overrides get_department_webhook() so we don't touch redis/db.
|
||||
*/
|
||||
final class SlackNewBookingPickupFilterFake extends slack
|
||||
{
|
||||
public array $messages = [];
|
||||
public string $webhook = 'https://hooks.slack.test/services/TRU-106-pickup-filter';
|
||||
public ?string $webhookOverride = null; // null => use $this->webhook, '' => empty, etc.
|
||||
public string $sendResult = 'Message sent successfully. Response: ok';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Skip parent config loading for unit isolation.
|
||||
}
|
||||
|
||||
protected function get_department_webhook(int $department_id): string
|
||||
{
|
||||
return $this->webhookOverride ?? $this->webhook;
|
||||
}
|
||||
|
||||
public function send_webhook_message(string $message, string $webhook): string
|
||||
{
|
||||
$this->messages[] = [
|
||||
'message' => $message,
|
||||
'webhook' => $webhook,
|
||||
];
|
||||
|
||||
return $this->sendResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub format_new_booking so unit tests don't need a live redis/db
|
||||
* (the real implementation calls departments_o::getDepartmentName,
|
||||
* which dereferences the global `redis` object that is not loaded
|
||||
* in the unit test bootstrap).
|
||||
*/
|
||||
public function format_new_booking(
|
||||
$id,
|
||||
$customer_number,
|
||||
string $wash_type,
|
||||
string $contact_email,
|
||||
string $reference_number,
|
||||
string $regNrTraekker,
|
||||
string $regNrTrailer,
|
||||
string $washCertificateEmail,
|
||||
string $date,
|
||||
int $department,
|
||||
$pickup_bool,
|
||||
string $notes,
|
||||
string $washCertificateStatus,
|
||||
string $washCertificateUrl,
|
||||
string $status
|
||||
): string {
|
||||
$pickupLabel = $pickup_bool ? '1' : '0';
|
||||
return "*Ny booking oprettet* ( ID: {$id} )\n"
|
||||
. "Kunde: ({$customer_number})\n"
|
||||
. "Type: {$wash_type}\n"
|
||||
. "Reference nummer: {$reference_number}\n"
|
||||
. "RegNr Traekker: {$regNrTraekker}\n"
|
||||
. "RegNr Trailer: {$regNrTrailer}\n"
|
||||
. "Dato: {$date}\n"
|
||||
. "Hentning: {$pickupLabel}\n"
|
||||
. "Noter: {$notes}";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample booking data used by all the tests below.
|
||||
*/
|
||||
function tru106_sample_booking(): array
|
||||
{
|
||||
return [
|
||||
'id' => 4242,
|
||||
'customer_number' => 1001,
|
||||
'wash_type' => 'Standard wash',
|
||||
'contact_email' => 'dispatcher@example.com',
|
||||
'reference_number' => 'REF-001',
|
||||
'regNrTraekker' => 'AB12345',
|
||||
'regNrTrailer' => 'CD67890',
|
||||
'washCertificateEmail' => '',
|
||||
'date' => '2026-08-16 09:00:00',
|
||||
'department' => 4,
|
||||
'notes' => 'No notes',
|
||||
'washCertificateStatus' => '',
|
||||
'washCertificateUrl' => '',
|
||||
'status' => 'pending',
|
||||
];
|
||||
}
|
||||
|
||||
function tru106_call_send_new_booking_notification(slack $slack, array $b, bool $pickup): bool
|
||||
{
|
||||
return $slack->send_new_booking_notification(
|
||||
$b['id'],
|
||||
$b['customer_number'],
|
||||
$b['wash_type'],
|
||||
$b['contact_email'],
|
||||
$b['reference_number'],
|
||||
$b['regNrTraekker'],
|
||||
$b['regNrTrailer'],
|
||||
$b['washCertificateEmail'],
|
||||
$b['date'],
|
||||
$b['department'],
|
||||
$pickup,
|
||||
$b['notes'],
|
||||
$b['washCertificateStatus'],
|
||||
$b['washCertificateUrl'],
|
||||
$b['status']
|
||||
);
|
||||
}
|
||||
|
||||
it('posts a Slack notification when the new booking is a pickup (TRU-106)', function (): void {
|
||||
$slack = new SlackNewBookingPickupFilterFake();
|
||||
$sent = tru106_call_send_new_booking_notification($slack, tru106_sample_booking(), true);
|
||||
|
||||
expect($sent)->toBeTrue()
|
||||
->and($slack->messages)->toHaveCount(1)
|
||||
->and($slack->messages[0]['webhook'])->toBe('https://hooks.slack.test/services/TRU-106-pickup-filter')
|
||||
->and($slack->messages[0]['message'])->toContain('Ny booking oprettet')
|
||||
->and($slack->messages[0]['message'])->toContain('ID: 4242')
|
||||
->and($slack->messages[0]['message'])->toContain('Kunde:')
|
||||
->and(json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES))->toContain('sent successfully');
|
||||
});
|
||||
|
||||
it('does NOT post a Slack notification when the new booking is a drop-off (TRU-106)', function (): void {
|
||||
$slack = new SlackNewBookingPickupFilterFake();
|
||||
$sent = tru106_call_send_new_booking_notification($slack, tru106_sample_booking(), false);
|
||||
|
||||
expect($sent)->toBeFalse()
|
||||
->and($slack->messages)->toBe([])
|
||||
->and($slack->get_log())->toBe([]);
|
||||
});
|
||||
|
||||
it('does NOT post a Slack notification when the department has no webhook configured (TRU-106)', function (): void {
|
||||
$slack = new SlackNewBookingPickupFilterFake();
|
||||
$slack->webhookOverride = '';
|
||||
$sent = tru106_call_send_new_booking_notification($slack, tru106_sample_booking(), true);
|
||||
|
||||
expect($sent)->toBeFalse()
|
||||
->and($slack->messages)->toBe([])
|
||||
->and($slack->get_log())->toBe([]);
|
||||
});
|
||||
|
||||
it('does not leak the webhook URL into the log payload for a pickup (TRU-106)', function (): void {
|
||||
$slack = new SlackNewBookingPickupFilterFake();
|
||||
tru106_call_send_new_booking_notification($slack, tru106_sample_booking(), true);
|
||||
|
||||
$logDump = json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES);
|
||||
expect($logDump)->not->toContain('hooks.slack.test')
|
||||
->and($logDump)->toContain('sent successfully');
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user