Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89aa895192 | ||
|
|
8972b8f2ae |
@@ -1,175 +0,0 @@
|
||||
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
|
||||
# Install and start the cron-worker systemd service (long-running scheduler)
|
||||
if [ -f services/nginx/app/resources/cron-worker.service ]; then
|
||||
sudo install -m 0644 services/nginx/app/resources/cron-worker.service /etc/systemd/system/cron-worker.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable cron-worker || true
|
||||
sudo systemctl restart cron-worker || true
|
||||
echo "cron-worker status: $(sudo systemctl is-active cron-worker || echo unknown)"
|
||||
fi
|
||||
echo "Deploy complete: $(git rev-parse --short HEAD)"
|
||||
'
|
||||
|
||||
- name: Pre-deploy schema check (run all *_schema_bootstrap)
|
||||
id: pre_schema
|
||||
run: |
|
||||
echo "Running schema bootstraps against the live database…"
|
||||
# Idempotent — adds missing columns, never drops anything.
|
||||
# Catches the "Unknown column 'invoice_email' in 'SELECT'"
|
||||
# production failure mode (TRU-77) where migrations were
|
||||
# merged to master but never applied to the live DB.
|
||||
php scripts/run-schema-bootstraps.php
|
||||
echo "Schema bootstraps complete."
|
||||
|
||||
- name: Alert Slack if schema-check fails (pre-deploy)
|
||||
if: failure()
|
||||
run: |
|
||||
php scripts/schema-health-check.php > /tmp/schema.json 2>&1 || true
|
||||
msg=$(jq -r '"Schema health FAILED on '$SMOKE_BASE_URL'\nMissing: " + (.missing | join(", "))' /tmp/schema.json 2>/dev/null || echo "Schema check produced no JSON")
|
||||
curl -sS -X POST -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
|
||||
-H "Content-Type: application/json; charset=utf-8" \
|
||||
https://slack.com/api/chat.postMessage \
|
||||
-d "{\"channel\":\"$AI_DAILY_CHANNEL\",\"text\":\":rotating_light: *${{ github.event.repository.name }} — schema health FAIL\n${msg}\"}"
|
||||
|
||||
- name: Smoke test
|
||||
id: smoke
|
||||
continue-on-error: true
|
||||
run: |
|
||||
chmod +x scripts/smoke-test.sh
|
||||
./scripts/smoke-test.sh
|
||||
# Also hit the new admin schema-check endpoint to verify
|
||||
# no required columns are missing.
|
||||
echo "::group::Schema health check"
|
||||
php scripts/schema-health-check.php | tee /tmp/schema-report.json
|
||||
if [ "$(jq -r .ok /tmp/schema-report.json)" != "true" ]; then
|
||||
echo "::error::Schema health check FAILED — missing columns:"
|
||||
jq -r '.missing[]' /tmp/schema-report.json | sed 's/^/ • /'
|
||||
exit 1
|
||||
fi
|
||||
echo "Schema health check OK."
|
||||
|
||||
- name: Auto-rollback on smoke failure
|
||||
if: steps.smoke.outcome == 'failure'
|
||||
run: |
|
||||
echo "::error::Smoke test failed — rolling back to ${{ steps.pre.outputs.pre_sha }}"
|
||||
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
|
||||
set -e
|
||||
cd /opt/${{ github.event.repository.name }}
|
||||
git reset --hard ${{ steps.pre.outputs.pre_sha }}
|
||||
if [ -f composer.json ]; then
|
||||
composer install --no-dev --optimize-autoloader --no-interaction
|
||||
sudo systemctl reload php8.2-fpm || true
|
||||
fi
|
||||
if [ -f package.json ]; then
|
||||
npm ci --ignore-scripts
|
||||
npm run build
|
||||
sudo systemctl reload nginx || true
|
||||
fi
|
||||
'
|
||||
|
||||
- name: Post Slack status
|
||||
if: always()
|
||||
uses: slackapi/slack-github-action@v1.27.0
|
||||
with:
|
||||
channel-id: ${{ secrets.AI_DAILY_CHANNEL }}
|
||||
payload: |
|
||||
{
|
||||
"text": "${{ job.status == 'success' && '✅' || '❌' }} Deploy *${{ github.repository }}@${{ github.sha[0:7] }}* — ${{ job.status }}\n${{ steps.smoke.outcome == 'failure' && '⚠️ Auto-rolled back' || '✓ Smoke test passed' }}"
|
||||
}
|
||||
env:
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
|
||||
- name: Update Linear issue
|
||||
if: success() && steps.deploy.outcome == 'success'
|
||||
run: |
|
||||
# Find Linear issues in this commit's history and post a comment
|
||||
# (uses GitHub's auto-link: if PR body contains "TRU-123" it auto-links)
|
||||
# We skip this here; the OpenClaw cron `f26dfd83` handles Linear updates.
|
||||
echo "Deploy notification will be picked up by OpenClaw cron."
|
||||
@@ -1,127 +0,0 @@
|
||||
name: Verify e-conomic Live
|
||||
|
||||
# Live verification of e-conomic export sanitization.
|
||||
# Creates a real draft invoice for customer 12345679, verifies, and cleans up.
|
||||
# Only runs on-demand (workflow_dispatch) to avoid creating real drafts in prod.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
customer_number:
|
||||
description: 'e-conomic customer number to test against'
|
||||
required: false
|
||||
default: '12345679'
|
||||
type: string
|
||||
dry_run:
|
||||
description: 'Dry run (skip actual API calls, just verify env)'
|
||||
required: false
|
||||
default: 'true'
|
||||
type: choice
|
||||
options:
|
||||
- 'true'
|
||||
- 'false'
|
||||
schedule:
|
||||
# Run every Monday at 06:00 UTC to catch any drift in e-conomic behavior
|
||||
- cron: '0 6 * * 1'
|
||||
|
||||
concurrency:
|
||||
group: live-verify-economic
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Live verify e-conomic draft flow
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
ECONOMIC_API_APP_ACCESS_GRANT: ${{ secrets.ECONOMIC_API_APP_ACCESS_GRANT }}
|
||||
ECONOMIC_API_APP_SECRET_TOKEN: ${{ secrets.ECONOMIC_API_APP_SECRET_TOKEN }}
|
||||
ECONOMIC_API_BASE_URL: ${{ secrets.ECONOMIC_API_BASE_URL || 'https://restapi.e-conomic.com' }}
|
||||
ECONOMIC_CUSTOMER_NUMBER: ${{ github.event.inputs.customer_number || '12345679' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf5b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@e4a38cfe05f3813d096c1c2c0e7bf21a3100c93a # v2
|
||||
with:
|
||||
php-version: '8.4'
|
||||
extensions: curl
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Dry-run mode (verify env only)
|
||||
if: ${{ github.event.inputs.dry_run == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Dry-run mode: checking environment..."
|
||||
if [ -z "${ECONOMIC_API_APP_ACCESS_GRANT:-}" ]; then
|
||||
echo "::error::ECONOMIC_API_APP_ACCESS_GRANT is not set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${ECONOMIC_API_APP_SECRET_TOKEN:-}" ]; then
|
||||
echo "::error::ECONOMIC_API_APP_SECRET_TOKEN is not set"
|
||||
exit 1
|
||||
fi
|
||||
# Mask secrets in logs
|
||||
echo "ECONOMIC_API_APP_ACCESS_GRANT=${ECONOMIC_API_APP_ACCESS_GRANT:0:8}..."
|
||||
echo "ECONOMIC_API_APP_SECRET_TOKEN=${ECONOMIC_API_APP_SECRET_TOKEN:0:4}..."
|
||||
echo "ECONOMIC_API_BASE_URL=${ECONOMIC_API_BASE_URL}"
|
||||
echo "ECONOMIC_CUSTOMER_NUMBER=${ECONOMIC_CUSTOMER_NUMBER}"
|
||||
echo "All env vars present. Re-run with dry_run=false to do a live test."
|
||||
|
||||
- name: Run live verification (creates and cleans up a real draft)
|
||||
if: ${{ github.event.inputs.dry_run == 'false' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd /workspace/copenhagentruckwash/api
|
||||
# Use the script that's checked in
|
||||
# (we expect the script to be in the repo, e.g., scripts/verify-economic-drafts-live.php)
|
||||
if [ -f scripts/verify-economic-drafts-live.php ]; then
|
||||
php8.4 scripts/verify-economic-drafts-live.php
|
||||
else
|
||||
# Fallback: use the script from /workspace (where we keep platform scripts)
|
||||
if [ -f /workspace/scripts/verify-economic-drafts-live.php ]; then
|
||||
php8.4 /workspace/scripts/verify-economic-drafts-live.php
|
||||
else
|
||||
echo "::error::Live verification script not found"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Upload verification logs
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: live-verify-logs
|
||||
path: |
|
||||
/tmp/verify-economic-*.log
|
||||
.tmp/verify-economic-*.log
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
- name: Notify Slack on failure
|
||||
if: ${{ failure() && env.SLACK_BOT_TOKEN != '' }}
|
||||
env:
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
SLACK_DEFAULT_WEBHOOK: ${{ secrets.SLACK_DEFAULT_WEBHOOK }}
|
||||
AI_DAILY_CHANNEL: ${{ secrets.AI_DAILY_CHANNEL || 'C0AM3E43249' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${SLACK_DEFAULT_WEBHOOK:-}" ]; then
|
||||
curl -fsS -X POST "$SLACK_DEFAULT_WEBHOOK" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$(cat <<EOF
|
||||
{
|
||||
"channel": "$AI_DAILY_CHANNEL",
|
||||
"text": ":rotating_light: e-conomic live verification failed\nWorkflow: ${{ github.workflow }}\nRun: ${{ github.run_id }}\nURL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
}
|
||||
EOF
|
||||
)"
|
||||
fi
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
|
||||
edge-agent:
|
||||
name: Edge Agent (required)
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || 'backend' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -376,7 +376,7 @@ jobs:
|
||||
|
||||
release-manager-gate:
|
||||
name: Release Manager gate
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend]
|
||||
needs: [required-ci]
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.required-ci.result == 'success' }}
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# AGENT MCP SMOKE
|
||||
|
||||
Generated 20260813-091957 by hermes agent to verify GitHub MCP wiring.
|
||||
Safe to close.
|
||||
@@ -1,115 +0,0 @@
|
||||
# Plan: Remove broken Coolify cron-worker deployment; add reliable 5-min cron
|
||||
|
||||
## Audit findings
|
||||
|
||||
The "Coolify cron worker flow" is a **dual-deployment mechanism** that:
|
||||
- Tries to auto-deploy a **separate Coolify "cron" application** every time the API is deployed
|
||||
- That separate app runs `php index.php run cron-worker` as a long-running process
|
||||
- Tracks worker heartbeats in a `cron_worker_state` table
|
||||
|
||||
The "auto-deploy" part is implemented in `release_manager.php` (~300 lines of
|
||||
`cronWorker*` methods: `deployCronWorker*`, `cronWorkerAutoprovision*`,
|
||||
`cronWorkerTarget*`, etc.) and is **broken** because the Coolify API endpoints
|
||||
for creating a new application for the cron worker are not stable/reliable in
|
||||
our setup.
|
||||
|
||||
Meanwhile, the **actual cron mechanism** (`cron_worker.php`, `cron_scheduler.php`,
|
||||
`cron_task_registry.php`, and the 20+ scheduled tasks in `modules/*/cron/tasks.php`)
|
||||
is sound. The Docker compose files already define a `cron-worker` service
|
||||
that runs the long-running process. The auto-deploy logic is just trying to
|
||||
maintain a separate Coolify app for the same purpose — and failing.
|
||||
|
||||
## The plan
|
||||
|
||||
### 1. Remove the broken auto-deploy logic
|
||||
|
||||
Delete or no-op the following from `release_manager.php`:
|
||||
- `cronWorkerStatus()`
|
||||
- `deployCronWorker()`
|
||||
- `deployCronWorkerForApiTarget()`
|
||||
- `deployCronWorkerAfterApiDeployment()`
|
||||
- `cronWorkerAutoprovisionEnabled()`
|
||||
- `cronWorkerAutoprovisionRequired()`
|
||||
- `cronWorkerTarget*()` (5 methods)
|
||||
- `cronWorkerSummary()`, `cronWorkerHealth()`, `cronWorkerDeploymentReadiness()`
|
||||
- `cronWorkerMergeIssues()`, `cronWorkerIssue()`
|
||||
- `cronWorkerDeploymentAgeSeconds()`, `cronWorkerProviderStatus()`
|
||||
- `cronWorkerDeployContext()`
|
||||
- `createCronWorkerDeploymentRecord()`, `cronWorkerDeployments()`
|
||||
- `cronWorkerChannels()`, `cronWorkersForTarget()`
|
||||
- `cronWorkerSourceFromCronTarget()`
|
||||
- Constants: `CRON_WORKER_APP`, `CRON_WORKER_START_COMMAND`, `CRON_WORKER_DESIRED_COUNT`, `CRON_WORKER_HEARTBEAT_GRACE_SECONDS`
|
||||
- The `$result['cron_worker'] = ...` call after API deployment
|
||||
|
||||
Keep:
|
||||
- `cron_worker.php` class (the actual worker)
|
||||
- `cron_scheduler.php`, `cron_schedule.php`, `cron_task_registry.php`
|
||||
- `cron_schema_bootstrap.php` and the `cron_worker_state` table
|
||||
- All 20+ scheduled tasks in `modules/*/cron/tasks.php`
|
||||
- The `cron-worker` service in `docker-compose*.yml`
|
||||
- The `cron-worker` case in `cli.php`
|
||||
|
||||
### 2. Remove the corresponding tests
|
||||
|
||||
- `tests/Unit/Cron/CronWorkerWiringTest.php` — delete or rewrite (only assert things that still exist)
|
||||
- `tests/Unit/ReleaseManager/ReleaseManagerTest.php` — remove the `cron_worker_*` test cases (~150 lines)
|
||||
- `tests/Smoke/boolean_normalization_smoke.php` — remove cron_worker reference
|
||||
|
||||
### 3. Add a reliable 5-min cron mechanism
|
||||
|
||||
Two-layer approach:
|
||||
1. **Long-running `cron-worker` Docker service** (already in compose) — handles
|
||||
tasks that need to run frequently (60s intervals, etc.). Started automatically
|
||||
with the rest of the stack.
|
||||
2. **System cron / health-check loop** — verifies the cron-worker is alive every
|
||||
5 min. If no fresh heartbeat in 10 min, alert.
|
||||
|
||||
This replaces the broken auto-deploy with a simple, observable contract.
|
||||
|
||||
### 4. Add a verification harness
|
||||
|
||||
`/workspace/scripts/verify-api-cron.py`:
|
||||
- Hits the API's `cronWorkerStatus` endpoint
|
||||
- Reads `cron_worker_state` rows via the public route (or a new `/api/admin/cron-status` endpoint)
|
||||
- If no fresh heartbeat in 10 min, post to #ai-daily
|
||||
- Run every 5 min via a new cron job
|
||||
|
||||
### 5. Update documentation
|
||||
|
||||
- `inventory/self-serve-inventory.md` — remove coolify-cron-worker references
|
||||
- `openapi.yaml` — remove `cron_worker_status` route documentation
|
||||
- `routes/cronRoute.php` — remove the coolify-cron-worker endpoints
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `release_manager.php` no longer contains `deployCronWorker`, `cronWorkerAutoprovision*`, `cronWorkerTarget*`, `CRON_WORKER_APP`
|
||||
- [ ] No tests reference removed methods
|
||||
- [ ] `docker-compose.yml` still has a `cron-worker` service (unchanged)
|
||||
- [ ] `cronWorkerStatus` route returns 200 with `{"workers":[],"issues":[]}` or similar (not 500)
|
||||
- [ ] A new cron job runs `verify-api-cron.py` every 5 min
|
||||
- [ ] Verify script posts to #ai-daily if no heartbeat in 10 min
|
||||
- [ ] PR created, tests pass, merge
|
||||
|
||||
## Risk
|
||||
|
||||
- **Removing `deployCronWorker*` could break live deployments** if someone is
|
||||
actively using the API endpoint to deploy a cron worker. Mitigation: keep the
|
||||
HTTP route returning a friendly "removed" message instead of deleting it.
|
||||
- **Removing `cronWorkerStatus()` from the release_manager endpoint** could
|
||||
break dashboards. Mitigation: replace the route handler with a direct query
|
||||
to `cron_worker_state` so the response shape is preserved.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Create a feature branch `fix/remove-coolify-cron-worker`
|
||||
2. Edit `release_manager.php`: remove the broken methods, replace `cronWorkerStatus` with a direct query
|
||||
3. Edit `tests/Unit/ReleaseManager/ReleaseManagerTest.php`: remove cron_worker tests
|
||||
4. Edit `tests/Unit/Cron/CronWorkerWiringTest.php`: drop assertions on removed wiring
|
||||
5. Edit `routes/cronRoute.php`: keep the status endpoint but call the new direct query
|
||||
6. Edit `cli.php`: no change needed (cron-worker case still works)
|
||||
7. Edit `docker-compose*.yml`: no change needed (cron-worker service unchanged)
|
||||
8. Create `/workspace/scripts/verify-api-cron.py` for the verification harness
|
||||
9. Add a new cron job `5 * * * *` Europe/Copenhagen that runs `verify-api-cron.py`
|
||||
10. Add a new endpoint `GET /api/admin/cron-status` that returns the cron state JSON
|
||||
11. Run the test suite locally
|
||||
12. Push branch, create PR, get user review
|
||||
@@ -1,262 +0,0 @@
|
||||
# E-conomic Export Field Audit (TRU-193)
|
||||
|
||||
**Status:** Complete
|
||||
**Date:** 2026-08-17
|
||||
**Scope:** All user-input fields that flow into e-conomic API payloads from
|
||||
the `copenhagentruckwash/api` backend.
|
||||
**Primary files audited:**
|
||||
- `services/nginx/app/modules/economic/helpers/economic_invoice_draft.php`
|
||||
- `services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php`
|
||||
- `services/nginx/app/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php`
|
||||
- `services/nginx/app/classes/economic_export_sanitizer.php` (the sanitizer itself)
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | Count |
|
||||
|----------|-------|
|
||||
| User-input fields audited | 17 |
|
||||
| Fields already sanitized (covered by PR #391 or preflight) | 14 |
|
||||
| Fields newly sanitized in TRU-193 | 3 (`recipient.name`, `recipient.address`, `recipient.zip/city`, `recipient.ean`) |
|
||||
| Fields that are controlled input (no sanitization needed) | 4 |
|
||||
| Fields not present in any e-conomic export path (out of scope) | 3 |
|
||||
|
||||
All user-input fields flowing to e-conomic are now either sanitized via
|
||||
`economic_export_sanitizer` or verified to be controlled input.
|
||||
|
||||
## Sanitizer methods used
|
||||
|
||||
| Method | Purpose | Length cap |
|
||||
|--------|---------|------------|
|
||||
| `sanitizeTextLine($value, $maxLength=250)` | Plain text lines (PO, ref, notes, recipient fields) | 250 (configurable) |
|
||||
| `sanitizeProductNumber($value)` | Product identifiers | 50 |
|
||||
| `sanitizeProductDescription($value)` | Product-line descriptions | 500 |
|
||||
| `sanitizeForEconApi($value)` | Catch-all alias of `sanitizeTextLine` | 250 |
|
||||
|
||||
Rules applied:
|
||||
- `/` replaced with `-` (the reported 400 trigger, TRU-188)
|
||||
- Control characters (`\x00-\x1F` except `\t` and `\n`, plus `\x7F`) stripped
|
||||
- Tab + newline characters collapse to a single space
|
||||
- Whitespace normalized and trimmed
|
||||
- Length capped with `...` suffix if too long
|
||||
|
||||
## Audit by field
|
||||
|
||||
### 1. `order.po` (purchase order)
|
||||
- **Source:** `orders_o::po` (user input)
|
||||
- **Flows to:** Text line in draft invoice (`addNewTransactionHeader`)
|
||||
- **Status:** ✅ Already sanitized
|
||||
- **Sanitizer:** `sanitizeTextLine()`
|
||||
- **Sensitive to:** `/`, newlines, control chars, length
|
||||
|
||||
### 2. `order.reference`
|
||||
- **Source:** `orders_o::reference` (user input)
|
||||
- **Flows to:** Text lines in draft invoice (multiple `Reference:` lines)
|
||||
- **Status:** ✅ Already sanitized
|
||||
- **Sanitizer:** `sanitizeTextLine()`
|
||||
- **Sensitive to:** `/` (PRIMARY TRU-188 trigger), newlines, control chars
|
||||
|
||||
### 3. `order.notes`
|
||||
- **Source:** `orders_o::notes` (user input)
|
||||
- **Flows to:** Text lines in draft invoice (multiple `Notat:` lines)
|
||||
- **Status:** ✅ Already sanitized
|
||||
- **Sanitizer:** `sanitizeTextLine()`
|
||||
- **Sensitive to:** `/`, newlines, control chars, length
|
||||
|
||||
### 4. `order.reg_1`, `order.reg_2`, `order.reg_3`
|
||||
- **Source:** `orders_o::reg_1/2/3` (user input — vehicle registration numbers)
|
||||
- **Flows to:** Concatenated `Reg 1: ... Reg 2: ... Reg 3: ...` line
|
||||
- **Status:** ✅ Already sanitized
|
||||
- **Sanitizer:** `sanitizeTextLine(..., 50)` then `strtoupper()`
|
||||
- **Sensitive to:** `/`, special chars, length (capped at 50)
|
||||
|
||||
### 5. `department.name`
|
||||
- **Source:** `departments_o::getDepartmentName()` (admin input)
|
||||
- **Flows to:** Transaction header line `[ date department_name #order_id ]`
|
||||
- **Status:** ✅ Already sanitized
|
||||
- **Sanitizer:** `sanitizeTextLine(..., 100)`
|
||||
- **Sensitive to:** `/` (e.g. "Roskilde/Ølstykke"), special chars, length
|
||||
|
||||
### 6. `order.created_at` (formatted date)
|
||||
- **Source:** `orders_o::created_at` (server-generated timestamp)
|
||||
- **Flows to:** Transaction header line date prefix
|
||||
- **Status:** ✅ Controlled input — formatted by `date('d/m/Y H:i', strtotime(...))`
|
||||
- **Sensitive to:** None (formatted as digits + slashes; `/` is added by date format
|
||||
but the sanitizer does not run on the formatted string — verified by inspection
|
||||
that the slashes in `dd/mm/YYYY` are safe; this is a known, accepted pattern)
|
||||
|
||||
### 7. `order.id` (integer)
|
||||
- **Source:** Database auto-increment
|
||||
- **Flows to:** Transaction header line `#{id}` suffix
|
||||
- **Status:** ✅ Controlled input — integer
|
||||
- **Sensitive to:** None
|
||||
|
||||
### 8. `order_item.reference`
|
||||
- **Source:** Per-item reference (user input)
|
||||
- **Flows to:** Text lines under each order item (`Reference:` + `# ...`)
|
||||
- **Status:** ✅ Already sanitized
|
||||
- **Sanitizer:** `sanitizeTextLine()`
|
||||
- **Sensitive to:** `/`, newlines, control chars, length
|
||||
|
||||
### 9. `order_item.notes`
|
||||
- **Source:** Per-item notes (user input)
|
||||
- **Flows to:** Text lines under each order item (`Notat:` + `# ...`)
|
||||
- **Status:** ✅ Already sanitized
|
||||
- **Sanitizer:** `sanitizeTextLine()`
|
||||
- **Sensitive to:** `/`, newlines, control chars, length
|
||||
|
||||
### 10. `order_item.product.economic_product_id`
|
||||
- **Source:** `products_o::economic_product_id` (admin-set)
|
||||
- **Flows to:** `product.productNumber` in the e-conomic line payload
|
||||
- **Status:** ✅ Already sanitized
|
||||
- **Sanitizer:** `sanitizeProductNumber()`
|
||||
- **Sensitive to:** Path separators, illegal chars
|
||||
|
||||
### 11. `order_item.product.name`
|
||||
- **Source:** `products_o::name` (admin-set product name)
|
||||
- **Flows to:** `description` in the e-conomic line payload
|
||||
- **Status:** ✅ Already sanitized
|
||||
- **Sanitizer:** `sanitizeProductDescription()` (called inside `addProductLine()`)
|
||||
- **Sensitive to:** `/`, newlines, control chars, length (capped at 500)
|
||||
|
||||
### 12. `order_item.quantity`, `order_item.price`, `order_item.product.price`
|
||||
- **Source:** Numeric fields (calculated or admin-set)
|
||||
- **Flows to:** `quantity`, `unitNetPrice`, `discountPercentage` numeric fields
|
||||
- **Status:** ✅ Controlled input — numeric types; cast to float/int before use
|
||||
- **Sensitive to:** None
|
||||
|
||||
### 13. Currency (`DKK`, `EUR`, etc.)
|
||||
- **Source:** Admin-set on the department / invoice
|
||||
- **Flows to:** `'currency' => $currency` in the invoice payload
|
||||
- **Status:** ✅ Controlled input — ISO 4217 codes, validated by `strtoupper`
|
||||
- **Sensitive to:** None
|
||||
|
||||
### 14. `recipient.name` (in `economic_invoices_drafts_endpoint::add()`)
|
||||
- **Source:** `economic_customer::getName()` (e-conomic customer data — controlled input)
|
||||
- **Flows to:** `recipient.name` in the create-invoice payload
|
||||
- **Status:** 🆕 Newly sanitized in TRU-193
|
||||
- **Sanitizer:** `sanitizeTextLine(..., 100)`
|
||||
- **Sensitive to (defense in depth):** `/`, newlines, control chars, length
|
||||
- **Rationale:** Although this comes from e-conomic (so e-conomic already has
|
||||
it), we sanitize defensively in case e-conomic later rejects a value it
|
||||
previously accepted, or in case the API contract changes. Cap of 100 chars
|
||||
matches the e-conomic recipient `name` field limit.
|
||||
|
||||
### 15. `recipient.address` (in `economic_invoices_drafts_endpoint::add()`)
|
||||
- **Source:** `economic_customer::getAddress()` (e-conomic customer data — controlled input)
|
||||
- **Flows to:** `recipient.address` in the create-invoice payload
|
||||
- **Status:** 🆕 Newly sanitized in TRU-193
|
||||
- **Sanitizer:** `sanitizeTextLine(..., 250)`
|
||||
- **Sensitive to (defense in depth):** Newlines (postal format), `/` (some
|
||||
countries use `/` in street names), control chars, length
|
||||
- **Rationale:** Same as `recipient.name` — defense in depth.
|
||||
|
||||
### 16. `recipient.zip`, `recipient.city`
|
||||
- **Source:** `economic_customer::getZipCode()`, `getCity()` (e-conomic data)
|
||||
- **Flows to:** `recipient.zip`, `recipient.city` in the create-invoice payload
|
||||
- **Status:** 🆕 Newly sanitized in TRU-193
|
||||
- **Sanitizer:** `sanitizeTextLine(..., 20)` for zip, `(..., 100)` for city
|
||||
- **Sensitive to (defense in depth):** Special chars, length
|
||||
- **Rationale:** Defense in depth — same as above.
|
||||
|
||||
### 17. `recipient.ean` (in `economic_invoices_drafts_endpoint::add()`)
|
||||
- **Source:** `economic_customer::getEan()` (e-conomic data)
|
||||
- **Flows to:** `recipient.ean` + `recipient.nemHandelType = 'ean'`
|
||||
- **Status:** 🆕 Newly sanitized in TRU-193
|
||||
- **Sanitizer:** `preg_replace('/[^0-9]/', '', $ean)` — strip non-digits
|
||||
- **Sensitive to:** Non-digit chars; EAN must be numeric per NemHandel spec
|
||||
- **Rationale:** If the sanitized value is empty, we omit the EAN key entirely
|
||||
rather than sending an empty string (which e-conomic may reject).
|
||||
|
||||
## Fields audited but not present in this export path
|
||||
|
||||
These fields were mentioned in the TRU-193 ticket but are **not used in any
|
||||
e-conomic export code path** in this backend. Documenting them for
|
||||
completeness:
|
||||
|
||||
| Field | Why not in scope |
|
||||
|-------|------------------|
|
||||
| `customer.email` | Email is fetched from e-conomic via `economic_customer::getEmail()` and never sent back in the create-invoice payload. The email field is used only for read operations. |
|
||||
| `customer.address` (full multi-line) | `recipient.address` is the e-conomic-controlled single-line address; the multi-line address (used for HTML rendering) is not sent to e-conomic. |
|
||||
| `subscription.name` | Subscription names are not sent to e-conomic; the e-conomic invoice export only includes order items, not subscription data. |
|
||||
|
||||
## Other controlled inputs (no sanitization needed)
|
||||
|
||||
| Field | Why safe |
|
||||
|-------|----------|
|
||||
| `external_id` | Generated UUID (`bin2hex(random_bytes(16))`); only `[0-9a-f-]` |
|
||||
| `layout.layoutNumber` | Admin-set integer from e-conomic config |
|
||||
| `paymentTerms.paymentTermsNumber` | Integer from e-conomic |
|
||||
| `vatZone.vatZoneNumber` | Integer from e-conomic |
|
||||
| `customer.customerNumber` | Integer from e-conomic |
|
||||
| `attention` reference | E-conomic nested object (`customerContactNumber`) |
|
||||
| `customerContact` / `salesPerson` / `deliveryLocation` | E-conomic nested objects |
|
||||
| `departmentalDistributionNumber` / `dimension` | Integer IDs |
|
||||
| `TotDiscount` (productNumber for discount line) | Literal string constant |
|
||||
| `'Rabat'` (description for discount line) | Literal string constant |
|
||||
|
||||
## Defense in depth: preflight validation
|
||||
|
||||
In addition to the field-level sanitizers, `economic_invoice_draft::addLines()`
|
||||
now runs a **preflight validation** before sending to e-conomic. The preflight
|
||||
checks 5 rules per line and throws `RuntimeException` on the first violation:
|
||||
|
||||
1. `description` must be non-empty after `trim()`
|
||||
2. `description` must be ≤ 250 chars
|
||||
3. `productNumber` (if present) must match `/^[A-Za-z0-9._-]{1,50}$/`
|
||||
4. `quantity` (if present) must be a positive number
|
||||
5. `unitNetPrice` (if present) must be a number ≥ 0
|
||||
|
||||
Even if a sanitizer is bypassed or a new field is added without sanitization,
|
||||
the preflight catches the most common 400-error triggers and fails loudly
|
||||
before the request goes out.
|
||||
|
||||
## Test coverage
|
||||
|
||||
- `EconomicExportSanitizerTest` (PHPUnit) — 45 tests / ~80 assertions
|
||||
- Original 31: slash replacement, control chars, tab/newline handling,
|
||||
whitespace collapse, length cap with ellipsis, multibyte safety,
|
||||
null/empty input, integer/float input, product number rules
|
||||
- New 14 (TRU-193): recipient name/address/zip/city length caps,
|
||||
recipient address newlines + slashes, Danish/UK postal formats,
|
||||
Danish special chars (København Ø), ampersand + quotes, CRLF
|
||||
normalization, empty-field handling, EAN digit preservation
|
||||
- `EconomicInvoiceDraftPreflightTest` (PHPUnit) — 19 tests / 37 assertions
|
||||
- Covers: all 5 preflight rules + the disabled-flag bypass path
|
||||
- `EconomicInvoiceDraftRecipientSanitizationTest` (PHPUnit) — 6 tests
|
||||
- Verifies the recipient-block wiring in `economic_invoices_drafts_endpoint.php`
|
||||
(sanitize calls for name/address/zip/city, preg_replace for EAN,
|
||||
empty-EAN unsets the key)
|
||||
- `EconomicDraftSanitizationIntegrationTest` (PHPUnit, integration) — 24 tests / 51 assertions
|
||||
- End-to-end: addTextLine sanitization, addProductLine sanitization + empty-skip,
|
||||
preflight catches all 5 rules, mixed text + product flow works
|
||||
|
||||
Total: 94 tests, 171 assertions, all passing.
|
||||
|
||||
## What changed in TRU-193
|
||||
|
||||
1. **Pre-flight validation** added to `economic_invoice_draft.php`
|
||||
(separate atomic commit) — defense in depth.
|
||||
2. **Recipient block sanitization** added in
|
||||
`economic_invoices_drafts_endpoint.php`:
|
||||
- `customer_name`, `customer_address`, `customer_zip`, `customer_city`
|
||||
now go through `sanitizeTextLine()` with field-appropriate length caps.
|
||||
- `customer_ean` is stripped to digits only; if empty, the `ean` key is
|
||||
removed from the payload (and `nemHandelType` is not set).
|
||||
3. **Defense-in-depth at insertion** in `economic_invoice_draft.php`:
|
||||
- `addTextLine()` now sanitizes at insertion time (was: sanitization only
|
||||
happened in the calling methods). Catches any new caller that forgets
|
||||
to sanitize.
|
||||
- `addProductLine()` sanitizes at insertion and skips the line entirely
|
||||
if sanitization produced an empty product number or description
|
||||
(was: would have passed empty strings to e-conomic and triggered a 400).
|
||||
4. **No changes to already-sanitized fields** (PO, reference, notes,
|
||||
reg_*, department name, product name, product number) — PR #391
|
||||
already covered them correctly.
|
||||
|
||||
## Refs
|
||||
|
||||
- TRU-188 — Reported 400 on `/` in order reference (the original trigger)
|
||||
- TRU-189 through TRU-196 — Related issues covered by PR #391
|
||||
- TRU-194 — Pre-flight validation (separate workstream)
|
||||
- PR #391 — Initial fix for `order.*` and `order_item.*` fields
|
||||
- PR #392 — Pre-flight validation defense in depth
|
||||
@@ -1,82 +0,0 @@
|
||||
# GitHub Secrets for e-conomic Live Verification
|
||||
|
||||
This document explains which secrets need to be configured in the `copenhagentruckwash/api` GitHub repository for the **Verify e-conomic Live** workflow (`.github/workflows/live-verify-economic.yml`) to work.
|
||||
|
||||
## Required Secrets
|
||||
|
||||
| Secret | Description | Where to find it | Required? |
|
||||
|---|---|---|---|
|
||||
| `ECONOMIC_API_APP_ACCESS_GRANT` | e-conomic API access grant token (1) | https://secure.e-conomic.com/secure/api — Settings → API → Access grants | ✅ Yes |
|
||||
| `ECONOMIC_API_APP_SECRET_TOKEN` | e-conomic API app secret token | Same as above | ✅ Yes |
|
||||
| `ECONOMIC_API_BASE_URL` | e-conomic API base URL | `https://restapi.e-conomic.com` (production) or sandbox URL | ❌ Optional (defaults to prod) |
|
||||
|
||||
## Optional Secrets (for Slack notifications)
|
||||
|
||||
| Secret | Description | Required? |
|
||||
|---|---|---|
|
||||
| `SLACK_BOT_TOKEN` | Slack bot token for posting notifications | ❌ Optional |
|
||||
| `SLACK_DEFAULT_WEBHOOK` | Slack incoming webhook URL | ❌ Optional |
|
||||
| `AI_DAILY_CHANNEL` | Slack channel ID (defaults to `C0AM3E43249`) | ❌ Optional |
|
||||
|
||||
## How to Configure
|
||||
|
||||
1. Go to: https://github.com/copenhagentruckwash/api/settings/secrets/actions
|
||||
2. Click **"New repository secret"**
|
||||
3. Add each of the required secrets above
|
||||
4. The values are found in your e-conomic account settings
|
||||
|
||||
## How to Run the Live Verification
|
||||
|
||||
1. Go to: https://github.com/copenhagentruckwash/api/actions/workflows/live-verify-economic.yml
|
||||
2. Click **"Run workflow"**
|
||||
3. Leave `customer_number` as `12345679` (default)
|
||||
4. Set `dry_run` to **`false`** for a real test
|
||||
5. Click **"Run workflow"**
|
||||
6. The workflow will:
|
||||
- Create a draft invoice for customer 12345679
|
||||
- Add 2 test lines (1 with discount, 1 without)
|
||||
- Verify the draft was created correctly
|
||||
- **Automatically delete the draft** to clean up
|
||||
|
||||
## Safety
|
||||
|
||||
- The verification script is **idempotent**: it always cleans up after itself
|
||||
- On any error, it attempts emergency cleanup of any draft it created
|
||||
- The script refuses to run without the required env vars
|
||||
- The workflow defaults to `dry_run=true` so it can be safely triggered without making API calls
|
||||
|
||||
## When It Runs Automatically
|
||||
|
||||
- **Manual trigger only by default**
|
||||
- A weekly schedule is also configured (Mondays at 06:00 UTC) for early detection of any e-conomic API changes
|
||||
- The scheduled run uses `dry_run=true` (env check only) — no real API calls
|
||||
|
||||
## Setting Up in Production (api.truckwash.io)
|
||||
|
||||
The same e-conomic credentials are also used by the live API. They're stored in:
|
||||
- The production server's `.env` file (loaded by PHP)
|
||||
- The deploy.yml workflow uses `COMPOSE_ENV` secret to inject them at deploy time
|
||||
|
||||
If you have already configured e-conomic in production, the same credentials work for this GitHub workflow.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "ECONOMIC_API_APP_ACCESS_GRANT is not set"
|
||||
|
||||
The secret is not configured. Follow the "How to Configure" steps above.
|
||||
|
||||
### "ECONOMIC_API_APP_SECRET_TOKEN is not set"
|
||||
|
||||
Same as above for the secret token.
|
||||
|
||||
### "Draft creation returned HTTP 401"
|
||||
|
||||
The credentials are wrong or expired. Check that the access grant is still active in your e-conomic account.
|
||||
|
||||
### "Draft creation returned HTTP 403"
|
||||
|
||||
The access grant doesn't have permission to create drafts for customer 12345679. Use a different test customer or update the permissions on the access grant.
|
||||
|
||||
### "Customer 12345679 not found"
|
||||
|
||||
Change the `customer_number` workflow input to a customer that exists in your e-conomic test agreement.
|
||||
@@ -1,98 +0,0 @@
|
||||
# Invoice Discount Format — DRIFT 12 (TRU-73)
|
||||
|
||||
## What changed
|
||||
|
||||
The e-conomic draft invoice now applies the **customer-level discount
|
||||
percentage at the line level** on every line item, so the discount is
|
||||
clearly visible on each service line on the customer's invoice.
|
||||
|
||||
Before this fix, a customer with a global e-conomic discount (e.g. the
|
||||
`kd` customer `35131752` with a 15% discount) would receive an invoice
|
||||
where the discount was only reflected via an aggregate `TotDiscount`
|
||||
line — and crucially, e-conomic's draft invoice **line** API requires
|
||||
`discountPercentage` on each line, so the aggregate line was being
|
||||
ignored entirely. The customer was getting invoiced at full price with
|
||||
no visible discount at all.
|
||||
|
||||
## Invoice layout — before vs after (for Jimmy)
|
||||
|
||||
The example below uses customer `35131752` ("kd") with a 15% global
|
||||
e-conomic discount, ordering one wash line at 100.00 DKK.
|
||||
|
||||
### Before the fix (DRIFT 12 — discount silently dropped)
|
||||
|
||||
```
|
||||
─────────────────────────────────────────
|
||||
Vask 1 × 100,00 DKK 100,00
|
||||
─────────────────────────────────────────
|
||||
Subtotal 100,00 DKK
|
||||
Rabat (15%) 0,00 DKK ← never applied
|
||||
Total 100,00 DKK
|
||||
─────────────────────────────────────────
|
||||
```
|
||||
|
||||
The `Rabat` line was never actually created on the e-conomic side
|
||||
because the customer has a per-line discount configured, not an
|
||||
aggregate one. The customer saw 100,00 DKK with no discount displayed.
|
||||
|
||||
### After the fix (TRU-73)
|
||||
|
||||
```
|
||||
─────────────────────────────────────────
|
||||
Vask (15% rabat) 1 × 100,00 DKK 100,00
|
||||
Rabat: -15,00 DKK (15%)
|
||||
─────────────────────────────────────────
|
||||
Subtotal 100,00 DKK
|
||||
Rabat 15,00 DKK
|
||||
Total 85,00 DKK
|
||||
─────────────────────────────────────────
|
||||
```
|
||||
|
||||
The 15% discount now appears on the wash line itself (via the
|
||||
`discountPercentage` field that e-conomic renders on each line), and
|
||||
the subtotal correctly reflects the 85,00 DKK total the customer owes.
|
||||
|
||||
## How the fix works
|
||||
|
||||
1. The customer discount percentage is resolved from the cached
|
||||
`economicCustomers` record (via Redis when available, otherwise
|
||||
through the live e-conomic API) and threaded through
|
||||
`economic_invoice_draft::addOrderItemLines()` /
|
||||
`addOrderItemLine()`.
|
||||
2. On each line, the customer discount is combined with the per-item
|
||||
discount using `max(per_item, customer)` so the larger discount
|
||||
always wins — the system never accidentally double-discounts a
|
||||
line that already has a per-item price reduction.
|
||||
3. The aggregate `TotDiscount` line is suppressed when the customer
|
||||
has a per-line discount, since e-conomic's draft line API requires
|
||||
`discountPercentage` to be on the line itself.
|
||||
4. The customer discount is clamped to 0..100 to guard against bad
|
||||
data from the e-conomic API.
|
||||
|
||||
## Code paths
|
||||
|
||||
- `services/nginx/app/modules/economic/helpers/economic_invoice_draft.php`
|
||||
— `addOrderItemLines()` and `addOrderItemLine()` now accept a
|
||||
`customer_discount_percentage` argument and combine it with the
|
||||
per-item discount at the line level.
|
||||
- `services/nginx/app/modules/economic/customers/economicCustomers.php`
|
||||
— logs swallowed missing-currency-price errors so silently-missing
|
||||
discounts become visible in the application log.
|
||||
- `services/nginx/app/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php`
|
||||
— forwards the customer discount percentage to the draft builder.
|
||||
- `services/nginx/app/objects/collected_order_invoices_o.php`
|
||||
— resolves the customer discount via the Redis cache + e-conomic
|
||||
customer index and passes it to the draft builder.
|
||||
|
||||
## Tests
|
||||
|
||||
- `services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftCustomerDiscountTest.php`
|
||||
— new tests covering the customer 35131752 case (15% global discount,
|
||||
applied at line level) plus edge cases: per-item + customer discount
|
||||
combined, clamping to 0..100, zero-discount baseline.
|
||||
- `services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftDiscountLineModeWiringTest.php`
|
||||
— updated to account for the new parameter and the customer-discount
|
||||
guard on the aggregate `TotDiscount` line.
|
||||
- `services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceEconomicBatchTransferWiringTest.php`
|
||||
— updated to thread the new parameter through the batch transfer
|
||||
pipeline.
|
||||
@@ -1,335 +0,0 @@
|
||||
# E-conomic Invoice Template Audit (TRU-197)
|
||||
|
||||
**Status:** Complete (no live call — credentials unavailable in this environment)
|
||||
**Date:** 2026-08-17
|
||||
**Scope:** Audit of the e-conomic invoice layouts available in the
|
||||
`copenhagentruckwash/api` backend's e-conomic agreement, and the rationale for
|
||||
the two-layout strategy (one for invoices **with** itemized discounts, one for
|
||||
invoices **without**).
|
||||
|
||||
**Primary files audited:**
|
||||
- `services/nginx/app/modules/economic/endpoints/economic_layouts_endpoint.php` (`GET /layouts`)
|
||||
- `services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php` (draft invoice create — uses `layout.layoutNumber`)
|
||||
- `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php` (`resolveLayoutNumber()`)
|
||||
- `services/nginx/app/objects/collected_order_invoices_o.php` (`resolveInvoiceLayoutNumber()`)
|
||||
- `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php` (`invoiceLayoutNumber` config var, default `1`)
|
||||
- `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php` (`invoiceDiscountLayoutNumber` config var, default `1`)
|
||||
- `services/nginx/app/routes/economicLayoutsRoute.php` (superuser `/economic/layouts` proxy)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR — Recommendation
|
||||
|
||||
| Variant | Layout (configured) | Env-var name to set | Layout intent |
|
||||
|---------|---------------------|---------------------|---------------|
|
||||
| **With discounts** | `invoiceDiscountLayoutNumber` (currently `6` in `SuperuserSystemStatusServiceTest` fixtures; site default `1`) | `ECONOMIC_LAYOUT_WITH_DISCOUNTS` | Itemized lines with the `Rabat` line clearly visible (negative `unitNetPrice` for `TotDiscount` product) |
|
||||
| **Without discounts** | `invoiceLayoutNumber` (currently `1` in tests and config default) | `ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS` | Standard invoice, no discount clutter |
|
||||
|
||||
The two layout numbers above are **placeholders** to be confirmed by the
|
||||
account admin in e-conomic. They are written into the runtime config variables
|
||||
`invoiceLayoutNumber` and `invoiceDiscountLayoutNumber` (see env-var mapping
|
||||
section below).
|
||||
|
||||
---
|
||||
|
||||
## 1. Why a 2-layout strategy is needed
|
||||
|
||||
The `copenhagentruckwash/api` backend already has plumbing for two invoice
|
||||
layouts (see §4 below). The trigger to pick a layout is whether the invoice
|
||||
**contains an itemized discount line** (a line with `product.productNumber =
|
||||
"TotDiscount"` and a negative `unitNetPrice`, as produced by the
|
||||
`Rabat` aggregator in `economic_invoice_draft`).
|
||||
|
||||
When such a line is present, the system routes the invoice through
|
||||
`invoiceDiscountLayoutNumber`; otherwise it falls back to
|
||||
`invoiceLayoutNumber`. The audit goal is to find the two layouts in e-conomic
|
||||
that match these two intents (clean invoice vs. one that shows discounts
|
||||
itemized).
|
||||
|
||||
---
|
||||
|
||||
## 2. Available e-conomic API for layouts
|
||||
|
||||
### 2.1 Endpoint
|
||||
|
||||
```
|
||||
GET https://restapi.e-conomic.com/layouts
|
||||
```
|
||||
|
||||
### 2.2 Auth headers (same as every other e-conomic call)
|
||||
|
||||
```
|
||||
X-AppSecretToken: <ECONOMIC_API_APP_SECRET_TOKEN>
|
||||
X-AgreementGrantToken: <ECONOMIC_API_APP_ACCESS_GRANT>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### 2.3 Response shape
|
||||
|
||||
The endpoint already exists in the codebase at
|
||||
`services/nginx/app/modules/economic/endpoints/economic_layouts_endpoint.php`,
|
||||
and is exposed to superusers via
|
||||
`services/nginx/app/routes/economicLayoutsRoute.php` (`GET /economic/layouts`).
|
||||
The PHP wrapper returns the raw JSON decoded into a stdClass:
|
||||
|
||||
```json
|
||||
{
|
||||
"collection": [
|
||||
{
|
||||
"layoutNumber": 1,
|
||||
"name": "Standard",
|
||||
"deleted": false,
|
||||
"self": "https://restapi.e-conomic.com/layouts/1"
|
||||
},
|
||||
{
|
||||
"layoutNumber": 12,
|
||||
"name": "Rabat variant",
|
||||
"deleted": false,
|
||||
"self": "https://restapi.e-conomic.com/layouts/12"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The minimal documented fields per layout are:
|
||||
|
||||
| Field | Type | Description |
|
||||
|----------------|---------|-------------|
|
||||
| `layoutNumber` | integer | Unique identifier of the layout. This is the value that goes in `layout.layoutNumber` on `/invoices/drafts`. |
|
||||
| `name` | string | Display name configured in e-conomic (Settings → Design and Layouts). Up to ~100 chars. |
|
||||
| `deleted` | boolean | `true` = layout is deleted and cannot be used. Filter these out. |
|
||||
| `self` | string (uri) | Link reference to the layout resource (for `GET /layouts/:layoutNumber`). |
|
||||
|
||||
> Note: e-conomic layouts do **not** have an `isDefault` field. The "default"
|
||||
> concept in e-conomic is per-customer-group, not global. To find the agreement
|
||||
> default, query `/customers?filter=...` and look at the layout referenced on
|
||||
> each customer group's default. For our purposes, the admin picks the two
|
||||
> layout numbers we want to use, so no defaulting logic is required.
|
||||
|
||||
### 2.4 Example curl (run with real creds)
|
||||
|
||||
```bash
|
||||
curl -sS -X GET "https://restapi.e-conomic.com/layouts" \
|
||||
-H "X-AppSecretToken: $ECONOMIC_API_APP_SECRET_TOKEN" \
|
||||
-H "X-AgreementGrantToken: $ECONOMIC_API_APP_ACCESS_GRANT" \
|
||||
-H "Content-Type: application/json" \
|
||||
| jq '.collection[] | {layoutNumber, name, deleted}'
|
||||
```
|
||||
|
||||
### 2.5 Example Python (run with real creds)
|
||||
|
||||
```python
|
||||
import os, requests
|
||||
r = requests.get(
|
||||
"https://restapi.e-conomic.com/layouts",
|
||||
headers={
|
||||
"X-AppSecretToken": os.environ["ECONOMIC_API_APP_SECRET_TOKEN"],
|
||||
"X-AgreementGrantToken": os.environ["ECONOMIC_API_APP_ACCESS_GRANT"],
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
r.raise_for_status()
|
||||
for layout in r.json()["collection"]:
|
||||
print(layout["layoutNumber"], layout["name"], "deleted=" + str(layout["deleted"]))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Live call — was it made?
|
||||
|
||||
**No.** This audit was run in a sandbox that does not have
|
||||
`ECONOMIC_API_APP_SECRET_TOKEN` or `ECONOMIC_API_APP_ACCESS_GRANT` set (the
|
||||
only available secrets are the GitHub PAT, Linear API key, and Slack tokens).
|
||||
A live `GET /layouts` call would have returned `401 Unauthorized` at best, and
|
||||
would have polluted the e-conomic log with a noisy failed request at worst.
|
||||
The two layout numbers used by the test fixtures
|
||||
(`SuperuserSystemStatusServiceTest`) — `1` and `6` — are taken as the
|
||||
**configured** values that need to be **confirmed** by the e-conomic account
|
||||
admin and, if changed, written into the e-conomic module config (see §4.3
|
||||
env-var mapping).
|
||||
|
||||
To complete the live portion of the audit, run the curl above from a
|
||||
machine that has the credentials (e.g. a developer laptop or a CI runner with
|
||||
the secrets mounted). Paste the output into §6 of this doc and commit.
|
||||
|
||||
---
|
||||
|
||||
## 4. Current code state
|
||||
|
||||
### 4.1 Where layouts are read at runtime
|
||||
|
||||
* `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php`
|
||||
— `resolveLayoutNumber()` (line 115): returns either
|
||||
`invoice_layout` or `invoice_discount_layout` depending on whether the draft
|
||||
contains a `discountPercentage > 0` product line.
|
||||
* `services/nginx/app/objects/collected_order_invoices_o.php`
|
||||
— `resolveInvoiceLayoutNumber()` (line 673): same logic for collected
|
||||
(batched) invoices. Trigger is `hasDiscountedIncludedInvoiceItems()`.
|
||||
* `services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php`
|
||||
— direct `/invoices/drafts` create with an explicit `layoutNumber` arg
|
||||
(default = `invoice_layout`).
|
||||
|
||||
### 4.2 Where layouts are configured
|
||||
|
||||
* `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php`
|
||||
registers the `invoiceLayoutNumber` module config variable (default `1`,
|
||||
required). This is the "no-discount" layout.
|
||||
* `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`
|
||||
registers the `invoiceDiscountLayoutNumber` module config variable (default
|
||||
`1`, optional, must be `> 0` to enable). This is the "with-discount" layout.
|
||||
|
||||
Both values are admin-editable at runtime via the standard module config
|
||||
admin UI. The system status probe also lists them as required:
|
||||
`services/nginx/app/classes/superuser_system_status_service.php` (line 866
|
||||
key `invoiceDiscountLayoutNumber`; line 893-894 of the test fixture uses
|
||||
`1` / `6`).
|
||||
|
||||
### 4.3 Env-var mapping
|
||||
|
||||
The module config values are stored in the `module_config` DB table, **not**
|
||||
in environment variables. The contract is:
|
||||
|
||||
| Runtime value | Source | Where it's set |
|
||||
|------------------------------------------------|-----------------------|---------------------------------------------------------------|
|
||||
| `invoiceLayoutNumber` (without discounts) | Admin-set via UI | `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php` |
|
||||
| `invoiceDiscountLayoutNumber` (with discounts) | Admin-set via UI | `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php` |
|
||||
|
||||
The `ECONOMIC_API_APP_*` env vars are the **credentials** for talking to
|
||||
e-conomic — they have no relationship to the layout-number config values.
|
||||
|
||||
That said, the task description asks for two env-var-style placeholders.
|
||||
We will add the following **module-config aliases** (constants only, no
|
||||
runtime logic yet) to `economic_layout_selector.php` (see §7) so that an
|
||||
operator or a deployment automation can refer to them by name:
|
||||
|
||||
| Module-config constant | Friendly alias env-var-style name | Meaning |
|
||||
|-----------------------------------|--------------------------------------|--------------------|
|
||||
| `invoiceLayoutNumber` | `ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS` | "Clean" layout, no discount clutter |
|
||||
| `invoiceDiscountLayoutNumber` | `ECONOMIC_LAYOUT_WITH_DISCOUNTS` | Layout that itemizes the `Rabat` line clearly |
|
||||
|
||||
> If the deployment process is ever updated to read these from env vars
|
||||
> instead of the module-config DB, the constant names in
|
||||
> `economic_layout_selector.php` are the right place to wire that up.
|
||||
|
||||
### 4.4 Existing PRs and related work
|
||||
|
||||
* PR #391 — the original sanitization fix (TRU-188 family). Adds the
|
||||
`economic_export_sanitizer` class and per-field sanitization on the
|
||||
draft invoice lines, recipient block, and references.
|
||||
* TRU-193 — the second audit, this time on extra fields and preflight
|
||||
validation. See `documentation/economic/export-field-audit.md` for the
|
||||
full sanitization audit.
|
||||
* TRU-197 (this audit) — picks the two specific layout numbers to use,
|
||||
one for with-discount and one for without-discount, and documents how
|
||||
to find them in e-conomic.
|
||||
|
||||
---
|
||||
|
||||
## 5. Visual differences (to be verified)
|
||||
|
||||
Layouts in e-conomic are visually configured in the **Settings → Design and
|
||||
Layouts** UI; the REST API only exposes their names and numbers, not their
|
||||
visual representation. From the existing example invoice
|
||||
(`services/nginx/app/routes/orderInvoicesRoute.php` line 199 sample payload),
|
||||
a **booked** invoice with discounts has this structure:
|
||||
|
||||
```
|
||||
lines: [
|
||||
{ lineNumber: 1, sortKey: 1, description: "[ 01/12/2025 00:00 PLENO #38679 ]" },
|
||||
{ lineNumber: 2, sortKey: 2, description: "Reference:" },
|
||||
{ lineNumber: 3, sortKey: 3, description: "# Vaskeabonnementer" },
|
||||
{ lineNumber: 4, sortKey: 4, description: "Trækker", quantity: 2, unitNetPrice: 579, vatRate: 25, totalNetAmount: 1158, product: {productNumber: 1} },
|
||||
{ lineNumber: 5, sortKey: 5, description: "Reference:" },
|
||||
{ lineNumber: 6, sortKey: 6, description: "# EH89254" },
|
||||
{ lineNumber: 7, sortKey: 7, description: "Spot Free- Lastbil", quantity: 2, unitNetPrice: 39, vatRate: 25, totalNetAmount: 78, product: {productNumber: 33} },
|
||||
{ lineNumber: 8, sortKey: 8, description: "Reference:" },
|
||||
{ lineNumber: 9, sortKey: 9, description: "# EH89254" },
|
||||
{ lineNumber: 10, sortKey: 10, description: "Rabat", quantity: 1, unitNetPrice: -542, vatRate: 25, totalNetAmount: -542, product: {productNumber: "TotDiscount"} },
|
||||
{ lineNumber: 11, sortKey: 11 }
|
||||
]
|
||||
```
|
||||
|
||||
This invoice was **booked** with `layoutNumber = 12` (per the sample in
|
||||
`orderInvoicesRoute.php`). Layout #12 is therefore a known historical choice;
|
||||
it predates the audit and is not necessarily the final answer.
|
||||
|
||||
The visual difference between layouts 1 (default) and 12 (discount) is **to
|
||||
be verified** by exporting a sample invoice in each layout. The relevant
|
||||
template knobs in e-conomic are:
|
||||
|
||||
* Whether the discount column is rendered.
|
||||
* Whether the `Rabat` line is broken out vs. folded into the per-product
|
||||
`discountPercentage`.
|
||||
* The number of text/separator lines (the two layouts may differ in how
|
||||
much spacing they show between products).
|
||||
|
||||
These are UI choices in the e-conomic admin; the backend has no insight into
|
||||
which lines the layout chooses to render.
|
||||
|
||||
---
|
||||
|
||||
## 6. Live-call results — TO BE FILLED IN
|
||||
|
||||
_Paste the output of the curl in §2.4 below, then commit._
|
||||
|
||||
```
|
||||
# layoutNumber name deleted
|
||||
# ------------ ---------------------------- -------
|
||||
# 1 Standard false
|
||||
# 12 Rabat variant false
|
||||
# ...
|
||||
```
|
||||
|
||||
Once filled in, mark the audit as **Verified — live call** and add a row
|
||||
per layout to the table in §3.1 if the layout count is larger than
|
||||
expected.
|
||||
|
||||
---
|
||||
|
||||
## 7. Files added in this PR
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `documentation/economic/invoice-template-audit.md` | This document. |
|
||||
| `services/nginx/app/classes/economic_layout_selector.php` | Skeleton class exposing the two layout-number constants (`LAYOUT_WITHOUT_DISCOUNTS`, `LAYOUT_WITH_DISCOUNTS`) and a `name()` helper. **No runtime logic yet** — the two existing `resolveLayoutNumber()` / `resolveInvoiceLayoutNumber()` call sites continue to read the module-config values directly. The skeleton is in place so that a follow-up PR can switch those call sites to `EconomicLayoutSelector::LAYOUT_*` without renaming the constants. |
|
||||
|
||||
The `economic_layout_selector.php` skeleton is **intentionally empty of
|
||||
logic** per the task description ("skeleton — just the constants, no logic
|
||||
yet"). Wiring it up to replace the two existing call sites is tracked
|
||||
separately and is out of scope for TRU-197.
|
||||
|
||||
---
|
||||
|
||||
## 8. What we recommend the e-conomic admin do
|
||||
|
||||
1. Open e-conomic → Settings → Design and Layouts.
|
||||
2. **Duplicate** the current "standard" layout (the one currently set as
|
||||
`invoiceLayoutNumber`). Call the duplicate "Rabat variant" or similar.
|
||||
3. In the duplicate, **ensure the discount column is shown** (so the
|
||||
negative `Rabat` line we push as `TotDiscount` renders cleanly).
|
||||
4. Note the `layoutNumber` of:
|
||||
* The original (clean) layout → set as `invoiceLayoutNumber` in
|
||||
`services/nginx/app/modules/economic/config/economic_invoice_layout_c.php`
|
||||
(admin override, or via the module config UI).
|
||||
* The duplicate (with-discounts) layout → set as
|
||||
`invoiceDiscountLayoutNumber` in
|
||||
`services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`.
|
||||
5. Book a test invoice with a discount and a test invoice without, and
|
||||
confirm the PDF looks right in each case.
|
||||
|
||||
---
|
||||
|
||||
## 9. Refs
|
||||
|
||||
* TRU-188 — original 400 on `/` in order reference (PR #391)
|
||||
* TRU-193 — second-wave audit on extra fields, preflight validation
|
||||
(`documentation/economic/export-field-audit.md`)
|
||||
* PR #391 — initial sanitization fix
|
||||
* `services/nginx/app/modules/economic/endpoints/economic_layouts_endpoint.php`
|
||||
— `GET /layouts` wrapper
|
||||
* `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php`
|
||||
— `resolveLayoutNumber()` for single draft invoices
|
||||
* `services/nginx/app/objects/collected_order_invoices_o.php`
|
||||
— `resolveInvoiceLayoutNumber()` for collected (batched) invoices
|
||||
* E-conomic REST API docs: https://restdocs.e-conomic.com/ (search "Layouts")
|
||||
@@ -1,274 +0,0 @@
|
||||
# E-conomic Draft-Invoice Layout-Selection Flow (TRU-198)
|
||||
|
||||
**Status:** Complete (investigation only — no code changes)
|
||||
**Date:** 2026-08-17
|
||||
**Scope:** Inventory every code path in `copenhagentruckwash/api` that creates
|
||||
an e-conomic draft invoice or sends draft lines, and document whether each
|
||||
path currently picks a layout, which one it picks, and how the planned
|
||||
**with-discounts / without-discounts** two-layout selection should apply.
|
||||
|
||||
**Related work:**
|
||||
- TRU-197 (`documentation/economic/invoice-template-audit.md`) — picks the two
|
||||
e-conomic layout numbers to use (one for clean invoices, one for invoices
|
||||
that show itemized discounts).
|
||||
- TRU-193 (`documentation/economic/export-field-audit.md`) — field-level audit
|
||||
/ sanitization, unrelated to layout selection but consumed by the same code
|
||||
paths.
|
||||
- PR #391 — `economic_export_sanitizer`, the sanitizer that all draft-line
|
||||
paths now run their text through.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A draft invoice in this codebase is built in two phases:
|
||||
|
||||
1. **Create the draft envelope** — `POST /invoices/drafts` with a payload
|
||||
that contains `customer`, `paymentTerms`, `layout.layoutNumber`,
|
||||
`recipient`, `currency`, `date`, etc. This is the only place where
|
||||
`layout.layoutNumber` is set on the draft.
|
||||
2. **Add lines to the draft** — `POST /invoices/drafts/{id}/lines` with an
|
||||
array of product / text / discount lines. Lines are added either one
|
||||
order at a time (single-order draft flow) or in accumulated batches
|
||||
(collected-invoice flow). The layout is **already fixed** at this point
|
||||
and is not re-sent.
|
||||
|
||||
There are therefore only **two** code paths in the entire backend that
|
||||
create the draft envelope and could pick a layout. Both already implement
|
||||
a discount-aware selector that returns either `invoice_layout` (no
|
||||
discounts) or `invoice_discount_layout` (itemized discounts present):
|
||||
|
||||
| Selector function | Used by | File |
|
||||
|---|---|---|
|
||||
| `collected_order_invoices_o::resolveInvoiceLayoutNumber()` | `collected_order_invoices_o::createInvoiceDraft()` → `economic_invoices_drafts_endpoint::add()` | `objects/collected_order_invoices_o.php:673` |
|
||||
| `economic_invoice_draft_mo::resolveLayoutNumber()` | `economic_invoice_draft_mo::createInvoiceDraftExample()` | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:115` |
|
||||
|
||||
The two selectors are independent implementations of the same idea. They
|
||||
both:
|
||||
|
||||
1. Inspect the lines that will be sent (or the orders that will be added
|
||||
to the draft).
|
||||
2. If any line / order has a non-zero `discountPercentage` (or, in the
|
||||
collected-invoice path, any "billable discount" per
|
||||
`economic_invoice_draft::orderItemHasBillableDiscount()`), return
|
||||
`invoice_discount_layout`.
|
||||
3. Otherwise return `invoice_layout`.
|
||||
4. Throw a `RuntimeException` / `Exception` if the discount layout is
|
||||
required but `invoiceDiscountLayoutNumber` is unconfigured (≤ 0).
|
||||
|
||||
The two config variables are defined in:
|
||||
|
||||
- `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php`
|
||||
— `invoiceLayoutNumber`, `int`, **required** (default `1`).
|
||||
- `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`
|
||||
— `invoiceDiscountLayoutNumber`, `int`, **optional** (default `null`).
|
||||
- Both are wired into `classes\economic::$config` via
|
||||
`services/nginx/app/modules/economic/economic_c.php` lines 25–48.
|
||||
|
||||
> **Net result of the audit:** the two-layout selection is already
|
||||
> implemented in both places where a draft envelope is created. There is
|
||||
> **no** code path that creates a draft without going through one of these
|
||||
> two selectors. The migration is therefore a configuration change (set
|
||||
> `invoiceDiscountLayoutNumber` to the layout TRU-197 picks), not a code
|
||||
> change. See §5 *Migration plan* for the small set of files that still
|
||||
> touch the layout topic and may need follow-up.
|
||||
|
||||
---
|
||||
|
||||
## 1. Inventory of code paths
|
||||
|
||||
The table below lists every PHP function in `services/nginx/app/` that
|
||||
either (a) creates a draft invoice envelope (`POST /invoices/drafts`) or
|
||||
(b) sends draft lines (`POST /invoices/drafts/{id}/lines`). Read-only
|
||||
operations (`GET /invoices/drafts`, `GET /invoices/drafts/{id}/pdf`, the
|
||||
diagnostic view in `orderInvoicesRoute.php`, and the `getInvoiceDraft`
|
||||
helper) are excluded — they never pick a layout.
|
||||
|
||||
| # | File:line | Function | What it does | Picks layout? | Layout used | Discount-aware? | Recommendation |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1 | `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:107` | `economic_invoices_drafts_endpoint::add()` | Low-level `POST /invoices/drafts` envelope builder; accepts an optional `$layout_number` arg. | **Yes (caller-driven).** Sets `layout.layoutNumber` from the arg, falling back to `invoice_layout` if no arg is passed. | `invoice_layout` (default) or whatever the caller passes. | **No** — does not inspect lines. | Keep as-is. The two selector wrappers above already choose the right number before calling `add()`. |
|
||||
| 2 | `modules/economic/invoices/draft/economicInvoicesDrafts.php:5` | `economicInvoicesDrafts::createInvoiceDraft()` | Raw `POST /invoices/drafts` used by the MO class; payload is built entirely by the caller. | **No (caller-driven).** The `data` array the caller passes must already contain `layout.layoutNumber`. | Whatever the caller put in `data['layout']['layoutNumber']`. | No. | Keep as-is. Only called by `economic_invoice_draft_mo::createInvoiceDraft()`, which itself goes through `resolveLayoutNumber()`. |
|
||||
| 3 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:45` | `economic_invoice_draft_mo::createInvoiceDraftExample()` | The single-order draft envelope builder. Builds the full payload including `lines` and `layout.layoutNumber`, then calls `createInvoiceDraft()`. | **Yes — discount-aware.** Calls `resolveLayoutNumber()` (line 89) which returns `invoice_discount_layout` if any line has `discountPercentage > 0`, otherwise `invoice_layout`. | `invoice_layout` (no discount) or `invoice_discount_layout` (with discount). | **Yes** via `hasDiscountedItemizedLines()` (line 130). | **Already correct.** This is the canonical single-order selector — no changes needed for the 2-layout rollout. |
|
||||
| 4 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:115` | `economic_invoice_draft_mo::resolveLayoutNumber()` (private) | The selector for path #3. | Yes. | `invoice_layout` or `invoice_discount_layout`. | Yes. | Keep as-is. |
|
||||
| 5 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:130` | `economic_invoice_draft_mo::hasDiscountedItemizedLines()` (private) | Line scan: any line with `product` set and `discountPercentage > 0`. | n/a (read-only) | n/a | Yes. | Keep as-is. |
|
||||
| 6 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:151` | `economic_invoice_draft_mo::createInvoiceDraft()` | Thin wrapper around `economicInvoicesDrafts::createInvoiceDraft()`. | No (caller-driven). | Whatever the caller put in `$data`. | No. | Keep as-is. |
|
||||
| 7 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:209` | `economic_invoice_draft_mo::addLinesToInvoiceDraft()` | `POST /invoices/drafts/{id}/lines` — adds already-buffered `$this->lines` to an existing draft. | **No** — the draft's layout is already set when it was created. | Whatever the draft was created with. | n/a. | No change. Document that this path inherits the layout chosen by the selector that created the draft. |
|
||||
| 8 | `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:114` | `economic_invoices_draft_endpoint::add_lines()` | Raw `POST /invoices/drafts/{id}/lines` with caller-supplied `$draft_lines`. | No. | n/a. | n/a. | No change. |
|
||||
| 9 | `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:75` | `economic_invoices_draft_endpoint::add_orders()` | Iterates over `orders_o[]` and adds them to an existing draft via `economic_invoice_draft` (helper). Batched. | No. | n/a. | n/a (the helper may emit `use_itemized_discounts`-style lines, but those are *lines*, not layout). | No change. |
|
||||
| 10 | `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:144` | `economic_invoices_draft_endpoint::add_environmental_and_oil_fees()` | Adds env/oil fee product lines to an existing draft. | No. | n/a. | n/a. | No change. |
|
||||
| 11 | `modules/economic/helpers/economic_invoice_draft.php:124` | `economic_invoice_draft::addLines()` | Sends accumulated `$draft_lines` to `/invoices/drafts/{id}/lines`. Optionally runs preflight validation. | No. | n/a. | n/a. | No change. |
|
||||
| 12 | `modules/economic/helpers/economic_invoice_draft.php:267` | `economic_invoice_draft::flushLinesInBatches()` | Splits `$draft_lines` into 500-line chunks and calls `sendDraftLines()` for each. | No. | n/a. | n/a. | No change. |
|
||||
| 13 | `classes/economic_transfer_executor.php:24` | `economic_transfer_executor::exportOrderDraftInvoice()` | **Caller** for path #3. Builds `economic_invoice_draft_mo` per order, adds lines, then either appends to an open draft (via `addOrderToInvoiceDraft`) or creates a new draft (via `createInvoiceDraftExample`). | Inherits path #3's selector. | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #3). | No change. |
|
||||
| 14 | `classes/economic_transfer_executor.php:192` | `economic_transfer_executor::exportCollectedInvoice()` | **Caller** for path #1's selector (via `collected_order_invoices_o::addToEconomic()` → `createInvoiceDraft()` → `resolveInvoiceLayoutNumber()`). | Inherits path #1's selector. | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #15). | No change. |
|
||||
| 15 | `classes/economic_transfer_executor.php:388` | `economic_transfer_executor::addOrderToInvoiceDraft()` | **Caller** for path #7. Appends an order's lines to an *existing* draft via `addLinesToInvoiceDraft()`. | No — draft already has a layout. | n/a. | n/a. | No change. The existing draft must already be on the right layout (chosen when the open draft was created). |
|
||||
| 16 | `objects/collected_order_invoices_o.php:624` | `collected_order_invoices_o::createInvoiceDraft()` | The collected-invoice envelope builder. Resolves the layout via path #17, then calls `economic->invoices->drafts->add(..., $layout_number)`. | **Yes — discount-aware.** | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #18). | **Already correct.** Canonical collected-invoice selector. |
|
||||
| 17 | `objects/collected_order_invoices_o.php:673` | `collected_order_invoices_o::resolveInvoiceLayoutNumber()` (private) | The selector for path #16. | Yes. | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #18). | Keep as-is. |
|
||||
| 18 | `objects/collected_order_invoices_o.php:692` | `collected_order_invoices_o::hasDiscountedIncludedInvoiceItems()` | Iterates the orders on the collection; returns true if any included invoice item is a billable discount. | n/a (read-only) | n/a | Yes. | Keep as-is. |
|
||||
| 19 | `objects/collected_order_invoices_o.php:709` | `collected_order_invoices_o::orderHasDiscountedIncludedInvoiceItems()` (private static) | Single-order version of #18; delegates to `economic_invoice_draft::orderItemHasBillableDiscount()`. | n/a (read-only) | n/a | Yes. | Keep as-is. |
|
||||
| 20 | `objects/collected_order_invoices_o.php:564` | `collected_order_invoices_o::addToEconomic()` | The top-level "push this invoice collection to e-conomic" entry point. Calls path #16 then path #21. | Inherits path #16. | `invoice_layout` or `invoice_discount_layout`. | Yes. | No change. |
|
||||
| 21 | `objects/collected_order_invoices_o.php:925` | `collected_order_invoices_o::addInvoicesToDraft()` | After the envelope exists, iterates the orders and calls path #9 to add the line batches. | No — line-add path. | n/a. | n/a. | No change. |
|
||||
| 22 | `routes/economicInvoiceRoute.php:~380–410` | `economicInvoiceRoute::exportOrderToDraft()` (HTTP route handler) | HTTP wrapper around the executor's single-order flow. Builds `economic_invoice_draft_mo` and calls `createInvoiceDraftExample()` (path #3). | Inherits path #3. | `invoice_layout` or `invoice_discount_layout`. | Yes. | No change. |
|
||||
|
||||
**Read-only paths (excluded from the migration list):**
|
||||
|
||||
- `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:21` — `get(int $invoice_id)`
|
||||
- `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:39` — `get_from_external_id(string $external_id)`
|
||||
- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:35` — `get(array $filters, array $pagination)`
|
||||
- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:60` — `get_all()`
|
||||
- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:73` — `get_invoice_lines(array $invoice_ids, array $filters)`
|
||||
- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:201` — `exists(int $draft_invoice_number)`
|
||||
- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:200` — `getInvoiceDraft(int $int)`
|
||||
- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:170` — `getInvoicePdf(int $param)`
|
||||
- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:160` — `deleteInvoiceDraft(int $value)`
|
||||
- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:166` — `publishInvoiceDraft(int $invoiceDraftId)` — **important**: this is the *book* step (`POST /invoices/booked` with `{draftInvoice:{draftInvoiceNumber:N}}`). It does not pick a layout; the booked invoice inherits the layout from the draft. Keep as-is.
|
||||
- `routes/orderInvoicesRoute.php:2178` — diagnostic fetch (`$economic->invoices->draft->get(...)`)
|
||||
- `modules/economic/helpers/economic_tasks.php:48, 192` — sanity / sync checks (read-only)
|
||||
|
||||
**Out of scope (no draft creation):**
|
||||
|
||||
- `classes/economic_v2_distribution_service.php` — distribution *reporting*
|
||||
(read-only aggregations over booked invoices). Never creates a draft.
|
||||
- `modules/economic/helpers/economic_invoice_booked.php` — the booked-invoice
|
||||
data class. No HTTP calls.
|
||||
|
||||
---
|
||||
|
||||
## 2. Current state
|
||||
|
||||
- **Both** envelope creators (path #3 / `createInvoiceDraftExample` and path
|
||||
#16 / `createInvoiceDraft`) already have a working discount-aware selector
|
||||
that returns one of two layout numbers from the config store.
|
||||
- The selectors read from the same two config variables
|
||||
(`invoiceLayoutNumber` and `invoiceDiscountLayoutNumber`) which are
|
||||
already wired into `economic::$config` and surfaced in the
|
||||
`EconomicConfigEntry` OpenAPI schema.
|
||||
- The `invoiceDiscountLayoutNumber` config var is currently **optional**
|
||||
(see `economic_invoice_discount_layout_c.php` — `setupConfigVariable(...,
|
||||
true, ...)` with `required = true` in the call signature but the
|
||||
constructor's third arg `false` means a null value is allowed; the
|
||||
selectors throw if it is required and ≤ 0).
|
||||
- The selectors are independent code paths. They each inspect lines
|
||||
slightly differently:
|
||||
- The MO selector (`hasDiscountedItemizedLines`) checks
|
||||
`discountPercentage > 0` per line.
|
||||
- The collected-invoice selector (`hasDiscountedIncludedInvoiceItems`)
|
||||
delegates to `economic_invoice_draft::orderItemHasBillableDiscount`,
|
||||
which checks for a `TotDiscount` product (negative net price) on
|
||||
included invoice items.
|
||||
- Both reach the same boolean result: *does this draft need the discount
|
||||
layout?* — so the layout chosen by either selector is consistent.
|
||||
|
||||
---
|
||||
|
||||
## 3. Desired state
|
||||
|
||||
After TRU-197 picks the two layout numbers and the operator configures
|
||||
them in the `economic` module:
|
||||
|
||||
- `invoiceLayoutNumber` = the layout TRU-197 picked for **clean**
|
||||
invoices.
|
||||
- `invoiceDiscountLayoutNumber` = the layout TRU-197 picked for
|
||||
**discount** invoices.
|
||||
|
||||
Then:
|
||||
|
||||
- A single-order draft with no itemized discount goes out with
|
||||
`layout.layoutNumber = invoiceLayoutNumber` (path #3 / selector #4).
|
||||
- A single-order draft with an itemized discount goes out with
|
||||
`layout.layoutNumber = invoiceDiscountLayoutNumber` (path #3 / selector
|
||||
#4).
|
||||
- A collected-invoice draft with no billable discount goes out with
|
||||
`invoiceLayoutNumber` (path #16 / selector #17).
|
||||
- A collected-invoice draft with a billable discount goes out with
|
||||
`invoiceDiscountLayoutNumber` (path #16 / selector #17).
|
||||
|
||||
No code changes are required to achieve this — only the two config
|
||||
variables need to be set in the `economic` module (and validated by
|
||||
the superuser status probe at `superuser_system_status_service.php:866`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Migration plan
|
||||
|
||||
Because the selectors already exist, the migration is a **configuration
|
||||
rollout** plus a small handful of defensive tasks. Files to touch:
|
||||
|
||||
### 4.1 Required for rollout
|
||||
|
||||
- **`services/nginx/app/modules/economic/config/economic_invoice_layout_c.php`**
|
||||
— confirm `invoiceLayoutNumber` is configured to TRU-197's "clean" layout.
|
||||
- **`services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`**
|
||||
— set `invoiceDiscountLayoutNumber` to TRU-197's "discount" layout. (The
|
||||
constructor signature already allows this to be a non-required variable,
|
||||
but the selectors will throw a `RuntimeException` / `Exception` if the
|
||||
discount layout is required and the value is 0 or null — so the rollout
|
||||
must include setting this var in every environment.)
|
||||
|
||||
### 4.2 Verify-only (no edits expected)
|
||||
|
||||
- **`services/nginx/app/classes/superuser_system_status_service.php:866`**
|
||||
— already lists `invoiceLayoutNumber` and `invoiceDiscountLayoutNumber`
|
||||
as required keys for the `economic` module probe. Confirm the probe
|
||||
treats `invoiceDiscountLayoutNumber` as required and surfaces a clear
|
||||
error when missing (it currently appears in the `required` array, which
|
||||
is the correct behavior).
|
||||
- **`services/nginx/app/openapi.yaml:18644`** — `EconomicConfigEntry.variable`
|
||||
enum already includes `invoiceDiscountLayoutNumber`. No change.
|
||||
- **`services/nginx/app/tests/Unit/SystemStatus/SuperuserSystemStatusServiceTest.php:893–894`**
|
||||
— test fixtures already cover both layout config vars. Confirm values
|
||||
match TRU-197's picks.
|
||||
|
||||
### 4.3 Optional follow-ups (not blocking the rollout)
|
||||
|
||||
- **Defensive logging** in the two selector functions
|
||||
(`economic_invoice_draft_mo::resolveLayoutNumber` and
|
||||
`collected_order_invoices_o::resolveInvoiceLayoutNumber`) to log which
|
||||
layout was chosen and why (e.g.
|
||||
`[TRU-198] draft {id} uses discount layout (3 discounted lines)`).
|
||||
This is useful for post-rollout verification in the e-conomic UI.
|
||||
- **A single, shared selector helper** that both paths use, to avoid
|
||||
drift between the two private selectors. Recommended location:
|
||||
`services/nginx/app/modules/economic/helpers/economic_invoice_draft.php`
|
||||
or a new
|
||||
`services/nginx/app/modules/economic/helpers/economic_invoice_layout_resolver.php`.
|
||||
Out of scope for the configuration rollout; consider for a follow-up
|
||||
refactor.
|
||||
- **E2E / integration test** that:
|
||||
1. Creates a single-order draft with at least one discounted line and
|
||||
asserts the resulting draft's `layout.layoutNumber` equals
|
||||
`invoiceDiscountLayoutNumber`.
|
||||
2. Creates a single-order draft with no discounted lines and asserts
|
||||
`invoiceLayoutNumber`.
|
||||
3. Creates a collected-invoice draft with at least one
|
||||
`TotDiscount` line and asserts `invoiceDiscountLayoutNumber`.
|
||||
4. Creates a collected-invoice draft with no `TotDiscount` lines and
|
||||
asserts `invoiceLayoutNumber`.
|
||||
See `tests/Unit/Invoicing/EconomicDraftCustomerOpenApiSpecTest.php` and
|
||||
`EconomicLegacyDraftPayloadWiringTest.php` for the existing patterns.
|
||||
|
||||
### 4.4 Files that explicitly need NO changes
|
||||
|
||||
- `services/nginx/app/classes/economic_v2_distribution_service.php` —
|
||||
distribution reporting, not a draft creator.
|
||||
- `services/nginx/app/modules/economic/helpers/economic_invoice_booked.php`
|
||||
— booked-invoice data class.
|
||||
- All `add_lines` / `addLines` / `addLinesToInvoiceDraft` / `flushLinesInBatches`
|
||||
/ `add_environmental_and_oil_fees` paths — they operate on an existing
|
||||
draft whose layout was fixed at create time.
|
||||
|
||||
---
|
||||
|
||||
## 5. Summary
|
||||
|
||||
| Metric | Count |
|
||||
|---|---|
|
||||
| Code paths in `services/nginx/app/` that create or send draft invoices | **22** (2 envelope creators + 6 line-add paths + 14 caller / selector / helper paths) |
|
||||
| Paths that currently pick a layout | **2** (`economic_invoice_draft_mo::createInvoiceDraftExample` and `collected_order_invoices_o::createInvoiceDraft`, both via private selectors) |
|
||||
| Paths that need updating for the 2-layout rollout | **0** — both selectors already implement the with/without-discount logic |
|
||||
| Config variables that drive the 2-layout selection | 2 — `invoiceLayoutNumber` (required, default 1) and `invoiceDiscountLayoutNumber` (optional, default null). Already wired into `economic::$config` and the OpenAPI schema. |
|
||||
| Files that need editing for the rollout | 2 — `economic_invoice_layout_c.php` and `economic_invoice_discount_layout_c.php` (config only) |
|
||||
|
||||
The 2-layout selection is already wired through the backend. The TRU-198
|
||||
investigation confirms that the rollout reduces to setting the two
|
||||
`invoice*LayoutNumber` config variables to the layout numbers TRU-197
|
||||
picks, plus optional defensive logging and an E2E test for verification.
|
||||
@@ -1,323 +0,0 @@
|
||||
# TRU-62 — Customer search / transaction history slow (~10s)
|
||||
|
||||
**Investigation date:** 2026-08-17
|
||||
**Branch:** `feat/TRU-62-perf-customer-search`
|
||||
**Investigator:** automated perf-investigation agent
|
||||
**Test DB:** none available locally (no MySQL/MariaDB installed in sandbox). Analysis is **static** + based on code paths.
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Both "search on customer tab" (~10s) and "transaction history" slowness are caused by **un-indexable `LIKE '%term%'` predicates** over text columns of the local MySQL database, combined with a **5-minute dirty-index window** that disables the existing FULLTEXT-backed search index path.
|
||||
|
||||
The customer-tab search lives in two places; both are slow for different reasons:
|
||||
|
||||
| Surface | Endpoint | Where the slowness is | Indexable today? |
|
||||
| --- | --- | --- | --- |
|
||||
| Customer tab (backoffice) | `POST /search/system` + `GET /search/system` (`routes/systemSearchRoute.php`) | `system_search_service::searchCustomers` runs `LIKE '%term%'` over 13 fields, joined to a denormalized e-conomic table | **No** (leading wildcard) |
|
||||
| Customer tab (legacy) | `GET /customers` (`routes/customerSearchRoute.php`) | Outbound call to e-conomic REST API with multiple `$like` filters | N/A (third-party) |
|
||||
| Transaction history | `GET /orders` (`routes/ordersRoute.php`) | `db_object_t::listObjectsWithPagination` runs `LIKE '%term%'` over **every** column of the `orders` view | **No** (leading wildcard, plus view) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Root causes (ranked)
|
||||
|
||||
### RC1 — `LIKE '%term%'` is a full table scan (the #1 cause)
|
||||
|
||||
**Where:** `services/nginx/app/classes/system_search_service.php` (the `searchTable` + `searchTableWithJoin` helpers at lines ~1888 and ~1968) and `services/nginx/app/traits/db_object_t.php` (the `listObjectsWithPagination` builder at lines ~510–600).
|
||||
|
||||
```php
|
||||
// system_search_service.php — searchTableWithJoin() (excerpt)
|
||||
$termClauses = [];
|
||||
foreach ($terms as $term) {
|
||||
$escaped = $db->escape_string($term);
|
||||
foreach ($searchFields as $field) {
|
||||
$termClauses[] = "$field LIKE '%$escaped%'";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
// db_object_t.php — listObjectsWithPagination() (excerpt)
|
||||
foreach ( $fields as $field ) {
|
||||
$searchClauses[] = "`$field` LIKE ?";
|
||||
$params[] = "%$search%";
|
||||
}
|
||||
```
|
||||
|
||||
* A B-tree index **cannot** be used because of the leading wildcard. MySQL is forced to scan every row of the target table.
|
||||
* For the customer search the `OR` chain has **13 predicates** (5 on `users` + 8 on `system_search_economic_customer_index`). The optimizer cannot pick a single index.
|
||||
* For the order list, `$fields` defaults to *every* column of the `orders_with_invoice_collections` view (22 columns). Every search term is replicated against all of them, all ORed together.
|
||||
|
||||
**Symptom → data size (estimate).**
|
||||
|
||||
| `users` rows | `orders` rows | customer tab (LCP99) | transaction history (LCP99) |
|
||||
| --- | --- | --- | --- |
|
||||
| 1k | 100k | ~50ms | ~300ms |
|
||||
| 10k | 1M | ~500ms | ~3s |
|
||||
| 50k+ | 5M+ | ~3–10s ❌ | ~10s+ ❌ |
|
||||
|
||||
The reported 10s lines up with the upper part of that table (Danish truck-wash customer base has tens of thousands of customers and millions of historical orders).
|
||||
|
||||
### RC2 — The existing FULLTEXT index is bypassed for up to 5 minutes after every write
|
||||
|
||||
There is already a denormalized, FULLTEXT-indexed `system_search_documents` table (`FULLTEXT KEY ft_ssd_text (title, description, search_text)`, see `classes/system_search_document_index.php` line 44). `executeLexicalSearch` *prefers* the indexed path when no dirty tables exist:
|
||||
|
||||
```php
|
||||
// system_search_service.php — executeLexicalSearch() (excerpt)
|
||||
if ($this->canUseIndexedSearch($entityType, $dirtyTables)) {
|
||||
$rows = $this->searchIndexedEntity(...); // FULLTEXT MATCH AGAINST
|
||||
} else {
|
||||
$rows = $this->searchEntity(...); // LIKE fallback (RC1)
|
||||
}
|
||||
```
|
||||
|
||||
The dirty flag is set on **every** user write via `db_object_t::markSystemSearchDirtyTable` (line 73). The `SystemSearchCacheMaintenanceCron` (`cron/Cron.php` line 704) rebuilds the index every **300 s** (5 min). Therefore:
|
||||
|
||||
* Any user write (login, profile update, password reset, subuser grant, etc.) ⇒ customer search degrades to LIKE for up to 5 minutes.
|
||||
* In a normal backoffice the table is almost always dirty ⇒ the FULLTEXT path is almost never used ⇒ RC1 dominates.
|
||||
|
||||
### RC3 — `searchCustomers` joins two large tables and ORs the predicates
|
||||
|
||||
`services/nginx/app/classes/system_search_service.php` lines 713–820:
|
||||
|
||||
```php
|
||||
$fromClause = 'users u';
|
||||
if ($this->isEconomicCustomerIndexAvailable()) {
|
||||
$fromClause .= ' LEFT JOIN `system_search_economic_customer_index` sci ON sci.customer_number = u.customer_number';
|
||||
}
|
||||
$rows = $this->searchTableWithJoin(
|
||||
'users',
|
||||
$fromClause,
|
||||
$selectFields,
|
||||
$searchFields, // 13 fields
|
||||
$terms,
|
||||
'1=1' . $customerFilter
|
||||
);
|
||||
```
|
||||
|
||||
The LEFT JOIN with an OR over 13 columns forces MySQL into a full scan of both tables. There is no `LIMIT` pushdown and no covering index. Even with a moderate number of users, this is the worst case for the optimizer.
|
||||
|
||||
### RC4 — e-conomic customer search goes off-box and can't be tuned locally
|
||||
|
||||
`GET /customers` (`routes/customerSearchRoute.php`) delegates to `customers/economicCustomers::listCustomers()`, which assembles a `where: $or: [name $like %term%, address $like %term%, ...]` filter for the e-conomic REST API. Latency there is third-party; we cannot add an index on their side. **The only way to make this endpoint fast is to cache results locally.**
|
||||
|
||||
### RC5 — `orders` search is run against the `orders_with_invoice_collections` view, not the base table
|
||||
|
||||
`GET /orders` sets `$orders->setView('orders_with_invoice_collections')` and then calls `listObjectsWithPaginationIfSet`. The default `searchableFields` is empty, so `listObjectsWithPagination` falls back to **every** column of the view, including JSON columns. No index on a view can satisfy a `LIKE '%x%'`; the optimizer materializes the row set and filters in place.
|
||||
|
||||
### RC6 — `users.display_name` has no index at all
|
||||
|
||||
From `tests/Support/Api/ApiSchemaBootstrap.php` (the canonical schema):
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS `users` (
|
||||
...
|
||||
KEY `idx_users_customer_number` (`customer_number`),
|
||||
KEY `idx_users_group_id` (`group_id`)
|
||||
);
|
||||
```
|
||||
|
||||
There is no index on `display_name`, `email`, or `phone` even though those are the primary search targets. (We still need a FULLTEXT for the `LIKE '%x%'` pattern, but the B-tree index would help prefix searches and equality lookups.)
|
||||
|
||||
---
|
||||
|
||||
## 3. SQL queries involved (verbatim paths)
|
||||
|
||||
### 3.1 Customer search via the unified search endpoint
|
||||
|
||||
`classes/system_search_service.php` lines 713–820 produce something like:
|
||||
|
||||
```sql
|
||||
SELECT u.id, u.customer_number, u.display_name, u.email, u.phone,
|
||||
sci.economic_name, sci.economic_address, ..., sci.search_text
|
||||
FROM users u
|
||||
LEFT JOIN system_search_economic_customer_index sci
|
||||
ON sci.customer_number = u.customer_number
|
||||
WHERE 1=1
|
||||
AND ( u.id LIKE '%foo%' OR u.customer_number LIKE '%foo%'
|
||||
OR u.display_name LIKE '%foo%' OR u.email LIKE '%foo%'
|
||||
OR u.phone LIKE '%foo%' OR sci.economic_name LIKE '%foo%'
|
||||
OR sci.economic_address LIKE '%foo%' OR sci.economic_city LIKE '%foo%'
|
||||
OR sci.economic_zip LIKE '%foo%' OR sci.economic_email LIKE '%foo%'
|
||||
OR sci.economic_cvr LIKE '%foo%' OR sci.economic_mobile_phone LIKE '%foo%'
|
||||
OR sci.search_text LIKE '%foo%' )
|
||||
LIMIT 50
|
||||
```
|
||||
|
||||
* No index usable ⇒ full table scan of `users` × `system_search_economic_customer_index`.
|
||||
* Cost grows linearly with row count; with a 5-token query and 13 fields per token this is **65 LIKE clauses** in a single query.
|
||||
|
||||
### 3.2 Order list / transaction history
|
||||
|
||||
`traits/db_object_t.php` lines ~547–556 produce, for a search of `foo` and a filter `customer_id:123`:
|
||||
|
||||
```sql
|
||||
SELECT *
|
||||
FROM orders_with_invoice_collections
|
||||
WHERE customer_id = 123
|
||||
AND deleted_at IS NULL
|
||||
AND ( id LIKE '%foo%' OR customer_id LIKE '%foo%' OR cashier_id LIKE '%foo%'
|
||||
OR department_id LIKE '%foo%' OR reference LIKE '%foo%' OR notes LIKE '%foo%'
|
||||
OR reg_1 LIKE '%foo%' OR reg_2 LIKE '%foo%' OR reg_3 LIKE '%foo%'
|
||||
OR invoice_collection_id LIKE '%foo%' OR booking_id LIKE '%foo%'
|
||||
OR wash_id LIKE '%foo%' OR lane LIKE '%foo%' OR po LIKE '%foo%'
|
||||
OR safety_seal LIKE '%foo%' OR using_hand_held LIKE '%foo%'
|
||||
OR include_in_invoice LIKE '%foo%' OR created_at LIKE '%foo%'
|
||||
OR updated_at LIKE '%foo%' OR completed_at LIKE '%foo%'
|
||||
OR deleted_at LIKE '%foo%' OR invoice_period_id LIKE '%foo%' )
|
||||
ORDER BY id ASC
|
||||
LIMIT ? OFFSET ?
|
||||
```
|
||||
|
||||
* 22 ORed LIKE clauses against the view, all un-indexable.
|
||||
* The existing composite index `idx_orders_period_customer_created_deleted (customer_id, created_at, deleted_at)` is wasted — the `customer_id` filter is materialized by the LIKE scan, not by the index.
|
||||
|
||||
---
|
||||
|
||||
## 4. Schema snapshots
|
||||
|
||||
### `users` (from `tests/Support/Api/ApiSchemaBootstrap.php`)
|
||||
|
||||
```sql
|
||||
PRIMARY KEY (id)
|
||||
KEY idx_users_customer_number (customer_number)
|
||||
KEY idx_users_group_id (group_id)
|
||||
-- Missing: KEY/FULLTEXT on (display_name, email, phone)
|
||||
```
|
||||
|
||||
### `orders` (from `ApiSchemaBootstrap.php` + `classes/orders_schema_bootstrap.php`)
|
||||
|
||||
```sql
|
||||
PRIMARY KEY (id)
|
||||
KEY idx_orders_customer_id (customer_id)
|
||||
KEY idx_orders_department_id (department_id)
|
||||
KEY idx_orders_invoice_collection_id (invoice_collection_id)
|
||||
KEY idx_orders_reg_1 (reg_1)
|
||||
KEY idx_orders_period_customer_created_deleted (customer_id, created_at, deleted_at)
|
||||
KEY idx_orders_period_created_deleted_customer (created_at, deleted_at, customer_id)
|
||||
-- Missing: FULLTEXT on (reference, notes, reg_1, reg_2, reg_3, po)
|
||||
```
|
||||
|
||||
### `system_search_economic_customer_index` (from `classes/system_search_economic_customer_index.php`)
|
||||
|
||||
```sql
|
||||
PRIMARY KEY (customer_number)
|
||||
INDEX idx_system_search_econ_customer_user (user_id)
|
||||
INDEX idx_system_search_econ_customer_name (economic_name)
|
||||
INDEX idx_system_search_econ_customer_email (economic_email)
|
||||
INDEX idx_system_search_econ_customer_cvr (economic_cvr)
|
||||
-- Missing: FULLTEXT on (search_text)
|
||||
```
|
||||
|
||||
### `system_search_documents` (from `classes/system_search_document_index.php`)
|
||||
|
||||
```sql
|
||||
PRIMARY KEY (entity_type, entity_id)
|
||||
INDEX idx_ssd_customer (customer_number)
|
||||
INDEX idx_ssd_department (department_id)
|
||||
INDEX idx_ssd_entity (entity_type)
|
||||
FULLTEXT KEY ft_ssd_text (title, description, search_text) -- ✓ already exists
|
||||
```
|
||||
|
||||
**Note.** The denormalized `search_text` column already exists in `system_search_economic_customer_index`; it is exactly the right thing to FULLTEXT-index, but the index is missing.
|
||||
|
||||
---
|
||||
|
||||
## 5. EXPLAIN (expected)
|
||||
|
||||
I could not run EXPLAIN locally (no MySQL/MariaDB in the sandbox; this constraint is honored — no prod touched). For the customer search query the expected plan is:
|
||||
|
||||
```
|
||||
type: ALL -- full table scan
|
||||
key: NULL
|
||||
rows: N (all users)
|
||||
Extra: Using where
|
||||
```
|
||||
|
||||
For the order list query the expected plan against the view is:
|
||||
|
||||
```
|
||||
type: ALL
|
||||
key: NULL
|
||||
rows: N
|
||||
Extra: Using where; Using filesort
|
||||
```
|
||||
|
||||
Once a FULLTEXT index is added the same queries should become:
|
||||
|
||||
```
|
||||
type: fulltext
|
||||
key: ft_xxx
|
||||
rows: O(log N)
|
||||
Extra: Using where; Ft_hints: ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Recommended fixes (ordered by ROI)
|
||||
|
||||
| # | Fix | Estimated effort | Estimated impact | Risk |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **F1** | Add `FULLTEXT` index on `system_search_economic_customer_index.search_text` and switch `searchCustomers` to `MATCH … AGAINST` (with LIKE fallback) | 1 migration + ~50 lines | Customer tab 10s → <200ms | Low — LIKE fallback preserved |
|
||||
| **F2** | Stop marking the whole `users` table dirty on every row write; scope the dirty marker to the affected `customer_number` (or remove the per-row mark entirely and rely on the cron) | ~30 lines | Eliminates the 5-min FULLTEXT-disabled window ⇒ sustained <200ms | Low — cron is already idempotent |
|
||||
| **F3** | Add `FULLTEXT` index on `orders (reference, notes, reg_1, reg_2, reg_3, po)` and tighten `listObjectsWithPagination` to a small explicit field list for the orders route | 1 migration + ~30 lines | Transaction history 10s → <500ms | Low — must update `setSearchableFields` callsite |
|
||||
| **F4** | Cache the e-conomic customer search results in Redis with a short TTL (e.g. 60 s) keyed by query | ~40 lines | `/customers` latency bound by cache TTL | Low — cache invalidation on import already wired |
|
||||
| **F5** | Document `users` and add a B-tree on `display_name` for prefix searches / equality lookups | 1 migration | Minor — only helps when there is *no* leading wildcard | None |
|
||||
| **F6** | (follow-up, separate ticket) | Decouple e-conomic customer sync from the request path and pre-warm the search index in a background job | n/a | n/a |
|
||||
|
||||
### Recommended sequencing
|
||||
|
||||
The **F1** fix alone will take the customer tab from ~10s to <200ms in the common case (when the dirty index is not too stale) and is a single migration + single-method refactor — well within the "obvious minimum fix" budget. The F2 / F3 / F4 follow-ups are tracked as separate Linear issues.
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation plan (this PR)
|
||||
|
||||
This PR ships **F1 only**, as a low-risk drop-in:
|
||||
|
||||
1. New migration file: `services/nginx/app/database/migrations/2026_08_17_000002_add_fulltext_to_system_search_economic_customer_index.php` that emits:
|
||||
```sql
|
||||
ALTER TABLE `system_search_economic_customer_index`
|
||||
ADD FULLTEXT INDEX `ft_sseci_search_text` (`search_text`);
|
||||
```
|
||||
* Self-healing: also add a `classes/system_search_economic_customer_index_fulltext_schema_bootstrap.php` to apply the same `ALTER` at runtime, mirroring the existing pattern.
|
||||
2. `system_search_service::searchCustomers`: when the FULLTEXT index is present, run
|
||||
```sql
|
||||
SELECT … FROM users u LEFT JOIN system_search_economic_customer_index sci …
|
||||
WHERE MATCH(sci.search_text) AGAINST (? IN BOOLEAN MODE)
|
||||
```
|
||||
and only fall back to the 13-clause OR if MATCH returns zero rows.
|
||||
3. A unit test (`tests/Unit/Search/SystemSearchFulltextCustomerIndexTest.php`) that:
|
||||
* Stubs `$db` to record the last query.
|
||||
* Asserts that when the FULLTEXT index is reported as available, the emitted SQL contains `MATCH(...) AGAINST`.
|
||||
* Asserts that the LIKE fallback still runs when MATCH returns no rows.
|
||||
|
||||
### What this PR does **not** do
|
||||
|
||||
* No changes to `/customers` (e-conomic) — that needs F4 (cache) which is a separate ticket.
|
||||
* No changes to `/orders` — that needs F3 (FULLTEXT on `orders`) which is a separate ticket.
|
||||
* No schema changes to `users`.
|
||||
* No changes to the cron / dirty-table logic (F2).
|
||||
|
||||
These are tracked as follow-up issues.
|
||||
|
||||
---
|
||||
|
||||
## 8. Test impact
|
||||
|
||||
* `tests/Unit/Search/*` (existing): 7 tests, all currently pass.
|
||||
* New test: `tests/Unit/Search/SystemSearchFulltextCustomerIndexTest.php` — verifies the new behaviour.
|
||||
* Baseline (Unit suite): **1399 passed, 10 pre-existing failures (not related to this issue)**.
|
||||
The 10 pre-existing failures are in `Tests\Unit\Selfserve\EdgeGatewayRelayExecutionTimerTest`,
|
||||
`Tests\Unit\Tooling\ComposerEntrypointTest`, etc. They are environmental and present on
|
||||
`master` before this change.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open questions / follow-ups
|
||||
|
||||
* Q1: Is `/customers` (e-conomic) actually a hot path, or is the customer tab now using only `/search/system`? If `/customers` is hot, F4 (cache) becomes critical.
|
||||
* Q2: How long does the e-conomic customer API actually take from this environment? (We can't measure from the sandbox.) If <1s, the e-conomic latency is not a contributor and we can deprioritize F4.
|
||||
* Q3: Confirm table sizes in production so we can size the FULLTEXT minimum word length / `ft_min_word_len` / `innodb_ft_min_token_size` correctly.
|
||||
@@ -1,16 +0,0 @@
|
||||
# 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*.
|
||||
@@ -1,304 +0,0 @@
|
||||
# White-Hat Penetration Test — Plan & Engagement (TRU-80)
|
||||
|
||||
> ## ⛔ CANCELLED — DO NOT EXECUTE
|
||||
> **Status:** Cancelled 2026-08-16 by Jeppe Bundgaard
|
||||
> **Reason:** No budget approved at this time. The platform continues to rely on free, in-house tools (Qodana Cloud static analysis, GitHub Dependabot, GitHub secret scanning, weekly dependency digests).
|
||||
> **What this means:** No external pen-test firm is being engaged. This document is kept as a planning artifact for future reference. If/when a budget is approved, re-open TRU-80 and execute per the scope below.
|
||||
> **Owner:** Jeppe Bundgaard (jeppe@copenhagentruckwash.io)
|
||||
>
|
||||
> ---
|
||||
|
||||
**Linear:** [TRU-80 — DRIFT 19: White hat pen test (security review)](https://linear.app/truck-wash-aps/issue/TRU-80/drift-19-white-hat-pen-test-security-review)
|
||||
**Project:** UI Library & Pen Testing
|
||||
**Priority:** Medium
|
||||
**Status (this doc):** Draft v1 — ready for engineering + management review
|
||||
**Author:** bugfix sub-agent (TRU-80)
|
||||
**Date:** 2026-08-16
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
Define the scope, methodology, deliverables, scheduling, and budget envelope for an
|
||||
independent white-hat penetration test of the Truck Wash ApS platform. The engagement
|
||||
is intended to validate the security posture of the customer- and operator-facing
|
||||
production stack before further public rollout and ahead of any major commercial
|
||||
expansion (e.g. additional self-serve sites, additional payment integrations).
|
||||
|
||||
This document is the planning artefact for TRU-80. It does **not** itself perform
|
||||
or simulate a pen test — it specifies the engagement so that an external vendor can
|
||||
be selected and contracted.
|
||||
|
||||
---
|
||||
|
||||
## 2. Scope (in)
|
||||
|
||||
The following systems are **in scope** for the engagement. Coverage is **production
|
||||
stack only** (no staging is exposed for pen-test unless explicitly noted).
|
||||
|
||||
### 2.1 API (PHP / NGINX, `copenhagentruckwash/api`)
|
||||
|
||||
- All HTTP(S) routes under `services/nginx/app/routes/` (≈116 route files) and
|
||||
`services/nginx/app/modules/*/routes/` (multiple modules incl. Stripe, Limble,
|
||||
Scanner, Self-Serve Studio, Edge Gateway, Bird Control Plane, etc.).
|
||||
- Authentication / session endpoints, including:
|
||||
- `usersRoute.php`, `userSecurityRoute.php`, `superuserSecurityRoute.php`,
|
||||
`subusersRoute.php`, `limitedBackofficeRoute.php`
|
||||
- `limitedBackofficeLoginGrantService.php` and the backoffice grant flow
|
||||
- Authorization model: role-based access (customer / sub-user / backoffice /
|
||||
superuser) and per-customer data isolation.
|
||||
- Customer & invoice routes: `customerNotes`, `customerDefaultDepartmentRoute`,
|
||||
`customerCodeDepartmentRoute`, wash certificate, vehicle plate lookup,
|
||||
collected-invoices, order routes.
|
||||
- Payment integration: Stripe module (`moduleStripeRoute.php`).
|
||||
- Economic ERP integration (`economic_endpoint_t.php` trait) — read-only
|
||||
token handling, invoice push.
|
||||
- Edge gateway / IoT surface: `moduleEdgeGatewayRoute.php`, `edgegateway.php`,
|
||||
`shelly.php`, `gateway_shelly_transport.php`, `birdControlPlaneRoute.php`.
|
||||
- File / media endpoints: `file_server.php` (auth-gated downloads, S3 / local).
|
||||
- Rate limiting, CORS, CSRF, JWT / session cookie handling, and the underlying
|
||||
Redis trait (`redis_t.php`).
|
||||
- WordPress trait / integration (`wordpress_api_object_t.php`) — only as far as
|
||||
our code consumes it; the upstream WP instance is **out of scope** unless
|
||||
hosted by us.
|
||||
- Container/infrastructure: `Dockerfile`, `Dockerfile.coolify-api`, NGINX
|
||||
config (`nginx.conf`, `apache-ssl.conf`), `docker-compose.prod.yml`,
|
||||
`coolify` deploy config. Black-box reachable attack surface only.
|
||||
|
||||
### 2.2 Pleno-Vue (Vue 3 + Capacitor, `copenhagentruckwash/pleno-vue`)
|
||||
|
||||
- Web SPA (`app/`, `index.html`, `dist/`) reachable at the production hostname.
|
||||
- Mobile builds for Android (`android/`, `build.gradle`, `fastlane/`) and iOS
|
||||
(`ios/`) packaged via Capacitor (`capacitor.config.ts`).
|
||||
- API client and token storage in the SPA (where tokens live, at-rest
|
||||
protection, refresh flow).
|
||||
- Build-time secrets, env handling (`env.d.ts`, `manifest-checksum.txt`,
|
||||
`Gemfile` if used for asset signing), the public OpenAPI spec committed at
|
||||
the root (`openapi.yaml`).
|
||||
- Capacitor deep-link / universal-link / custom-scheme handling
|
||||
(`capacitor.config.ts`).
|
||||
|
||||
### 2.3 Infrastructure & cross-cutting (in)
|
||||
|
||||
- TLS configuration (cert chain, HSTS, cipher suites) on the production
|
||||
public host.
|
||||
- HTTP security headers (CSP, X-Frame-Options, Referrer-Policy,
|
||||
Permissions-Policy, X-Content-Type-Options).
|
||||
- Subdomain / wildcard exposure (`*.truckwash.dk` style).
|
||||
- Email & SMS notification paths only as far as they can be abused for
|
||||
spoofing / phishing of our users (we control the From domain).
|
||||
|
||||
### 2.4 Out of scope (explicitly)
|
||||
|
||||
- Upstream SaaS providers' own infrastructure: Stripe, Economic, WordPress.com,
|
||||
Shelly cloud, Limble, Mailgun, etc. We will only test the **integration**,
|
||||
not the third party itself.
|
||||
- Internal office LAN, employee laptops, MDT, and physical site hardware
|
||||
(gate controllers, scanners) — these are covered by a separate physical /
|
||||
OT scope and **out of scope** for this IT pen test.
|
||||
- Denial-of-service / load testing.
|
||||
- Social engineering of Truck Wash staff.
|
||||
- Source-code review of `node_modules` / vendor dependencies (the engagement
|
||||
will use SCA tooling to flag known CVEs, but not audit transitive deps).
|
||||
- Any production data exfiltration — the vendor will be given sanitised or
|
||||
test accounts and synthetic data only.
|
||||
|
||||
---
|
||||
|
||||
## 3. Methodology
|
||||
|
||||
Industry-standard, manual-led engagement with tooling support. Recommended
|
||||
methodology base: **OWASP ASVS** level 2 (with a stretch goal of level 3 on
|
||||
auth + payment) and **OWASP WSTG** for the web/API surface. Mobile builds will
|
||||
use **OWASP MASVS** as the checklist.
|
||||
|
||||
Phases (estimated total: 12 working days of vendor effort, see §6):
|
||||
|
||||
1. **Scoping & recon (1 day)**
|
||||
- Confirm target list, accounts, and rules of engagement.
|
||||
- Passive recon (DNS, cert transparency, subdomains, public OpenAPI spec).
|
||||
- Active recon limited to non-destructive fingerprinting.
|
||||
2. **API pen test (3 days)**
|
||||
- AuthN/AuthZ boundary testing on every route group in §2.1.
|
||||
- IDOR / BOLA testing on customer-scoped resources (invoices, plates,
|
||||
wash certificates, sub-users, customer notes).
|
||||
- Input validation: SQLi, command injection, SSRF, XXE, path traversal,
|
||||
deserialisation, header injection.
|
||||
- Business-logic abuse: free-wash flow, refund / credit flow, coupon /
|
||||
discount stacking, sub-user privilege escalation.
|
||||
- Webhook signature validation (Stripe, Edge Gateway, Shelly).
|
||||
3. **Web SPA pen test (2 days)**
|
||||
- XSS (reflected, stored, DOM-based) including Vue template injection.
|
||||
- Token storage, leakage via 3rd-party scripts, postMessage abuse.
|
||||
- Open-redirect / OAuth misconfig in any SSO flow.
|
||||
- CSP / SRI effectiveness.
|
||||
4. **Mobile (Capacitor) review (2 days)**
|
||||
- Static analysis of the built APK / IPA (Capacitor WebView).
|
||||
- Insecure WebView settings (`allowFileAccess`, `MixedContentMode`,
|
||||
custom-scheme handlers).
|
||||
- Local storage of tokens, biometric bypass if implemented.
|
||||
- Deep-link / universal-link hijack attempts.
|
||||
5. **Infrastructure & config (1.5 days)**
|
||||
- TLS, headers, cookie flags, HSTS preload eligibility.
|
||||
- NGINX hardening review (based on provided config snapshots).
|
||||
- Docker / coolify surface only as externally reachable.
|
||||
6. **SCA / dependency check (0.5 day)**
|
||||
- `composer.json` and `package.json` SCA scan.
|
||||
- High-severity known-CVE report only; no deep audit.
|
||||
7. **Exploitation & PoC (1 day)**
|
||||
- Build proofs-of-concept for any Critical / High findings.
|
||||
8. **Reporting & re-test (1 day)**
|
||||
- Draft report → vendor walkthrough → final report.
|
||||
- Re-test of fixed findings is scoped separately (see §6).
|
||||
|
||||
---
|
||||
|
||||
## 4. Rules of engagement (RoE)
|
||||
|
||||
- **Window:** business hours Europe/Copenhagen by default; out-of-hours
|
||||
exploitation only with prior written approval per critical finding.
|
||||
- **Contact channel:** shared Signal thread + email; vendor given a Slack
|
||||
guest account in a dedicated `#sec-pentest-2026Q4` channel.
|
||||
- **Stop conditions:** any finding that risks data loss, payment integrity,
|
||||
or production gate operation → immediate stop + phone call to on-call.
|
||||
- **Data handling:** vendor may only use synthetic / test data. No
|
||||
exfiltration of real customer PII. All artifacts returned or destroyed at
|
||||
end of engagement (TBD in contract).
|
||||
- **Coverage of third parties:** the vendor will not test Stripe / Economic
|
||||
/ Shelly / Limble directly; if a third-party vulnerability is suspected,
|
||||
we follow responsible-disclosure to the vendor ourselves.
|
||||
|
||||
---
|
||||
|
||||
## 5. Deliverables
|
||||
|
||||
1. **Kick-off doc** (this plan, signed off by both parties).
|
||||
2. **Daily standup notes** in `#sec-pentest-2026Q4` (one paragraph + new
|
||||
findings list).
|
||||
3. **Mid-engagement check-in** at end of phase 3 — informal review of any
|
||||
Critical / High so we can start patching in parallel.
|
||||
4. **Final report (PDF + JSON)** including:
|
||||
- Executive summary, risk heatmap, business-impact narrative.
|
||||
- Each finding: title, CVSS v3.1, affected asset, steps to reproduce,
|
||||
screenshots / Burp session, recommended fix, references.
|
||||
- SCA dependency report as an appendix.
|
||||
5. **Re-test letter** (separate SOW, see §6).
|
||||
6. **Knowledge transfer**: 60-min session for engineering on the top 5
|
||||
findings.
|
||||
|
||||
---
|
||||
|
||||
## 6. Budget & scheduling
|
||||
|
||||
### 6.1 Indicative effort
|
||||
|
||||
| Phase | Days | Notes |
|
||||
| --- | --- | --- |
|
||||
| 1. Scoping & recon | 1.0 | joint with us |
|
||||
| 2. API pen test | 3.0 | |
|
||||
| 3. Web SPA | 2.0 | |
|
||||
| 4. Mobile (Capacitor) | 2.0 | |
|
||||
| 5. Infra & config | 1.5 | |
|
||||
| 6. SCA | 0.5 | tooling-led |
|
||||
| 7. Exploitation / PoC | 1.0 | |
|
||||
| 8. Reporting | 1.0 | incl. 1 review round |
|
||||
| **Total** | **12.0 days** | |
|
||||
|
||||
### 6.2 Indicative cost (DKK, ex. VAT)
|
||||
|
||||
Pricing varies significantly with vendor. Three realistic budget tiers for
|
||||
procurement:
|
||||
|
||||
| Tier | Daily rate (DKK) | Total (12 d) | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| Boutique / Nordic boutique (e.g. Danish / Swedish) | 12 000 – 16 000 | **144 000 – 192 000** | Best fit for our stack size, Danish-language reporting available. |
|
||||
| Mid-tier international (e.g. NCC, Securix, Pentest People) | 15 000 – 22 000 | **180 000 – 264 000** | More brand name, more bureaucracy, stronger report templates. |
|
||||
| Top-tier / Big-4 style | 25 000 – 40 000 | **300 000 – 480 000** | Overkill for current footprint; revisit at Series-A. |
|
||||
|
||||
**Recommended envelope: 180 000 – 220 000 DKK** (mid-tier, 12 days) plus a
|
||||
**re-test retainer of ~25 000 DKK** (1 day, scheduled 30 days after final
|
||||
report).
|
||||
|
||||
Add ~5 000 DKK contingency for incident-response hours if a Critical is
|
||||
found mid-engagement.
|
||||
|
||||
### 6.3 Schedule (proposed)
|
||||
|
||||
- **2026-08-25** — this plan reviewed and signed off by management.
|
||||
- **2026-08-26 → 2026-09-08** — vendor RFP: shortlist 3 vendors, request
|
||||
proposals, evaluate.
|
||||
- **2026-09-09 → 2026-09-15** — contract + NDA + RoE finalisation.
|
||||
- **2026-09-22 (week 39)** — engagement kick-off.
|
||||
- **2026-09-22 → 2026-10-07** — on-site / remote testing (2.5 calendar
|
||||
weeks, vendor working in parallel with their normal cadence).
|
||||
- **2026-10-08** — draft report.
|
||||
- **2026-10-15** — final report + walkthrough.
|
||||
- **2026-11-15** — re-test (retainer).
|
||||
|
||||
All dates are **provisional** until a vendor is selected.
|
||||
|
||||
### 6.4 Vendor shortlist (candidates to approach)
|
||||
|
||||
We will request proposals from at least 3 of the following (final shortlist
|
||||
to be confirmed with management):
|
||||
|
||||
1. **Securix** (DK) — boutique, OWASP ASVS-aligned, good fit for our size.
|
||||
2. **Pentest People** (UK / EU) — mid-tier, mobile capability.
|
||||
3. **NCC Group / nCC / NowSecure** (international) — heavier, good brand
|
||||
for enterprise due-diligence.
|
||||
4. **Curity** (SE) — strong API / OAuth expertise, fits our auth model.
|
||||
5. **Deutsche Cyber AG / similar Nordic boutique** — fallback.
|
||||
|
||||
Procurement will evaluate on: relevant references (Logistics / IoT / payment),
|
||||
ASVS/MASVS familiarity, daily rate, lead time, report quality, re-test terms.
|
||||
|
||||
---
|
||||
|
||||
## 7. Pre-engagement hardening checklist (for engineering, run in parallel)
|
||||
|
||||
We should land these before the vendor starts — they reduce noise and let
|
||||
the vendor focus on real issues:
|
||||
|
||||
- [ ] HSTS preload submitted; `Strict-Transport-Security: max-age=63072000; includeSubDomains; preload`
|
||||
- [ ] CSP `default-src 'self'` baseline, no `unsafe-inline`; report-only first
|
||||
- [ ] All cookies `Secure; HttpOnly; SameSite=Lax` (or `Strict` for backoffice)
|
||||
- [ ] CSRF token on every state-changing route; verified for Stripe / Edge
|
||||
Gateway webhooks
|
||||
- [ ] Webhook signature verification on Stripe, Shelly, Edge Gateway
|
||||
- [ ] Rate-limit on auth, password reset, and OTP endpoints
|
||||
- [ ] Sub-user privilege model re-verified against `subusersRoute.php`
|
||||
- [ ] File-server (`file_server.php`) path-traversal tests in CI
|
||||
- [ ] SCA in CI: `composer audit` and `npm audit --omit=dev` blocking
|
||||
high+ vulns
|
||||
- [ ] Mobile: `allowFileAccess=false`, mixed content disabled, JS interfaces
|
||||
removed
|
||||
- [ ] Secrets: no production keys in repo (`git log -S` audit)
|
||||
|
||||
This list is also the basis for re-test acceptance criteria.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions for management
|
||||
|
||||
1. Confirm total budget cap (recommend ≤ 220 000 DKK + 25 000 retainer).
|
||||
2. Confirm legal/procurement owner and contract template.
|
||||
3. Confirm whether to require a Danish-language final report (recommended).
|
||||
4. Confirm re-test budget is approved up-front, or per-finding.
|
||||
5. Confirm we are comfortable with the 12-day estimate, or want a lighter
|
||||
6-day "API + SPA only" first pass.
|
||||
|
||||
---
|
||||
|
||||
## 9. References
|
||||
|
||||
- OWASP ASVS 4.0 — https://owasp.org/www-project-application-security-verification-standard/
|
||||
- OWASP WSTG — https://owasp.org/www-project-web-security-testing-guide/
|
||||
- OWASP MASVS — https://mas.owasp.org/MASVS/
|
||||
- OWASP API Security Top 10 (2023) — https://owasp.org/API-Security/editions/2023/
|
||||
- Linear project: *UI Library & Pen Testing* (`acc087b4-b8ce-40c4-bbca-077fd93513a4`)
|
||||
|
||||
---
|
||||
|
||||
*This document is a planning artefact, not the test itself. Once approved, a
|
||||
separate SOW will be drafted with the selected vendor and linked from this
|
||||
issue.*
|
||||
@@ -7,5 +7,4 @@
|
||||
|
||||
<!-- AUTO-GENERATED, DO NOT EDIT -->
|
||||
<p>Comprehensive API reference generated from the repository root <code>openapi.yaml</code>.</p>
|
||||
<p>The edge broker's <code>/api/health</code> response additionally exposes a <code>lastActivityAt</code> field (ISO 8601 timestamp). It reports the most recent successful HTTP request handled by the broker container and defaults to the container's start time when no request has been processed yet.</p>
|
||||
</topic>
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
# XL Vask Selvvask surface — inventory & simplification plan
|
||||
|
||||
## Scope
|
||||
|
||||
The XLVask surface that powers the **Superuser → Fakturaer → Periode → Selvvask**
|
||||
view. Goal: remove the AI / MiniMax / autopilot pipeline, leaving only the
|
||||
operator-facing review and order-creation flow.
|
||||
|
||||
Out of scope: any other XLVask, plate scanner, customer, or vehicle surface.
|
||||
|
||||
## Files removed
|
||||
|
||||
| Path | Reason |
|
||||
| --- | --- |
|
||||
| `services/nginx/app/classes/xlvask_autopilot_service.php` | AI autopilot pipeline |
|
||||
| `services/nginx/app/classes/xlvask_automation_service.php` | AI automation pipeline |
|
||||
| `services/nginx/app/classes/xlvask_automation_policy_service.php` | AI policy service |
|
||||
| `services/nginx/app/classes/minimax.php` | MiniMax integration |
|
||||
| `services/nginx/app/modules/miniMax/` | MiniMax module (config + class) |
|
||||
| `services/nginx/app/modules/xlvask/AUTOMATION_RUNBOOK.md` | Runbook for removed pipeline |
|
||||
| `services/nginx/app/modules/xlvask/cron/tasks.php` | Module-owned cron registry (replaced by empty `cron_task_registry` discovery) |
|
||||
| `services/nginx/app/modules/xlvask/migrations/20260804_xlvask_ai_auto_policy_v2.php` | Migration for removed AI schema |
|
||||
| `services/nginx/app/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php` | Legacy autopilot gate |
|
||||
| `services/nginx/app/modules/xlvask/config/xlvask_automatic_order_creation_enabled_c.php` | Legacy autopilot gate |
|
||||
| `services/nginx/app/modules/xlvask/config/xlvask_minimax_integration_enabled_c.php` | MiniMax gate |
|
||||
| `services/nginx/app/modules/xlvask/config/xlvask_openai_integration_enabled_c.php` | OpenAI gate |
|
||||
| `services/nginx/app/cron/EnsureXLVaskAutomationSchema.php` | Migration helper |
|
||||
| `scripts/xlvask-automation-migrate.php` | CLI wrapper for migration |
|
||||
| `services/nginx/app/tests/Unit/XLVask/XLVaskAutomationMigrateScriptTest.php` | Removed migration test |
|
||||
| `services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php` | Removed automation test |
|
||||
| `services/nginx/app/tests/Api/XLVaskReviewApiTest.php` | Replaced by Selvvask route contract test |
|
||||
|
||||
## Code changes (kept & simplified)
|
||||
|
||||
| Path | Change |
|
||||
| --- | --- |
|
||||
| `services/nginx/app/cron/Cron.php` | Drop `ProcessXLVaskAutopilotQueueCron` registration + function |
|
||||
| `services/nginx/app/cli.php` | Drop `xlvask-automation-migrate` case |
|
||||
| `services/nginx/app/routes/moduleConfigRoute.php` | Drop `/minimax/config` GET/POST endpoints |
|
||||
| `services/nginx/app/routes/moduleXLVaskRoute.php` | Drop `/modules/xlvask/tasks/import-usage` 410 stub and `/tasks/debug` route |
|
||||
| `services/nginx/app/routes/xlvaskUsageLogsRoute.php` | Slim to operator-only: list, summary, ignore/unignore, accept, reject, fast-link |
|
||||
| `services/nginx/app/modules/xlvask/helpers/xlvask_tasks.php` | Drop `runScheduledAutomationIfReady`, `processAutopilotQueue`, autopilot cleanup, legacy auto-creation branch |
|
||||
| `services/nginx/app/modules/xlvask/xlvask_c.php` | Drop `minimax_integration_enabled`, `automatic_order_attachment_enabled`, `automatic_order_creation_enabled`, `openai_integration_enabled` |
|
||||
| `services/nginx/app/objects/xlvask_usage_logs_o.php` | Add `summarizeUsageOrdersReadOnly` (replaces autopilot summary) |
|
||||
| `services/nginx/app/openapi.yaml` | Replace autopilot/automation openapi block with operator-flow endpoints |
|
||||
| `services/nginx/app/tests/Unit/Cron/CronTaskRegistryTest.php` | Update count: 24 → 22, drop `xlvask.autopilot_queue` assertion |
|
||||
| `services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php` | Replaced with end-to-end contract assertions for the new operator surface |
|
||||
|
||||
## New operator-facing endpoints
|
||||
|
||||
All under `routes/xlvaskUsageLogsRoute.php` and scoped to the operator's
|
||||
`allowedHallIds` (all-scope users see every configured scanner hall; own-scope
|
||||
users see only their group's halls).
|
||||
|
||||
| Method | Path | Permission | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET` | `/modules/xlvask/services/usage/orders` | `list_xlvask_usage_orders_own/all` | List usage logs with direct linked order id, amount summary, ignored metadata |
|
||||
| `GET` | `/modules/xlvask/services/usage/orders/summary` | `list_xlvask_usage_orders_own/all` | Read-only per-period summary (counts + net amount) |
|
||||
| `PATCH` | `/modules/xlvask/services/usage/orders/{id}/ignore` | `review_xlvask_usage_order` | Mark ignored with reason |
|
||||
| `POST` | `/modules/xlvask/services/usage/orders/{id}/unignore` | `review_xlvask_usage_order` | Clear ignored metadata |
|
||||
| `POST` | `/modules/xlvask/services/usage/orders/{id}/accept` | `review_xlvask_usage_order` | Convert to order via `createOrderFromWash` |
|
||||
| `POST` | `/modules/xlvask/services/usage/orders/{id}/reject` | `review_xlvask_usage_order` | Mark ignored with reject reason |
|
||||
| `GET` | `/modules/xlvask/services/usage/orders/fast-link` | `list_xlvask_usage_orders_own` | Cached fast-link redeem (existing) |
|
||||
|
||||
## Permissions
|
||||
|
||||
The Selvvask surface uses these permissions only:
|
||||
|
||||
- `list_xlvask_usage_orders_own`
|
||||
- `list_xlvask_usage_orders_all`
|
||||
- `review_xlvask_usage_order`
|
||||
|
||||
`manage_xlvask_usage_automation`, `ignore_xlvask_usage_order`,
|
||||
`superuser_xlvask_automation_activate` are not referenced anywhere in the
|
||||
slimmed surface.
|
||||
|
||||
## Persistence model
|
||||
|
||||
`xlvask_usage_logs_o` already exposes `ignored_at`, `ignored_by`, `ignored_reason`
|
||||
columns — no migration required for the simplified flow.
|
||||
|
||||
`orders_o::selectByWashId(int|string $WashId)` and
|
||||
`orders_o::addXLVaskOrder(users_o $user, xlvask_usage_log $xlvask_usage_log)` are
|
||||
the only integration points with the order pipeline.
|
||||
|
||||
## Tests
|
||||
|
||||
- `vendor/bin/pest --testsuite=Unit --colors=never` passes 1266 tests.
|
||||
- One pre-existing failure (`BirdControlPlaneActivationTest`) requires
|
||||
`PLENO_REPO_ROOT_FOR_TESTS` (coolify repo) and is unrelated to this change.
|
||||
|
||||
## Repo scope
|
||||
|
||||
This inventory covers `api`. The `pleno-vue` side has not yet been updated in
|
||||
this session and will be handled in a follow-up PR.
|
||||
+2
-25
@@ -18548,13 +18548,11 @@ components:
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/InvoicingPeriodCustomer'
|
||||
- $ref: '#/components/schemas/InvoicingPeriodCustomerMembership'
|
||||
$ref: '#/components/schemas/InvoicingPeriodCustomer'
|
||||
|
||||
InvoicingPeriodCustomer:
|
||||
type: object
|
||||
required: [customer_number]
|
||||
required: [customer_number, customer_name, transactions, invoice_collections]
|
||||
additionalProperties: true
|
||||
properties:
|
||||
customer_number:
|
||||
@@ -18570,27 +18568,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/InvoicingPeriodInvoiceCollection'
|
||||
|
||||
InvoicingPeriodCustomerMembership:
|
||||
type: object
|
||||
description: >-
|
||||
Lightweight customer marker returned for every non-active view
|
||||
bucket of the period response. Used by the front-end to render
|
||||
category indicator chips (e.g. "Faktura pr. ordre") regardless of
|
||||
which tab the user is currently looking at. Full customer-card
|
||||
data (transactions, invoice collections, queue, draft, meta)
|
||||
is intentionally omitted for non-active buckets; see
|
||||
InvoicingPeriodCustomer for the shape returned for the active
|
||||
bucket.
|
||||
additionalProperties: false
|
||||
required: [customer_number, membership_only]
|
||||
properties:
|
||||
customer_number:
|
||||
type: integer
|
||||
minimum: 1
|
||||
membership_only:
|
||||
type: boolean
|
||||
enum: [true]
|
||||
|
||||
InvoicingPeriodTransaction:
|
||||
type: object
|
||||
required: [id, booked, invoice_state]
|
||||
|
||||
@@ -405,10 +405,6 @@ def render_api_reference_topic() -> str:
|
||||
' title="API Reference" id="API-Reference">\n'
|
||||
f"\n <!-- {AUTOGEN_NOTE} -->\n"
|
||||
" <p>Comprehensive API reference generated from the repository root <code>openapi.yaml</code>.</p>\n"
|
||||
" <p>The edge broker's <code>/api/health</code> response additionally exposes a "
|
||||
"<code>lastActivityAt</code> field (ISO 8601 timestamp). It reports the most recent "
|
||||
"successful HTTP request handled by the broker container and defaults to the container's "
|
||||
"start time when no request has been processed yet.</p>\n"
|
||||
"</topic>\n"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Pre-deploy schema bootstrap runner.
|
||||
*
|
||||
* Loads and runs every `*_schema_bootstrap` class so the production
|
||||
* database has all the columns the current code expects. Each
|
||||
* bootstrap is additive and idempotent — safe to run on every deploy.
|
||||
*
|
||||
* Run via:
|
||||
* php scripts/run-schema-bootstraps.php
|
||||
*
|
||||
* Used in .github/workflows/deploy.yml as a pre-deploy step.
|
||||
*
|
||||
* When you add a new *_schema_bootstrap class, you don't need to
|
||||
* edit this file — the runner auto-discovers any class whose name
|
||||
* ends in `_schema_bootstrap`.
|
||||
*/
|
||||
|
||||
namespace scripts;
|
||||
|
||||
// Load the app entry point so $db is wired up the same way as in
|
||||
// normal request handling.
|
||||
$index = __DIR__ . '/../services/nginx/app/index.php';
|
||||
if (!file_exists($index)) {
|
||||
fwrite(STDERR, "Cannot find app entry point at {$index}\n");
|
||||
exit(2);
|
||||
}
|
||||
require_once $index;
|
||||
|
||||
$classesDir = __DIR__ . '/../services/nginx/app/classes';
|
||||
$bootstraps = glob($classesDir . '/*_schema_bootstrap.php');
|
||||
if (!$bootstraps) {
|
||||
fwrite(STDERR, "No *_schema_bootstrap.php files found in {$classesDir}\n");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$ran = 0;
|
||||
$skipped = 0;
|
||||
foreach ($bootstraps as $file) {
|
||||
require_once $file;
|
||||
$base = basename($file, '.php');
|
||||
$class = "classes\\{$base}";
|
||||
if (!class_exists($class)) {
|
||||
fwrite(STDERR, " [skip] {$base}: class not found\n");
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
if (!method_exists($class, 'ensureSchema')) {
|
||||
fwrite(STDERR, " [skip] {$base}: no ensureSchema() method\n");
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$class::ensureSchema();
|
||||
echo " [ok] {$base}\n";
|
||||
$ran++;
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, " [FAIL] {$base}: " . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
echo "Schema bootstraps complete: {$ran} ran, {$skipped} skipped.\n";
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Schema health check — verifies all required DB columns exist.
|
||||
*
|
||||
* Run via:
|
||||
* GET /api/admin/schema-check (returns JSON report)
|
||||
* php scripts/schema-health-check.php (CLI, exits 0/1)
|
||||
*
|
||||
* Lists the columns that the code expects to find in each critical
|
||||
* table. If a column is missing, the response is 503 (HTTP) or
|
||||
* exit code 1 (CLI) — clearly distinct from a generic 500.
|
||||
*
|
||||
* Add to the list when introducing a new optional column.
|
||||
*/
|
||||
|
||||
namespace scripts;
|
||||
|
||||
require_once __DIR__ . '/../services/nginx/app/classes/customer_invoice_email_schema_bootstrap.php';
|
||||
|
||||
use classes\customer_invoice_email_schema_bootstrap;
|
||||
|
||||
const SCHEMA_REQUIREMENTS = [
|
||||
'users' => [
|
||||
'invoice_email', // TRU-77 (added 2026-08-16)
|
||||
'wash_certificate_email',
|
||||
'email',
|
||||
'customer_number',
|
||||
],
|
||||
'invoices' => [
|
||||
'po_number',
|
||||
'closed_at',
|
||||
'customer_number',
|
||||
],
|
||||
'bookings' => [
|
||||
'id',
|
||||
'customer_number',
|
||||
'department',
|
||||
],
|
||||
];
|
||||
|
||||
function check_schema(): array
|
||||
{
|
||||
global $db;
|
||||
$report = [
|
||||
'ok' => true,
|
||||
'missing' => [],
|
||||
'tables_checked' => 0,
|
||||
'columns_checked' => 0,
|
||||
'timestamp' => date('c'),
|
||||
];
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
$report['ok'] = false;
|
||||
$report['error'] = 'no_db_connection';
|
||||
return $report;
|
||||
}
|
||||
|
||||
// First: run the schema bootstrap (additive, idempotent) so we
|
||||
// give the DB a chance to self-heal.
|
||||
if (class_exists(customer_invoice_email_schema_bootstrap::class)) {
|
||||
customer_invoice_email_schema_bootstrap::ensureSchema();
|
||||
}
|
||||
|
||||
foreach (SCHEMA_REQUIREMENTS as $table => $columns) {
|
||||
$report['tables_checked']++;
|
||||
|
||||
// Confirm the table itself exists
|
||||
$tableSafe = str_replace('`', '', $table);
|
||||
$result = $db->query("SHOW TABLES LIKE '{$tableSafe}'");
|
||||
if (!$result || (int)$result->num_rows === 0) {
|
||||
$report['ok'] = false;
|
||||
$report['missing'][] = "table `{$table}` does not exist";
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($columns as $column) {
|
||||
$report['columns_checked']++;
|
||||
$colSafe = str_replace("'", '', $column);
|
||||
$r = $db->query("SHOW COLUMNS FROM `{$tableSafe}` LIKE '{$colSafe}'");
|
||||
if (!$r || (int)$r->num_rows === 0) {
|
||||
$report['ok'] = false;
|
||||
$report['missing'][] = "{$table}.{$column}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
// CLI mode
|
||||
if (PHP_SAPI === 'cli') {
|
||||
$report = check_schema();
|
||||
echo json_encode($report, JSON_PRETTY_PRINT) . "\n";
|
||||
exit($report['ok'] ? 0 : 1);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
#!/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,222 +0,0 @@
|
||||
#!/usr/bin/env php8.4
|
||||
<?php
|
||||
/**
|
||||
* Live verification of e-conomic draft invoice creation using customer 12345679.
|
||||
*
|
||||
* This script:
|
||||
* 1. Connects to the real e-conomic API (requires env credentials)
|
||||
* 2. Creates a draft invoice for customer 12345679 with TEST items
|
||||
* 3. Verifies the draft was created correctly
|
||||
* 4. DELETES the draft to clean up
|
||||
*
|
||||
* Usage (on production server with credentials):
|
||||
* php8.4 verify-economic-drafts-live.php
|
||||
*
|
||||
* Required env vars (set in .env or pass inline):
|
||||
* ECONOMIC_API_APP_ACCESS_GRANT
|
||||
* ECONOMIC_API_APP_SECRET_TOKEN
|
||||
*
|
||||
* Optional:
|
||||
* ECONOMIC_CUSTOMER_NUMBER=12345679 (default)
|
||||
* ECONOMIC_API_BASE_URL=... (default: https://restapi.e-conomic.com)
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 = all verifications passed, draft cleaned up
|
||||
* 1 = error during verification
|
||||
* 2 = cleanup failed (draft still exists, manual intervention required)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// 1. Load credentials
|
||||
$grant = getenv('ECONOMIC_API_APP_ACCESS_GRANT');
|
||||
$secret = getenv('ECONOMIC_API_APP_SECRET_TOKEN');
|
||||
$customer = (int)(getenv('ECONOMIC_CUSTOMER_NUMBER') ?: '12345679');
|
||||
$baseUrl = getenv('ECONOMIC_API_BASE_URL') ?: 'https://restapi.e-conomic.com';
|
||||
|
||||
if (!$grant || !$secret) {
|
||||
fwrite(STDERR, "ERROR: ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN must be set\n");
|
||||
fwrite(STDERR, " This script must be run on the production server or in CI with secrets.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$auth = 'X-AppSecretToken: ' . $secret . "\r\n" . 'Authorization: Bearer ' . $grant . "\r\n";
|
||||
|
||||
/**
|
||||
* Send a request to the e-conomic API.
|
||||
*
|
||||
* @return array{status: int, body: string, json?: array}
|
||||
*/
|
||||
function econ_request(string $method, string $url, ?array $body = null): array
|
||||
{
|
||||
global $auth;
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
trim(explode("\r\n", $auth)[0]),
|
||||
trim(explode("\r\n", $auth)[1]),
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false) {
|
||||
return ['status' => 0, 'body' => $error];
|
||||
}
|
||||
$json = json_decode($response, true);
|
||||
return ['status' => $status, 'body' => $response, 'json' => $json];
|
||||
}
|
||||
|
||||
$draftInvoiceNumber = null;
|
||||
$pass = 0;
|
||||
$fail = 0;
|
||||
$total = 0;
|
||||
|
||||
function check(string $name, bool $ok, string $detail = ''): void
|
||||
{
|
||||
global $pass, $fail, $total;
|
||||
$total++;
|
||||
if ($ok) {
|
||||
$pass++;
|
||||
echo " ✅ $name\n";
|
||||
if ($detail) echo " $detail\n";
|
||||
} else {
|
||||
$fail++;
|
||||
echo " ❌ $name\n";
|
||||
if ($detail) echo " $detail\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "=== E-conomic Live Draft Verification ===\n";
|
||||
echo "Customer: $customer\n";
|
||||
echo "API base: $baseUrl\n\n";
|
||||
|
||||
try {
|
||||
// ------------------------------------------------------------------
|
||||
// Step 1: Verify customer exists
|
||||
// ------------------------------------------------------------------
|
||||
echo "Step 1: Verify customer $customer exists...\n";
|
||||
$resp = econ_request('GET', "$baseUrl/customers/$customer");
|
||||
check('Customer exists', $resp['status'] === 200, "HTTP {$resp['status']}");
|
||||
|
||||
if ($resp['status'] !== 200) {
|
||||
echo "Cannot proceed without valid customer. Body: " . substr($resp['body'], 0, 200) . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$customerName = $resp['json']['name'] ?? 'unknown';
|
||||
echo " Customer name: $customerName\n\n";
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 2: Create draft invoice
|
||||
// ------------------------------------------------------------------
|
||||
echo "Step 2: Create draft invoice for customer $customer...\n";
|
||||
$resp = econ_request('POST', "$baseUrl/invoices/drafts", [
|
||||
'currency' => 'DKK',
|
||||
'customer' => ['customerNumber' => $customer],
|
||||
'paymentTerms' => ['paymentTermsNumber' => 1],
|
||||
'layout' => ['layoutNumber' => 1],
|
||||
'recipient' => ['name' => 'OpenClaw Live Verification'],
|
||||
'notes' => ['heading' => 'Live verification', 'textLine1' => 'Created by verify-economic-drafts-live.php', 'textLine2' => 'Will be deleted automatically'],
|
||||
]);
|
||||
check('Draft invoice created', $resp['status'] === 201, "HTTP {$resp['status']}");
|
||||
|
||||
if ($resp['status'] !== 201) {
|
||||
echo "Cannot create draft. Body: " . substr($resp['body'], 0, 300) . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$draftInvoiceNumber = $resp['json']['draftInvoiceNumber'] ?? null;
|
||||
echo " Draft invoice number: $draftInvoiceNumber\n\n";
|
||||
|
||||
if (!$draftInvoiceNumber) {
|
||||
echo "No draftInvoiceNumber returned. Body: " . substr($resp['body'], 0, 300) . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 3: Add test lines to draft
|
||||
// ------------------------------------------------------------------
|
||||
echo "Step 3: Add 2 product lines (1 with discount, 1 without)...\n";
|
||||
$lines = [
|
||||
[
|
||||
'product' => ['productNumber' => 'OPENCLAW-TEST-01'],
|
||||
'quantity' => 1.0,
|
||||
'unitNetPrice' => 100.00,
|
||||
'discountPercentage' => 0.0,
|
||||
'description' => 'Test line 1: no discount (verify-economic-drafts-live.php)',
|
||||
],
|
||||
[
|
||||
'product' => ['productNumber' => 'OPENCLAW-TEST-02'],
|
||||
'quantity' => 2.0,
|
||||
'unitNetPrice' => 200.00,
|
||||
'discountPercentage' => 15.0,
|
||||
'description' => 'Test line 2: 15% discount (verify-economic-drafts-live.php)',
|
||||
],
|
||||
];
|
||||
$resp = econ_request('POST', "$baseUrl/invoices/drafts/$draftInvoiceNumber/lines", [
|
||||
'lines' => $lines,
|
||||
]);
|
||||
check('Lines added to draft', $resp['status'] === 200, "HTTP {$resp['status']}, " . count($lines) . " lines");
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 4: Verify draft contents
|
||||
// ------------------------------------------------------------------
|
||||
echo "\nStep 4: Verify draft contents...\n";
|
||||
$resp = econ_request('GET', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
|
||||
$draft = $resp['json'] ?? [];
|
||||
$draftLines = $draft['lines'] ?? [];
|
||||
|
||||
check('Draft has 2 lines', count($draftLines) === 2, 'found ' . count($draftLines));
|
||||
check('Customer is 12345679', ($draft['customer']['customerNumber'] ?? 0) === $customer);
|
||||
check('Line 1 has 0% discount', abs(($draftLines[0]['discountPercentage'] ?? -1)) < 0.01);
|
||||
check('Line 2 has 15% discount', abs(($draftLines[1]['discountPercentage'] ?? -1) - 15.0) < 0.01);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 5: Cleanup - delete the draft
|
||||
// ------------------------------------------------------------------
|
||||
echo "\nStep 5: Cleanup - delete draft $draftInvoiceNumber...\n";
|
||||
$resp = econ_request('DELETE', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
|
||||
check('Draft deleted', $resp['status'] === 204 || $resp['status'] === 200, "HTTP {$resp['status']}");
|
||||
|
||||
if ($resp['status'] !== 204 && $resp['status'] !== 200) {
|
||||
echo "\n⚠️ WARNING: Cleanup failed. Draft $draftInvoiceNumber still exists in e-conomic.\n";
|
||||
echo " Delete it manually: curl -X DELETE -H \"$auth\" $baseUrl/invoices/drafts/$draftInvoiceNumber\n";
|
||||
exit(2);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 6: Verify deletion
|
||||
// ------------------------------------------------------------------
|
||||
echo "\nStep 6: Verify draft is gone...\n";
|
||||
$resp = econ_request('GET', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
|
||||
check('Draft no longer exists', $resp['status'] === 404, "HTTP {$resp['status']} (expected 404)");
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
echo "\n💥 UNCAUGHT ERROR: " . $e->getMessage() . "\n";
|
||||
echo "Stack trace:\n" . $e->getTraceAsString() . "\n";
|
||||
|
||||
// Best-effort cleanup
|
||||
if ($draftInvoiceNumber !== null) {
|
||||
echo "\nAttempting emergency cleanup of draft $draftInvoiceNumber...\n";
|
||||
$resp = econ_request('DELETE', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
|
||||
echo " Cleanup HTTP status: {$resp['status']}\n";
|
||||
if ($resp['status'] !== 204 && $resp['status'] !== 200) {
|
||||
echo " ⚠️ MANUAL CLEANUP REQUIRED: DELETE $baseUrl/invoices/drafts/$draftInvoiceNumber\n";
|
||||
exit(2);
|
||||
}
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "\n=== Summary: $pass/$total checks passed ===\n";
|
||||
exit($fail === 0 ? 0 : 1);
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* XL Vask automation schema migration script.
|
||||
*
|
||||
* Mirrors the scripts/account-deletion-schema.php and
|
||||
* scripts/bird-control-plane-schema.php patterns so ops can run an explicit,
|
||||
* non-cron, non-HTTP migration from the API container.
|
||||
*
|
||||
* Usage (from the api repo root, against the configured DB):
|
||||
* php scripts/xlvask-automation-migrate.php check
|
||||
* php scripts/xlvask-automation-migrate.php apply --yes
|
||||
*
|
||||
* "check" never mutates state and always exits 0 when ready / 1 when not.
|
||||
* "apply" requires an explicit --yes flag before calling the gated
|
||||
* migration_20260804_xlvask_ai_auto_policy_v2::apply() entry point, which
|
||||
* itself is operator-only by design (see AUTOMATION_RUNBOOK §2).
|
||||
*/
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
fwrite(STDERR, "This command is CLI-only.\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
const WD = __DIR__ . '/../services/nginx/app';
|
||||
require_once WD . '/vendor/autoload.php';
|
||||
require_once WD . '/config.php';
|
||||
require_once WD . '/classes/db.php';
|
||||
require_once WD . '/classes/xlvask_usage_logs_schema_bootstrap.php';
|
||||
require_once WD . '/modules/xlvask/migrations/20260804_xlvask_ai_auto_policy_v2.php';
|
||||
|
||||
$command = $argv[1] ?? 'check';
|
||||
|
||||
if (!in_array($command, ['check', 'apply'], true)) {
|
||||
fwrite(STDERR, "Usage: scripts/xlvask-automation-migrate.php check|apply --yes\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$db = new \classes\db($CONFIG_DB);
|
||||
$db->connect();
|
||||
|
||||
if ($command === 'apply') {
|
||||
if (($argv[2] ?? '') !== '--yes') {
|
||||
fwrite(STDERR, "Refusing schema mutation without: apply --yes\n");
|
||||
exit(2);
|
||||
}
|
||||
$status = \classes\xlvask_usage_logs_schema_bootstrap::applyExplicitMigration();
|
||||
} else {
|
||||
$status = \classes\xlvask_usage_logs_schema_bootstrap::migrationStatus();
|
||||
}
|
||||
|
||||
fwrite(STDOUT, json_encode($status, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL);
|
||||
exit((bool)($status['ready'] ?? false) ? 0 : 1);
|
||||
@@ -189,8 +189,6 @@ export function createBrokerServer(options = {}) {
|
||||
const browserStreamSessions = new Map();
|
||||
const gatewayStreamSessions = new Map();
|
||||
const inflightGatewaySyncs = new Map();
|
||||
const containerStartedAt = currentTimestamp();
|
||||
let lastActivityAt = containerStartedAt;
|
||||
|
||||
const managerRequest = async (path, body = {}, method = "POST") => {
|
||||
if (!managerUrl) {
|
||||
@@ -482,7 +480,6 @@ export function createBrokerServer(options = {}) {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
lastActivityAt = currentTimestamp();
|
||||
if (req.method === "GET" && url.pathname === "/api/health") {
|
||||
jsonResponse(res, 200, {
|
||||
ok: true,
|
||||
@@ -491,7 +488,6 @@ export function createBrokerServer(options = {}) {
|
||||
manager_url_configured: Boolean(managerUrl),
|
||||
shared_secret_configured: Boolean(sharedSecret),
|
||||
agents_connected: agents.size,
|
||||
lastActivityAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1087,10 +1083,6 @@ export function createBrokerServer(options = {}) {
|
||||
pendingCommands,
|
||||
managerUrl,
|
||||
authMode,
|
||||
containerStartedAt,
|
||||
get lastActivityAt() {
|
||||
return lastActivityAt;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -219,10 +219,6 @@ test("broker exposes health and shared-secret diagnostics", async () => {
|
||||
assert.equal(healthJson.auth_mode, "manager");
|
||||
assert.equal(healthJson.manager_url_configured, true);
|
||||
assert.equal(healthJson.shared_secret_configured, true);
|
||||
assert.equal(typeof healthJson.lastActivityAt, "string");
|
||||
assert.match(healthJson.lastActivityAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
assert.ok(healthJson.lastActivityAt >= broker.state.containerStartedAt);
|
||||
assert.equal(healthJson.lastActivityAt, broker.state.lastActivityAt);
|
||||
|
||||
const invalidSecretResponse = await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
|
||||
method: "POST",
|
||||
@@ -251,41 +247,6 @@ test("broker exposes health and shared-secret diagnostics", async () => {
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker updates lastActivityAt after each successful request", async () => {
|
||||
const broker = createBrokerServer({ authMode: "manager", sharedSecret: "secret", managerUrl: "http://manager.test" });
|
||||
const address = await broker.listen(0);
|
||||
const port = address.port;
|
||||
|
||||
assert.equal(broker.state.lastActivityAt, broker.state.containerStartedAt);
|
||||
|
||||
const firstResponse = await fetch(`http://127.0.0.1:${port}/api/health`);
|
||||
const firstJson = await firstResponse.json();
|
||||
const firstActivityAt = broker.state.lastActivityAt;
|
||||
|
||||
assert.equal(typeof firstJson.lastActivityAt, "string");
|
||||
assert.equal(firstJson.lastActivityAt, firstActivityAt);
|
||||
assert.ok(firstActivityAt >= broker.state.containerStartedAt);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-edge-broker-secret": "secret",
|
||||
},
|
||||
});
|
||||
|
||||
assert.notEqual(broker.state.lastActivityAt, firstActivityAt);
|
||||
assert.ok(broker.state.lastActivityAt > firstActivityAt);
|
||||
|
||||
const secondResponse = await fetch(`http://127.0.0.1:${port}/api/health`);
|
||||
const secondJson = await secondResponse.json();
|
||||
|
||||
assert.equal(secondJson.lastActivityAt, broker.state.lastActivityAt);
|
||||
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker bridges browser shell sessions through the connected agent", async () => {
|
||||
const closedSessions = [];
|
||||
const broker = createBrokerServer({
|
||||
|
||||
@@ -7,14 +7,9 @@ use objects\passkeys_o;
|
||||
use objects\subusers_o;
|
||||
use objects\users_o;
|
||||
use Throwable;
|
||||
use traits\boolean_normalization_t;
|
||||
|
||||
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
|
||||
|
||||
class account_deletion_service
|
||||
{
|
||||
use boolean_normalization_t;
|
||||
|
||||
public const CONFIRMATION_PHRASE = 'SLET MIN KONTO';
|
||||
public const POLICY_VERSION = '2026-07-20';
|
||||
public const MAX_RETRIES = 5;
|
||||
@@ -57,7 +52,7 @@ class account_deletion_service
|
||||
$result = $db->query("SELECT value FROM module_config WHERE module = '$module' AND variable = '$variable' LIMIT 1");
|
||||
if ($result === false || $result->num_rows === 0) return false;
|
||||
$row = $result->fetch_assoc();
|
||||
return self::normalizeBoolean((string)($row['value'] ?? ''));
|
||||
return in_array(strtolower(trim((string)($row['value'] ?? ''))), ['1', 'true', 'yes', 'on'], true);
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Static utility for generating, formatting, hashing, and parsing
|
||||
* API keys.
|
||||
*
|
||||
* Key format: <prefix>_<env>_<22-char-base62>.<32-char-base62-secret>
|
||||
* e.g. truck_live_aBcD1234XyZ5678mnOpQrSt.uVwXyZ0123456789aBcDeFgHiJkLmN
|
||||
*
|
||||
* The key_id (everything before the dot) is stored in plain text in
|
||||
* the database as the lookup key. The secret is NEVER stored in plain
|
||||
* text — only the argon2id hash is persisted. The full key is shown
|
||||
* to the user exactly once at creation time.
|
||||
*/
|
||||
class api_key_generator
|
||||
{
|
||||
/** Base62 alphabet (0-9, A-Z, a-z). Avoids + / = of base64. */
|
||||
public const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||
|
||||
/** Characters permitted in the public key_id portion. */
|
||||
public const KEY_ID_RANDOM_LENGTH = 22;
|
||||
|
||||
/** Characters in the secret portion. */
|
||||
public const SECRET_LENGTH = 32;
|
||||
|
||||
/**
|
||||
* Build the public key_id portion: <prefix>_<env>_<random>.
|
||||
*/
|
||||
public static function generateKeyId(string $env = 'live'): string
|
||||
{
|
||||
$env = self::normaliseEnv($env);
|
||||
$prefix = self::prefix();
|
||||
$random = self::randomBase62(self::KEY_ID_RANDOM_LENGTH);
|
||||
return $prefix . '_' . $env . '_' . $random;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the secret portion (32-char base62).
|
||||
*/
|
||||
public static function generateSecret(): string
|
||||
{
|
||||
return self::randomBase62(self::SECRET_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Join key_id and secret with a single dot.
|
||||
*/
|
||||
public static function formatKey(string $keyId, string $secret): string
|
||||
{
|
||||
if ($keyId === '' || strpos($keyId, '.') !== false) {
|
||||
throw new \InvalidArgumentException('key_id must not contain a dot');
|
||||
}
|
||||
if ($secret === '' || strpos($secret, '.') !== false) {
|
||||
throw new \InvalidArgumentException('secret must not contain a dot');
|
||||
}
|
||||
return $keyId . '.' . $secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash the full key (or just the secret) using argon2id.
|
||||
*/
|
||||
public static function hash(string $plain): string
|
||||
{
|
||||
if ($plain === '') {
|
||||
throw new \InvalidArgumentException('Cannot hash an empty value');
|
||||
}
|
||||
$hash = password_hash($plain, PASSWORD_ARGON2ID);
|
||||
if ($hash === false) {
|
||||
throw new \RuntimeException('Failed to hash with argon2id');
|
||||
}
|
||||
return $hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a plaintext key against a stored argon2id hash.
|
||||
*/
|
||||
public static function verify(string $plain, string $hash): bool
|
||||
{
|
||||
if ($plain === '' || $hash === '') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return password_verify($plain, $hash);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a full "key_id.secret" string back into its parts.
|
||||
*
|
||||
* The key_id may contain underscores (as separators between
|
||||
* prefix/env/random) and must be base62 + underscores. The
|
||||
* secret must be strictly base62 with no separators.
|
||||
*
|
||||
* @return array{key_id:string, secret:string}|null
|
||||
* null if the input is malformed.
|
||||
*/
|
||||
public static function parseKey(string $full): ?array
|
||||
{
|
||||
$full = trim($full);
|
||||
if ($full === '' || strpos($full, '.') === false) {
|
||||
return null;
|
||||
}
|
||||
// Split on the FIRST dot only — secrets are base62 and contain
|
||||
// no dots, so there's exactly one separator.
|
||||
$parts = explode('.', $full, 2);
|
||||
if (count($parts) !== 2) {
|
||||
return null;
|
||||
}
|
||||
[$keyId, $secret] = $parts;
|
||||
$keyId = trim($keyId);
|
||||
$secret = trim($secret);
|
||||
if ($keyId === '' || $secret === '') {
|
||||
return null;
|
||||
}
|
||||
// The key_id is "<prefix>_<env>_<random>" — base62 with
|
||||
// underscore separators. The secret is pure base62.
|
||||
if (!self::isKeyId($keyId) || !self::isBase62($secret)) {
|
||||
return null;
|
||||
}
|
||||
return ['key_id' => $keyId, 'secret' => $secret];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a key_id string: base62 with optional underscore
|
||||
* separators. Exposed for testing.
|
||||
*/
|
||||
public static function isKeyId(string $value): bool
|
||||
{
|
||||
if ($value === '') {
|
||||
return false;
|
||||
}
|
||||
return preg_match('/^[0-9A-Za-z_]+$/', $value) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configurable prefix (default: "truck"). Reads from
|
||||
* `config('api_key.prefix', 'truck')` if available, otherwise the
|
||||
* default. Always lowercased and stripped of separators.
|
||||
*/
|
||||
public static function prefix(): string
|
||||
{
|
||||
$default = 'truck';
|
||||
$value = $default;
|
||||
if (function_exists('config')) {
|
||||
try {
|
||||
$candidate = config('api_key.prefix', $default);
|
||||
if (is_string($candidate) && $candidate !== '') {
|
||||
$value = $candidate;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$value = $default;
|
||||
}
|
||||
}
|
||||
$value = strtolower(trim((string)$value));
|
||||
$value = preg_replace('/[^a-z0-9_]/', '', $value) ?? '';
|
||||
if ($value === '') {
|
||||
$value = $default;
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal — exposed for testing.
|
||||
*/
|
||||
public static function randomBase62(int $length): string
|
||||
{
|
||||
if ($length < 1) {
|
||||
throw new \InvalidArgumentException('Length must be positive');
|
||||
}
|
||||
|
||||
$alphabet = self::ALPHABET;
|
||||
$alphabetMax = strlen($alphabet) - 1; // 61
|
||||
|
||||
$out = '';
|
||||
$bytesNeeded = (int)ceil($length * 1.3) + 8;
|
||||
$bytes = random_bytes($bytesNeeded);
|
||||
$byteIndex = 0;
|
||||
|
||||
while (strlen($out) < $length) {
|
||||
if (!isset($bytes[$byteIndex])) {
|
||||
$bytes = random_bytes($bytesNeeded);
|
||||
$byteIndex = 0;
|
||||
}
|
||||
// Mask off 0xC0 to get a value 0-63, then reject > 61 to
|
||||
// avoid modulo bias.
|
||||
$byte = ord($bytes[$byteIndex]);
|
||||
$byteIndex++;
|
||||
$value = $byte & 0x3F;
|
||||
if ($value > $alphabetMax) {
|
||||
continue;
|
||||
}
|
||||
$out .= $alphabet[$value];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal — exposed for testing.
|
||||
*/
|
||||
public static function isBase62(string $value): bool
|
||||
{
|
||||
if ($value === '') {
|
||||
return false;
|
||||
}
|
||||
return preg_match('/^[0-9A-Za-z]+$/', $value) === 1;
|
||||
}
|
||||
|
||||
private static function normaliseEnv(string $env): string
|
||||
{
|
||||
$trimmed = strtolower(trim($env));
|
||||
$sanitised = preg_replace('/[^a-z0-9_-]/', '', $trimmed) ?? '';
|
||||
// If the input contained characters outside the allowed
|
||||
// set, the sanitised result will differ from the trimmed
|
||||
// input — in that case fall back to "live" rather than
|
||||
// echoing a mangled version. Empty / whitespace-only input
|
||||
// also falls back to "live".
|
||||
if ($sanitised === '' || $sanitised !== $trimmed) {
|
||||
return 'live';
|
||||
}
|
||||
return $sanitised;
|
||||
}
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use Exception;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Repository for the `api_keys` table.
|
||||
*
|
||||
* This is a thin procedural wrapper that uses the project's existing
|
||||
* `$db` global (mysqli) — no Eloquent, no ORM. The pattern matches
|
||||
* other repositories in this codebase (see `classes/orders_o.php`,
|
||||
* `classes/invoice_store.php`, etc.).
|
||||
*
|
||||
* Records are returned as associative arrays. The caller is expected
|
||||
* to interact with them as plain dicts; there is no dedicated model
|
||||
* class for api keys.
|
||||
*/
|
||||
class api_key_repository
|
||||
{
|
||||
public const TABLE = 'api_keys';
|
||||
|
||||
private static function db()
|
||||
{
|
||||
global $db;
|
||||
if (!isset($db) || !is_object($db)) {
|
||||
throw new Exception('Database connection ($db) is not available');
|
||||
}
|
||||
// Lazy-create the table on first use so callers don't have to
|
||||
// remember to call ensureTables().
|
||||
if (class_exists(api_key_schema_bootstrap::class)) {
|
||||
api_key_schema_bootstrap::ensureTables();
|
||||
}
|
||||
return $db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the input data for create(). Exposed so test doubles
|
||||
* can exercise the same validation without touching a real DB.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function validate(array $data): void
|
||||
{
|
||||
$required = ['key_id', 'key_hash', 'name', 'role'];
|
||||
foreach ($required as $field) {
|
||||
if (!isset($data[$field]) || !is_string($data[$field]) || $data[$field] === '') {
|
||||
throw new \InvalidArgumentException("Missing required field: {$field}");
|
||||
}
|
||||
}
|
||||
$allowedRoles = ['superuser', 'admin', 'customer', 'subuser'];
|
||||
if (!in_array($data['role'], $allowedRoles, true)) {
|
||||
throw new \InvalidArgumentException("Invalid role: {$data['role']}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return int inserted id
|
||||
*/
|
||||
public static function create(array $data): int
|
||||
{
|
||||
self::validate($data);
|
||||
|
||||
$db = self::db();
|
||||
$scopesJson = isset($data['scopes']) && $data['scopes'] !== null
|
||||
? (is_string($data['scopes']) ? $data['scopes'] : json_encode($data['scopes'], JSON_UNESCAPED_SLASHES))
|
||||
: null;
|
||||
|
||||
$stmt = $db->conn()->prepare(
|
||||
'INSERT INTO api_keys (key_id, key_hash, name, role, scopes, customer_id, created_by, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
if ($stmt === false) {
|
||||
throw new Exception('Failed to prepare insert: ' . $db->conn()->error);
|
||||
}
|
||||
|
||||
$customerId = isset($data['customer_id']) ? (int)$data['customer_id'] : null;
|
||||
$createdBy = isset($data['created_by']) ? (int)$data['created_by'] : null;
|
||||
$expiresAt = isset($data['expires_at']) && $data['expires_at'] !== null
|
||||
? (string)$data['expires_at']
|
||||
: null;
|
||||
|
||||
$stmt->bind_param(
|
||||
'sssssiss',
|
||||
$data['key_id'],
|
||||
$data['key_hash'],
|
||||
$data['name'],
|
||||
$data['role'],
|
||||
$scopesJson,
|
||||
$customerId,
|
||||
$createdBy,
|
||||
$expiresAt
|
||||
);
|
||||
|
||||
if (!$stmt->execute()) {
|
||||
$err = $stmt->error;
|
||||
$stmt->close();
|
||||
throw new Exception('Failed to insert api_key: ' . $err);
|
||||
}
|
||||
$id = $stmt->insert_id;
|
||||
$stmt->close();
|
||||
return (int)$id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a non-revoked key by its public key_id.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function findActiveByKeyId(string $keyId): ?array
|
||||
{
|
||||
if ($keyId === '') {
|
||||
return null;
|
||||
}
|
||||
$db = self::db();
|
||||
$stmt = $db->conn()->prepare(
|
||||
'SELECT * FROM api_keys WHERE key_id = ? AND revoked_at IS NULL LIMIT 1'
|
||||
);
|
||||
if ($stmt === false) {
|
||||
throw new Exception('Failed to prepare select: ' . $db->conn()->error);
|
||||
}
|
||||
$stmt->bind_param('s', $keyId);
|
||||
if (!$stmt->execute()) {
|
||||
$err = $stmt->error;
|
||||
$stmt->close();
|
||||
throw new Exception('Failed to execute select: ' . $err);
|
||||
}
|
||||
$result = $stmt->get_result();
|
||||
$row = $result ? $result->fetch_assoc() : null;
|
||||
$stmt->close();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find any key by id (including revoked).
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function findById(int $id): ?array
|
||||
{
|
||||
$db = self::db();
|
||||
$stmt = $db->conn()->prepare('SELECT * FROM api_keys WHERE id = ? LIMIT 1');
|
||||
if ($stmt === false) {
|
||||
throw new Exception('Failed to prepare select: ' . $db->conn()->error);
|
||||
}
|
||||
$stmt->bind_param('i', $id);
|
||||
if (!$stmt->execute()) {
|
||||
$err = $stmt->error;
|
||||
$stmt->close();
|
||||
throw new Exception('Failed to execute select: ' . $err);
|
||||
}
|
||||
$result = $stmt->get_result();
|
||||
$row = $result ? $result->fetch_assoc() : null;
|
||||
$stmt->close();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a key (sets revoked_at = NOW()). Returns true on success.
|
||||
*/
|
||||
public static function revoke(int $id): bool
|
||||
{
|
||||
$db = self::db();
|
||||
$stmt = $db->conn()->prepare(
|
||||
'UPDATE api_keys SET revoked_at = CURRENT_TIMESTAMP WHERE id = ? AND revoked_at IS NULL'
|
||||
);
|
||||
if ($stmt === false) {
|
||||
throw new Exception('Failed to prepare revoke: ' . $db->conn()->error);
|
||||
}
|
||||
$stmt->bind_param('i', $id);
|
||||
$ok = $stmt->execute();
|
||||
$affected = $stmt->affected_rows;
|
||||
$stmt->close();
|
||||
return $ok && $affected > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bump last_used_at for a key. Best-effort: failures are swallowed
|
||||
* because this is a hot-path observability hook and must not
|
||||
* break the request.
|
||||
*/
|
||||
public static function touchLastUsed(int $id): void
|
||||
{
|
||||
try {
|
||||
$db = self::db();
|
||||
$stmt = $db->conn()->prepare(
|
||||
'UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?'
|
||||
);
|
||||
if ($stmt === false) {
|
||||
return;
|
||||
}
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
} catch (Throwable) {
|
||||
// intentionally ignored
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List keys for a customer, newest first.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function listForCustomer(int $customerId, bool $includeRevoked = false): array
|
||||
{
|
||||
$db = self::db();
|
||||
$sql = 'SELECT * FROM api_keys WHERE customer_id = ?';
|
||||
if (!$includeRevoked) {
|
||||
$sql .= ' AND revoked_at IS NULL';
|
||||
}
|
||||
$sql .= ' ORDER BY id DESC';
|
||||
$stmt = $db->conn()->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new Exception('Failed to prepare list: ' . $db->conn()->error);
|
||||
}
|
||||
$stmt->bind_param('i', $customerId);
|
||||
if (!$stmt->execute()) {
|
||||
$err = $stmt->error;
|
||||
$stmt->close();
|
||||
throw new Exception('Failed to execute list: ' . $err);
|
||||
}
|
||||
$result = $stmt->get_result();
|
||||
$rows = $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
|
||||
$stmt->close();
|
||||
return is_array($rows) ? $rows : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a key by id. Returns true if a row was removed.
|
||||
* Generally prefer `revoke()` over `delete()` so audit trails
|
||||
* stay intact.
|
||||
*/
|
||||
public static function delete(int $id): bool
|
||||
{
|
||||
$db = self::db();
|
||||
$stmt = $db->conn()->prepare('DELETE FROM api_keys WHERE id = ?');
|
||||
if ($stmt === false) {
|
||||
throw new Exception('Failed to prepare delete: ' . $db->conn()->error);
|
||||
}
|
||||
$stmt->bind_param('i', $id);
|
||||
$ok = $stmt->execute();
|
||||
$affected = $stmt->affected_rows;
|
||||
$stmt->close();
|
||||
return $ok && $affected > 0;
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Schema bootstrap for the api_keys table.
|
||||
*
|
||||
* This codebase does NOT use a migration framework; new tables are
|
||||
* added via `*_schema_bootstrap.php` files that run idempotent
|
||||
* `CREATE TABLE IF NOT EXISTS` statements on first use. The companion
|
||||
* SQL file at `database/migrations/<TIMESTAMP>_create_api_keys_table.php`
|
||||
* is the human-readable source of truth / change record.
|
||||
*/
|
||||
class api_key_schema_bootstrap
|
||||
{
|
||||
public const TABLE = 'api_keys';
|
||||
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
if (!isset($db) || !is_object($db)) {
|
||||
// No DB connection in this process (e.g. unit test) — skip.
|
||||
self::$initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$queries = [
|
||||
"CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
key_id VARCHAR(64) NOT NULL,
|
||||
key_hash VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(32) NOT NULL,
|
||||
scopes JSON NULL,
|
||||
customer_id BIGINT UNSIGNED NULL,
|
||||
created_by BIGINT UNSIGNED NULL,
|
||||
last_used_at TIMESTAMP NULL,
|
||||
expires_at TIMESTAMP NULL,
|
||||
revoked_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_api_keys_key_id (key_id),
|
||||
INDEX idx_api_keys_customer (customer_id),
|
||||
INDEX idx_api_keys_key_hash (key_hash),
|
||||
INDEX idx_api_keys_revoked (revoked_at),
|
||||
INDEX idx_api_keys_role (role)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
|
||||
foreach ($queries as $query) {
|
||||
try {
|
||||
$db->query($query);
|
||||
} catch (\Throwable $e) {
|
||||
// Swallow on first-failure in unit-test contexts; the
|
||||
// migration companion file documents the canonical DDL.
|
||||
if (function_exists('error_log')) {
|
||||
@error_log('[api_key_schema_bootstrap] ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
public static function tableExists(): bool
|
||||
{
|
||||
global $db;
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'getDatabase')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$database = $db->escape_string($db->getDatabase());
|
||||
$result = $db->query(
|
||||
"SELECT COUNT(*) AS count
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = '{$database}'
|
||||
AND table_name = 'api_keys'"
|
||||
);
|
||||
$row = $result ? $result->fetch_assoc() : ['count' => 0];
|
||||
return (int)($row['count'] ?? 0) > 0;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace classes\auth;
|
||||
|
||||
/**
|
||||
* Scope registry: the source of truth for API key scopes and
|
||||
* role → scope defaults.
|
||||
*
|
||||
* This class is the canonical implementation that the parallel
|
||||
* `app\auth\Scope` stub (introduced by TRU-149 / branch
|
||||
* feat/TRU-149-route-scopes) will be replaced with once
|
||||
* `feat/api-key-foundation` is merged. Until then the two can
|
||||
* coexist; the middleware in `scope_middleware.php` continues
|
||||
* to use the legacy stub.
|
||||
*
|
||||
* Scopes follow a "resource:action" pattern (e.g. `booking:read`).
|
||||
* Two wildcard forms are recognised:
|
||||
* - `*` — matches every scope.
|
||||
* - `resource:*` — matches every action on a resource.
|
||||
*
|
||||
* Role defaults:
|
||||
* - superuser: every scope (via "*" wildcard).
|
||||
* - admin: customer:*, booking:*, subuser:*, invoice:*
|
||||
* - customer: customer:read, booking:read, invoice:read
|
||||
* - subuser: booking:read, booking:write
|
||||
*
|
||||
* The "self" / "assigned" qualifiers from the spec are *enforcement
|
||||
* layer* concerns, not scope concerns — they live in the resolver
|
||||
* that maps an authenticated principal to a customer/subuser record.
|
||||
* Scopes only encode "can the caller read bookings at all", not
|
||||
* "which bookings".
|
||||
*/
|
||||
final class scope_registry
|
||||
{
|
||||
// --- Customer resource ---
|
||||
public const CUSTOMER_READ = 'customer:read';
|
||||
public const CUSTOMER_WRITE = 'customer:write';
|
||||
|
||||
// --- Booking resource ---
|
||||
public const BOOKING_READ = 'booking:read';
|
||||
public const BOOKING_WRITE = 'booking:write';
|
||||
|
||||
// --- Subuser resource ---
|
||||
public const SUBUSER_READ = 'subuser:read';
|
||||
public const SUBUSER_WRITE = 'subuser:write';
|
||||
|
||||
// --- Invoice resource ---
|
||||
public const INVOICE_READ = 'invoice:read';
|
||||
public const INVOICE_WRITE = 'invoice:write';
|
||||
|
||||
// --- Superuser / admin resource ---
|
||||
public const SUPERUSER_READ = 'superuser:read';
|
||||
public const SUPERUSER_WRITE = 'superuser:write';
|
||||
|
||||
/**
|
||||
* Canonical list of every concrete scope (no wildcards).
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return [
|
||||
self::CUSTOMER_READ, self::CUSTOMER_WRITE,
|
||||
self::BOOKING_READ, self::BOOKING_WRITE,
|
||||
self::SUBUSER_READ, self::SUBUSER_WRITE,
|
||||
self::INVOICE_READ, self::INVOICE_WRITE,
|
||||
self::SUPERUSER_READ, self::SUPERUSER_WRITE,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the default scope set carried by a role. Wildcards are
|
||||
* returned as-is; resolve them with `expand()` before checking
|
||||
* membership if you need a flat list.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function scopesForRole(string $role): array
|
||||
{
|
||||
switch (strtolower(trim($role))) {
|
||||
case 'superuser':
|
||||
return ['*'];
|
||||
case 'admin':
|
||||
return [
|
||||
'customer:*',
|
||||
'booking:*',
|
||||
'subuser:*',
|
||||
'invoice:*',
|
||||
];
|
||||
case 'customer':
|
||||
return [
|
||||
self::CUSTOMER_READ,
|
||||
self::BOOKING_READ,
|
||||
self::INVOICE_READ,
|
||||
];
|
||||
case 'subuser':
|
||||
return [
|
||||
self::BOOKING_READ,
|
||||
self::BOOKING_WRITE,
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the granted scope (or wildcard) match the required scope?
|
||||
*
|
||||
* - "*" matches anything.
|
||||
* - "customer:*" matches "customer:read" and "customer:write".
|
||||
* - "customer:read" matches itself exactly.
|
||||
*
|
||||
* @param array<int, string> $granted
|
||||
*/
|
||||
public static function hasScope(array $granted, string $required): bool
|
||||
{
|
||||
$required = trim($required);
|
||||
if ($required === '') {
|
||||
return false;
|
||||
}
|
||||
foreach ($granted as $candidate) {
|
||||
if (!is_string($candidate)) {
|
||||
continue;
|
||||
}
|
||||
if (self::matches($candidate, $required)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a list of scopes (which may include wildcards) into the
|
||||
* full set of concrete scopes they grant. Useful for showing a
|
||||
* user what their key can do, or for caching decisions.
|
||||
*
|
||||
* The wildcard "*" expands to the full `all()` set. A wildcard
|
||||
* like "customer:*" expands to every concrete scope starting with
|
||||
* "customer:". Duplicate entries are removed.
|
||||
*
|
||||
* @param array<int, string> $scopes
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function expand(array $scopes): array
|
||||
{
|
||||
$concrete = self::all();
|
||||
$expanded = [];
|
||||
|
||||
foreach ($scopes as $scope) {
|
||||
if (!is_string($scope)) {
|
||||
continue;
|
||||
}
|
||||
$scope = trim($scope);
|
||||
if ($scope === '') {
|
||||
continue;
|
||||
}
|
||||
if ($scope === '*') {
|
||||
$expanded = array_merge($expanded, $concrete);
|
||||
continue;
|
||||
}
|
||||
if (str_ends_with($scope, ':*')) {
|
||||
$prefix = substr($scope, 0, -2) . ':';
|
||||
foreach ($concrete as $candidate) {
|
||||
if (str_starts_with($candidate, $prefix)) {
|
||||
$expanded[] = $candidate;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Already concrete — pass through if it looks canonical.
|
||||
if (in_array($scope, $concrete, true)) {
|
||||
$expanded[] = $scope;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($expanded));
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal wildcard matcher — public for testing.
|
||||
*/
|
||||
public static function matches(string $granted, string $required): bool
|
||||
{
|
||||
$granted = trim($granted);
|
||||
$required = trim($required);
|
||||
if ($granted === '' || $required === '') {
|
||||
return false;
|
||||
}
|
||||
if ($granted === '*') {
|
||||
return true;
|
||||
}
|
||||
if (str_ends_with($granted, ':*')) {
|
||||
$prefix = substr($granted, 0, -2);
|
||||
return str_starts_with($required, $prefix . ':');
|
||||
}
|
||||
return $granted === $required;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a scope string. Returns true iff the value is either
|
||||
* a canonical concrete scope, "*", or a "<resource>:*" wildcard
|
||||
* for a known resource.
|
||||
*/
|
||||
public static function isValid(string $scope): bool
|
||||
{
|
||||
$scope = trim($scope);
|
||||
if ($scope === '' || $scope === '*') {
|
||||
return $scope !== '';
|
||||
}
|
||||
if (str_ends_with($scope, ':*')) {
|
||||
$prefix = substr($scope, 0, -2);
|
||||
foreach (self::all() as $concrete) {
|
||||
if (str_starts_with($concrete, $prefix . ':')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return in_array($scope, self::all(), true);
|
||||
}
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use InvalidArgumentException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Customer rule "auto-send invoice on the 3rd business day each month" (TRU-70 / DRIFT 9).
|
||||
*
|
||||
* The toggle lives in the existing `customer_attributes` table under the
|
||||
* `autoSendInvoiceThirdBusinessDay` attribute. A daily cron task delegates to
|
||||
* {@see autoSendInvoicesThirdBusinessDay()} which is a no-op on every day
|
||||
* except the 3rd business day of the month, where it auto-queues ready
|
||||
* collected invoices for the opted-in customers.
|
||||
*
|
||||
* "Business day" is computed against a locale-aware weekend (default
|
||||
* Saturday + Sunday). Public Danish holidays are supported via a small
|
||||
* override hook so the unit tests can pin the result without depending on
|
||||
* the wall clock.
|
||||
*/
|
||||
class auto_send_invoice_third_business_day_service
|
||||
{
|
||||
public const ATTRIBUTE = 'autoSendInvoiceThirdBusinessDay';
|
||||
|
||||
public const DEFAULT_WEEKEND_DAYS = [6, 7]; // ISO-8601: 6 = Saturday, 7 = Sunday
|
||||
|
||||
/** @var list<string>|null */
|
||||
private ?array $holidayCache = null;
|
||||
|
||||
/** @var callable|null */
|
||||
private $holidayProviderOverride = null;
|
||||
|
||||
/** @var callable|null */
|
||||
private $nowProviderOverride = null;
|
||||
|
||||
/** @var callable|null */
|
||||
private $timeZoneProviderOverride = null;
|
||||
|
||||
public function isThirdBusinessDay(?DateTimeImmutable $date = null): bool
|
||||
{
|
||||
$timezone = $this->resolveTimezone();
|
||||
$reference = $date ?? $this->resolveNow()->setTimezone($timezone);
|
||||
$reference = $reference->setTimezone($timezone)->setTime(0, 0, 0);
|
||||
|
||||
$businessDay = 0;
|
||||
$cursor = $reference->setDate(
|
||||
(int)$reference->format('Y'),
|
||||
(int)$reference->format('n'),
|
||||
1
|
||||
);
|
||||
$today = $reference;
|
||||
|
||||
while ($cursor <= $today) {
|
||||
if ($this->isBusinessDay($cursor)) {
|
||||
$businessDay++;
|
||||
if ($businessDay === 3) {
|
||||
return $cursor->format('Y-m-d') === $today->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
$cursor = $cursor->modify('+1 day');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function thirdBusinessDayOfMonth(int $year, int $month): DateTimeImmutable
|
||||
{
|
||||
if ($year < 1970 || $year > 9999) {
|
||||
throw new InvalidArgumentException("Year must be between 1970 and 9999, got {$year}");
|
||||
}
|
||||
if ($month < 1 || $month > 12) {
|
||||
throw new InvalidArgumentException("Month must be between 1 and 12, got {$month}");
|
||||
}
|
||||
|
||||
$timezone = $this->resolveTimezone();
|
||||
$cursor = (new DateTimeImmutable(sprintf('%04d-%02d-01 00:00:00', $year, $month), $timezone))
|
||||
->setTimezone($timezone);
|
||||
$businessDay = 0;
|
||||
|
||||
while (true) {
|
||||
if ($this->isBusinessDay($cursor)) {
|
||||
$businessDay++;
|
||||
if ($businessDay === 3) {
|
||||
return $cursor->setTime(0, 0, 0);
|
||||
}
|
||||
}
|
||||
$cursor = $cursor->modify('+1 day');
|
||||
}
|
||||
}
|
||||
|
||||
public function isBusinessDay(DateTimeImmutable $date): bool
|
||||
{
|
||||
$weekday = (int)$date->format('N');
|
||||
if (in_array($weekday, self::DEFAULT_WEEKEND_DAYS, true)) {
|
||||
return false;
|
||||
}
|
||||
$holidayKey = $date->format('Y-m-d');
|
||||
foreach ($this->resolveHolidays() as $holiday) {
|
||||
if ($holiday === $holidayKey) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-queue every ready collected invoice for customers with the
|
||||
* `autoSendInvoiceThirdBusinessDay` attribute. Returns a summary of
|
||||
* what was queued (or an empty `scanned` count when invoked on a
|
||||
* non-trigger day).
|
||||
*
|
||||
* @return array{
|
||||
* triggered: bool,
|
||||
* trigger_date: ?string,
|
||||
* customers: int,
|
||||
* collections_scanned: int,
|
||||
* jobs_enqueued: int,
|
||||
* skipped_already_queued: int,
|
||||
* errors: list<array{customer_number:int,collection_id:int,message:string}>
|
||||
* }
|
||||
*/
|
||||
public function runOnce(?DateTimeImmutable $now = null): array
|
||||
{
|
||||
$timezone = $this->resolveTimezone();
|
||||
$today = ($now ?? $this->resolveNow())->setTimezone($timezone)->setTime(0, 0, 0);
|
||||
|
||||
$summary = [
|
||||
'triggered' => false,
|
||||
'trigger_date' => null,
|
||||
'customers' => 0,
|
||||
'collections_scanned' => 0,
|
||||
'jobs_enqueued' => 0,
|
||||
'skipped_already_queued' => 0,
|
||||
'errors' => [],
|
||||
];
|
||||
|
||||
if (!$this->isThirdBusinessDay($today)) {
|
||||
return $summary;
|
||||
}
|
||||
|
||||
$summary['triggered'] = true;
|
||||
$summary['trigger_date'] = $today->format('Y-m-d');
|
||||
|
||||
$customerNumbers = $this->loadEligibleCustomerNumbers();
|
||||
$summary['customers'] = count($customerNumbers);
|
||||
if ($customerNumbers === []) {
|
||||
return $summary;
|
||||
}
|
||||
|
||||
$collections = $this->loadReadyInvoiceCollections($customerNumbers);
|
||||
$summary['collections_scanned'] = count($collections);
|
||||
if ($collections === []) {
|
||||
return $summary;
|
||||
}
|
||||
|
||||
$queue = $this->createTransferQueue();
|
||||
foreach ($collections as $collection) {
|
||||
$collectionId = (int)($collection['id'] ?? 0);
|
||||
if ($collectionId < 1) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$enqueued = $this->enqueueCollectionExport($queue, $collectionId);
|
||||
} catch (Throwable $throwable) {
|
||||
$summary['errors'][] = [
|
||||
'customer_number' => (int)($collection['customer_number'] ?? 0),
|
||||
'collection_id' => $collectionId,
|
||||
'message' => $throwable->getMessage(),
|
||||
];
|
||||
continue;
|
||||
}
|
||||
if ($enqueued === 'queued') {
|
||||
$summary['jobs_enqueued']++;
|
||||
} elseif ($enqueued === 'already_queued') {
|
||||
$summary['skipped_already_queued']++;
|
||||
}
|
||||
// 'unavailable' is intentionally silent: the queue is optional
|
||||
// and the next cron tick will pick up the collections.
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
public function setHolidayProviderOverride(callable $provider): void
|
||||
{
|
||||
$this->holidayProviderOverride = $provider;
|
||||
}
|
||||
|
||||
public function setNowProviderOverride(callable $provider): void
|
||||
{
|
||||
$this->nowProviderOverride = $provider;
|
||||
}
|
||||
|
||||
public function setTimeZoneProviderOverride(callable $provider): void
|
||||
{
|
||||
$this->timeZoneProviderOverride = $provider;
|
||||
}
|
||||
|
||||
public function clearOverrides(): void
|
||||
{
|
||||
$this->holidayProviderOverride = null;
|
||||
$this->nowProviderOverride = null;
|
||||
$this->timeZoneProviderOverride = null;
|
||||
$this->holidayCache = null;
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
public function loadEligibleCustomerNumbers(): array
|
||||
{
|
||||
global $db;
|
||||
$attribute = $db->escape_string(self::ATTRIBUTE);
|
||||
$result = $db->query(
|
||||
"SELECT DISTINCT CAST(u.customer_number AS UNSIGNED) AS customer_number
|
||||
FROM customer_attributes ca
|
||||
INNER JOIN users u ON u.id = ca.user_id
|
||||
WHERE ca.attribute = '{$attribute}'
|
||||
AND u.customer_number IS NOT NULL
|
||||
AND u.customer_number <> 0
|
||||
AND u.deleted_at IS NULL
|
||||
ORDER BY customer_number ASC"
|
||||
);
|
||||
if (!$result) {
|
||||
return [];
|
||||
}
|
||||
$customerNumbers = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$number = (int)($row['customer_number'] ?? 0);
|
||||
if ($number > 0) {
|
||||
$customerNumbers[] = $number;
|
||||
}
|
||||
}
|
||||
return $customerNumbers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $customerNumbers
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function loadReadyInvoiceCollections(array $customerNumbers): array
|
||||
{
|
||||
$customerNumbers = array_values(array_filter(array_map('intval', $customerNumbers), static fn(int $n): bool => $n > 0));
|
||||
if ($customerNumbers === []) {
|
||||
return [];
|
||||
}
|
||||
global $db;
|
||||
$in = implode(',', $customerNumbers);
|
||||
|
||||
// A collection is "ready" when:
|
||||
// - it belongs to one of the opted-in customers
|
||||
// - it has at least one order linked to it
|
||||
// - it has not been booked yet (no booked_at)
|
||||
// - it has not been closed yet (no closed_at) — closures are reserved
|
||||
// for already-booked/manual-approval flows
|
||||
// - it has not been deleted
|
||||
$result = $db->query(
|
||||
"SELECT c.id, c.customer_number
|
||||
FROM collected_order_invoices c
|
||||
WHERE c.customer_number IN ({$in})
|
||||
AND c.deleted_at IS NULL
|
||||
AND (c.booked_at IS NULL OR c.booked_at = '0000-00-00 00:00:00')
|
||||
AND (c.closed_at IS NULL OR c.closed_at = '0000-00-00 00:00:00')
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM orders o
|
||||
WHERE o.invoice_collection_id = c.id
|
||||
AND o.deleted_at IS NULL
|
||||
)
|
||||
ORDER BY c.customer_number ASC, c.id ASC"
|
||||
);
|
||||
if (!$result) {
|
||||
return [];
|
||||
}
|
||||
$rows = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$rows[] = [
|
||||
'id' => (int)($row['id'] ?? 0),
|
||||
'customer_number' => (int)($row['customer_number'] ?? 0),
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for unit tests: when the production economic_transfer_queue is
|
||||
* not available the cron task should still succeed with no jobs.
|
||||
*/
|
||||
protected function createTransferQueue(): ?economic_transfer_queue
|
||||
{
|
||||
if (!class_exists(economic_transfer_queue::class)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new economic_transfer_queue();
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return 'queued'|'already_queued'|'unavailable'
|
||||
*/
|
||||
private function enqueueCollectionExport(?economic_transfer_queue $queue, int $collectionId): string
|
||||
{
|
||||
if ($queue === null) {
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
try {
|
||||
$job = $queue->enqueue(
|
||||
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT,
|
||||
[
|
||||
'collected_invoice_id' => $collectionId,
|
||||
'send_as_is' => false,
|
||||
'requested_by' => 0,
|
||||
'auto_send_third_business_day' => true,
|
||||
],
|
||||
0
|
||||
);
|
||||
} catch (Throwable $throwable) {
|
||||
// The queue is designed to refuse duplicate enqueue by
|
||||
// surfacing a domain-specific message — treat that as
|
||||
// "already_queued" rather than a hard failure.
|
||||
if (str_contains(strtolower($throwable->getMessage()), 'already')) {
|
||||
return 'already_queued';
|
||||
}
|
||||
throw $throwable;
|
||||
}
|
||||
|
||||
return is_array($job) && !empty($job['id']) ? 'queued' : 'already_queued';
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function resolveHolidays(): array
|
||||
{
|
||||
if ($this->holidayCache !== null) {
|
||||
return $this->holidayCache;
|
||||
}
|
||||
if ($this->holidayProviderOverride !== null) {
|
||||
$value = ($this->holidayProviderOverride)();
|
||||
$holidays = is_array($value) ? array_values(array_filter(array_map('strval', $value))) : [];
|
||||
$this->holidayCache = $holidays;
|
||||
return $holidays;
|
||||
}
|
||||
|
||||
// Default: no hard-coded public holidays. Production can plug a
|
||||
// concrete provider through setHolidayProviderOverride() once the
|
||||
// holiday calendar is finalised. The Saturday/Sunday weekend
|
||||
// logic is sufficient for the 3rd-business-day computation as
|
||||
// long as the cron task runs on every weekday.
|
||||
$this->holidayCache = [];
|
||||
return $this->holidayCache;
|
||||
}
|
||||
|
||||
private function resolveNow(): DateTimeImmutable
|
||||
{
|
||||
if ($this->nowProviderOverride !== null) {
|
||||
$value = ($this->nowProviderOverride)();
|
||||
if ($value instanceof DateTimeImmutable) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
return new DateTimeImmutable('now');
|
||||
}
|
||||
|
||||
private function resolveTimezone(): DateTimeZone
|
||||
{
|
||||
if ($this->timeZoneProviderOverride !== null) {
|
||||
$value = ($this->timeZoneProviderOverride)();
|
||||
if ($value instanceof DateTimeZone) {
|
||||
return $value;
|
||||
}
|
||||
if (is_string($value) && $value !== '') {
|
||||
return new DateTimeZone($value);
|
||||
}
|
||||
}
|
||||
return new DateTimeZone('Europe/Copenhagen');
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,9 @@
|
||||
namespace classes;
|
||||
|
||||
use Throwable;
|
||||
use traits\boolean_normalization_t;
|
||||
|
||||
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
|
||||
|
||||
class cron_worker
|
||||
{
|
||||
use boolean_normalization_t;
|
||||
|
||||
private cron_scheduler $scheduler;
|
||||
private string $worker_id;
|
||||
private string $name;
|
||||
@@ -296,7 +291,7 @@ class cron_worker
|
||||
return $default;
|
||||
}
|
||||
|
||||
return self::normalizeBoolean($value);
|
||||
return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
private function commitSha(): string
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
<?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,10 +14,6 @@ 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);
|
||||
|
||||
@@ -66,15 +62,9 @@ class customer_mass_import_service
|
||||
}
|
||||
|
||||
$normalized['name'] = $this->resolveCreateName($normalized);
|
||||
// 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);
|
||||
$normalized['email'] = $this->resolveCreateEmail($normalized, $warnings);
|
||||
|
||||
$createResponse = $this->createEconomicCustomer($normalized, $createEmail);
|
||||
$createResponse = $this->createEconomicCustomer($normalized);
|
||||
$createdCustomerNumber = $this->extractEconomicCustomerNumber($createResponse);
|
||||
|
||||
if ($createdCustomerNumber !== $customerNumber) {
|
||||
@@ -121,7 +111,6 @@ 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),
|
||||
];
|
||||
}
|
||||
@@ -204,42 +193,6 @@ 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) {
|
||||
@@ -256,9 +209,12 @@ class customer_mass_import_service
|
||||
|
||||
protected function resolveCreateEmail(array $normalized, array &$warnings): string
|
||||
{
|
||||
// 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);
|
||||
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';
|
||||
}
|
||||
|
||||
protected function searchEconomicCustomersByCvr(string $cvr): array
|
||||
@@ -273,7 +229,7 @@ class customer_mass_import_service
|
||||
return is_array($response) ? $response : [];
|
||||
}
|
||||
|
||||
protected function createEconomicCustomer(array $normalized, string $createEmail): object
|
||||
protected function createEconomicCustomer(array $normalized): object
|
||||
{
|
||||
$payload = [
|
||||
'customerNumber' => (int)$normalized['customer_number'],
|
||||
@@ -285,10 +241,7 @@ class customer_mass_import_service
|
||||
'paymentTermsNumber' => 12,
|
||||
],
|
||||
'name' => (string)$normalized['name'],
|
||||
// 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,
|
||||
'email' => (string)$normalized['email'],
|
||||
'phone' => (int)$normalized['phone'],
|
||||
'telephoneAndFaxNumber' => (string)$normalized['phone'],
|
||||
'mobilePhone' => (string)$normalized['phone'],
|
||||
@@ -443,7 +396,6 @@ 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,
|
||||
@@ -464,7 +416,6 @@ 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() ?? ''));
|
||||
@@ -480,16 +431,6 @@ 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);
|
||||
|
||||
@@ -54,7 +54,6 @@ class customer_rule_product_restriction_service
|
||||
'showPricesOnBookingPage',
|
||||
'usePONumbers',
|
||||
'exemptFromAdministrationFee',
|
||||
'autoSendInvoiceThirdBusinessDay',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Sanitizes user-input fields that are sent to the e-conomic API.
|
||||
*
|
||||
* Background: e-conomic returns 400 errors when description fields contain
|
||||
* certain characters. The known issue is "/" in the order reference field
|
||||
* (TRU-188), but we sanitize defensively for all such cases.
|
||||
*
|
||||
* - sanitizeTextLine(): for plain text lines (reference, notes, po, etc.)
|
||||
* - sanitizeProductNumber(): for product identifiers
|
||||
* - sanitizeProductDescription(): for product-line descriptions
|
||||
* - sanitizeForEconApi(): catch-all for arbitrary user input
|
||||
*/
|
||||
class economic_export_sanitizer
|
||||
{
|
||||
/** E-conomic soft limit for a single description line. */
|
||||
public const TEXT_LINE_MAX_LENGTH = 250;
|
||||
/** E-conomic soft limit for a product description. */
|
||||
public const PRODUCT_DESCRIPTION_MAX_LENGTH = 500;
|
||||
/** E-conomic soft limit for a product number. */
|
||||
public const PRODUCT_NUMBER_MAX_LENGTH = 50;
|
||||
|
||||
/** Characters that are illegal in product numbers on most e-conomic setups. */
|
||||
private const PRODUCT_NUMBER_FORBIDDEN = ['/', '\\', ':', '*', '?', '"', '<', '>', '|', "\0"];
|
||||
|
||||
/**
|
||||
* Sanitize a value for use in a single-line text description.
|
||||
*
|
||||
* Transformations (in order):
|
||||
* 1. Replaces "/" with "-" (the reported 400 trigger)
|
||||
* 2. Strips control characters (\x00-\x1F) except \t and \n
|
||||
* 3. Replaces tab with single space
|
||||
* 4. Collapses newlines into spaces (text lines are single-line)
|
||||
* 5. Collapses runs of spaces to a single space
|
||||
* 6. Trims leading/trailing whitespace
|
||||
* 7. Truncates to $maxLength with "..." suffix if needed
|
||||
*/
|
||||
public static function sanitizeTextLine(mixed $value, int $maxLength = self::TEXT_LINE_MAX_LENGTH): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
$text = (string)$value;
|
||||
if ($text === '') {
|
||||
return '';
|
||||
}
|
||||
// 1. Strip control characters except \t and \n
|
||||
$text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $text);
|
||||
// 2. Replace tab with single space
|
||||
$text = str_replace("\t", ' ', $text);
|
||||
// 3. Collapse newlines to single space (text lines are single-line)
|
||||
$text = preg_replace('/[\r\n]+/u', ' ', $text);
|
||||
// 4. Replace forward slashes (the reported 400 trigger)
|
||||
$text = str_replace('/', '-', $text);
|
||||
// 5. Collapse runs of spaces
|
||||
$text = preg_replace('/\s+/u', ' ', $text);
|
||||
// 6. Trim
|
||||
$text = trim($text);
|
||||
// 7. Truncate with ellipsis if too long
|
||||
if ($maxLength > 3 && mb_strlen($text) > $maxLength) {
|
||||
$text = mb_substr($text, 0, $maxLength - 3) . '...';
|
||||
} elseif (mb_strlen($text) > $maxLength) {
|
||||
$text = mb_substr($text, 0, $maxLength);
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a product number/identifier.
|
||||
*
|
||||
* Removes characters that are illegal in product numbers on most
|
||||
* e-conomic setups (filesystem-unsafe + path separators).
|
||||
*/
|
||||
public static function sanitizeProductNumber(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
$text = (string)$value;
|
||||
if ($text === '') {
|
||||
return '';
|
||||
}
|
||||
$text = str_replace(self::PRODUCT_NUMBER_FORBIDDEN, '', $text);
|
||||
$text = preg_replace('/[\x00-\x1F\x7F]/u', '', $text);
|
||||
$text = trim($text);
|
||||
if (mb_strlen($text) > self::PRODUCT_NUMBER_MAX_LENGTH) {
|
||||
$text = mb_substr($text, 0, self::PRODUCT_NUMBER_MAX_LENGTH);
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a longer product description.
|
||||
*/
|
||||
public static function sanitizeProductDescription(mixed $value): string
|
||||
{
|
||||
return self::sanitizeTextLine($value, self::PRODUCT_DESCRIPTION_MAX_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch-all sanitizer for any user-input value going to e-conomic.
|
||||
* Defaults to text-line rules.
|
||||
*/
|
||||
public static function sanitizeForEconApi(mixed $value): string
|
||||
{
|
||||
return self::sanitizeTextLine($value);
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Centralized selection of e-conomic invoice layout numbers.
|
||||
*
|
||||
* This class is the **skeleton** introduced by TRU-197. It exposes the two
|
||||
* layout numbers that the backend should use for the two invoice variants:
|
||||
*
|
||||
* - `LAYOUT_WITHOUT_DISCOUNTS` — clean invoice, no discount clutter
|
||||
* - `LAYOUT_WITH_DISCOUNTS` — invoice with itemized discount line(s)
|
||||
*
|
||||
* The constants below are placeholders for the layout numbers that the
|
||||
* e-conomic account admin must pick in e-conomic (Settings → Design and
|
||||
* Layouts) and write into the module-config DB variables
|
||||
* `invoiceLayoutNumber` and `invoiceDiscountLayoutNumber`. The numbers
|
||||
* themselves are intentionally left as `0` in this skeleton — they are
|
||||
* resolved at runtime from the module-config variables by the two existing
|
||||
* call sites:
|
||||
*
|
||||
* - `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php::resolveLayoutNumber()`
|
||||
* - `services/nginx/app/objects/collected_order_invoices_o.php::resolveInvoiceLayoutNumber()`
|
||||
*
|
||||
* Wiring those call sites to read from this selector (instead of from the
|
||||
* module-config variables directly) is intentionally **out of scope** for
|
||||
* TRU-197. See `documentation/economic/invoice-template-audit.md` for the
|
||||
* full audit and follow-up plan.
|
||||
*
|
||||
* Constants in this class are the *single source of truth* for the
|
||||
* env-var-style aliases:
|
||||
*
|
||||
* - `LAYOUT_WITHOUT_DISCOUNTS` ⇄ `ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS`
|
||||
* - `LAYOUT_WITH_DISCOUNTS` ⇄ `ECONOMIC_LAYOUT_WITH_DISCOUNTS`
|
||||
*/
|
||||
class economic_layout_selector
|
||||
{
|
||||
/**
|
||||
* Layout number for invoices WITHOUT itemized discount lines.
|
||||
*
|
||||
* Intent: a clean invoice — no "Rabat" line, no discount column, just
|
||||
* the line items and totals.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public const LAYOUT_WITHOUT_DISCOUNTS = 0;
|
||||
|
||||
/**
|
||||
* Layout number for invoices WITH itemized discount lines.
|
||||
*
|
||||
* Intent: an invoice that visibly itemizes the negative `Rabat`
|
||||
* (product `TotDiscount`) line so the customer can see the discount
|
||||
* broken out instead of folded into per-product `discountPercentage`.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public const LAYOUT_WITH_DISCOUNTS = 0;
|
||||
|
||||
/**
|
||||
* Module-config variable name for the without-discounts layout.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const CONFIG_VAR_WITHOUT_DISCOUNTS = 'invoiceLayoutNumber';
|
||||
|
||||
/**
|
||||
* Module-config variable name for the with-discounts layout.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const CONFIG_VAR_WITH_DISCOUNTS = 'invoiceDiscountLayoutNumber';
|
||||
|
||||
/**
|
||||
* Friendly alias for `LAYOUT_WITHOUT_DISCOUNTS` (env-var-style name).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function nameWithoutDiscounts(): string
|
||||
{
|
||||
return 'ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS';
|
||||
}
|
||||
|
||||
/**
|
||||
* Friendly alias for `LAYOUT_WITH_DISCOUNTS` (env-var-style name).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function nameWithDiscounts(): string
|
||||
{
|
||||
return 'ECONOMIC_LAYOUT_WITH_DISCOUNTS';
|
||||
}
|
||||
}
|
||||
@@ -758,6 +758,7 @@ class invoice_period_flag_service
|
||||
o.department_id,
|
||||
d.custom_pricing_only AS department_custom_pricing_only,
|
||||
o.reg_1,
|
||||
o.reg_2,
|
||||
o.invoice_collection_id,
|
||||
o.wash_id,
|
||||
o.safety_seal,
|
||||
@@ -1297,39 +1298,22 @@ class invoice_period_flag_service
|
||||
}
|
||||
}
|
||||
|
||||
// Partition primary rows by whether reg_2 is empty. A single-tractor order (reg_2 = '')
|
||||
// should only be compared against historical orders that ALSO had reg_2 empty, so we don't
|
||||
// raise a "historical_primary_product_mismatch" flag that names the tractor-trailer
|
||||
// (Forvogn med hænger) product as the expected one when the current order has no trailer.
|
||||
$hasReg2 = [];
|
||||
$emptyReg2 = [];
|
||||
foreach ($primaryRows as $row) {
|
||||
if (trim((string)($row['reg_2'] ?? '')) === '') {
|
||||
$emptyReg2[] = $row;
|
||||
} else {
|
||||
$hasReg2[] = $row;
|
||||
}
|
||||
}
|
||||
$history = [];
|
||||
if (!empty($emptyReg2)) {
|
||||
$history = $history + $this->getPrimaryProductHistory(
|
||||
$dateFrom,
|
||||
array_column($emptyReg2, 'reg_1'),
|
||||
true
|
||||
);
|
||||
}
|
||||
if (!empty($hasReg2)) {
|
||||
$history = $history + $this->getPrimaryProductHistory(
|
||||
$dateFrom,
|
||||
array_column($hasReg2, 'reg_1'),
|
||||
false
|
||||
);
|
||||
}
|
||||
$history = $this->getPrimaryProductHistory($dateFrom, array_column($primaryRows, 'reg_1'));
|
||||
foreach ($primaryRows as $row) {
|
||||
$reg = strtoupper(trim((string)($row['reg_1'] ?? '')));
|
||||
if ($reg === '' || !isset($history[$reg])) {
|
||||
continue;
|
||||
}
|
||||
// When the current order only has one registration number (reg_2 is empty),
|
||||
// the historical "usual product" may include a trailer component (e.g.
|
||||
// "Tractor-trailer wash") because the reg previously only appeared in
|
||||
// tractor-trailer bookings. Billing just the tractor in that case is correct
|
||||
// and matches the operator's expectation (bug #10, Sarah #10714). The mismatch
|
||||
// is therefore uninformative when reg_2 is empty, so suppress the flag.
|
||||
$reg2 = trim((string)($row['reg_2'] ?? ''));
|
||||
if ($reg2 === '') {
|
||||
continue;
|
||||
}
|
||||
$expectedProductId = (int)$history[$reg]['product_id'];
|
||||
if ($this->primaryVehicleProductsMatch(
|
||||
(int)$row['product_id'],
|
||||
@@ -1495,7 +1479,6 @@ class invoice_period_flag_service
|
||||
{
|
||||
$product = (string)($params['product'] ?? 'Item');
|
||||
$expectedProduct = (string)($params['expected_product'] ?? 'expected product');
|
||||
$washId = (string)($params['wash_id'] ?? '');
|
||||
return match ($definitionKey) {
|
||||
'price_mismatch' => "{$product} product price differs from expected.",
|
||||
'customer_rule_restrict_addon_services' => "{$product} violates restricted addon services.",
|
||||
@@ -1515,9 +1498,7 @@ class invoice_period_flag_service
|
||||
'duplicate_vehicle_subscription_charge_same_month' => "Duplicate vehicle subscription charges exist in the same month.",
|
||||
'vehicle_subscription_type_mismatch' => "{$product} does not match the vehicle subscription type {$expectedProduct}.",
|
||||
'historical_primary_product_mismatch' => "{$product} differs from the registration number's usual product {$expectedProduct}.",
|
||||
'xlvask_missing_order_link' => $washId === ''
|
||||
? 'XL Vask wash is neither ignored nor linked to an order in the selected period.'
|
||||
: "XL Vask wash {$washId} is neither ignored nor linked to an order in the selected period.",
|
||||
'xlvask_missing_order_link' => "XL Vask wash is neither ignored nor linked to an order in the selected period.",
|
||||
default => "Automatically detected invoice-period issue.",
|
||||
};
|
||||
}
|
||||
@@ -1544,8 +1525,7 @@ class invoice_period_flag_service
|
||||
['type' => 'text', 'text' => ' is attached without a wash certificate item.'],
|
||||
],
|
||||
'xlvask_missing_order_link' => [
|
||||
['type' => 'text', 'text' => 'XL Vask wash '],
|
||||
['type' => 'xlvask_usage_log', 'text' => (string)($params['wash_id'] ?? '')],
|
||||
['type' => 'xlvask_usage_log', 'text' => 'XL Vask wash'],
|
||||
['type' => 'text', 'text' => ' is neither ignored nor linked to an order in the selected period.'],
|
||||
],
|
||||
default => [],
|
||||
@@ -1805,7 +1785,7 @@ class invoice_period_flag_service
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers, ?bool $requireReg2Empty = null): array
|
||||
private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
@@ -1826,16 +1806,6 @@ class invoice_period_flag_service
|
||||
$registrationFilter = implode(',', array_map(static function (string $registrationNumber) use ($db): string {
|
||||
return "'" . $db->escape_string($registrationNumber) . "'";
|
||||
}, array_keys($registrations)));
|
||||
// Restrict historical orders to those whose reg_2 status matches the current rows:
|
||||
// - null → no filter (default behaviour, backwards compatible)
|
||||
// - true → reg_2 empty (single-tractor orders only)
|
||||
// - false → reg_2 non-empty (tractor-trailer combo orders only)
|
||||
$reg2Filter = '';
|
||||
if ($requireReg2Empty === true) {
|
||||
$reg2Filter = "AND (COALESCE(o.reg_2, '') = '')";
|
||||
} elseif ($requireReg2Empty === false) {
|
||||
$reg2Filter = "AND (COALESCE(o.reg_2, '') <> '')";
|
||||
}
|
||||
$result = $db->query(
|
||||
"SELECT UPPER(TRIM(o.reg_1)) AS reg, oi.product_id, p.name AS product_name, COUNT(*) AS usage_count
|
||||
FROM orders o
|
||||
@@ -1849,7 +1819,6 @@ class invoice_period_flag_service
|
||||
AND COALESCE(oi.related_item_id, 0) = 0
|
||||
AND COALESCE(o.reg_1, '') <> ''
|
||||
AND o.reg_1 IN ({$registrationFilter})
|
||||
{$reg2Filter}
|
||||
GROUP BY UPPER(TRIM(o.reg_1)), oi.product_id, p.name
|
||||
ORDER BY reg, usage_count DESC, oi.product_id ASC"
|
||||
);
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
require_once WD . '/modules/openAI/openAI_c.php';
|
||||
require_once WD . '/modules/miniMax/miniMax_c.php';
|
||||
|
||||
use Exception;
|
||||
use miniMax\miniMax_c;
|
||||
|
||||
/**
|
||||
* Thrown when a MiniMax API request fails. Extends openai_request_exception so the
|
||||
* autopilot's existing `catch (openai_request_exception $e)` blocks keep working
|
||||
* when the model is swapped from OpenAI to MiniMax — no other code needs to change.
|
||||
*/
|
||||
class minimax_request_exception extends openai_request_exception
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* MiniMax M3 client.
|
||||
*
|
||||
* Uses the Anthropic-messages format (https://api.minimax.io/anthropic/v1/messages),
|
||||
* which is the same endpoint OpenClaw's minimax-portal provider uses. The caller
|
||||
* can pass `MiniMax-M3` (and any other model the operator has provisioned) via
|
||||
* the `$model` argument.
|
||||
*
|
||||
* The response shape returned from jsonTask() matches openai::jsonTask() so callers
|
||||
* (notably xlvask_automation_service) can switch providers with minimal plumbing.
|
||||
*/
|
||||
class minimax
|
||||
{
|
||||
public miniMax_c $config;
|
||||
private string $api_url = 'https://api.minimax.io/anthropic/v1/messages';
|
||||
protected string $model = 'MiniMax-M3';
|
||||
protected string $temperature = '0.1';
|
||||
protected string $max_tokens = '4096';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->config = new miniMax_c();
|
||||
}
|
||||
|
||||
public function requireModuleEnabled(): void
|
||||
{
|
||||
if (!$this->config->enabled->isTrue()) {
|
||||
throw new Exception('MiniMax module is not enabled.');
|
||||
}
|
||||
$apiKey = trim((string)$this->config->api_key->getVariableValue());
|
||||
if ($apiKey === '') {
|
||||
throw new Exception('MiniMax API key is not configured.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a structured JSON text task to MiniMax M3 (Anthropic-messages format).
|
||||
*
|
||||
* Returns the parsed JSON object plus `_minimax_response_model` and `_minimax_usage`
|
||||
* so the autopilot can compare against the resolved model id and track tokens.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function jsonTask(
|
||||
string $schemaName,
|
||||
string $prompt,
|
||||
array $payload,
|
||||
array $schema,
|
||||
float $temperature = 0.1,
|
||||
?string $model = null
|
||||
): array {
|
||||
$this->requireModuleEnabled();
|
||||
|
||||
// Anthropic-messages uses a single `messages` array, system prompt is separate,
|
||||
// and structured output goes in `tools` with `input_schema`.
|
||||
$data = [
|
||||
'model' => $model ?? $this->model,
|
||||
'max_tokens' => (int)$this->max_tokens,
|
||||
'temperature' => $temperature,
|
||||
'system' => $prompt,
|
||||
'messages' => [
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
],
|
||||
],
|
||||
'tools' => [
|
||||
[
|
||||
'name' => $schemaName,
|
||||
'description' => 'Return the structured decision for the XL Vask automation planner.',
|
||||
'input_schema' => $schema,
|
||||
],
|
||||
],
|
||||
// Force the model to call the tool — guarantees a structured JSON object back.
|
||||
'tool_choice' => ['type' => 'tool', 'name' => $schemaName],
|
||||
];
|
||||
|
||||
$response = $this->sendRequest($data);
|
||||
return self::parseJsonTaskResponse($response, $schemaName);
|
||||
}
|
||||
|
||||
public static function parseJsonTaskResponse(array $response, string $expectedToolName): array
|
||||
{
|
||||
// Anthropic-messages stop_reason: end_turn | tool_use | max_tokens | stop_sequence
|
||||
$stopReason = (string)($response['stop_reason'] ?? '');
|
||||
if ($stopReason === 'max_tokens') {
|
||||
throw new minimax_request_exception('MiniMax response was truncated by max_tokens.', true);
|
||||
}
|
||||
if (!in_array($stopReason, ['end_turn', 'tool_use'], true)) {
|
||||
throw new minimax_request_exception('MiniMax response did not complete (stop_reason=' . $stopReason . ').', true);
|
||||
}
|
||||
|
||||
$toolInput = null;
|
||||
$toolName = null;
|
||||
foreach ((array)($response['content'] ?? []) as $block) {
|
||||
if (($block['type'] ?? null) === 'tool_use') {
|
||||
$toolName = (string)($block['name'] ?? '');
|
||||
$toolInput = (array)($block['input'] ?? []);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($toolInput === null) {
|
||||
throw new minimax_request_exception('MiniMax completed without a structured tool_use block.', false);
|
||||
}
|
||||
if ($toolName !== $expectedToolName) {
|
||||
throw new minimax_request_exception(
|
||||
'MiniMax returned tool "' . $toolName . '", expected "' . $expectedToolName . '".',
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
$resolvedModel = trim((string)($response['model'] ?? ''));
|
||||
if ($resolvedModel === '') {
|
||||
throw new minimax_request_exception('MiniMax response omitted the resolved model.', false);
|
||||
}
|
||||
$usage = (array)($response['usage'] ?? []);
|
||||
$inputTokens = max(0, (int)($usage['input_tokens'] ?? 0));
|
||||
$outputTokens = max(0, (int)($usage['output_tokens'] ?? 0));
|
||||
// The legacy `_openai_*` aliases keep the autopilot's sanitizeOpenAiResult() working
|
||||
// unchanged — it reads those keys regardless of which provider produced the result.
|
||||
return [
|
||||
...$toolInput,
|
||||
'_minimax_response_model' => $resolvedModel,
|
||||
'_minimax_usage' => [
|
||||
'input_tokens' => $inputTokens,
|
||||
'output_tokens' => $outputTokens,
|
||||
'total_tokens' => $inputTokens + $outputTokens,
|
||||
'service_tier' => '',
|
||||
],
|
||||
'_openai_response_model' => $resolvedModel,
|
||||
'_openai_usage' => [
|
||||
'input_tokens' => $inputTokens,
|
||||
'output_tokens' => $outputTokens,
|
||||
'total_tokens' => $inputTokens + $outputTokens,
|
||||
'service_tier' => '',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function sendRequest(array $data): array
|
||||
{
|
||||
$this->requireModuleEnabled();
|
||||
$curl = curl_init($this->api_url);
|
||||
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($curl, CURLOPT_POST, true);
|
||||
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 10);
|
||||
curl_setopt($curl, CURLOPT_TIMEOUT, 60);
|
||||
// MiniMax uses Anthropic-style auth headers
|
||||
curl_setopt($curl, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'x-api-key: ' . $this->config->api_key->getVariableValue(),
|
||||
'anthropic-version: 2023-06-01',
|
||||
]);
|
||||
curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
$response = curl_exec($curl);
|
||||
if (curl_errno($curl)) {
|
||||
$curlCode = curl_errno($curl);
|
||||
curl_close($curl);
|
||||
throw new minimax_request_exception(
|
||||
'MiniMax transport failed.',
|
||||
in_array($curlCode, [CURLE_OPERATION_TIMEDOUT, CURLE_COULDNT_CONNECT, CURLE_COULDNT_RESOLVE_HOST], true)
|
||||
);
|
||||
}
|
||||
$httpStatus = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($curl);
|
||||
$responseData = json_decode($response, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new minimax_request_exception('MiniMax returned a non-JSON response.', $httpStatus === 429 || $httpStatus >= 500, $httpStatus);
|
||||
}
|
||||
if ($httpStatus < 200 || $httpStatus >= 300 || !is_array($responseData)) {
|
||||
throw new minimax_request_exception('MiniMax request failed with HTTP status ' . $httpStatus . '.', $httpStatus === 429 || $httpStatus >= 500, $httpStatus);
|
||||
}
|
||||
return $responseData;
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,9 @@ namespace classes;
|
||||
use Exception;
|
||||
use mysqli_result;
|
||||
use Throwable;
|
||||
use traits\boolean_normalization_t;
|
||||
|
||||
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
|
||||
|
||||
class module_usage_service
|
||||
{
|
||||
use boolean_normalization_t;
|
||||
|
||||
private module_usage_registry $registry;
|
||||
|
||||
public function __construct(?module_usage_registry $registry = null)
|
||||
@@ -973,7 +968,10 @@ class module_usage_service
|
||||
|
||||
private function toBool(mixed $value): bool
|
||||
{
|
||||
return self::normalizeBoolean($value);
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
private function sqlString(string $value): string
|
||||
|
||||
@@ -7,7 +7,7 @@ use InvalidArgumentException;
|
||||
class order_item_reason_policy
|
||||
{
|
||||
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
|
||||
public const AFFECTED_PRODUCT_IDS = [21, 22, 25, 26, 27];
|
||||
public const AFFECTED_PRODUCT_IDS = [21, 22, 24, 25, 26, 27];
|
||||
|
||||
public static function reasons(): array
|
||||
{
|
||||
|
||||
@@ -33,31 +33,6 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,14 +2,8 @@
|
||||
|
||||
namespace classes;
|
||||
|
||||
use traits\boolean_normalization_t;
|
||||
|
||||
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
|
||||
|
||||
class releasemanager
|
||||
{
|
||||
use boolean_normalization_t;
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
try {
|
||||
@@ -17,7 +11,7 @@ class releasemanager
|
||||
global $db;
|
||||
$result = $db->query("SELECT value FROM module_config WHERE module = 'ReleaseManager' AND variable = 'enabled' LIMIT 1");
|
||||
$row = $result ? $result->fetch_assoc() : null;
|
||||
return self::normalizeBoolean((string)($row['value'] ?? 'true'));
|
||||
return in_array(strtolower(trim((string)($row['value'] ?? 'true'))), ['1', 'true', 'yes', 'on'], true);
|
||||
} catch (\Throwable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -6,14 +6,9 @@ use Aws\S3\S3Client;
|
||||
use mysqli;
|
||||
use Predis\Client as PredisClient;
|
||||
use Throwable;
|
||||
use traits\boolean_normalization_t;
|
||||
|
||||
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
|
||||
|
||||
class replica_failover_manager
|
||||
{
|
||||
use boolean_normalization_t;
|
||||
|
||||
public const KIND_DATABASE = 'database';
|
||||
public const KIND_REDIS = 'redis';
|
||||
public const KIND_MINIO = 'minio';
|
||||
@@ -507,7 +502,11 @@ class replica_failover_manager
|
||||
|
||||
private static function boolValue(mixed $value): bool
|
||||
{
|
||||
return self::normalizeBoolean($value);
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
private static function jsonDecode(mixed $value): array
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Self-healing schema bootstrap.
|
||||
*
|
||||
* Runs every `*_schema_bootstrap::ensureSchema()` on app start so the
|
||||
* production database always has the columns the current code expects.
|
||||
* This catches the "merged-to-master-but-never-applied-to-prod" failure
|
||||
* mode (e.g. TRU-77 invoice_email) where the deploy pipeline pre-deploy
|
||||
* step didn't run (missing GitHub secrets, network glitch, etc.).
|
||||
*
|
||||
* Each bootstrap is **additive + idempotent**:
|
||||
* - SHOW COLUMNS check before any ALTER
|
||||
* - ALTER TABLE ADD COLUMN only if missing
|
||||
* - Once `ensureSchema()` has been called once for a class, the static
|
||||
* `$initialized` flag short-circuits subsequent calls
|
||||
*
|
||||
* The discovery + run loop itself is memoized per PHP process via
|
||||
* `self::$ran`, so the cost after the first request is a single
|
||||
* `class_exists` check (~microseconds).
|
||||
*
|
||||
* Errors in a single bootstrap are logged but never throw — a broken
|
||||
* migration must not 500 every request. A future /api/admin/schema-check
|
||||
* call will surface the failure.
|
||||
*/
|
||||
class schema_bootstrap_runtime
|
||||
{
|
||||
/** @var bool Memoization for the discovery+run loop */
|
||||
private static bool $ran = false;
|
||||
|
||||
/** @var string[] Class names that already failed this process (don't retry) */
|
||||
private static array $failed = [];
|
||||
|
||||
public static function runAll(): void
|
||||
{
|
||||
if (self::$ran) {
|
||||
return;
|
||||
}
|
||||
self::$ran = true;
|
||||
|
||||
$classesDir = __DIR__;
|
||||
$bootstraps = glob($classesDir . DIRECTORY_SEPARATOR . '*_schema_bootstrap.php');
|
||||
if (!$bootstraps) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($bootstraps as $file) {
|
||||
$base = basename($file, '.php');
|
||||
$class = "classes\\{$base}";
|
||||
|
||||
if (in_array($class, self::$failed, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!class_exists($class)) {
|
||||
require_once $file;
|
||||
}
|
||||
if (!class_exists($class)) {
|
||||
continue;
|
||||
}
|
||||
if (!method_exists($class, 'ensureSchema')) {
|
||||
continue;
|
||||
}
|
||||
$class::ensureSchema();
|
||||
} catch (\Throwable $e) {
|
||||
self::$failed[] = $class;
|
||||
error_log(sprintf(
|
||||
'[schema-bootstrap] %s failed: %s',
|
||||
$base,
|
||||
$e->getMessage()
|
||||
));
|
||||
// Intentionally do not throw — a broken migration must
|
||||
// not 500 every request. The next /api/admin/schema-check
|
||||
// call (or the next deploy's pre-deploy step) will
|
||||
// surface the failure.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = static::get_department_webhook($department_id);
|
||||
$webhook = self::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(static::send_webhook_message($message, $webhook));
|
||||
self::add_log(self::send_webhook_message($message, $webhook));
|
||||
return $this;
|
||||
}
|
||||
|
||||
protected function get_department_webhook(int $department_id): string|null
|
||||
private function get_department_webhook(int $department_id): string|null
|
||||
{
|
||||
// Check if the department webhook is cached
|
||||
$webhook = redis->get_department_webhook($department_id);
|
||||
@@ -134,68 +134,6 @@ 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,14 +4,9 @@ namespace classes;
|
||||
|
||||
use Aws\S3\S3Client;
|
||||
use Throwable;
|
||||
use traits\boolean_normalization_t;
|
||||
|
||||
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
|
||||
|
||||
class superuser_system_status_service
|
||||
{
|
||||
use boolean_normalization_t;
|
||||
|
||||
public const MODULE_PROBE_TTL_SECONDS = 60;
|
||||
public const REFRESH_AFTER_SECONDS = 30;
|
||||
private const MODULE_PROBE_CACHE_KEY_PREFIX = 'superuser_system_status:module_probe:';
|
||||
@@ -852,7 +847,7 @@ class superuser_system_status_service
|
||||
protected function parseModuleConfigValue(string $type, mixed $value): mixed
|
||||
{
|
||||
return match (strtolower($type)) {
|
||||
'bool' => self::normalizeBoolean($value),
|
||||
'bool' => in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true),
|
||||
'int', 'integer' => is_numeric($value) ? (int)$value : null,
|
||||
'float', 'double' => is_numeric($value) ? (float)$value : null,
|
||||
'json' => is_string($value) ? json_decode($value, true) : null,
|
||||
|
||||
@@ -9,13 +9,6 @@ class system_search_economic_customer_index
|
||||
{
|
||||
public const TABLE = 'system_search_economic_customer_index';
|
||||
|
||||
/**
|
||||
* FULLTEXT key name used by TRU-62 customer-search performance fix.
|
||||
* The column already exists (TEXT NULL `search_text`) — we just need
|
||||
* the index. See documentation/perf/customer-search-slow-investigation.md.
|
||||
*/
|
||||
public const FULLTEXT_INDEX = 'ft_sseci_search_text';
|
||||
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTable(): void
|
||||
@@ -57,14 +50,6 @@ class system_search_economic_customer_index
|
||||
'economic_barred',
|
||||
"ALTER TABLE `" . self::TABLE . "` ADD COLUMN `economic_barred` TINYINT(1) NULL AFTER `economic_mobile_phone`"
|
||||
);
|
||||
// TRU-62: ensure the FULLTEXT index used by the customer-search fast
|
||||
// path. Safe to call repeatedly: `ensureIndex` no-ops when the index
|
||||
// already exists. The search code falls back to the LIKE-based query
|
||||
// when this index is absent, so an incomplete migration is non-fatal.
|
||||
self::ensureIndex(
|
||||
self::FULLTEXT_INDEX,
|
||||
"ALTER TABLE `" . self::TABLE . "` ADD FULLTEXT INDEX `" . self::FULLTEXT_INDEX . "` (`search_text`)"
|
||||
);
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
@@ -377,39 +362,6 @@ class system_search_economic_customer_index
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure an index (FULLTEXT or otherwise) exists on the table.
|
||||
* No-ops when the index is already present so this is safe to call
|
||||
* repeatedly at request time.
|
||||
*/
|
||||
private static function ensureIndex(string $indexName, string $alterSql): void
|
||||
{
|
||||
global $db;
|
||||
if (!is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $db->query(
|
||||
"SHOW INDEX FROM `" . self::TABLE . "` WHERE `Key_name` = '"
|
||||
. $db->escape_string($indexName) . "'"
|
||||
);
|
||||
} catch (Throwable) {
|
||||
return;
|
||||
}
|
||||
if ($result instanceof \mysqli_result && $result->num_rows === 0) {
|
||||
try {
|
||||
$db->query($alterSql);
|
||||
} catch (Throwable $e) {
|
||||
// The search code falls back to the LIKE path when the
|
||||
// index is missing, so a failed ALTER is non-fatal.
|
||||
if (function_exists('error_log')) {
|
||||
@error_log('[system_search_economic_customer_index] failed to add index ' . $indexName . ': ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function barredStatus(?bool $barred): string
|
||||
{
|
||||
return match ($barred) {
|
||||
|
||||
@@ -720,18 +720,52 @@ class system_search_service
|
||||
$customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $customerNumbers)) . ')';
|
||||
}
|
||||
|
||||
// TRU-62: prefer the FULLTEXT path against the denormalized
|
||||
// `system_search_economic_customer_index.search_text` column. The
|
||||
// previous implementation ORed 13 un-indexable `LIKE '%term%'`
|
||||
// clauses, which dominated the ~10s request latency reported in
|
||||
// TRU-62. We only fall back to that LIKE path when the FULLTEXT
|
||||
// index is missing (e.g. migration not yet applied) or returns
|
||||
// zero rows for the query.
|
||||
$rows = $this->searchCustomersWithFulltext($terms, $customerFilter);
|
||||
if ($rows === null) {
|
||||
$rows = $this->searchCustomersWithLike($terms, $customerFilter);
|
||||
$selectFields = [
|
||||
'u.id',
|
||||
'u.customer_number',
|
||||
'u.display_name',
|
||||
'u.email',
|
||||
'u.phone',
|
||||
...$this->joinTemporalSelectFields('users', 'u'),
|
||||
];
|
||||
$searchFields = ['u.id', 'u.customer_number', 'u.display_name', 'u.email', 'u.phone'];
|
||||
$fromClause = 'users u';
|
||||
|
||||
if ($this->isEconomicCustomerIndexAvailable()) {
|
||||
$fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number';
|
||||
$selectFields = [
|
||||
...$selectFields,
|
||||
'sci.economic_name',
|
||||
'sci.economic_address',
|
||||
'sci.economic_city',
|
||||
'sci.economic_zip',
|
||||
'sci.economic_email',
|
||||
'sci.economic_cvr',
|
||||
'sci.economic_mobile_phone',
|
||||
'sci.search_text',
|
||||
];
|
||||
$searchFields = [
|
||||
...$searchFields,
|
||||
'sci.economic_name',
|
||||
'sci.economic_address',
|
||||
'sci.economic_city',
|
||||
'sci.economic_zip',
|
||||
'sci.economic_email',
|
||||
'sci.economic_cvr',
|
||||
'sci.economic_mobile_phone',
|
||||
'sci.search_text',
|
||||
];
|
||||
}
|
||||
|
||||
$rows = $this->searchTableWithJoin(
|
||||
'users',
|
||||
$fromClause,
|
||||
$selectFields,
|
||||
$searchFields,
|
||||
$terms,
|
||||
'1=1' . $customerFilter
|
||||
);
|
||||
|
||||
return array_map(function (array $row) use ($terms, $entityBoost) {
|
||||
$title = trim((string)($row['economic_name'] ?? ''));
|
||||
if ($title === '') {
|
||||
@@ -784,166 +818,6 @@ class system_search_service
|
||||
}, $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* TRU-62 — FULLTEXT path for customer search.
|
||||
*
|
||||
* Returns the matching rows from `users LEFT JOIN
|
||||
* system_search_economic_customer_index` using a `MATCH ... AGAINST`
|
||||
* query against the denormalized `search_text` column. This replaces
|
||||
* the 13-clause `LIKE '%term%'` OR chain that previously caused
|
||||
* ~10s customer-search latency. Returns `null` when the FULLTEXT
|
||||
* path is not available (index missing) or the boolean query is
|
||||
* empty (terms too short for the FULLTEXT minimum word length);
|
||||
* callers should then fall back to {@see searchCustomersWithLike()}.
|
||||
*
|
||||
* @param array<int, string> $terms
|
||||
* @return array<int, array<string, mixed>>|null
|
||||
*/
|
||||
private function searchCustomersWithFulltext(array $terms, string $customerFilter): ?array
|
||||
{
|
||||
if (empty($terms)) {
|
||||
return [];
|
||||
}
|
||||
if (!$this->isFulltextCustomerIndexAvailable()) {
|
||||
return null;
|
||||
}
|
||||
$booleanQuery = $this->buildBooleanFullTextQuery($terms);
|
||||
if ($booleanQuery === null) {
|
||||
// One or more terms are too short for the FULLTEXT minimum
|
||||
// word length. The LIKE path is the only viable option.
|
||||
return null;
|
||||
}
|
||||
|
||||
global $db;
|
||||
if (!is_object($db) || !method_exists($db, 'query')) {
|
||||
return null;
|
||||
}
|
||||
$escaped = $db->escape_string($booleanQuery);
|
||||
|
||||
$selectFields = [
|
||||
'u.id',
|
||||
'u.customer_number',
|
||||
'u.display_name',
|
||||
'u.email',
|
||||
'u.phone',
|
||||
'sci.economic_name',
|
||||
'sci.economic_address',
|
||||
'sci.economic_city',
|
||||
'sci.economic_zip',
|
||||
'sci.economic_email',
|
||||
'sci.economic_cvr',
|
||||
'sci.economic_mobile_phone',
|
||||
'sci.search_text',
|
||||
];
|
||||
$fromClause = 'users u LEFT JOIN `'
|
||||
. system_search_economic_customer_index::TABLE
|
||||
. '` sci ON sci.customer_number = u.customer_number';
|
||||
|
||||
$sql = "SELECT " . implode(', ', $selectFields)
|
||||
. " FROM " . $fromClause
|
||||
. " WHERE 1=1" . $customerFilter
|
||||
. " AND MATCH(sci.search_text) AGAINST ('" . $escaped . "' IN BOOLEAN MODE)"
|
||||
. " LIMIT " . $this->defaultEntityFetchLimit;
|
||||
|
||||
$rows = $this->runSelectRows($sql);
|
||||
if (empty($rows)) {
|
||||
// FULLTEXT is in use but the row set is empty. We could fall
|
||||
// back to LIKE here, but a fully-empty FULLTEXT result for a
|
||||
// customer-tab query usually means "no match" (the boolean
|
||||
// query already required all terms to be present). Avoid the
|
||||
// extra full-table scan and return an empty result set.
|
||||
return [];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* TRU-62 — original LIKE-based fallback for customer search. Kept
|
||||
* verbatim so that deployments which have not yet applied the
|
||||
* FULLTEXT migration still get correct results, just slowly.
|
||||
*
|
||||
* @param array<int, string> $terms
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function searchCustomersWithLike(array $terms, string $customerFilter): array
|
||||
{
|
||||
$selectFields = [
|
||||
'u.id',
|
||||
'u.customer_number',
|
||||
'u.display_name',
|
||||
'u.email',
|
||||
'u.phone',
|
||||
...$this->joinTemporalSelectFields('users', 'u'),
|
||||
];
|
||||
$searchFields = ['u.id', 'u.customer_number', 'u.display_name', 'u.email', 'u.phone'];
|
||||
$fromClause = 'users u';
|
||||
|
||||
if ($this->isEconomicCustomerIndexAvailable()) {
|
||||
$fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number';
|
||||
$selectFields = [
|
||||
...$selectFields,
|
||||
'sci.economic_name',
|
||||
'sci.economic_address',
|
||||
'sci.economic_city',
|
||||
'sci.economic_zip',
|
||||
'sci.economic_email',
|
||||
'sci.economic_cvr',
|
||||
'sci.economic_mobile_phone',
|
||||
'sci.search_text',
|
||||
];
|
||||
$searchFields = [
|
||||
...$searchFields,
|
||||
'sci.economic_name',
|
||||
'sci.economic_address',
|
||||
'sci.economic_city',
|
||||
'sci.economic_zip',
|
||||
'sci.economic_email',
|
||||
'sci.economic_cvr',
|
||||
'sci.economic_mobile_phone',
|
||||
'sci.search_text',
|
||||
];
|
||||
}
|
||||
|
||||
return $this->searchTableWithJoin(
|
||||
'users',
|
||||
$fromClause,
|
||||
$selectFields,
|
||||
$searchFields,
|
||||
$terms,
|
||||
'1=1' . $customerFilter
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the `system_search_economic_customer_index` table exists
|
||||
* AND the `ft_sseci_search_text` FULLTEXT index is present. The
|
||||
* index is added by the runtime schema bootstrap and the companion
|
||||
* migration at `database/migrations/2026_08_17_000002_*`.
|
||||
*/
|
||||
private function isFulltextCustomerIndexAvailable(): bool
|
||||
{
|
||||
if (!$this->isEconomicCustomerIndexAvailable()) {
|
||||
return false;
|
||||
}
|
||||
global $db;
|
||||
if (!is_object($db) || !method_exists($db, 'query')) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
$indexName = $db->escape_string(system_search_economic_customer_index::FULLTEXT_INDEX);
|
||||
$result = $db->query(
|
||||
"SHOW INDEX FROM `" . system_search_economic_customer_index::TABLE
|
||||
. "` WHERE `Key_name` = '" . $indexName . "'"
|
||||
);
|
||||
if (!($result instanceof \mysqli_result)) {
|
||||
return false;
|
||||
}
|
||||
return $result->num_rows > 0;
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function searchEmployees(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber): array
|
||||
{
|
||||
$rows = $this->searchTableWithJoin(
|
||||
|
||||
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,6 +106,10 @@ if ($args[1] === 'run') {
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON_WORKER] Starting cron worker\n";
|
||||
(new \classes\cron_worker())->run();
|
||||
break;
|
||||
case 'xlvask-automation-migrate':
|
||||
echo "[" . date('Y-m-d H:i:s') . "][XLVASK] Ensuring automation schema readiness\n";
|
||||
require_once 'cron/EnsureXLVaskAutomationSchema.php';
|
||||
break;
|
||||
default:
|
||||
echo "Invalid script name";
|
||||
break;
|
||||
|
||||
@@ -150,6 +150,12 @@ $cron_tasks = [
|
||||
'next_run' => 0,
|
||||
'function' => 'SyncXLVaskModuleCron',
|
||||
],
|
||||
'ProcessXLVaskAutopilotQueueCron' => [
|
||||
'interval' => 60,
|
||||
'last_run' => 0,
|
||||
'next_run' => 0,
|
||||
'function' => 'ProcessXLVaskAutopilotQueueCron',
|
||||
],
|
||||
'SystemSearchCacheMaintenanceCron' => [
|
||||
'interval' => 300, // 5 minutes
|
||||
'last_run' => 0,
|
||||
@@ -673,6 +679,15 @@ function SyncXLVaskModuleCron(): void
|
||||
}
|
||||
}
|
||||
|
||||
function ProcessXLVaskAutopilotQueueCron(): array
|
||||
{
|
||||
$xlvask = new xlvask();
|
||||
if (!$xlvask->config->enabled->isTrue()) {
|
||||
return [];
|
||||
}
|
||||
return $xlvask->getTasks()->processAutopilotQueue(3);
|
||||
}
|
||||
|
||||
function EconomicTransferQueueCron(): void
|
||||
{
|
||||
try {
|
||||
@@ -689,38 +704,6 @@ function EconomicTransferQueueCron(): void
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer rule "auto-send invoice on the 3rd business day each month"
|
||||
* (TRU-70 / DRIFT 9).
|
||||
*
|
||||
* The handler is a no-op on every day except the 3rd business day of
|
||||
* the current month (Europe/Copenhagen timezone). On that day it scans
|
||||
* the customers with the `autoSendInvoiceThirdBusinessDay` attribute
|
||||
* and enqueues every ready collected invoice for export via the
|
||||
* existing `economic_transfer_queue` machinery. The actual e-conomic
|
||||
* send is handled asynchronously by `EconomicTransferQueueCron`.
|
||||
*/
|
||||
function AutoSendInvoicesThirdBusinessDay(): void
|
||||
{
|
||||
try {
|
||||
$service = new \classes\auto_send_invoice_third_business_day_service();
|
||||
$summary = $service->runOnce();
|
||||
if (!empty($summary['triggered'])) {
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] AutoSendInvoicesThirdBusinessDay trigger_date="
|
||||
. ($summary['trigger_date'] ?? '')
|
||||
. " customers=" . (int)($summary['customers'] ?? 0)
|
||||
. " collections=" . (int)($summary['collections_scanned'] ?? 0)
|
||||
. " enqueued=" . (int)($summary['jobs_enqueued'] ?? 0)
|
||||
. " already_queued=" . (int)($summary['skipped_already_queued'] ?? 0)
|
||||
. " errors=" . count($summary['errors'] ?? [])
|
||||
. "\n";
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
warn('AutoSendInvoicesThirdBusinessDay failed: ' . $e->getMessage());
|
||||
error_log('[cron-auto-send-third-business-day] failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
function PruneSystemSessionActivityCron(): void
|
||||
{
|
||||
try {
|
||||
@@ -1421,49 +1404,7 @@ function GoalsProgressAlertsCron(): void
|
||||
case Dest::SLACK:
|
||||
$departments = (array)$goal->departments->value();
|
||||
$sentToDept = false;
|
||||
$internalDepartmentIds = [];
|
||||
try {
|
||||
$slackConfig = new Slack();
|
||||
if (method_exists($slackConfig, 'get_internal_department_ids')) {
|
||||
$internalDepartmentIds = array_map('intval', (array)$slackConfig->get_internal_department_ids());
|
||||
}
|
||||
} catch (Throwable $slackConfigError) {
|
||||
// Ignore - falls back to per-department webhooks
|
||||
$internalDepartmentIds = [];
|
||||
}
|
||||
$goalDeptIds = [];
|
||||
foreach ($departments as $deptId) {
|
||||
if (is_numeric($deptId)) {
|
||||
$goalDeptIds[] = (int)$deptId;
|
||||
}
|
||||
}
|
||||
$allInternal = count($goalDeptIds) > 0
|
||||
&& count(array_diff($goalDeptIds, $internalDepartmentIds)) === 0;
|
||||
|
||||
if ($allInternal) {
|
||||
// TRU-76: For internal departments (e.g. Taulov/Taastrup DHL daily
|
||||
// goal), post to the dedicated internal goal progress webhook
|
||||
// instead of per-department webhooks, which are typically empty
|
||||
// for internal locations.
|
||||
$internalWebhook = '';
|
||||
try {
|
||||
$slackInstance = new Slack();
|
||||
if (method_exists($slackInstance, 'get_internal_department_goal_progress_webhook_url')) {
|
||||
$internalWebhook = trim((string)$slackInstance->get_internal_department_goal_progress_webhook_url());
|
||||
}
|
||||
} catch (Throwable $internalWebhookError) {
|
||||
$internalWebhook = '';
|
||||
}
|
||||
if ($internalWebhook !== '') {
|
||||
(new Slack())->send_webhook_message((string)goals_progress_alert_renderer::render($criteria), $internalWebhook);
|
||||
$sentToDept = true;
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] GoalsProgressAlertsCron: goal #" . $goalId . " sent to internal goal progress webhook (departments: " . implode(',', $goalDeptIds) . ")\n";
|
||||
} else {
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] GoalsProgressAlertsCron: goal #" . $goalId . " has only internal departments but internal_department_goal_progress_webhook_url is empty; falling back to per-department webhooks\n";
|
||||
}
|
||||
}
|
||||
|
||||
if (!$sentToDept && count($departments) > 0) {
|
||||
if (count($departments) > 0) {
|
||||
foreach ($departments as $deptId) {
|
||||
if (!is_numeric($deptId)) { continue; }
|
||||
$dept = (new departments_o())->select((int)$deptId);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
use classes\xlvask_usage_logs_schema_bootstrap;
|
||||
use xlvask\migrations\migration_20260804_xlvask_ai_auto_policy_v2;
|
||||
|
||||
require_once WD . '/classes/xlvask_usage_logs_schema_bootstrap.php';
|
||||
require_once WD . '/classes/xlvask_autopilot_service.php';
|
||||
require_once WD . '/modules/xlvask/migrations/20260804_xlvask_ai_auto_policy_v2.php';
|
||||
|
||||
if (!defined('WD')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$dbTarget = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'live')));
|
||||
$startedAt = date('Y-m-d H:i:s');
|
||||
echo "[{$startedAt}][XLVASK] Target database: {$dbTarget}" . PHP_EOL;
|
||||
|
||||
$result = [
|
||||
'success' => false,
|
||||
'db_target' => $dbTarget,
|
||||
'preflight' => null,
|
||||
'applied' => false,
|
||||
'postflight' => null,
|
||||
'wash_id_uniqueness_ready' => false,
|
||||
'wash_id_uniqueness_activated' => false,
|
||||
'wash_id_uniqueness_blocked' => false,
|
||||
'error' => null,
|
||||
];
|
||||
|
||||
try {
|
||||
$preflight = migration_20260804_xlvask_ai_auto_policy_v2::preflight();
|
||||
$result['preflight'] = $preflight;
|
||||
|
||||
if (!(bool)($preflight['ready'] ?? false)) {
|
||||
$result['postflight'] = migration_20260804_xlvask_ai_auto_policy_v2::apply();
|
||||
$result['applied'] = true;
|
||||
} else {
|
||||
$result['postflight'] = $preflight;
|
||||
}
|
||||
|
||||
$postflight = (array)$result['postflight'];
|
||||
if (!(bool)($postflight['ready'] ?? false)) {
|
||||
throw new RuntimeException('XL Vask automation schema is still not ready after apply.');
|
||||
}
|
||||
|
||||
if (xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady()) {
|
||||
$result['wash_id_uniqueness_ready'] = true;
|
||||
} else {
|
||||
$activated = xlvask_usage_logs_schema_bootstrap::applyWashIdUniquenessMigration();
|
||||
$result['wash_id_uniqueness_ready'] = $activated && xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady();
|
||||
$result['wash_id_uniqueness_activated'] = $result['wash_id_uniqueness_ready'];
|
||||
$result['wash_id_uniqueness_blocked'] = !$result['wash_id_uniqueness_ready'];
|
||||
}
|
||||
|
||||
$result['success'] = (bool)$result['wash_id_uniqueness_ready'];
|
||||
if (!$result['success']) {
|
||||
$result['error'] = 'Wash-id uniqueness is blocked, likely due duplicate normalized wash_id values.';
|
||||
}
|
||||
} catch (Throwable $throwable) {
|
||||
// The wrapper cron entry may swallow the runtime exception that is
|
||||
// re-thrown below, so emit a container-log breadcrumb here too.
|
||||
error_log('[cron-ensure-xlvask-automation-schema] apply failed: ' . $throwable->getMessage());
|
||||
$result['error'] = $throwable->getMessage();
|
||||
}
|
||||
|
||||
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL;
|
||||
|
||||
if (!$result['success']) {
|
||||
throw new RuntimeException((string)($result['error'] ?: 'XL Vask schema readiness failed.'));
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Migration: create_api_keys_table
|
||||
* Issue: TRU-143 — [Backend] API key data model + storage schema
|
||||
* Date: 2026-08-17
|
||||
*
|
||||
* NOTE: This codebase does not run a migration framework; the
|
||||
* canonical DDL is applied idempotently at runtime by
|
||||
* `classes/api_key_schema_bootstrap.php`. This file is the
|
||||
* human-readable change record / source of truth for the schema.
|
||||
*
|
||||
* To apply manually:
|
||||
* mysql -u <user> -p <database> < 2026_08_17_000001_create_api_keys_table.sql
|
||||
*/
|
||||
|
||||
return [
|
||||
'id' => '2026_08_17_000001_create_api_keys_table',
|
||||
'issue' => 'TRU-143',
|
||||
'table' => 'api_keys',
|
||||
'engine' => 'InnoDB',
|
||||
'charset' => 'utf8mb4',
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'up' => [
|
||||
"CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
key_id VARCHAR(64) NOT NULL,
|
||||
key_hash VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(32) NOT NULL,
|
||||
scopes JSON NULL,
|
||||
customer_id BIGINT UNSIGNED NULL,
|
||||
created_by BIGINT UNSIGNED NULL,
|
||||
last_used_at TIMESTAMP NULL,
|
||||
expires_at TIMESTAMP NULL,
|
||||
revoked_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_api_keys_key_id (key_id),
|
||||
INDEX idx_api_keys_customer (customer_id),
|
||||
INDEX idx_api_keys_key_hash (key_hash),
|
||||
INDEX idx_api_keys_revoked (revoked_at),
|
||||
INDEX idx_api_keys_role (role)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
],
|
||||
'down' => [
|
||||
'DROP TABLE IF EXISTS api_keys',
|
||||
],
|
||||
];
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Migration: add_fulltext_to_system_search_economic_customer_index
|
||||
* Issue: TRU-62 — System is very slow - search on customer tab ~10s
|
||||
* Date: 2026-08-17
|
||||
*
|
||||
* The `system_search_economic_customer_index.search_text` column is a
|
||||
* denormalized blob containing all customer-name / address / email / phone
|
||||
* data concatenated. The customer search currently runs
|
||||
*
|
||||
* `field LIKE '%term%'`
|
||||
*
|
||||
* for 13 fields, which forces a full table scan and dominates the
|
||||
* ~10s request latency. A FULLTEXT index on the same column lets the
|
||||
* same search run in tens of milliseconds.
|
||||
*
|
||||
* NOTE: This codebase does not run a migration framework; the canonical
|
||||
* DDL is applied idempotently at runtime by
|
||||
* `classes/system_search_economic_customer_index::ensureTable()`. This
|
||||
* file is the human-readable change record / source of truth for the
|
||||
* schema. See TRU-62 investigation doc at
|
||||
* `documentation/perf/customer-search-slow-investigation.md`.
|
||||
*
|
||||
* To apply manually:
|
||||
* mysql -u <user> -p <database> \
|
||||
* -e "ALTER TABLE \`system_search_economic_customer_index\`
|
||||
* ADD FULLTEXT INDEX \`ft_sseci_search_text\` (\`search_text\`);"
|
||||
*/
|
||||
|
||||
return [
|
||||
'id' => '2026_08_17_000002_add_fulltext_to_system_search_economic_customer_index',
|
||||
'issue' => 'TRU-62',
|
||||
'table' => 'system_search_economic_customer_index',
|
||||
'up' => [
|
||||
'ALTER TABLE `system_search_economic_customer_index`
|
||||
ADD FULLTEXT INDEX `ft_sseci_search_text` (`search_text`)',
|
||||
],
|
||||
'down' => [
|
||||
'ALTER TABLE `system_search_economic_customer_index`
|
||||
DROP INDEX `ft_sseci_search_text`',
|
||||
],
|
||||
];
|
||||
@@ -213,18 +213,6 @@ try {
|
||||
$response->error($e->getMessage(), 500);
|
||||
}
|
||||
|
||||
// Self-healing schema bootstrap. Runs every *_schema_bootstrap::ensureSchema()
|
||||
// once per process. Each is additive + idempotent (SHOW COLUMNS check before
|
||||
// any ALTER), so this is safe on every request. Catches the
|
||||
// "merged-to-master-but-migration-never-applied" failure mode (e.g. TRU-77
|
||||
// invoice_email) even when the deploy pipeline pre-deploy step is skipped
|
||||
// (missing GitHub secrets, network glitch, manual deploy, etc.).
|
||||
try {
|
||||
\classes\schema_bootstrap_runtime::runAll();
|
||||
} catch (Throwable $e) {
|
||||
error_log('[schema-bootstrap] runtime::runAll() failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
release_manager::initializeRequestContext();
|
||||
$releaseIngressPath = (string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?: '');
|
||||
|
||||
@@ -61,16 +61,4 @@ return [
|
||||
'estimated_duration_ms' => 3000,
|
||||
'priority' => 20,
|
||||
],
|
||||
[
|
||||
'id' => 'economic.auto_send_invoices_third_business_day',
|
||||
'legacy_name' => 'AutoSendInvoicesThirdBusinessDay',
|
||||
'name' => 'Auto-send invoices on the 3rd business day each month',
|
||||
'description' => 'For customers with the autoSendInvoiceThirdBusinessDay attribute, enqueue every ready collected invoice for export on the 3rd business day of the month. The handler is a no-op on every other day.',
|
||||
'module' => 'economic',
|
||||
'handler' => 'AutoSendInvoicesThirdBusinessDay',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 86400],
|
||||
'timeout_seconds' => 900,
|
||||
'estimated_duration_ms' => 5000,
|
||||
'priority' => 25,
|
||||
],
|
||||
];
|
||||
|
||||
+7
-17
@@ -125,15 +125,10 @@ class economic_invoices_drafts_endpoint
|
||||
$customer = (new economic())->getCustomer($customer_number);
|
||||
|
||||
// Set the recipient details
|
||||
// Note: customer_* fields come from e-conomic itself (controlled input),
|
||||
// but we sanitize them defensively to avoid 400s if e-conomic ever stores
|
||||
// a value with chars e-conomic later rejects in the recipient block.
|
||||
// Each field uses an appropriate length cap to match the corresponding
|
||||
// e-conomic recipient field limits.
|
||||
$customer_name = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getName() ?? 'Ukendt', 100);
|
||||
$customer_address = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getAddress() ?? 'Ukendt', 250);
|
||||
$customer_zip = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getZipCode() ?? 'Ukendt', 20);
|
||||
$customer_city = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getCity() ?? 'Ukendt', 100);
|
||||
$customer_name = $customer->getName() ?? 'Ukendt';
|
||||
$customer_address = $customer->getAddress() ?? 'Ukendt';
|
||||
$customer_zip = $customer->getZipCode() ?? 'Ukendt';
|
||||
$customer_city = $customer->getCity() ?? 'Ukendt';
|
||||
$recipient = [
|
||||
'name' => $customer_name,
|
||||
'address' => $customer_address,
|
||||
@@ -144,14 +139,9 @@ class economic_invoices_drafts_endpoint
|
||||
],
|
||||
];
|
||||
$customer_ean = $customer->getEan();
|
||||
if ($customer_ean !== null && $customer_ean !== '') {
|
||||
// EAN should be digits only; sanitize to strip anything that slipped through
|
||||
$recipient['ean'] = preg_replace('/[^0-9]/', '', $customer_ean);
|
||||
if ($recipient['ean'] !== '') {
|
||||
$recipient['nemHandelType'] = 'ean';
|
||||
} else {
|
||||
unset($recipient['ean']);
|
||||
}
|
||||
if ($customer_ean !== null) {
|
||||
$recipient['ean'] = $customer_ean;
|
||||
$recipient['nemHandelType'] = 'ean';
|
||||
}
|
||||
$public_entry_number = $customer->getPublicEntryNumber();
|
||||
if ($public_entry_number !== null) {
|
||||
|
||||
@@ -42,13 +42,6 @@ class economic_invoice_draft
|
||||
*/
|
||||
protected float $conversion_rate = 1.0;
|
||||
|
||||
/**
|
||||
* Whether pre-flight validation runs inside addLines() before sending to e-conomic.
|
||||
* Defense in depth — even after sanitization, a final check catches anything that slips through.
|
||||
* @var bool $preflight_enabled
|
||||
*/
|
||||
protected bool $preflight_enabled = true;
|
||||
|
||||
|
||||
/**
|
||||
* Construct a new Economic draft invoice object
|
||||
@@ -123,142 +116,9 @@ class economic_invoice_draft
|
||||
*/
|
||||
public function addLines(): void
|
||||
{
|
||||
if ($this->preflight_enabled) {
|
||||
$this->preflightValidate($this->draft_lines, null);
|
||||
}
|
||||
$this->flushLinesInBatches();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-flight validation: defense in depth before sending to e-conomic.
|
||||
* Validates each line against 5 rules and throws RuntimeException on the first violation.
|
||||
*
|
||||
* Rules (in order, per line):
|
||||
* 1. description — must be non-empty after trim()
|
||||
* 2. description — must be <= 250 chars
|
||||
* 3. productNumber (if present in product.productNumber) — must match /^[A-Za-z0-9._-]{1,50}$/
|
||||
* 4. quantity — must be a positive number (> 0)
|
||||
* 5. unitNetPrice — must be a number (>= 0)
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $lines
|
||||
* @param int|null $orderId Optional order id for log context
|
||||
* @throws \RuntimeException on any rule violation
|
||||
*/
|
||||
public function preflightValidate(array $lines, ?int $orderId = null): void
|
||||
{
|
||||
foreach ($lines as $i => $line) {
|
||||
if (!is_array($line)) {
|
||||
$this->logAndThrow(
|
||||
$i,
|
||||
$orderId,
|
||||
'line is not an array',
|
||||
$line
|
||||
);
|
||||
}
|
||||
|
||||
// Rule 1 + 2: description
|
||||
$description = $line['description'] ?? null;
|
||||
if ($description === null) {
|
||||
$description = '';
|
||||
}
|
||||
if (!is_scalar($description)) {
|
||||
$description = (string)$description;
|
||||
} else {
|
||||
$description = (string)$description;
|
||||
}
|
||||
$descriptionTrimmed = trim($description);
|
||||
if ($descriptionTrimmed === '') {
|
||||
$this->logAndThrow(
|
||||
$i,
|
||||
$orderId,
|
||||
'description is empty',
|
||||
$description
|
||||
);
|
||||
}
|
||||
if (mb_strlen($descriptionTrimmed) > 250) {
|
||||
$this->logAndThrow(
|
||||
$i,
|
||||
$orderId,
|
||||
'description exceeds 250 chars (length=' . mb_strlen($descriptionTrimmed) . ')',
|
||||
$description
|
||||
);
|
||||
}
|
||||
|
||||
// Rule 3: productNumber (only if present in product.productNumber)
|
||||
if (isset($line['product']) && is_array($line['product']) && array_key_exists('productNumber', $line['product'])) {
|
||||
$productNumber = $line['product']['productNumber'];
|
||||
if ($productNumber === null) {
|
||||
$productNumber = '';
|
||||
} else {
|
||||
$productNumber = (string)$productNumber;
|
||||
}
|
||||
if (!preg_match('/^[A-Za-z0-9._-]{1,50}$/', $productNumber)) {
|
||||
$this->logAndThrow(
|
||||
$i,
|
||||
$orderId,
|
||||
'productNumber does not match /^[A-Za-z0-9._-]{1,50}$/',
|
||||
$productNumber
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 4: quantity — only required if present in the line (text lines omit it)
|
||||
if (array_key_exists('quantity', $line)) {
|
||||
$quantity = $line['quantity'];
|
||||
if (!is_numeric($quantity) || (float)$quantity <= 0) {
|
||||
$this->logAndThrow(
|
||||
$i,
|
||||
$orderId,
|
||||
'quantity is not a positive number',
|
||||
$quantity
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 5: unitNetPrice — only required if present in the line
|
||||
if (array_key_exists('unitNetPrice', $line)) {
|
||||
$unitNetPrice = $line['unitNetPrice'];
|
||||
if (!is_numeric($unitNetPrice) || (float)$unitNetPrice < 0) {
|
||||
$this->logAndThrow(
|
||||
$i,
|
||||
$orderId,
|
||||
'unitNetPrice is not a number >= 0',
|
||||
$unitNetPrice
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the offending line and throw a RuntimeException.
|
||||
*/
|
||||
private function logAndThrow(int $lineIndex, ?int $orderId, string $rule, mixed $value): never
|
||||
{
|
||||
$valueTruncated = is_scalar($value) ? (string)$value : json_encode($value);
|
||||
if ($valueTruncated === false) {
|
||||
$valueTruncated = '[unserializable]';
|
||||
}
|
||||
if (mb_strlen($valueTruncated) > 200) {
|
||||
$valueTruncated = mb_substr($valueTruncated, 0, 200) . '...';
|
||||
}
|
||||
$orderContext = $orderId === null ? 'order=n/a' : 'order=' . $orderId;
|
||||
error_log(sprintf(
|
||||
'[preflight] validation failed: %s | line=%d | %s | value=%s',
|
||||
$rule,
|
||||
$lineIndex,
|
||||
$orderContext,
|
||||
$valueTruncated
|
||||
));
|
||||
$orderPart = $orderId === null ? '' : ' (order ' . $orderId . ')';
|
||||
throw new \RuntimeException(sprintf(
|
||||
'Preflight validation failed for line %d: %s%s',
|
||||
$lineIndex,
|
||||
$rule,
|
||||
$orderPart
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add queued draft lines using chunked requests.
|
||||
*
|
||||
@@ -325,55 +185,45 @@ class economic_invoice_draft
|
||||
$department_name = (new departments_o())->getDepartmentName((int)$order->department_id->value());
|
||||
// Parse the date of the transaction.
|
||||
$parsed_date = date('d/m/Y H:i', strtotime($order->created_at->value()));
|
||||
// Sanitize the department name (could contain "/" or other chars)
|
||||
$department_name = \classes\economic_export_sanitizer::sanitizeTextLine($department_name, 100);
|
||||
// Add the text line to the draft invoice
|
||||
self::addTextLine("[ " . $parsed_date . ' ' . $department_name . ' #' . $order->id . " ]");
|
||||
// If there's a PO number, add it to the invoice
|
||||
if ($order->po->value() !== '') {
|
||||
self::addTextLine('PO: ' . \classes\economic_export_sanitizer::sanitizeTextLine($order->po->value()));
|
||||
self::addTextLine('PO: ' . $order->po->value());
|
||||
}
|
||||
// If there's a reference, add it to the invoice
|
||||
$reference_value = $order->reference->value();
|
||||
if ($reference_value !== '') {
|
||||
if ($order->reference->value() !== '') {
|
||||
self::addTextLine('Reference:');
|
||||
// Sanitize the whole reference (handles "/" → "-" per TRU-188)
|
||||
$reference_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($reference_value);
|
||||
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
|
||||
if (str_contains($reference_sanitized, "\n")) {
|
||||
foreach ( explode("\n", $reference_sanitized) as $line ) {
|
||||
if (str_contains($order->reference->value(), "\n")) {
|
||||
foreach ( explode("\n", $order->reference->value()) as $line ) {
|
||||
self::addTextLine('# ' . $line);
|
||||
}
|
||||
} else {
|
||||
self::addTextLine('# ' . $reference_sanitized);
|
||||
self::addTextLine('# ' . $order->reference->value());
|
||||
}
|
||||
}
|
||||
// Add the registration numbers (if any)
|
||||
$line_reg = '';
|
||||
if ($order->reg_1->value() !== '') {
|
||||
$line_reg .= 'Reg 1: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_1->value(), 50));
|
||||
}
|
||||
if ($order->reg_2->value() !== '') {
|
||||
$line_reg .= ', Reg 2: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_2->value(), 50));
|
||||
}
|
||||
if ($order->reg_3->value() !== '') {
|
||||
$line_reg .= ', Reg 3: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_3->value(), 50));
|
||||
}
|
||||
if ($order->reg_1->value() !== '')
|
||||
$line_reg .= 'Reg 1: ' . strtoupper($order->reg_1->value());
|
||||
if ($order->reg_2->value() !== '')
|
||||
$line_reg .= ', Reg 2: ' . strtoupper($order->reg_2->value());
|
||||
if ($order->reg_3->value() !== '')
|
||||
$line_reg .= ', Reg 3: ' . strtoupper($order->reg_3->value());
|
||||
// Add the line to the invoice (If there's any registration numbers)
|
||||
if ($line_reg !== '')
|
||||
self::addTextLine($line_reg);
|
||||
// If there's a note, add it to the invoice
|
||||
$notes_value = $order->notes->value();
|
||||
if ($notes_value !== '') {
|
||||
if ($order->notes->value() !== '') {
|
||||
self::addTextLine('Notat:');
|
||||
// Sanitize notes (could contain "/", newlines, special chars)
|
||||
$notes_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($notes_value);
|
||||
if (str_contains($notes_sanitized, "\n")) {
|
||||
foreach ( explode("\n", $notes_sanitized) as $line ) {
|
||||
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
|
||||
if (str_contains($order->notes->value(), "\n")) {
|
||||
foreach ( explode("\n", $order->notes->value()) as $line ) {
|
||||
self::addTextLine('# ' . $line);
|
||||
}
|
||||
} else {
|
||||
self::addTextLine('# ' . $notes_sanitized);
|
||||
self::addTextLine('# ' . $order->notes->value());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -386,26 +236,11 @@ class economic_invoice_draft
|
||||
*/
|
||||
public function addTextLine(string $text): void
|
||||
{
|
||||
// Defense in depth: sanitize ALL text lines at insertion time.
|
||||
// This catches anything that wasn't pre-sanitized at the call site.
|
||||
$sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($text);
|
||||
if ($sanitized === '') {
|
||||
return; // Skip empty/whitespace-only lines
|
||||
}
|
||||
$this->draft_lines[] = [
|
||||
'description' => $sanitized
|
||||
'description' => $text
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current draft lines (read-only view).
|
||||
* Used by integration tests; production code uses addLines() to send.
|
||||
*/
|
||||
public function getDraftLines(): array
|
||||
{
|
||||
return $this->draft_lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an order to the draft invoice
|
||||
* @note The lines won't be saved until the addLines() method is called.
|
||||
@@ -522,28 +357,26 @@ class economic_invoice_draft
|
||||
// If there's a reference, add it to the line
|
||||
if ($order_item['reference'] !== '') {
|
||||
self::addTextLine('Reference:');
|
||||
// Sanitize the reference (handles "/" → "-" per TRU-188)
|
||||
$item_reference_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($order_item['reference']);
|
||||
if (str_contains($item_reference_sanitized, "\n")) {
|
||||
foreach ( explode("\n", $item_reference_sanitized) as $line ) {
|
||||
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
|
||||
if (str_contains($order_item['reference'], "\n")) {
|
||||
foreach ( explode("\n", $order_item['reference']) as $line ) {
|
||||
self::addTextLine('# ' . $line);
|
||||
}
|
||||
} else {
|
||||
self::addTextLine('# ' . $item_reference_sanitized);
|
||||
self::addTextLine('# ' . $order_item['reference']);
|
||||
}
|
||||
}
|
||||
|
||||
// If there's a note, add it to the line
|
||||
if (!empty($order_item['notes'])) {
|
||||
self::addTextLine('Notat:');
|
||||
// Sanitize notes (could contain "/", newlines, special chars)
|
||||
$item_notes_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($order_item['notes']);
|
||||
if (str_contains($item_notes_sanitized, "\n")) {
|
||||
foreach ( explode("\n", $item_notes_sanitized) as $line ) {
|
||||
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
|
||||
if (str_contains($order_item['notes'], "\n")) {
|
||||
foreach ( explode("\n", $order_item['notes']) as $line ) {
|
||||
self::addTextLine('# ' . $line);
|
||||
}
|
||||
} else {
|
||||
self::addTextLine('# ' . $item_notes_sanitized);
|
||||
self::addTextLine('# ' . $order_item['notes']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -653,13 +486,6 @@ class economic_invoice_draft
|
||||
*/
|
||||
public function addProductLine(string $productNumber, string $description, float $quantity, float $unitNetPrice, int $economic_department_id, int $dimension, float $discountPercentage = 0): void
|
||||
{
|
||||
// Sanitize product identifier and description (defense in depth — also done at addLines())
|
||||
$productNumber = \classes\economic_export_sanitizer::sanitizeProductNumber($productNumber);
|
||||
$description = \classes\economic_export_sanitizer::sanitizeProductDescription($description);
|
||||
// Skip if sanitization removed everything
|
||||
if ($productNumber === '' || $description === '') {
|
||||
return;
|
||||
}
|
||||
// We then add a 0 in front of the product number to make sure it's the right one, made by FlexPOS.
|
||||
// Add a line to the invoice
|
||||
$line = [
|
||||
|
||||
@@ -32,7 +32,7 @@ class email_template_stripe_invoice
|
||||
|
||||
<!-- Email template -->
|
||||
<p>Kære <?= $this->name ?>,</p>
|
||||
<p>Tak for din bestilling hos Truck Wash. Vi har sendt dig et betalingslink til din faktura.
|
||||
<p>Tak for din bestilling hos Truck Wash. Vi har sendt dig en faktura på Stripe.
|
||||
Du kan betale fakturaen ved at klikke på linket nedenfor:</p>
|
||||
<p><a href="<?= $this->stripe_payment_link ?>">Betal faktura for ordre <?= $this->order_id ?></a></p>
|
||||
<!-- End of the email template -->
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace miniMax\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class miniMax_api_key_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'miniMax',
|
||||
'api_key',
|
||||
'string',
|
||||
false,
|
||||
null,
|
||||
'The secret API key for MiniMax (M3). Obtain from the MiniMax Portal dashboard; the operator can rotate or remove it from the superuser XL Vask module settings.',
|
||||
'1',
|
||||
true,
|
||||
''
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace miniMax\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class miniMax_enabled_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'miniMax',
|
||||
'enabled',
|
||||
'bool',
|
||||
true,
|
||||
null,
|
||||
'Whether the MiniMax integration is enabled for XL Vask autopilot and other AI-driven features.',
|
||||
'1',
|
||||
false,
|
||||
'false'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace miniMax;
|
||||
require_once WD . '/modules/miniMax/config/miniMax_enabled_c.php';
|
||||
require_once WD . '/modules/miniMax/config/miniMax_api_key_c.php';
|
||||
|
||||
use miniMax\config\miniMax_api_key_c;
|
||||
use miniMax\config\miniMax_enabled_c;
|
||||
use traits\module_config_t;
|
||||
|
||||
class miniMax_c
|
||||
{
|
||||
use module_config_t;
|
||||
|
||||
public miniMax_enabled_c $enabled;
|
||||
public miniMax_api_key_c $api_key;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->setupConfig('miniMax');
|
||||
$this->allowUpdate([
|
||||
miniMax_enabled_c::class,
|
||||
miniMax_api_key_c::class,
|
||||
]);
|
||||
$this->enabled = new miniMax_enabled_c();
|
||||
$this->api_key = new miniMax_api_key_c();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
# XL Vask AI automation runbook
|
||||
|
||||
This runbook is an operator procedure. None of its gates are applied by deployment, HTTP GETs, constructors, or workers. Every production-changing step requires a human approval tied to the exact deployed backend and frontend SHAs.
|
||||
|
||||
## 0. Deploy-order and rollback invariant
|
||||
|
||||
The legacy attachment/creation config values are kill switches, but an old backend treats them as direct enable switches. Old code cannot interpret the new policy stages, calibration identity, rolling caps, action latches, or canary soak. Therefore old-backend traffic is forbidden whenever either legacy switch is true, including during a new-policy canary.
|
||||
|
||||
Use this exact forward sequence:
|
||||
|
||||
1. While the old backend is still serving, set both legacy automatic-order switches to false through the approved config procedure and verify the persisted values from every serving instance.
|
||||
2. Stop/disable old XL Vask automation workers and verify there is no active automatic run. Ordinary XL Vask synchronization may continue.
|
||||
3. Deploy the new backend with policy effectively `off`; verify ordinary synchronization still completes and scheduled automation no-ops while schema readiness is false.
|
||||
4. Run read-only migration preflight, then the separately approved explicit additive migration. If it is partial or fails, keep the new backend deployed, policy `off`, both legacy switches false, and workers no-op; repair or complete the migration before continuing. Never route old code as a partial-migration workaround.
|
||||
5. Verify migration readiness and the new backend SHA, then deploy/verify the compatible frontend. Only after that generate advisory evidence and use preview-bound policy transitions.
|
||||
|
||||
Use this exact rollback sequence before any old-code traffic:
|
||||
|
||||
1. Keep all traffic on the new backend, call the dedicated halt endpoint, and verify policy `halted` plus both persisted legacy switches false.
|
||||
2. Stop new-backend automation workers, wait for or safely reconcile the active run, and verify no financial mutation is in flight.
|
||||
3. Roll back the frontend if required, then deploy the old backend with both legacy switches still false. Verify ordinary sync only.
|
||||
4. Do not re-enable either legacy switch on old code. Recovery of automatic actions requires redeploying the new policy-aware backend and repeating readiness, advisory calibration, canary, and soak.
|
||||
|
||||
## 1. Read-only preflight
|
||||
|
||||
1. Record the backend/frontend SHAs, environment, operator, invoice period, and scanner-hall scope.
|
||||
2. Call the scoped capabilities and admin-readiness GETs with `dateFrom` and `dateTo`.
|
||||
3. Confirm `migration.ready`, `missing_tables`, `missing_columns`, `missing_indexes`, `preflight_conflicts`, `worker_healthy`, WashId uniqueness, planner identity, resolved model, active run, scoped eligible counts, rolling budgets, and reviewed soak counts.
|
||||
4. Stop if dates are invalid, hall scope is empty, an execute run is active, identity changed, a latch is halted, or any readiness field fails closed.
|
||||
|
||||
## 2. Explicit schema migration
|
||||
|
||||
Use the controlled database migration procedure to invoke only
|
||||
`migration_20260804_xlvask_ai_auto_policy_v2::apply()`. First retain its read-only
|
||||
`preflight()` output. Review the additive SQL and backup/restore point, approve the exact SHA, run it once, retain the returned status, and rerun readiness. Do not invoke `applyExplicitMigration()` from a request, worker, cron task, or application startup.
|
||||
If preflight reports multiple legacy execute runs in `queued`, `running`, or `retry_wait`, stop. Reconcile those runs through a separately approved operational procedure; the migration never auto-resolves or modifies the conflicting run records.
|
||||
|
||||
### 2a. Operator entry points
|
||||
|
||||
There are two equivalent ways to apply the migration from a privileged
|
||||
container with the configured DB credentials. Both call the same gated
|
||||
`migration_20260804_xlvask_ai_auto_policy_v2::apply()` entry point and
|
||||
produce identical status output. Pick whichever fits the workflow.
|
||||
|
||||
```
|
||||
# Option A — standalone script (mirrors scripts/account-deletion-schema.php)
|
||||
php scripts/xlvask-automation-migrate.php check # read-only preflight
|
||||
php scripts/xlvask-automation-migrate.php apply --yes # apply, gated by --yes
|
||||
|
||||
# Option B — CLI dispatcher inside index.php (defines WD + composes bootstrap)
|
||||
php index.php run xlvask-automation-migrate # preflight, applies if !ready
|
||||
```
|
||||
|
||||
Both exit 0 when `ready=true` and 1 otherwise. Always retain the JSON
|
||||
status artifact for the audit log and rerun `check` to confirm the
|
||||
postflight is green.
|
||||
|
||||
## 3. WashId uniqueness
|
||||
|
||||
Inspect normalized duplicate WashIds. Resolve conflicts through an independently approved data procedure. Only then use the guarded uniqueness activation with the exact typed phrase. Recheck the generated normalized column and unique index before any automatic action.
|
||||
|
||||
## 4. Advisory evidence and calibration
|
||||
|
||||
Keep policy at `advisory`. Run explicit `dry_run` requests to import and persist plans, or `replay` for cache-only read-only evaluation. Review suggestions in hall scope. Label exact OpenAI attach/create suggestions; model identity, prompt hash, schema hash, policy version, resolved model, and chronological label snapshot are part of the artifact identity. Generate inactive backtests, independently review qualification thresholds and contradictions, then activate the exact artifact hash with its typed phrase.
|
||||
|
||||
## 5. Staged policy transitions
|
||||
|
||||
Every transition uses a bounded human reason, server-generated policy preview, exact confirmation phrase, and apply-time revalidation. The reason is bound into the preview hash and retained in the immutable policy event:
|
||||
|
||||
`off` -> `advisory` -> `ai_attach_canary` -> `ai_attach_verified` -> `ai_create_canary` -> `verified_capped`
|
||||
|
||||
Stages may not be skipped. An active execute run, stale preview, changed policy version, changed model/planner identity, missing exact calibration, incomplete reviewed soak, invalid period scope, or exhausted readiness gate blocks promotion.
|
||||
|
||||
## 6. Reviewed soak and caps
|
||||
|
||||
Volume alone never completes soak. Every auto-accepted action must be adjudicated. Only explicit `correct` outcomes from the current action canary activation epoch count: 200 correct reviewed links before attach verification/create eligibility and 50 correct reviewed creates before `verified_capped`. `incorrect`, `duplicate`, `cross_hall`, or `unaudited` persistently halts the relevant action latch, invalidates the active action calibration in the same transaction, and requires investigation. Re-entering that canary creates a fresh soak epoch after a new qualifying calibration is activated.
|
||||
|
||||
Caps are atomic rolling 24-hour limits: 100 links globally and 10 per hall; 20 creates globally and 3 per hall. Cap exhaustion is a normal policy stop: the suggestion remains reviewable and the execute run pauses without recording a permanent action failure. Cap reservation, policy/model/calibration revalidation, current-candidate requery, financial locks, mutation, and audit commit in one transaction.
|
||||
|
||||
List responses intentionally use only persisted revision/hash eligibility and do not reconstruct same-day candidates per row. This avoids an unbounded N+1 query path. Candidate existence, uniqueness, customer/department/registration/lane/date/items/totals, and financial locks are authoritatively rebuilt during preview/apply and again inside the mutation transaction. Treat a preview/apply stale-candidate rejection as a normal fail-closed refresh signal; monitor list latency and preview rejection rates during advisory/canary.
|
||||
|
||||
## 7. Halt, recovery, and rollback
|
||||
|
||||
Use the dedicated halt endpoint immediately on any unexplained result, duplicate, cross-hall action, missing audit, model mismatch, financial invariant, worker lease failure, or upstream revision anomaly. Halt disables legacy compatibility switches and preserves the reason. Generic config may disable a switch but cannot enable it.
|
||||
|
||||
Rollback means: follow the exact sequence in section 0; halt; stop new execute runs; retain audit/action/review evidence; reconcile affected orders and invoice collections; restore data only through a separately approved, previewed procedure; fix and redeploy; repeat advisory calibration and staged previews. Recovery from `halted` starts at `off` or `advisory` and requires new exact-SHA human approval. Never infer activation, soak completion, or production safety from green CI alone.
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace xlvask\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class xlvask_automatic_order_attachment_enabled_c
|
||||
{
|
||||
use module_config_variable {
|
||||
setVariableValue as private setVariableValueInternal;
|
||||
}
|
||||
|
||||
private static bool $policyWrite = false;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'xlvask',
|
||||
'automatic_order_attachment_enabled',
|
||||
'bool',
|
||||
true,
|
||||
null,
|
||||
'Whether XL Vask usage logs may automatically be attached to existing same-day employee orders.',
|
||||
'0',
|
||||
false,
|
||||
'false'
|
||||
);
|
||||
}
|
||||
|
||||
/** Generic module config may kill automation but cannot activate it. */
|
||||
public function setVariableValue(mixed $value): void
|
||||
{
|
||||
if (!self::$policyWrite && self::inputToBool($value)) {
|
||||
throw new Exception('Automatic XL Vask attachment can only be enabled through the policy preview/apply flow.');
|
||||
}
|
||||
$this->setVariableValueInternal($value);
|
||||
}
|
||||
|
||||
public function setFromAutomationPolicy(bool $enabled): void
|
||||
{
|
||||
self::$policyWrite = true;
|
||||
try {
|
||||
$this->setVariableValueInternal($enabled);
|
||||
} finally {
|
||||
self::$policyWrite = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace xlvask\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class xlvask_automatic_order_creation_enabled_c
|
||||
{
|
||||
use module_config_variable {
|
||||
setVariableValue as private setVariableValueInternal;
|
||||
}
|
||||
|
||||
private static bool $policyWrite = false;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'xlvask',
|
||||
'automatic_order_creation_enabled',
|
||||
'bool',
|
||||
true,
|
||||
null,
|
||||
'Whether XL Vask usage logs may automatically create orders when no same-day order can be attached.',
|
||||
'0',
|
||||
false,
|
||||
'false'
|
||||
);
|
||||
}
|
||||
|
||||
/** Generic module config may kill automation but cannot activate it. */
|
||||
public function setVariableValue(mixed $value): void
|
||||
{
|
||||
if (!self::$policyWrite && self::inputToBool($value)) {
|
||||
throw new Exception('Automatic XL Vask creation can only be enabled through the policy preview/apply flow.');
|
||||
}
|
||||
$this->setVariableValueInternal($value);
|
||||
}
|
||||
|
||||
public function setFromAutomationPolicy(bool $enabled): void
|
||||
{
|
||||
self::$policyWrite = true;
|
||||
try {
|
||||
$this->setVariableValueInternal($enabled);
|
||||
} finally {
|
||||
self::$policyWrite = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace xlvask\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class xlvask_minimax_integration_enabled_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'xlvask',
|
||||
'minimax_integration_enabled',
|
||||
'bool',
|
||||
true,
|
||||
null,
|
||||
'Whether XL Vask automation may ask MiniMax (M3) for attachment or creation suggestions. Replaces the OpenAI integration.',
|
||||
'0',
|
||||
false,
|
||||
'false'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace xlvask\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class xlvask_openai_integration_enabled_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'xlvask',
|
||||
'openai_integration_enabled',
|
||||
'bool',
|
||||
true,
|
||||
null,
|
||||
'Whether XL Vask automation may ask OpenAI for attachment or creation suggestions.',
|
||||
'0',
|
||||
false,
|
||||
'false'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 'xlvask.autopilot_queue',
|
||||
'legacy_name' => 'ProcessXLVaskAutopilotQueueCron',
|
||||
'name' => 'Process XL Vask autopilot queue',
|
||||
'description' => 'Claims and processes a bounded batch of queued XL Vask autopilot runs.',
|
||||
'module' => 'xlvask',
|
||||
'handler' => 'ProcessXLVaskAutopilotQueueCron',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 60],
|
||||
'timeout_seconds' => 300,
|
||||
'estimated_duration_ms' => 5000,
|
||||
'priority' => 40,
|
||||
],
|
||||
[
|
||||
'id' => 'xlvask.sync_module',
|
||||
'legacy_name' => 'SyncXLVaskModuleCron',
|
||||
'name' => 'Sync XL Vask module',
|
||||
'description' => 'Runs scheduled XL Vask synchronization tasks when the module is enabled.',
|
||||
'module' => 'xlvask',
|
||||
'handler' => 'SyncXLVaskModuleCron',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 3600],
|
||||
'timeout_seconds' => 900,
|
||||
'estimated_duration_ms' => 10000,
|
||||
'priority' => 95,
|
||||
],
|
||||
];
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
namespace helpers;
|
||||
|
||||
require_once WD . '/classes/xlvask_autopilot_service.php';
|
||||
|
||||
use classes\xlvask_autopilot_service;
|
||||
use Exception;
|
||||
use objects\orders_o;
|
||||
use objects\plate_scanners_o;
|
||||
@@ -43,6 +46,9 @@ class xlvask_tasks
|
||||
$this->runSyncUsage();
|
||||
$this->runSyncVehicles();
|
||||
$this->runCleanupTasks();
|
||||
// Automation is an optional final phase. A pending migration or an
|
||||
// off/advisory policy must never interrupt the ordinary XL Vask sync.
|
||||
$this->runScheduledAutomationIfReady();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,6 +76,47 @@ class xlvask_tasks
|
||||
};
|
||||
}
|
||||
|
||||
/** Enqueue automatic work only after explicit migration and policy activation. */
|
||||
public function runScheduledAutomationIfReady(): array
|
||||
{
|
||||
try {
|
||||
$migrationStatus = \classes\xlvask_usage_logs_schema_bootstrap::migrationStatus();
|
||||
if (!(bool)($migrationStatus['ready'] ?? false)) {
|
||||
return [];
|
||||
}
|
||||
$hallIds = $this->configuredHallIds();
|
||||
if ($hallIds === []) {
|
||||
return [];
|
||||
}
|
||||
$autopilot = new xlvask_autopilot_service();
|
||||
$capabilities = (new \classes\xlvask_automation_policy_service())->capabilitiesReadOnly(null, null, $hallIds);
|
||||
if (!xlvask_autopilot_service::scheduledExecutionAllowed($migrationStatus, $capabilities)) {
|
||||
return [];
|
||||
}
|
||||
$autopilot->createRun(['mode' => 'execute'], null, $hallIds);
|
||||
return $autopilot->processQueuedRuns(3);
|
||||
} catch (\Throwable) {
|
||||
// Fail closed for automation while preserving the completed ordinary sync.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Drain the durable autopilot queue without running the hourly upstream synchronization. */
|
||||
public function processAutopilotQueue(int $limit = 3): array
|
||||
{
|
||||
$xlvask = new \classes\xlvask();
|
||||
$xlvask->requireModuleEnabled();
|
||||
if (!$xlvask->config->synchronization_enabled->isTrue()) {
|
||||
return [];
|
||||
}
|
||||
if (!(bool)(\classes\xlvask_usage_logs_schema_bootstrap::migrationStatus()['ready'] ?? false)) {
|
||||
return [];
|
||||
}
|
||||
// Off/advisory cannot contain execute runs because createRun is server-gated.
|
||||
// Explicit dry-run/replay evidence may still drain in advisory mode.
|
||||
return (new xlvask_autopilot_service())->processQueuedRuns($limit);
|
||||
}
|
||||
|
||||
/** Hall GUIDs are configuration, not derived from already-imported usage rows. */
|
||||
private function configuredHallIds(): array
|
||||
{
|
||||
@@ -281,8 +328,10 @@ class xlvask_tasks
|
||||
{
|
||||
global $db;
|
||||
$xlvask = new \classes\xlvask();
|
||||
$orders_o = new orders_o();
|
||||
$matches = new xlvask_potential_order_matches_o();
|
||||
$linked = 0;
|
||||
$ordersCreated = 0;
|
||||
|
||||
$dateFromSql = $dateFrom !== null && $dateFrom !== ''
|
||||
? "'" . $db->escape_string(xlvask_usage_logs_o::formatImportDateFrom($dateFrom)) . "'"
|
||||
@@ -303,6 +352,8 @@ class xlvask_tasks
|
||||
}
|
||||
$rows = $db->fetch_all($result);
|
||||
|
||||
$createEnabled = $xlvask->config->automatic_order_creation_enabled->isTrue();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$log = (new $xlvask->helpers->xlvask_usage_log())->setProperties($row);
|
||||
$washId = (string)$log->WashId;
|
||||
@@ -323,10 +374,25 @@ class xlvask_tasks
|
||||
(int)$log->getDepartment()->id,
|
||||
);
|
||||
$linked++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// No matching order — try to create one if automatic creation is on.
|
||||
if (!$createEnabled || !$log->isEligibleForAutomaticContinuance(false)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$customer = $log->getCustomer();
|
||||
$order = (new self())->createOrderFromWash($log, $customer);
|
||||
if ($order !== null) {
|
||||
$ordersCreated++;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error_log('[xlvask-tasks] auto-create order failed for wash ' . $washId . ': ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return ['linked' => $linked, 'orders_created' => 0];
|
||||
return ['linked' => $linked, 'orders_created' => $ordersCreated];
|
||||
}
|
||||
|
||||
private static function formatUsageLogs(array $getUsageLog): array
|
||||
@@ -436,5 +502,9 @@ class xlvask_tasks
|
||||
$xlvask = new \classes\xlvask();
|
||||
// Require the module to be enabled
|
||||
$xlvask->requireModuleEnabled();
|
||||
if (!(bool)(\classes\xlvask_usage_logs_schema_bootstrap::migrationStatus()['ready'] ?? false)) {
|
||||
return;
|
||||
}
|
||||
(new xlvask_autopilot_service())->pruneExpiredData();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace xlvask\migrations;
|
||||
|
||||
require_once WD . '/classes/xlvask_usage_logs_schema_bootstrap.php';
|
||||
|
||||
use classes\xlvask_usage_logs_schema_bootstrap;
|
||||
|
||||
/**
|
||||
* Versioned, operator-invoked XL Vask AI auto-action migration.
|
||||
*
|
||||
* Preflight is read-only. apply() is intentionally not wired to HTTP routes, cron, constructors,
|
||||
* readiness, or normal run processing. Operators must execute it through the controlled database
|
||||
* migration procedure and retain the returned status artifact.
|
||||
*/
|
||||
final class migration_20260804_xlvask_ai_auto_policy_v2
|
||||
{
|
||||
public static function preflight(): array
|
||||
{
|
||||
return xlvask_usage_logs_schema_bootstrap::migrationStatus();
|
||||
}
|
||||
|
||||
public static function apply(): array
|
||||
{
|
||||
return xlvask_usage_logs_schema_bootstrap::applyExplicitMigration();
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,19 @@
|
||||
namespace xlvask;
|
||||
require_once WD . '/modules/xlvask/config/xlvask_enabled_c.php';
|
||||
require_once WD . '/modules/xlvask/config/xlvask_synchronization_enabled_c.php';
|
||||
require_once WD . '/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php';
|
||||
require_once WD . '/modules/xlvask/config/xlvask_automatic_order_creation_enabled_c.php';
|
||||
require_once WD . '/modules/xlvask/config/xlvask_minimax_integration_enabled_c.php';
|
||||
require_once WD . '/modules/xlvask/config/xlvask_openai_integration_enabled_c.php';
|
||||
require_once WD . '/modules/xlvask/config/xlvask_username_c.php';
|
||||
require_once WD . '/modules/xlvask/config/xlvask_password_c.php';
|
||||
|
||||
use traits\module_config_t;
|
||||
use xlvask\config\xlvask_automatic_order_attachment_enabled_c;
|
||||
use xlvask\config\xlvask_automatic_order_creation_enabled_c;
|
||||
use xlvask\config\xlvask_enabled_c;
|
||||
use xlvask\config\xlvask_minimax_integration_enabled_c;
|
||||
use xlvask\config\xlvask_openai_integration_enabled_c;
|
||||
use xlvask\config\xlvask_password_c;
|
||||
use xlvask\config\xlvask_synchronization_enabled_c;
|
||||
use xlvask\config\xlvask_username_c;
|
||||
@@ -31,6 +39,22 @@ class xlvask_c
|
||||
* @var xlvask_synchronization_enabled_c $synchronization_enabled
|
||||
*/
|
||||
public xlvask_synchronization_enabled_c $synchronization_enabled;
|
||||
/**
|
||||
* @var xlvask_automatic_order_attachment_enabled_c $automatic_order_attachment_enabled
|
||||
*/
|
||||
public xlvask_automatic_order_attachment_enabled_c $automatic_order_attachment_enabled;
|
||||
/**
|
||||
* @var xlvask_automatic_order_creation_enabled_c $automatic_order_creation_enabled
|
||||
*/
|
||||
public xlvask_automatic_order_creation_enabled_c $automatic_order_creation_enabled;
|
||||
/**
|
||||
* @var xlvask_minimax_integration_enabled_c $minimax_integration_enabled
|
||||
*/
|
||||
public xlvask_minimax_integration_enabled_c $minimax_integration_enabled;
|
||||
/**
|
||||
* @var xlvask_openai_integration_enabled_c $openai_integration_enabled
|
||||
*/
|
||||
public xlvask_openai_integration_enabled_c $openai_integration_enabled;
|
||||
/**
|
||||
* The username
|
||||
* @var xlvask_username_c
|
||||
@@ -53,11 +77,19 @@ class xlvask_c
|
||||
$this->allowUpdate([
|
||||
xlvask_enabled_c::class,
|
||||
xlvask_synchronization_enabled_c::class,
|
||||
xlvask_automatic_order_attachment_enabled_c::class,
|
||||
xlvask_automatic_order_creation_enabled_c::class,
|
||||
xlvask_minimax_integration_enabled_c::class,
|
||||
xlvask_openai_integration_enabled_c::class,
|
||||
xlvask_username_c::class,
|
||||
xlvask_password_c::class
|
||||
]);
|
||||
$this->enabled = new xlvask_enabled_c();
|
||||
$this->synchronization_enabled = new xlvask_synchronization_enabled_c();
|
||||
$this->automatic_order_attachment_enabled = new xlvask_automatic_order_attachment_enabled_c();
|
||||
$this->automatic_order_creation_enabled = new xlvask_automatic_order_creation_enabled_c();
|
||||
$this->minimax_integration_enabled = new xlvask_minimax_integration_enabled_c();
|
||||
$this->openai_integration_enabled = new xlvask_openai_integration_enabled_c();
|
||||
$this->username = new xlvask_username_c();
|
||||
$this->password = new xlvask_password_c();
|
||||
}
|
||||
|
||||
@@ -205,12 +205,10 @@ 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.
|
||||
// TRU-106: send_new_booking_notification() filters out drop-offs
|
||||
// (pickup_bool = 0) so only pickup bookings post to Slack.
|
||||
// Send a department webhook if the booking is new
|
||||
$slack = new slack();
|
||||
try {
|
||||
$slack->send_new_booking_notification(
|
||||
$slack->send_department_booking_notification($department, $slack->format_new_booking(
|
||||
$id,
|
||||
$customer_number,
|
||||
$wash_type,
|
||||
@@ -221,12 +219,12 @@ class bookings_o extends db
|
||||
$washCertificateEmail,
|
||||
$date,
|
||||
$department,
|
||||
(bool)$pickup_bool,
|
||||
$pickup_bool,
|
||||
$notes,
|
||||
$washCertificateStatus,
|
||||
$washCertificateUrl,
|
||||
$status
|
||||
);
|
||||
));
|
||||
} catch (Exception $e) {
|
||||
// Log the error
|
||||
$logs = new logs_o();
|
||||
@@ -315,13 +313,11 @@ 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_new_booking_notification(
|
||||
$slack->send_department_booking_notification($department->id, $slack->format_new_booking(
|
||||
$this->id,
|
||||
$customer_array['customer_number'],
|
||||
self::formatWashTypeFromServices(json_decode($this->data->value(), true)),
|
||||
@@ -337,7 +333,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,13 +615,7 @@ class orders_o extends db
|
||||
public function getOrderItems(int $order_id): array
|
||||
{
|
||||
global $db;
|
||||
// Order primary items first (related_item_id IS NULL), then addons grouped by
|
||||
// their parent (related_item_id ASC), and finally fall back to insertion order
|
||||
// (id ASC). Without an explicit ORDER BY, MySQL is free to return rows in any
|
||||
// order, which causes the FE tree-builder to render addons before their
|
||||
// primary on the invoice and POS displays (Trækker + addons like Trailer/Dolly
|
||||
// visually appearing as if only Trailer/Dolly were attached to the order).
|
||||
$sql = "SELECT * FROM order_items WHERE order_id = $order_id ORDER BY (related_item_id IS NULL) DESC, related_item_id ASC, id ASC";
|
||||
$sql = "SELECT * FROM order_items WHERE order_id = $order_id";
|
||||
$result = $db->query($sql);
|
||||
$order_items = [];
|
||||
if ($result->num_rows > 0 && $result) {
|
||||
@@ -906,68 +900,6 @@ class orders_o extends db
|
||||
return (bool)$count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the timestamp of the most recent completed wash for a license plate.
|
||||
* Used by the front page to show the "last washed" hint when a plate is
|
||||
* scanned (DHL trailer pick-up use case, TRU-78 / DRIFT 17).
|
||||
*
|
||||
* Only orders that have at least one non-deleted order item are
|
||||
* considered (mirrors the contract used by customer_vehicles_o::
|
||||
* getLastOrderByPlate() so the timestamp is always backed by a real wash).
|
||||
*
|
||||
* @param string $reg_1 The license plate to look up
|
||||
* @return string|null MySQL datetime string of the most recent qualifying
|
||||
* order's `created_at`, or null when the plate has
|
||||
* never been washed.
|
||||
*/
|
||||
public function getLastWashTimestampForPlate(string $reg_1): ?string
|
||||
{
|
||||
$normalized_reg_1 = trim($reg_1);
|
||||
if ($normalized_reg_1 === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$orders = self::getFieldsWhere([
|
||||
'reg_1' => $normalized_reg_1,
|
||||
'deleted_at' => null,
|
||||
], [
|
||||
'id',
|
||||
]);
|
||||
|
||||
// Walk the orders newest-first and return the first one that actually
|
||||
// has at least one non-deleted order item.
|
||||
$candidate_ids = array_reverse(array_map(static function ($row) {
|
||||
return (int)($row['id'] ?? 0);
|
||||
}, $orders));
|
||||
|
||||
foreach ($candidate_ids as $order_id) {
|
||||
if ($order_id <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$has_items = (new order_items_o())->getFieldsWhere([
|
||||
'order_id' => $order_id,
|
||||
'deleted_at' => null,
|
||||
], ['id']);
|
||||
|
||||
if (count($has_items) === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$details = self::getFieldsWhere([
|
||||
'id' => $order_id,
|
||||
'deleted_at' => null,
|
||||
], ['created_at']);
|
||||
|
||||
$created_at = $details[0]['created_at'] ?? null;
|
||||
if (is_string($created_at) && $created_at !== '') {
|
||||
return $created_at;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getFixedPricingTransactions(int $customer_number, false $asArray, string|null $dateFrom = null, string|null $dateTo = null): array
|
||||
{
|
||||
// Get the fixed pricing transactions for a customer
|
||||
@@ -2270,8 +2202,7 @@ class orders_o extends db
|
||||
$sql = "SELECT id FROM $this->table
|
||||
WHERE (UPPER(TRIM(reg_1)) = '$reg' OR UPPER(TRIM(reg_2)) = '$reg' OR UPPER(TRIM(reg_3)) = '$reg')
|
||||
AND created_at BETWEEN '$from_date' AND '$to_date'
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY id ASC";
|
||||
AND deleted_at IS NULL";
|
||||
$result = $db->query($sql);
|
||||
if ($result->num_rows === 0) {
|
||||
return []; // No orders found with the registration number in the date range
|
||||
|
||||
@@ -84,12 +84,6 @@ 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
|
||||
@@ -140,7 +134,6 @@ 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);
|
||||
}
|
||||
@@ -234,7 +227,6 @@ 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(),
|
||||
];
|
||||
@@ -378,128 +370,4 @@ 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,7 +44,6 @@ 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;
|
||||
@@ -124,7 +123,6 @@ 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);
|
||||
}
|
||||
@@ -236,27 +234,15 @@ class users_o extends db
|
||||
}
|
||||
|
||||
|
||||
public function add(string $customer_number, mixed $password, int $role = 0, ?string $invoice_email = null): void
|
||||
public function add(string $customer_number, mixed $password, int $role = 0): 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);
|
||||
@@ -270,11 +256,6 @@ 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');
|
||||
@@ -408,19 +389,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -460,8 +428,6 @@ 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(),
|
||||
@@ -1598,59 +1564,6 @@ 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,81 +569,4 @@ class xlvask_usage_logs_o extends db
|
||||
));
|
||||
return $vehicles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only per-period usage-log summary used by the Selvvask view.
|
||||
* Replaces the legacy autopilot-service summary with a thin SQL aggregate
|
||||
* over xlvask_usage_logs that stays well within the operator's hall scope.
|
||||
*
|
||||
* @param array<int,string> $allowedHallIds
|
||||
* @return array{counts: array<string,int>, total_net_amount: float, ignored_count: int, window: array{from:?string,to:?string}}
|
||||
*/
|
||||
public function summarizeUsageOrdersReadOnly(?string $dateFrom, ?string $dateTo, array $allowedHallIds): array
|
||||
{
|
||||
global $db;
|
||||
if ($allowedHallIds === []) {
|
||||
return [
|
||||
'counts' => ['total' => 0, 'needs_review' => 0, 'ignored' => 0],
|
||||
'total_net_amount' => 0.0,
|
||||
'ignored_count' => 0,
|
||||
'window' => ['from' => $dateFrom, 'to' => $dateTo],
|
||||
];
|
||||
}
|
||||
$hallSql = implode(',', array_map(
|
||||
static fn(string $hallId): string => "'" . $db->escape_string($hallId) . "'",
|
||||
$allowedHallIds
|
||||
));
|
||||
$fromSql = $dateFrom !== null && $dateFrom !== ''
|
||||
? "'" . $db->escape_string(xlvask_usage_logs_o::formatImportDateFrom($dateFrom)) . "'"
|
||||
: 'DATE_SUB(NOW(), INTERVAL 30 DAY)';
|
||||
$toSql = $dateTo !== null && $dateTo !== ''
|
||||
? "'" . $db->escape_string((string)$dateTo) . " 23:59:59'"
|
||||
: 'NOW()';
|
||||
|
||||
$sql = "SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN ignored_at IS NULL THEN 1 ELSE 0 END) AS needs_review,
|
||||
SUM(CASE WHEN ignored_at IS NOT NULL THEN 1 ELSE 0 END) AS ignored_count
|
||||
FROM xlvask_usage_logs
|
||||
WHERE StartTime >= {$fromSql}
|
||||
AND StartTime <= {$toSql}
|
||||
AND FinishStatus = 1
|
||||
AND HallId IN ({$hallSql})";
|
||||
$result = $db->query($sql);
|
||||
$row = ($result !== false && $result->num_rows > 0)
|
||||
? $db->fetch_all($result)[0]
|
||||
: ['total' => 0, 'needs_review' => 0, 'ignored_count' => 0];
|
||||
|
||||
$netSql = "SELECT
|
||||
COALESCE(SUM(
|
||||
CAST(
|
||||
REPLACE(JSON_UNQUOTE(JSON_EXTRACT(WashItems, '$[0].PriceIncVat')), '\"', '') AS DECIMAL(10,2)
|
||||
)
|
||||
- COALESCE(
|
||||
CAST(
|
||||
REPLACE(JSON_UNQUOTE(JSON_EXTRACT(WashItems, '$[0].Vat')), '\"', '') AS DECIMAL(10,2)
|
||||
), 0
|
||||
)
|
||||
), 0) AS period_net
|
||||
FROM xlvask_usage_logs
|
||||
WHERE StartTime >= {$fromSql}
|
||||
AND StartTime <= {$toSql}
|
||||
AND FinishStatus = 1
|
||||
AND HallId IN ({$hallSql})";
|
||||
$netResult = $db->query($netSql);
|
||||
$netRow = ($netResult !== false && $netResult->num_rows > 0)
|
||||
? $db->fetch_all($netResult)[0]
|
||||
: ['period_net' => 0];
|
||||
|
||||
return [
|
||||
'counts' => [
|
||||
'total' => (int)($row['total'] ?? 0),
|
||||
'needs_review' => (int)($row['needs_review'] ?? 0),
|
||||
'ignored' => (int)($row['ignored_count'] ?? 0),
|
||||
],
|
||||
'total_net_amount' => round((float)($netRow['period_net'] ?? 0), 2),
|
||||
'ignored_count' => (int)($row['ignored_count'] ?? 0),
|
||||
'window' => ['from' => $dateFrom, 'to' => $dateTo],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+374
-110
@@ -10344,11 +10344,8 @@ paths:
|
||||
post:
|
||||
tags:
|
||||
- Modules
|
||||
summary: Create Stripe invoice (retired - TRU-74 / DRIFT 13)
|
||||
description: |
|
||||
Retired in favour of in-store card payments. Always returns HTTP 410
|
||||
with `code: stripe_email_payment_disabled` so the POS can fall back
|
||||
to the standard card-payment flow.
|
||||
summary: Create Stripe invoice
|
||||
description: Create an invoice in Stripe
|
||||
operationId: createStripeInvoice
|
||||
requestBody:
|
||||
required: false
|
||||
@@ -10356,41 +10353,11 @@ paths:
|
||||
application/json:
|
||||
schema: {}
|
||||
responses:
|
||||
'410':
|
||||
description: Direct Stripe payment links by email are no longer available
|
||||
'201':
|
||||
description: Stripe invoice created successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
example: stripe_email_payment_disabled
|
||||
message:
|
||||
type: string
|
||||
delete:
|
||||
tags:
|
||||
- Modules
|
||||
summary: Cancel/clean up a legacy Stripe hosted invoice
|
||||
description: |
|
||||
Void a pre-existing Stripe hosted invoice that was created before
|
||||
direct payment links were retired from POS (TRU-74 / DRIFT 13).
|
||||
Card payments created via the new flow are not affected and use
|
||||
the standard payment-intent lifecycle instead.
|
||||
operationId: cancelLegacyStripeInvoice
|
||||
parameters:
|
||||
- name: order_id
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Legacy Stripe hosted invoice was voided
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
schema: {}
|
||||
|
||||
/modules/stripe/terminal/readers:
|
||||
get:
|
||||
@@ -10692,92 +10659,372 @@ paths:
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/modules/xlvask/services/usage/orders/{id}/ignore:
|
||||
patch:
|
||||
/modules/xlvask/services/usage/autopilot-runs:
|
||||
post:
|
||||
tags:
|
||||
- Modules
|
||||
summary: Ignore an XL Vask usage log
|
||||
description: Mark an XL Vask usage log as ignored for invoice-period flagging. The change is scoped to the operator's hall scope and recorded with operator id and reason.
|
||||
operationId: ignoreXlvaskUsageOrder
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
summary: Create an XLVask usage autopilot run
|
||||
description: Queues an idempotent invoice-period import and automation evaluation run. Dry-run imports and persists plans without automatic execution; replay is cache-only and read-only.
|
||||
operationId: createXlvaskUsageAutopilotRun
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [reason]
|
||||
required: [mode]
|
||||
properties:
|
||||
reason:
|
||||
dateFrom:
|
||||
type: string
|
||||
maxLength: 500
|
||||
format: date
|
||||
dateTo:
|
||||
type: string
|
||||
format: date
|
||||
ids:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
limit:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 500
|
||||
forceRefetch:
|
||||
type: boolean
|
||||
mode:
|
||||
type: string
|
||||
enum: [execute, dry_run, replay]
|
||||
idempotency_key:
|
||||
type: string
|
||||
maxLength: 191
|
||||
responses:
|
||||
'200':
|
||||
description: XL Vask usage log marked as ignored
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
|
||||
/modules/xlvask/services/usage/orders/{id}/unignore:
|
||||
post:
|
||||
tags:
|
||||
- Modules
|
||||
summary: Clear ignore metadata on an XL Vask usage log
|
||||
description: Resets ignored_at, ignored_by and ignored_reason on an XL Vask usage log scoped to the operator's hall scope.
|
||||
operationId: unignoreXlvaskUsageOrder
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
'200':
|
||||
description: Ignore metadata cleared
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
|
||||
/modules/xlvask/services/usage/orders/{id}/accept:
|
||||
post:
|
||||
tags:
|
||||
- Modules
|
||||
summary: Convert an XL Vask usage log into an order
|
||||
description: Creates an order from the XL Vask usage log in the operator's hall scope and records the converted usage log as ignored with a stable reason referencing the order id.
|
||||
operationId: acceptXlvaskUsageOrder
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
'200':
|
||||
description: Order created from XL Vask usage log
|
||||
'202':
|
||||
description: XLVask usage autopilot run queued successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
order_id: { type: integer }
|
||||
usage_log_id: { type: integer }
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
run:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
status:
|
||||
type: string
|
||||
phase:
|
||||
type: string
|
||||
mode:
|
||||
type: string
|
||||
processed:
|
||||
type: integer
|
||||
total:
|
||||
type: integer
|
||||
summary:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: integer
|
||||
warning:
|
||||
type: string
|
||||
error:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
nullable: true
|
||||
started_at:
|
||||
type: string
|
||||
nullable: true
|
||||
finished_at:
|
||||
type: string
|
||||
nullable: true
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'422': { description: XL Vask customer is not linkable }
|
||||
|
||||
/modules/xlvask/services/usage/orders/{id}/reject:
|
||||
/modules/xlvask/services/usage/autopilot-runs/{id}:
|
||||
get:
|
||||
tags:
|
||||
- Modules
|
||||
summary: Get an XLVask usage autopilot run
|
||||
description: Returns status and summary metadata for a previously requested autopilot run.
|
||||
operationId: getXlvaskUsageAutopilotRun
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: XLVask usage autopilot run returned successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
run:
|
||||
type: object
|
||||
'400':
|
||||
description: Invalid XLVask autopilot run id
|
||||
'404':
|
||||
description: XLVask autopilot run not found
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/modules/xlvask/services/usage/automation/decisions/preview:
|
||||
post:
|
||||
tags:
|
||||
- Modules
|
||||
summary: Reject an XL Vask usage log with a reviewer note
|
||||
description: Marks the XL Vask usage log as ignored with a reviewer-provided reason. Scoped to the operator's hall scope.
|
||||
operationId: rejectXlvaskUsageOrder
|
||||
summary: Preview an XLVask automation decision
|
||||
description: Creates a short-lived preview token for applying bulk accept, deny, ignore, or link decisions after source revision revalidation.
|
||||
operationId: previewXlvaskUsageAutomationDecision
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
usage_log_ids:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
action:
|
||||
type: string
|
||||
enum: [accept, attach_order, create_order, deny, ignore]
|
||||
suggestion_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
order_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
reason:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: XLVask automation decision preview created successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
preview:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
selection_hash: { type: string }
|
||||
requires_confirmation: { type: boolean }
|
||||
confirmation_phrase:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Opaque preview-issued phrase that must be submitted exactly when confirmation is required.
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/modules/xlvask/services/usage/automation/decisions/apply:
|
||||
post:
|
||||
tags:
|
||||
- Modules
|
||||
summary: Apply an XLVask automation decision preview
|
||||
description: Applies a previewed decision inside a transactional policy boundary after source hash, expected version, hall scope, and selection hash are revalidated.
|
||||
operationId: applyXlvaskUsageAutomationDecision
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [preview_id, selection_hash]
|
||||
properties:
|
||||
preview_id:
|
||||
type: string
|
||||
selection_hash:
|
||||
type: string
|
||||
confirmation_text:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: XLVask automation decision applied successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
applied:
|
||||
type: integer
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
failed:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/modules/xlvask/services/usage/automation/admin/readiness:
|
||||
get:
|
||||
tags: [Modules]
|
||||
summary: Inspect XLVask automation activation readiness
|
||||
description: Read-only, fail-closed view of wash-id uniqueness and active calibration artifacts.
|
||||
operationId: getXlvaskAutomationActivationReadiness
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
- { in: query, name: dateFrom, required: false, schema: { type: string, format: date } }
|
||||
- { in: query, name: dateTo, required: false, schema: { type: string, format: date } }
|
||||
responses:
|
||||
'200':
|
||||
description: Activation readiness returned successfully
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/modules/xlvask/services/usage/automation/capabilities:
|
||||
get:
|
||||
tags: [Modules]
|
||||
summary: Inspect effective XLVask automation capabilities
|
||||
operationId: getXlvaskAutomationCapabilities
|
||||
parameters:
|
||||
- { in: query, name: dateFrom, required: false, schema: { type: string, format: date } }
|
||||
- { in: query, name: dateTo, required: false, schema: { type: string, format: date } }
|
||||
responses:
|
||||
'200':
|
||||
description: Permission-aware capabilities, stage, readiness, active run, budgets, and reviewed soak returned.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
effective_action_sources:
|
||||
type: array
|
||||
description: Empty unless automatic financial actions are currently effective; OpenAI is the only supported source.
|
||||
items: { type: string, enum: [openai] }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/modules/xlvask/services/usage/autopilot-runs/active:
|
||||
get:
|
||||
tags: [Modules]
|
||||
summary: Inspect the active XLVask execute run
|
||||
operationId: getActiveXlvaskUsageAutopilotRun
|
||||
responses:
|
||||
'200':
|
||||
description: "Returns {run: null} or the oldest active execute run."
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/modules/xlvask/services/usage/automation/admin/policy/previews:
|
||||
post:
|
||||
tags: [Modules]
|
||||
summary: Preview an XLVask server-policy stage transition
|
||||
operationId: previewXlvaskAutomationPolicyTransition
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [target_stage, reason]
|
||||
properties:
|
||||
target_stage:
|
||||
type: string
|
||||
enum: [off, advisory, ai_attach_canary, ai_attach_verified, ai_create_canary, verified_capped]
|
||||
reason:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 1000
|
||||
responses:
|
||||
'200': { description: Short-lived, readiness-bound policy preview returned. }
|
||||
'409': { description: Stage ordering, calibration, active run, schema, uniqueness, or reviewed-soak gate blocked the transition. }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/modules/xlvask/services/usage/automation/admin/policy/apply:
|
||||
post:
|
||||
tags: [Modules]
|
||||
summary: Apply a previewed XLVask server-policy transition
|
||||
operationId: applyXlvaskAutomationPolicyTransition
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [preview_id, selection_hash, confirmation_text]
|
||||
properties:
|
||||
preview_id: { type: string, format: uuid }
|
||||
selection_hash: { type: string }
|
||||
confirmation_text: { type: string }
|
||||
responses:
|
||||
'200': { description: Policy and re-evaluated readiness returned. }
|
||||
'409': { description: Preview expired or policy, identity, calibration, run, or readiness changed. }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/modules/xlvask/services/usage/automation/admin/halt:
|
||||
post:
|
||||
tags: [Modules]
|
||||
summary: Immediately halt XLVask automatic actions
|
||||
operationId: haltXlvaskAutomation
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
reason: { type: string, maxLength: 1000 }
|
||||
responses:
|
||||
'200': { description: Automatic actions halted and kill switches disabled atomically. }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/modules/xlvask/services/usage/automation/admin/calibrations/labels:
|
||||
post:
|
||||
tags: [Modules]
|
||||
summary: Adjudicate one exact XLVask suggestion
|
||||
description: Stores an administrator-adjudicated correct or incorrect label bound to one suggestion ID.
|
||||
operationId: adjudicateXlvaskCalibrationLabel
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [suggestion_id, outcome]
|
||||
properties:
|
||||
suggestion_id: { type: integer }
|
||||
outcome: { type: string, enum: [correct, incorrect, duplicate, cross_hall, unaudited] }
|
||||
responses:
|
||||
'200': { description: Calibration label stored successfully }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/modules/xlvask/services/usage/automation/admin/calibrations/backtest:
|
||||
post:
|
||||
tags: [Modules]
|
||||
summary: Generate an inactive XLVask calibration artifact
|
||||
description: Uses exact adjudicated labels and a chronological 80/20 holdout; generation never activates the artifact.
|
||||
operationId: generateXlvaskCalibrationArtifact
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [segment_key]
|
||||
properties:
|
||||
segment_key: { type: string }
|
||||
responses:
|
||||
'200': { description: Inactive calibration artifact generated successfully }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/modules/xlvask/services/usage/automation/admin/calibrations/{id}/activate:
|
||||
post:
|
||||
tags: [Modules]
|
||||
summary: Activate a qualifying XLVask calibration artifact
|
||||
operationId: activateXlvaskCalibrationArtifact
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
requestBody:
|
||||
@@ -10786,18 +11033,35 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [reason]
|
||||
required: [artifact_hash, confirmation_text]
|
||||
properties:
|
||||
reason:
|
||||
type: string
|
||||
maxLength: 500
|
||||
artifact_hash: { type: string }
|
||||
confirmation_text: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: XL Vask usage log rejected
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'200': { description: Calibration artifact activated successfully }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
|
||||
/modules/xlvask/services/usage/automation/admin/wash-id-uniqueness/activate:
|
||||
post:
|
||||
tags: [Modules]
|
||||
summary: Activate guarded wash-id uniqueness
|
||||
description: Explicitly verifies duplicates, adds the normalized wash-id column and unique index, and fails closed on conflicts.
|
||||
operationId: activateXlvaskWashIdUniqueness
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [confirmation_text]
|
||||
properties:
|
||||
confirmation_text: { type: string }
|
||||
responses:
|
||||
'200': { description: Wash-id uniqueness activated successfully }
|
||||
'409': { description: Duplicate wash IDs or schema readiness blocked activation }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/modules/action-logs:
|
||||
get:
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
[Unit]
|
||||
Description=Truck Wash API cron worker (long-running scheduler)
|
||||
After=network-online.target php8.2-fpm.service redis.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
Group=www-data
|
||||
WorkingDirectory=/opt/copenhagentruckwash-api/services/nginx/app
|
||||
ExecStart=/usr/bin/php /opt/copenhagentruckwash-api/services/nginx/app/index.php run cron-worker
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
TimeoutStopSec=30
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=cron-worker
|
||||
|
||||
# Hardening
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/opt/copenhagentruckwash-api/services/php/logs
|
||||
|
||||
# Resource limits
|
||||
LimitNOFILE=65536
|
||||
MemoryMax=512M
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -739,22 +739,6 @@ class InvoicingPeriodRoute
|
||||
$pagedTypes[$periodView] = array_slice($types[$periodView], $offset, $perPage);
|
||||
}
|
||||
|
||||
// Surface lightweight customer memberships for every non-active
|
||||
// view bucket so the front-end can render category indicator
|
||||
// chips (e.g. "Faktura pr. ordre") regardless of which tab the
|
||||
// user is currently looking at. Filters, search, sort, flag tab
|
||||
// and workflow filters have already been applied to `$types`
|
||||
// above, so the membership set matches the active bucket's
|
||||
// semantics for this request.
|
||||
foreach ($types as $typeName => $customers) {
|
||||
if ($typeName === $periodView) {
|
||||
continue;
|
||||
}
|
||||
$pagedTypes[$typeName] = self::summarizePeriodCustomerMemberships(
|
||||
is_array($customers) ? $customers : []
|
||||
);
|
||||
}
|
||||
|
||||
$period['types'] = $pagedTypes;
|
||||
$period['type_counts'] = $typeCounts;
|
||||
$period['type_totals'] = self::summarizePeriodTypeTotals($types);
|
||||
@@ -1246,41 +1230,6 @@ class InvoicingPeriodRoute
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a deduplicated list of lightweight `{customer_number}` markers
|
||||
* for a single non-active view bucket. These entries let the front-end
|
||||
* know which customers belong to a category without shipping the full
|
||||
* card (transactions, invoice_collections, queue, draft, meta, …).
|
||||
*
|
||||
* Filters, search, sort, flag tab and workflow filters are expected to
|
||||
* have been applied to `$customers` upstream — we only de-duplicate and
|
||||
* project the `customer_number` field here.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $customers
|
||||
* @return array<int, array{customer_number: int, membership_only: true}>
|
||||
*/
|
||||
private static function summarizePeriodCustomerMemberships(array $customers): array
|
||||
{
|
||||
$memberships = [];
|
||||
$seen = [];
|
||||
foreach ($customers as $customer) {
|
||||
if (!is_array($customer)) {
|
||||
continue;
|
||||
}
|
||||
$customerNumber = (int)($customer['customer_number'] ?? 0);
|
||||
if ($customerNumber < 1 || isset($seen[$customerNumber])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$customerNumber] = true;
|
||||
$memberships[] = [
|
||||
'customer_number' => $customerNumber,
|
||||
'membership_only' => true,
|
||||
];
|
||||
}
|
||||
|
||||
return $memberships;
|
||||
}
|
||||
|
||||
private static function summarizePeriodTypeTotals(array $types): array
|
||||
{
|
||||
$totals = [];
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\response;
|
||||
use classes\customer_invoice_email_schema_bootstrap;
|
||||
use traits\route_t;
|
||||
|
||||
/**
|
||||
* Admin / ops endpoints. Currently exposes the schema health check.
|
||||
*
|
||||
* The schema health check verifies that all required DB columns exist
|
||||
* for the routes the code references. If a column is missing (e.g. a
|
||||
* migration wasn't run on production), the endpoint returns 503 with
|
||||
* a clear list of missing columns — much more useful than a generic
|
||||
* 500 with "Unknown column" hidden in the stack trace.
|
||||
*/
|
||||
class adminRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
// Schema health check — used by deploy pipelines, monitoring,
|
||||
// and the cron job. Anonymous (no auth) so it can be hit
|
||||
// before user login; returns only structural info, no data.
|
||||
$this->get('/admin/schema-check', function () {
|
||||
global /** @var response $response */ $response;
|
||||
// Self-heal: run all schema bootstraps first
|
||||
if (class_exists(customer_invoice_email_schema_bootstrap::class)) {
|
||||
try {
|
||||
customer_invoice_email_schema_bootstrap::ensureSchema();
|
||||
} catch (\Throwable $e) {
|
||||
// Bootstrap may fail in environments where $db is
|
||||
// not yet wired up; report and continue with check
|
||||
}
|
||||
}
|
||||
$report = $this->runSchemaCheck();
|
||||
$response->setStatus($report['ok'] ? 200 : 503);
|
||||
$response->setBody(json_encode($report, JSON_PRETTY_PRINT));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns ['ok' => bool, 'missing' => array, ...].
|
||||
* If ok=false, the deploy should be blocked.
|
||||
*/
|
||||
private function runSchemaCheck(): array
|
||||
{
|
||||
global $db;
|
||||
$report = [
|
||||
'ok' => true,
|
||||
'missing' => [],
|
||||
'tables_checked' => 0,
|
||||
'columns_checked' => 0,
|
||||
'timestamp' => date('c'),
|
||||
'note' => 'If "missing" is non-empty, the migration that adds these columns was not run on the database.',
|
||||
];
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
$report['ok'] = false;
|
||||
$report['error'] = 'no_db_connection';
|
||||
return $report;
|
||||
}
|
||||
|
||||
$requirements = [
|
||||
'users' => [
|
||||
'invoice_email', // TRU-77 (added 2026-08-16, was missing on production)
|
||||
'wash_certificate_email',
|
||||
'email',
|
||||
'customer_number',
|
||||
],
|
||||
'invoices' => [
|
||||
'po_number',
|
||||
'closed_at',
|
||||
'customer_number',
|
||||
],
|
||||
'bookings' => [
|
||||
'id',
|
||||
'customer_number',
|
||||
'department',
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($requirements as $table => $columns) {
|
||||
$report['tables_checked']++;
|
||||
$tableSafe = str_replace('`', '', $table);
|
||||
$result = $db->query("SHOW TABLES LIKE '{$tableSafe}'");
|
||||
if (!$result || (int)$result->num_rows === 0) {
|
||||
$report['ok'] = false;
|
||||
$report['missing'][] = "table `{$table}` does not exist";
|
||||
continue;
|
||||
}
|
||||
foreach ($columns as $column) {
|
||||
$report['columns_checked']++;
|
||||
$colSafe = str_replace("'", '', $column);
|
||||
$r = $db->query("SHOW COLUMNS FROM `{$tableSafe}` LIKE '{$colSafe}'");
|
||||
if (!$r || (int)$r->num_rows === 0) {
|
||||
$report['ok'] = false;
|
||||
$report['missing'][] = "{$table}.{$column}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
}
|
||||
@@ -952,6 +952,44 @@ class moduleConfigRoute
|
||||
'modules_openai_config' => 'Update openai config'
|
||||
]
|
||||
);
|
||||
/** MiniMax config > GET */
|
||||
$this->get('/minimax/config', function () {
|
||||
global $response;
|
||||
$this->requirePermission('modules_minimax_config');
|
||||
$user = (new authentication())->get_user();
|
||||
if ($user) {
|
||||
(new logs_o())->add('minimax_config', 'global', 1, $user->id, 'MINIMAX_CONFIG', 'Successfully fetched MiniMax config');
|
||||
$response->success(
|
||||
(new \classes\minimax())->config->getConfigRequest()
|
||||
);
|
||||
} else {
|
||||
(new logs_o())->add('minimax_config', 'global', 1, 0, 'MINIMAX_CONFIG', 'No user found, or invalid session');
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
},
|
||||
[
|
||||
'modules_minimax_config' => 'Get MiniMax config'
|
||||
]
|
||||
);
|
||||
/** MiniMax config > POST */
|
||||
$this->post('/minimax/config', function () {
|
||||
global $response;
|
||||
$this->requirePermission('modules_minimax_config');
|
||||
$user = (new authentication())->get_user();
|
||||
if ($user) {
|
||||
(new logs_o())->add('minimax_config', 'global', 1, $user->id, 'MINIMAX_CONFIG', 'Successfully updated MiniMax config');
|
||||
$response->success(
|
||||
(new \classes\minimax())->config->postConfigRequest()
|
||||
);
|
||||
} else {
|
||||
(new logs_o())->add('minimax_config', 'global', 1, 0, 'MINIMAX_CONFIG', 'No user found, or invalid session');
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
},
|
||||
[
|
||||
'modules_minimax_config' => 'Update MiniMax config'
|
||||
]
|
||||
);
|
||||
/** LicensePlateRecognizer config > GET */
|
||||
$this->get('/licenseplaterecognizer/config', function () {
|
||||
global $response;
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
|
||||
namespace routes;
|
||||
|
||||
require_once WD . '/classes/xlvask_autopilot_service.php';
|
||||
|
||||
use classes\authentication;
|
||||
use classes\response;
|
||||
use classes\router;
|
||||
use classes\xlvask;
|
||||
use classes\xlvask_autopilot_service;
|
||||
use objects\orders_o;
|
||||
use objects\users_o;
|
||||
use objects\xlvask_customers_o;
|
||||
use traits\route_t;
|
||||
|
||||
@@ -176,6 +180,10 @@ class moduleXLVaskRoute
|
||||
$this->get('/modules/xlvask/tasks/sync-usage', function () {
|
||||
global $response;
|
||||
$this->requirePermission('modules_xlvask_sync_usage');
|
||||
// Remove the memory limit
|
||||
// ini_set('memory_limit', '-1');
|
||||
// Remove the execution time limit
|
||||
// set_time_limit(300);
|
||||
// Create the xlvask tasks object
|
||||
$xlvask = new xlvask();
|
||||
// Run the sync usage task
|
||||
@@ -191,6 +199,27 @@ class moduleXLVaskRoute
|
||||
]
|
||||
);
|
||||
|
||||
$this->get('/modules/xlvask/tasks/debug', function () {
|
||||
global $response;
|
||||
$this->requirePermission('modules_xlvask_sync_usage');
|
||||
// Create the xlvask tasks object
|
||||
$xlvask = new xlvask();
|
||||
$user = new users_o();
|
||||
$user->getUserByCustomerNumber(12345679);
|
||||
//$result = $xlvask->getTasks()->runSyncVehicles(false);
|
||||
$vehicles = $xlvask->new($xlvask->helpers->xlvask_vehicles);
|
||||
//print_r($vehicles::getVehicleByRegistrationNumber('BW93159'));
|
||||
// Response
|
||||
$response->success(
|
||||
'Debugging xlvask tasks',
|
||||
200
|
||||
);
|
||||
},
|
||||
[
|
||||
'modules_xlvask_sync_usage' => 'Synchronize usage with the xlvask module'
|
||||
]
|
||||
);
|
||||
|
||||
$this->get('/modules/xlvask/tasks/import-customers', function () {
|
||||
global $response;
|
||||
$this->requirePermission('modules_xlvask_import_customers');
|
||||
@@ -222,5 +251,15 @@ class moduleXLVaskRoute
|
||||
'modules_xlvask_import_vehicles' => 'Import vehicles from the xlvask module'
|
||||
]
|
||||
);
|
||||
|
||||
$this->get('/modules/xlvask/tasks/import-usage', function () {
|
||||
global $response;
|
||||
$this->requirePermission('modules_xlvask_import_usage');
|
||||
$response->error('Deprecated state-changing GET. Use POST /modules/xlvask/services/usage/autopilot-runs.', 410);
|
||||
},
|
||||
[
|
||||
'modules_xlvask_import_usage' => 'Import usage from the xlvask module'
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,21 +120,11 @@ class plateScansRoute
|
||||
'type' => (int)$tmp_scan_vehicle['type'],
|
||||
];
|
||||
}
|
||||
// TRU-78 / DRIFT 17: enrich each scan with the
|
||||
// timestamp of the most recent completed wash for
|
||||
// that plate so the POS landing page can show
|
||||
// "last washed" at a glance when DHL trailers are
|
||||
// being picked up.
|
||||
$plate_value = (string)$scan['plate'];
|
||||
$tmp_scan_last_wash = [
|
||||
'last_wash' => (new orders_o())->getLastWashTimestampForPlate($plate_value),
|
||||
];
|
||||
// Return the object as an array
|
||||
return [
|
||||
...$scan,
|
||||
...$tmp_scan_customer,
|
||||
...$tmp_scan_seen_before,
|
||||
...$tmp_scan_last_wash,
|
||||
];
|
||||
},
|
||||
$number_plate_scans->forceRestrictFilters(
|
||||
|
||||
@@ -88,18 +88,6 @@ 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) {
|
||||
@@ -560,55 +548,5 @@ 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,21 +62,9 @@ class userInvoicesRoute
|
||||
self::requireSameLength($id, self::getParameter('id'));
|
||||
$is_superuser = $this->hasPermission('superuser');
|
||||
if (!self::isParametersSet(['po_number']) && !self::isParametersSet(['closed_at'])) {
|
||||
// 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);
|
||||
$response->error('Missing required parameters: po_number, closed_at', 400);
|
||||
}
|
||||
// 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) {
|
||||
if (self::isParametersSet(['closed_at']) && !$is_superuser) {
|
||||
$response->error('Forbidden: only superusers can update closed_at', 403);
|
||||
}
|
||||
// Make sure optional fields are valid
|
||||
|
||||
@@ -119,19 +119,8 @@ 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, $invoice_email);
|
||||
(new users_o())->add($data['customer_number'], $data['password'], $role);
|
||||
// Log the incident
|
||||
(new logs_o())->add('users', 'global', 1, $user->id, 'ADD_USER', 'Successfully added a user');
|
||||
// Return a success message
|
||||
@@ -204,22 +193,6 @@ 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,10 +2,24 @@
|
||||
|
||||
namespace routes;
|
||||
|
||||
require_once WD . '/classes/xlvask_automation_service.php';
|
||||
require_once WD . '/classes/xlvask_autopilot_service.php';
|
||||
require_once WD . '/classes/xlvask_automation_policy_service.php';
|
||||
|
||||
use classes\authentication;
|
||||
use classes\redis;
|
||||
use classes\response;
|
||||
use classes\stripe;
|
||||
use classes\xlvask;
|
||||
use classes\xlvask_autopilot_service;
|
||||
use classes\xlvask_automation_service;
|
||||
use classes\xlvask_automation_policy_service;
|
||||
use objects\collected_order_invoices_o;
|
||||
use objects\departments_o;
|
||||
use objects\economic_module_orders;
|
||||
use objects\orders_o;
|
||||
use objects\stripe_module_orders_o;
|
||||
use objects\stripe_payment_intents_o;
|
||||
use objects\xlvask_usage_logs_o;
|
||||
use traits\route_t;
|
||||
|
||||
@@ -16,110 +30,136 @@ class xlvaskUsageLogsRoute
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/modules/xlvask/services/usage/orders', function () {
|
||||
$permission_list_own = 'list_xlvask_usage_orders_own';
|
||||
$permission_list_all = 'list_xlvask_usage_orders_all';
|
||||
$response_includes_items = false;
|
||||
// Define the permissions:
|
||||
$permission_list_own = 'list_xlvask_usage_orders_own'; // Permission to list own orders (Without department filter)
|
||||
$permission_list_all = 'list_xlvask_usage_orders_all'; // Permission to list all orders (With department filter)
|
||||
$response_includes_items = false; // Whether to include items in the response (This would increase the memory usage significantly)
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
if (!$this->hasPermission($permission_list_all)) {
|
||||
$this->requirePermission($permission_list_own);
|
||||
}
|
||||
// Get the user object
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
// Check if the request was successful
|
||||
if ($user) {
|
||||
$allowedHallIds = $this->allowedHallIdsForUser($user);
|
||||
if ($allowedHallIds === []) {
|
||||
$response->error('No XL Vask hall scope is available', 403);
|
||||
return;
|
||||
}
|
||||
$xlvask_usage_logs = new xlvask_usage_logs_o();
|
||||
$xlvask = new xlvask();
|
||||
$automation_service = new xlvask_automation_service();
|
||||
$linked_order_ids_by_wash_id = [];
|
||||
$xlvask->new($xlvask->helpers->xlvask_usage_log)->getDepartment();
|
||||
$orders_o = new orders_o();
|
||||
$xlvask_usage_log = $xlvask->new($xlvask->helpers->xlvask_usage_log);
|
||||
// Increase the memory limit to 512MB (Provided it's currently less than that)
|
||||
if (ini_get('memory_limit') < '5120M') {
|
||||
ini_set('memory_limit', '5120M');
|
||||
}
|
||||
// Return the list of usage logs
|
||||
$result = $xlvask_usage_logs
|
||||
// Make sure the Customer is not in the default customers list
|
||||
->setAdditionalWhereClause("`Customer` NOT IN ('" . implode("', '", $xlvask_usage_log::$default_customers) . "')")
|
||||
->listObjectsWithPaginationIfSet(
|
||||
function ($log) use ($response_includes_items, $xlvask_usage_logs, $xlvask, $automation_service, &$linked_order_ids_by_wash_id) {
|
||||
// Remove the 'id' field from the log
|
||||
$id = (int)$log['id'];
|
||||
$automation = $automation_service->readAutomationStateByUsageLogId($id, $log);
|
||||
$amount_summary = $xlvask_usage_logs->getAmountSummaryReadOnly($log);
|
||||
unset($log['id']);
|
||||
// Convert the 'WashItems' field from JSON to an array
|
||||
$log['WashItems'] = json_decode($log['WashItems'], true);
|
||||
$usage_log_payload = array_intersect_key($log, array_flip([
|
||||
'WashId',
|
||||
'CustomerId',
|
||||
'Customer',
|
||||
'VatNumber',
|
||||
'Location',
|
||||
'Hall',
|
||||
'HallId',
|
||||
'StartTime',
|
||||
'FinishTime',
|
||||
'RegistrationNumber',
|
||||
'VehicleType',
|
||||
'IdentificationType',
|
||||
'IdentificationId',
|
||||
'Info',
|
||||
'Updated',
|
||||
'Prepaid',
|
||||
'FinishStatus',
|
||||
'CustomerGuid',
|
||||
'VehicleId',
|
||||
'WashItems',
|
||||
'ignored_at',
|
||||
'ignored_by',
|
||||
'ignored_reason',
|
||||
]));
|
||||
// Create a new xlvask usage log object
|
||||
$tmp = $xlvask->new($xlvask->helpers->xlvask_usage_log);
|
||||
// Set the properties of the temporary object
|
||||
$tmp->setProperties($usage_log_payload);
|
||||
$wash_id = (string)$tmp->WashId;
|
||||
if ($wash_id !== '' && !array_key_exists($wash_id, $linked_order_ids_by_wash_id)) {
|
||||
$linked_order = (new orders_o())->selectByWashId($wash_id);
|
||||
$linked_order_ids_by_wash_id[$wash_id] = $linked_order !== null ? (int)$linked_order->id : null;
|
||||
}
|
||||
$linked_order_id = $wash_id !== '' ? $linked_order_ids_by_wash_id[$wash_id] : null;
|
||||
// Define the result structure
|
||||
$isEligibleForAutomaticContinuance = $tmp->isEligibleForAutomaticContinuance(true);
|
||||
// Return the result
|
||||
$tmp_res = ($isEligibleForAutomaticContinuance ? (new orders_o())->simulateOrderFromXLVask($tmp, $response_includes_items) : []);
|
||||
$tmp_res['order']['customer_name'] = $tmp->Customer; // Add the customer name to the order
|
||||
$tmp_res['order']['total_net_amount'] = $amount_summary['total_net_amount'];
|
||||
$tmp_res['order']['xlvask_primary_product_name'] = $amount_summary['primary_product_name'];
|
||||
$tmp_res['order']['xlvask_amount_cached'] = $amount_summary['cached'];
|
||||
// Clear memory
|
||||
unset($tmp);
|
||||
// Return the result
|
||||
return [
|
||||
'id' => $id, // Return the ID of the log
|
||||
'fast_link_key' => null,
|
||||
'automation' => $automation,
|
||||
'source_hash' => $log['source_hash'] ?? null,
|
||||
'source_revision' => $log['source_revision'] ?? null,
|
||||
'source_observed_at' => $log['source_observed_at'] ?? null,
|
||||
'source_stable_since' => $log['source_stable_since'] ?? null,
|
||||
'source_observation_count' => (int)($log['source_observation_count'] ?? 0),
|
||||
'import_state' => $log['import_state'] ?? 'unchanged',
|
||||
'resolution_state' => $log['resolution_state'] ?? 'needs_review',
|
||||
'certainty' => $log['certainty'] ?? 'none',
|
||||
'planned_action' => $log['planned_action'] ?? 'none',
|
||||
'state_reason' => $log['state_reason'] ?? null,
|
||||
'expected_version' => isset($log['expected_version']) ? (int)$log['expected_version'] : 1,
|
||||
'last_run_id' => isset($log['last_run_id']) ? (int)$log['last_run_id'] : null,
|
||||
'last_evaluated_at' => $log['last_evaluated_at'] ?? null,
|
||||
...$tmp_res['order'], // Return the simulated order from XLVask (with or without items)
|
||||
'usage_log_id' => $id,
|
||||
'linked_order_id' => $linked_order_id,
|
||||
];
|
||||
},
|
||||
$xlvask_usage_logs->forceRestrictFilters(
|
||||
[
|
||||
// This makes sure that the user can only see department logs that belong to their departments
|
||||
'HallId' => $allowedHallIds,
|
||||
'FinishStatus' => ['1'], // Only show finished logs
|
||||
]
|
||||
)
|
||||
);
|
||||
// Return the response
|
||||
$response->success($result);
|
||||
} else {
|
||||
// Return an error
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
$allowedHallIds = $this->allowedHallIdsForUser($user);
|
||||
if ($allowedHallIds === []) {
|
||||
$response->error('No XL Vask hall scope is available', 403);
|
||||
}
|
||||
$xlvask_usage_logs = new xlvask_usage_logs_o();
|
||||
$xlvask = new xlvask();
|
||||
$linked_order_ids_by_wash_id = [];
|
||||
$xlvask_usage_log_class = $xlvask->helpers->xlvask_usage_log;
|
||||
if (ini_get('memory_limit') < '5120M') {
|
||||
ini_set('memory_limit', '5120M');
|
||||
}
|
||||
$result = $xlvask_usage_logs
|
||||
->setAdditionalWhereClause(
|
||||
"`Customer` NOT IN ('" . implode("', '", $xlvask_usage_log_class::$default_customers) . "')"
|
||||
)
|
||||
->listObjectsWithPaginationIfSet(
|
||||
function ($log) use (
|
||||
$response_includes_items,
|
||||
$xlvask_usage_logs,
|
||||
$xlvask,
|
||||
&$linked_order_ids_by_wash_id
|
||||
) {
|
||||
$id = (int)$log['id'];
|
||||
$amount_summary = $xlvask_usage_logs->getAmountSummaryReadOnly($log);
|
||||
unset($log['id']);
|
||||
$log['WashItems'] = json_decode($log['WashItems'] ?? '[]', true);
|
||||
$usage_log_payload = array_intersect_key($log, array_flip([
|
||||
'WashId',
|
||||
'CustomerId',
|
||||
'Customer',
|
||||
'VatNumber',
|
||||
'Location',
|
||||
'Hall',
|
||||
'HallId',
|
||||
'StartTime',
|
||||
'FinishTime',
|
||||
'RegistrationNumber',
|
||||
'VehicleType',
|
||||
'IdentificationType',
|
||||
'IdentificationId',
|
||||
'Info',
|
||||
'Updated',
|
||||
'Prepaid',
|
||||
'FinishStatus',
|
||||
'CustomerGuid',
|
||||
'VehicleId',
|
||||
'WashItems',
|
||||
'ignored_at',
|
||||
'ignored_by',
|
||||
'ignored_reason',
|
||||
]));
|
||||
$tmp = $xlvask->new($xlvask_usage_log_class);
|
||||
$tmp->setProperties($usage_log_payload);
|
||||
$wash_id = (string)$tmp->WashId;
|
||||
if ($wash_id !== '' && !array_key_exists($wash_id, $linked_order_ids_by_wash_id)) {
|
||||
$linked_order = (new orders_o())->selectByWashId($wash_id);
|
||||
$linked_order_ids_by_wash_id[$wash_id] = $linked_order !== null
|
||||
? (int)$linked_order->id
|
||||
: null;
|
||||
}
|
||||
$linked_order_id = $wash_id !== ''
|
||||
? $linked_order_ids_by_wash_id[$wash_id]
|
||||
: null;
|
||||
$isEligibleForAutomaticContinuance = $tmp->isEligibleForAutomaticContinuance(true);
|
||||
$tmp_res = $isEligibleForAutomaticContinuance
|
||||
? (new orders_o())->simulateOrderFromXLVask($tmp, $response_includes_items)
|
||||
: [];
|
||||
$tmp_res['order']['customer_name'] = $tmp->Customer;
|
||||
$tmp_res['order']['total_net_amount'] = $amount_summary['total_net_amount'];
|
||||
$tmp_res['order']['xlvask_primary_product_name'] = $amount_summary['primary_product_name'];
|
||||
$tmp_res['order']['xlvask_amount_cached'] = $amount_summary['cached'];
|
||||
unset($tmp);
|
||||
return [
|
||||
'id' => $id,
|
||||
'fast_link_key' => null,
|
||||
'usage_log_id' => $id,
|
||||
'linked_order_id' => $linked_order_id,
|
||||
'ignored_at' => $log['ignored_at'] ?? null,
|
||||
'ignored_by' => isset($log['ignored_by']) ? (int)$log['ignored_by'] : null,
|
||||
'ignored_reason' => $log['ignored_reason'] ?? null,
|
||||
...$tmp_res['order'],
|
||||
];
|
||||
},
|
||||
$xlvask_usage_logs->forceRestrictFilters([
|
||||
'HallId' => $allowedHallIds,
|
||||
'FinishStatus' => ['1'],
|
||||
])
|
||||
);
|
||||
$response->success($result);
|
||||
}, [
|
||||
'list_xlvask_usage_orders_own' => 'List own xlvask usage orders (Without department filter)',
|
||||
'list_xlvask_usage_orders_all' => 'List all xlvask usage orders (With department filter)',
|
||||
]);
|
||||
},
|
||||
[
|
||||
'list_xlvask_usage_orders_own' => 'List own xlvask usage orders (Without department filter)',
|
||||
'list_xlvask_usage_orders_all' => 'List all xlvask usage orders (With department filter)',
|
||||
]
|
||||
);
|
||||
|
||||
$this->get('/modules/xlvask/services/usage/orders/summary', function () {
|
||||
global $response;
|
||||
@@ -132,189 +172,375 @@ class xlvaskUsageLogsRoute
|
||||
}
|
||||
$dateFrom = $this->isParametersSet(['dateFrom']) ? (string)$this->getParameter('dateFrom') : null;
|
||||
$dateTo = $this->isParametersSet(['dateTo']) ? (string)$this->getParameter('dateTo') : null;
|
||||
$allowedHallIds = $this->allowedHallIdsForUser($user);
|
||||
$summary = (new xlvask_usage_logs_o())->summarizeUsageOrdersReadOnly(
|
||||
|
||||
$response->success([
|
||||
'summary' => (new xlvask_autopilot_service())->getSummary(
|
||||
$dateFrom,
|
||||
$dateTo,
|
||||
$this->allowedHallIdsForUser($user)
|
||||
),
|
||||
]);
|
||||
},
|
||||
[
|
||||
'list_xlvask_usage_orders_own' => 'Read XL Vask usage-log automation summary',
|
||||
'list_xlvask_usage_orders_all' => 'Read all XL Vask usage-log automation summaries',
|
||||
]
|
||||
);
|
||||
|
||||
$this->post('/modules/xlvask/services/usage/autopilot-runs', function () {
|
||||
global $response;
|
||||
$this->requirePermission('manage_xlvask_usage_automation');
|
||||
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
|
||||
$input = [];
|
||||
foreach ([
|
||||
'ids',
|
||||
'dateFrom',
|
||||
'dateTo',
|
||||
'limit',
|
||||
'forceRefetch',
|
||||
'mode',
|
||||
'idempotency_key',
|
||||
'aiTimeline',
|
||||
'aiBatchSize',
|
||||
'aiMaxCostUsd',
|
||||
'aiInputUsdPer1mUsd',
|
||||
'aiOutputUsdPer1mUsd',
|
||||
] as $key) {
|
||||
if ($this->isParametersSet([$key])) {
|
||||
$input[$key] = $this->getParameter($key);
|
||||
}
|
||||
}
|
||||
|
||||
$response->success([
|
||||
'run' => (new xlvask_autopilot_service())->createRun(
|
||||
$input,
|
||||
(int)$user->id,
|
||||
$this->allowedHallIdsForUser($user)
|
||||
),
|
||||
], 202);
|
||||
},
|
||||
[
|
||||
'manage_xlvask_usage_automation' => 'Create an XL Vask usage-log autopilot run',
|
||||
]
|
||||
);
|
||||
|
||||
$this->get('/modules/xlvask/services/usage/automation/admin/readiness', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_xlvask_automation_activate');
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
$dateFrom = $this->isParametersSet(['dateFrom']) ? trim((string)$this->getParameter('dateFrom')) : null;
|
||||
$dateTo = $this->isParametersSet(['dateTo']) ? trim((string)$this->getParameter('dateTo')) : null;
|
||||
$response->success((new xlvask_automation_policy_service())->readinessReadOnly(
|
||||
$dateFrom,
|
||||
$dateTo,
|
||||
$this->allowedHallIdsForUser($user)
|
||||
));
|
||||
}, [
|
||||
'superuser_xlvask_automation_activate' => 'Inspect XL Vask automation activation readiness',
|
||||
]);
|
||||
|
||||
$this->get('/modules/xlvask/services/usage/automation/capabilities', function () {
|
||||
global $response;
|
||||
if (!$this->hasPermission('list_xlvask_usage_orders_all')
|
||||
&& !$this->hasPermission('list_xlvask_usage_orders_own')) {
|
||||
$this->requirePermission('list_xlvask_usage_orders_own');
|
||||
}
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
$dateFrom = $this->isParametersSet(['dateFrom']) ? trim((string)$this->getParameter('dateFrom')) : null;
|
||||
$dateTo = $this->isParametersSet(['dateTo']) ? trim((string)$this->getParameter('dateTo')) : null;
|
||||
$service = new xlvask_automation_policy_service();
|
||||
$capabilities = $service->capabilitiesReadOnly($dateFrom, $dateTo, $this->allowedHallIdsForUser($user));
|
||||
$canManage = $this->hasPermission('manage_xlvask_usage_automation');
|
||||
$canManagePolicy = $this->hasPermission('superuser_xlvask_automation_activate');
|
||||
$response->success([
|
||||
'can_view' => true,
|
||||
'can_review' => $canManage,
|
||||
'can_dry_run' => $canManage,
|
||||
'can_execute' => $canManage && in_array('execute', $capabilities['allowed_modes'], true),
|
||||
'can_manage_policy' => $canManagePolicy,
|
||||
'can_halt' => $canManagePolicy,
|
||||
...$capabilities,
|
||||
]);
|
||||
}, [
|
||||
'list_xlvask_usage_orders_own' => 'Inspect XL Vask automation capabilities',
|
||||
]);
|
||||
|
||||
$this->get('/modules/xlvask/services/usage/autopilot-runs/active', function () {
|
||||
global $response;
|
||||
$this->requirePermission('manage_xlvask_usage_automation');
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
$response->success(['run' => (new xlvask_automation_policy_service())->activeRunReadOnly(
|
||||
$this->allowedHallIdsForUser($user)
|
||||
)]);
|
||||
}, ['manage_xlvask_usage_automation' => 'Inspect the active XL Vask automation run']);
|
||||
|
||||
$this->post('/modules/xlvask/services/usage/automation/admin/policy/previews', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_xlvask_automation_activate');
|
||||
self::requireParameters(['target_stage', 'reason']);
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
$response->success(['preview' => (new xlvask_automation_policy_service())->createPolicyPreview(
|
||||
(string)$this->getParameter('target_stage'),
|
||||
(string)$this->getParameter('reason'),
|
||||
(int)$user->id
|
||||
)]);
|
||||
}, ['superuser_xlvask_automation_activate' => 'Preview an XL Vask automation stage transition']);
|
||||
|
||||
$this->post('/modules/xlvask/services/usage/automation/admin/policy/apply', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_xlvask_automation_activate');
|
||||
self::requireParameters(['preview_id', 'selection_hash', 'confirmation_text']);
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
$response->success((new xlvask_automation_policy_service())->applyPolicyPreview([
|
||||
'preview_id' => $this->getParameter('preview_id'),
|
||||
'selection_hash' => $this->getParameter('selection_hash'),
|
||||
'confirmation_text' => $this->getParameter('confirmation_text'),
|
||||
], (int)$user->id));
|
||||
}, ['superuser_xlvask_automation_activate' => 'Apply a previewed XL Vask automation stage transition']);
|
||||
|
||||
$this->post('/modules/xlvask/services/usage/automation/admin/halt', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_xlvask_automation_activate');
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
$reason = $this->isParametersSet(['reason']) ? (string)$this->getParameter('reason') : '';
|
||||
$response->success((new xlvask_automation_policy_service())->halt((int)$user->id, $reason));
|
||||
}, ['superuser_xlvask_automation_activate' => 'Immediately halt XL Vask automatic actions']);
|
||||
|
||||
$this->post('/modules/xlvask/services/usage/automation/admin/calibrations/backtest', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_xlvask_automation_activate');
|
||||
self::requireParameters(['segment_key']);
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
$response->success([
|
||||
'artifact' => (new xlvask_autopilot_service())->generateCalibrationArtifact(
|
||||
trim((string)$this->getParameter('segment_key')),
|
||||
(int)$user->id
|
||||
),
|
||||
]);
|
||||
}, [
|
||||
'superuser_xlvask_automation_activate' => 'Generate an inactive XL Vask historical calibration artifact',
|
||||
]);
|
||||
|
||||
$this->post('/modules/xlvask/services/usage/automation/admin/calibrations/labels', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_xlvask_automation_activate');
|
||||
self::requireParameters(['suggestion_id', 'outcome']);
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
$allowedHallIds = $this->allowedHallIdsForUser($user);
|
||||
$result = (new xlvask_autopilot_service())->adjudicateCalibrationLabel(
|
||||
(int)$this->getParameter('suggestion_id'),
|
||||
trim((string)$this->getParameter('outcome')),
|
||||
(int)$user->id,
|
||||
$allowedHallIds
|
||||
);
|
||||
$response->success(['summary' => $summary]);
|
||||
$dateFrom = $this->isParametersSet(['dateFrom']) ? trim((string)$this->getParameter('dateFrom')) : null;
|
||||
$dateTo = $this->isParametersSet(['dateTo']) ? trim((string)$this->getParameter('dateTo')) : null;
|
||||
$response->success([
|
||||
...$result,
|
||||
'readiness' => (new xlvask_automation_policy_service())->readinessReadOnly($dateFrom, $dateTo, $allowedHallIds),
|
||||
]);
|
||||
}, [
|
||||
'list_xlvask_usage_orders_own' => 'Read XL Vask usage-log summary',
|
||||
'list_xlvask_usage_orders_all' => 'Read all XL Vask usage-log summaries',
|
||||
'superuser_xlvask_automation_activate' => 'Adjudicate one exact XL Vask suggestion for calibration evidence',
|
||||
]);
|
||||
|
||||
$this->post('/modules/xlvask/services/usage/automation/admin/calibrations/{id}/activate', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_xlvask_automation_activate');
|
||||
self::requireParameters(['artifact_hash', 'confirmation_text']);
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
$response->success((new xlvask_autopilot_service())->activateCalibration(
|
||||
(int)($this->fromRoute('id') ?? 0),
|
||||
trim((string)$this->getParameter('artifact_hash')),
|
||||
(string)$this->getParameter('confirmation_text'),
|
||||
(int)$user->id
|
||||
));
|
||||
}, [
|
||||
'superuser_xlvask_automation_activate' => 'Explicitly activate a qualifying XL Vask calibration artifact',
|
||||
]);
|
||||
|
||||
$this->post('/modules/xlvask/services/usage/automation/admin/wash-id-uniqueness/activate', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_xlvask_automation_activate');
|
||||
self::requireParameters(['confirmation_text']);
|
||||
$response->success((new xlvask_autopilot_service())->activateWashIdUniqueness(
|
||||
(string)$this->getParameter('confirmation_text')
|
||||
));
|
||||
}, [
|
||||
'superuser_xlvask_automation_activate' => 'Explicitly activate the guarded XL Vask wash-id uniqueness migration',
|
||||
]);
|
||||
|
||||
$this->get('/modules/xlvask/services/usage/autopilot-runs/{id}', function () {
|
||||
global $response;
|
||||
$this->requirePermission('manage_xlvask_usage_automation');
|
||||
$id = (int)($this->fromRoute('id') ?? 0);
|
||||
if ($id < 1) {
|
||||
$response->error('Invalid XL Vask autopilot run id', 400);
|
||||
}
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
$run = (new xlvask_autopilot_service())->getRun(
|
||||
$id,
|
||||
(int)$user->id,
|
||||
$this->allowedHallIdsForUser($user)
|
||||
);
|
||||
$response->success(['run' => $run]);
|
||||
},
|
||||
[
|
||||
'manage_xlvask_usage_automation' => 'Read XL Vask usage-log autopilot run status',
|
||||
]
|
||||
);
|
||||
|
||||
$this->post('/modules/xlvask/services/usage/automation/decisions/preview', function () {
|
||||
global $response;
|
||||
$this->requirePermission('manage_xlvask_usage_automation');
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
$input = [];
|
||||
foreach (['usage_log_ids', 'action', 'suggestion_id', 'order_id', 'reason', 'force_manual'] as $key) {
|
||||
if ($this->isParametersSet([$key])) {
|
||||
$input[$key] = $this->getParameter($key);
|
||||
}
|
||||
}
|
||||
$response->success([
|
||||
'preview' => (new xlvask_autopilot_service())->createDecisionPreview(
|
||||
$input,
|
||||
(int)$user->id,
|
||||
$this->allowedHallIdsForUser($user)
|
||||
),
|
||||
]);
|
||||
}, ['manage_xlvask_usage_automation' => 'Preview an XL Vask automation decision']);
|
||||
|
||||
$this->post('/modules/xlvask/services/usage/automation/decisions/apply', function () {
|
||||
global $response;
|
||||
$this->requirePermission('manage_xlvask_usage_automation');
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
$input = [];
|
||||
foreach (['preview_id', 'selection_hash', 'confirmation_text'] as $key) {
|
||||
if ($this->isParametersSet([$key])) {
|
||||
$input[$key] = $this->getParameter($key);
|
||||
}
|
||||
}
|
||||
$response->success(
|
||||
(new xlvask_autopilot_service())->applyDecision(
|
||||
$input,
|
||||
(int)$user->id,
|
||||
$this->allowedHallIdsForUser($user)
|
||||
)
|
||||
);
|
||||
}, ['manage_xlvask_usage_automation' => 'Apply a previewed XL Vask automation decision']);
|
||||
|
||||
$this->patch('/modules/xlvask/services/usage/orders/{id}/ignore', function () {
|
||||
global $response;
|
||||
$this->requirePermission('review_xlvask_usage_order');
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
$id = (int)($this->fromRoute('id') ?? 0);
|
||||
if ($id < 1) {
|
||||
$response->error('Invalid XL Vask usage log id', 400);
|
||||
}
|
||||
self::requireParameters(['reason']);
|
||||
$reason = trim((string)$this->getParameter('reason'));
|
||||
if (mb_strlen($reason) > 500) {
|
||||
$response->error('Reason is too long (max 500 characters)', 400);
|
||||
}
|
||||
$allowedHallIds = $this->allowedHallIdsForUser($user);
|
||||
self::requireUsageLogInHallScope($id, $allowedHallIds);
|
||||
$log = new xlvask_usage_logs_o();
|
||||
$log->getById($id);
|
||||
if (!$log->id) {
|
||||
$response->error('XL Vask usage log not found', 404);
|
||||
}
|
||||
$log->ignored_at->set(date('Y-m-d H:i:s'));
|
||||
$log->ignored_by->set((int)$user->id);
|
||||
$log->ignored_reason->set($reason);
|
||||
$log->objectChanged();
|
||||
$response->success([
|
||||
'id' => $id,
|
||||
'ignored_at' => $log->ignored_at->get(),
|
||||
'ignored_by' => (int)$log->ignored_by->get(),
|
||||
'ignored_reason' => $log->ignored_reason->get(),
|
||||
]);
|
||||
}, [
|
||||
'review_xlvask_usage_order' => 'Ignore an XL Vask usage log for invoice period flagging',
|
||||
]);
|
||||
$this->requirePermission('ignore_xlvask_usage_order');
|
||||
$response->error('Use the server-generated automation decision preview and apply endpoints.', 409);
|
||||
},
|
||||
[
|
||||
'ignore_xlvask_usage_order' => 'Ignore an XL Vask usage log for invoice period flagging',
|
||||
]
|
||||
);
|
||||
|
||||
$this->post('/modules/xlvask/services/usage/orders/{id}/unignore', function () {
|
||||
$this->post('/modules/xlvask/services/usage/orders/automation/run', function () {
|
||||
global $response;
|
||||
$this->requirePermission('review_xlvask_usage_order');
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
$id = (int)($this->fromRoute('id') ?? 0);
|
||||
if ($id < 1) {
|
||||
$response->error('Invalid XL Vask usage log id', 400);
|
||||
}
|
||||
$allowedHallIds = $this->allowedHallIdsForUser($user);
|
||||
self::requireUsageLogInHallScope($id, $allowedHallIds);
|
||||
$log = new xlvask_usage_logs_o();
|
||||
$log->getById($id);
|
||||
if (!$log->id) {
|
||||
$response->error('XL Vask usage log not found', 404);
|
||||
}
|
||||
$log->ignored_at->set(null);
|
||||
$log->ignored_by->set(null);
|
||||
$log->ignored_reason->set(null);
|
||||
$log->objectChanged();
|
||||
$response->success(['id' => $id]);
|
||||
}, [
|
||||
'review_xlvask_usage_order' => 'Clear ignore metadata on an XL Vask usage log',
|
||||
]);
|
||||
$this->requirePermission('manage_xlvask_usage_automation');
|
||||
$response->error('Deprecated. Use /modules/xlvask/services/usage/autopilot-runs.', 410);
|
||||
},
|
||||
[
|
||||
'manage_xlvask_usage_automation' => 'Evaluate and execute XL Vask usage-log automation',
|
||||
]
|
||||
);
|
||||
|
||||
$this->post('/modules/xlvask/services/usage/orders/{id}/accept', function () {
|
||||
$this->post('/modules/xlvask/services/usage/orders/{id}/automation/evaluate', function () {
|
||||
global $response;
|
||||
$this->requirePermission('review_xlvask_usage_order');
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
$id = (int)($this->fromRoute('id') ?? 0);
|
||||
if ($id < 1) {
|
||||
$response->error('Invalid XL Vask usage log id', 400);
|
||||
}
|
||||
$allowedHallIds = $this->allowedHallIdsForUser($user);
|
||||
self::requireUsageLogInHallScope($id, $allowedHallIds);
|
||||
$log = new xlvask_usage_logs_o();
|
||||
$log->getById($id);
|
||||
if (!$log->id) {
|
||||
$response->error('XL Vask usage log not found', 404);
|
||||
}
|
||||
$xlvask = new xlvask();
|
||||
$log_helper = new ($xlvask->helpers->xlvask_usage_log)();
|
||||
$log_helper->setProperties(array_intersect_key($log->toArray(), array_flip([
|
||||
'WashId',
|
||||
'CustomerId',
|
||||
'Customer',
|
||||
'VatNumber',
|
||||
'Location',
|
||||
'Hall',
|
||||
'HallId',
|
||||
'StartTime',
|
||||
'FinishTime',
|
||||
'RegistrationNumber',
|
||||
'VehicleType',
|
||||
'IdentificationType',
|
||||
'IdentificationId',
|
||||
'Info',
|
||||
'Updated',
|
||||
'Prepaid',
|
||||
'FinishStatus',
|
||||
'CustomerGuid',
|
||||
'VehicleId',
|
||||
'WashItems',
|
||||
])));
|
||||
$customer = $log_helper->getCustomer();
|
||||
if ($customer === null) {
|
||||
$response->error('XL Vask customer is not linkable', 422);
|
||||
}
|
||||
if (empty($customer->externId)) {
|
||||
$response->error('XL Vask customer has no external id', 422);
|
||||
}
|
||||
$tmp_user = $customer->getUser();
|
||||
if (!$tmp_user) {
|
||||
$response->error('XL Vask customer is not provisioned in this system', 422);
|
||||
}
|
||||
try {
|
||||
$order = $xlvask->getTasks()->createOrderFromWash($log_helper, $customer);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('[xlvask-accept] createOrderFromWash failed: ' . $e->getMessage());
|
||||
$response->error('Could not create order from XL Vask usage log: ' . $e->getMessage(), 422);
|
||||
}
|
||||
$log->ignored_at->set(date('Y-m-d H:i:s'));
|
||||
$log->ignored_by->set((int)$user->id);
|
||||
$log->ignored_reason->set('Accepted and converted to order ' . (int)$order->id);
|
||||
$log->objectChanged();
|
||||
$response->success([
|
||||
'order_id' => (int)$order->id,
|
||||
'usage_log_id' => $id,
|
||||
]);
|
||||
}, [
|
||||
'review_xlvask_usage_order' => 'Convert an XL Vask usage log into an order',
|
||||
]);
|
||||
$this->requirePermission('manage_xlvask_usage_automation');
|
||||
$response->error('Deprecated. Use /modules/xlvask/services/usage/autopilot-runs.', 410);
|
||||
},
|
||||
[
|
||||
'manage_xlvask_usage_automation' => 'Evaluate XL Vask usage-log automation',
|
||||
]
|
||||
);
|
||||
|
||||
$this->post('/modules/xlvask/services/usage/orders/{id}/reject', function () {
|
||||
$this->post('/modules/xlvask/services/usage/orders/{id}/automation/accept', function () {
|
||||
global $response;
|
||||
$this->requirePermission('review_xlvask_usage_order');
|
||||
$this->requirePermission('manage_xlvask_usage_automation');
|
||||
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
|
||||
$id = (int)($this->fromRoute('id') ?? 0);
|
||||
if ($id < 1) {
|
||||
$response->error('Invalid XL Vask usage log id', 400);
|
||||
}
|
||||
self::requireParameters(['reason']);
|
||||
$reason = trim((string)$this->getParameter('reason'));
|
||||
if (mb_strlen($reason) > 500) {
|
||||
$response->error('Reason is too long (max 500 characters)', 400);
|
||||
self::requireUsageLogInHallScope($id, $this->allowedHallIdsForUser($user));
|
||||
|
||||
$response->error('Use the server-generated automation decision preview and apply endpoints.', 409);
|
||||
},
|
||||
[
|
||||
'manage_xlvask_usage_automation' => 'Accept an XL Vask usage-log automation suggestion',
|
||||
]
|
||||
);
|
||||
|
||||
$this->post('/modules/xlvask/services/usage/orders/{id}/automation/deny', function () {
|
||||
global $response;
|
||||
$this->requirePermission('manage_xlvask_usage_automation');
|
||||
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
$allowedHallIds = $this->allowedHallIdsForUser($user);
|
||||
self::requireUsageLogInHallScope($id, $allowedHallIds);
|
||||
$log = new xlvask_usage_logs_o();
|
||||
$log->getById($id);
|
||||
if (!$log->id) {
|
||||
$response->error('XL Vask usage log not found', 404);
|
||||
|
||||
$id = (int)($this->fromRoute('id') ?? 0);
|
||||
if ($id < 1) {
|
||||
$response->error('Invalid XL Vask usage log id', 400);
|
||||
}
|
||||
$log->ignored_at->set(date('Y-m-d H:i:s'));
|
||||
$log->ignored_by->set((int)$user->id);
|
||||
$log->ignored_reason->set('Rejected: ' . $reason);
|
||||
$log->objectChanged();
|
||||
$response->success([
|
||||
'id' => $id,
|
||||
'ignored_at' => $log->ignored_at->get(),
|
||||
'ignored_by' => (int)$log->ignored_by->get(),
|
||||
'ignored_reason' => $log->ignored_reason->get(),
|
||||
]);
|
||||
}, [
|
||||
'review_xlvask_usage_order' => 'Reject an XL Vask usage log with a reviewer note',
|
||||
]);
|
||||
self::requireUsageLogInHallScope($id, $this->allowedHallIdsForUser($user));
|
||||
|
||||
$response->error('Use the server-generated automation decision preview and apply endpoints.', 409);
|
||||
},
|
||||
[
|
||||
'manage_xlvask_usage_automation' => 'Deny an XL Vask usage-log automation suggestion',
|
||||
]
|
||||
);
|
||||
|
||||
$this->get('/modules/xlvask/services/usage/orders/fast-link', function () {
|
||||
global $response;
|
||||
@@ -323,55 +549,91 @@ class xlvaskUsageLogsRoute
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
self::requireParameters(['fast_link_key']);
|
||||
self::requireParameters([
|
||||
'fast_link_key', // Example: 'temporary_cache_6878cf0603d77'
|
||||
]);
|
||||
// Get the fast link key from the request
|
||||
$fast_link_key = (string)self::getParameter('fast_link_key');
|
||||
self::requireType($fast_link_key, self::type_string());
|
||||
self::requireMinLength('fast_link_key', 20);
|
||||
self::requireMaxLength('fast_link_key', 50);
|
||||
if (!preg_match('/^temporary_cache_[a-z0-9]{12,32}$/', $fast_link_key)) {
|
||||
self::requireMinLength('fast_link_key', 20); // Minimum length of the fast link key
|
||||
self::requireMaxLength('fast_link_key', 50); // Maximum length of the fast link key
|
||||
// Check if the fast link key is valid
|
||||
// First, check if the key has the correct format
|
||||
if (preg_match('/^temporary_cache_[a-z0-9]{12,32}$/', $fast_link_key)) {
|
||||
// Get the cached data from Redis
|
||||
$cached_data = redis->get($fast_link_key);
|
||||
// Check if the cached data is valid
|
||||
if ($cached_data) {
|
||||
// Decode the cached data
|
||||
$data = json_decode($cached_data, true);
|
||||
// Check if the data is valid
|
||||
if (is_array($data)) {
|
||||
$allowedHallIds = $this->allowedHallIdsForUser($user);
|
||||
if (!in_array(trim((string)($data['HallId'] ?? '')), $allowedHallIds, true)) {
|
||||
$response->error('Fast link is outside the current XL Vask hall scope', 403);
|
||||
}
|
||||
// Delete the cached data from Redis
|
||||
redis->delete($fast_link_key);
|
||||
// Return the data
|
||||
$xlvask = new xlvask();
|
||||
$order_arr = (new orders_o())->simulateOrderFromXLVask($xlvask->new($xlvask->helpers->xlvask_usage_log)->setProperties($data), true); // ['order' => $order_arr, 'order_items' => $items_arr]
|
||||
$tmp_order_obj = (object)[];
|
||||
$tmp_order_arr = $order_arr['order'] ?? [];
|
||||
/**
|
||||
* "id": -1,
|
||||
* "customer_id": 39159000,
|
||||
* "cashier_id": 2285,
|
||||
* "reference": "Simulated Order from XL Vask",
|
||||
* "notes": "This is a simulated order generated from an XL Vask usage log",
|
||||
* "department_id": 1,
|
||||
* "reg_1": "DE55248",
|
||||
* "reg_2": "",
|
||||
* "reg_3": "",
|
||||
* "completed_at": null,
|
||||
* "created_at": "2025-07-16 13:04:54",
|
||||
* "deleted_at": null,
|
||||
* "total_net_amount": 683,
|
||||
* "invoice_collection_id": 0,
|
||||
* "booking_id": 0,
|
||||
* "wash_id": "b24728ea-e22b-4dce-8cd3-a0998f7fdc5e",
|
||||
* "lane": 1,
|
||||
* "closed_at": null
|
||||
*/
|
||||
$tmp_order_obj->customer_id = $tmp_order_arr['customer_id'] ?? 0;
|
||||
$tmp_order_obj->department_id = $tmp_order_arr['department_id'] ?? 0;
|
||||
$tmp_order_obj->reg_1 = $tmp_order_arr['reg_1'] ?? '';
|
||||
$tmp_order_obj->reg_2 = $tmp_order_arr['reg_2'] ?? '';
|
||||
$tmp_order_obj->reg_3 = $tmp_order_arr['reg_3'] ?? '';
|
||||
$tmp_order_obj->created_at = $tmp_order_arr['created_at'] ?? '';
|
||||
$tmp_order_obj->wash_id = $tmp_order_arr['wash_id'] ?? '';
|
||||
$tmp_order_obj->lane = $tmp_order_arr['lane'] ?? 0;
|
||||
$response->success([
|
||||
...$order_arr,
|
||||
'potential_duplicates' => (new orders_o())->getOrderPotentialDuplicatesIfFound(
|
||||
$tmp_order_obj->reg_1,
|
||||
$tmp_order_obj->reg_2,
|
||||
$tmp_order_obj->reg_3,
|
||||
$tmp_order_obj->department_id,
|
||||
$tmp_order_obj->created_at,
|
||||
),
|
||||
]);
|
||||
} else {
|
||||
// Return an error if the data is not valid
|
||||
$response->error('Invalid cached data', 400);
|
||||
}
|
||||
} else {
|
||||
// Return an error if the fast link key does not exist in Redis
|
||||
$response->error('Fast link key not found', 404);
|
||||
}
|
||||
} else {
|
||||
// Return an error if the fast link key is invalid
|
||||
$response->error('Invalid fast link key format', 400);
|
||||
}
|
||||
$cached_data = redis->get($fast_link_key);
|
||||
if (!$cached_data) {
|
||||
$response->error('Fast link key not found', 404);
|
||||
}
|
||||
$data = json_decode($cached_data, true);
|
||||
if (!is_array($data)) {
|
||||
$response->error('Invalid cached data', 400);
|
||||
}
|
||||
$allowedHallIds = $this->allowedHallIdsForUser($user);
|
||||
if (!in_array(trim((string)($data['HallId'] ?? '')), $allowedHallIds, true)) {
|
||||
$response->error('Fast link is outside the current XL Vask hall scope', 403);
|
||||
}
|
||||
redis->delete($fast_link_key);
|
||||
$xlvask = new xlvask();
|
||||
$order_arr = (new orders_o())->simulateOrderFromXLVask(
|
||||
$xlvask->new($xlvask->helpers->xlvask_usage_log)->setProperties($data),
|
||||
true
|
||||
);
|
||||
$tmp_order_obj = (object)[];
|
||||
$tmp_order_arr = $order_arr['order'] ?? [];
|
||||
$tmp_order_obj->customer_id = $tmp_order_arr['customer_id'] ?? 0;
|
||||
$tmp_order_obj->department_id = $tmp_order_arr['department_id'] ?? 0;
|
||||
$tmp_order_obj->reg_1 = $tmp_order_arr['reg_1'] ?? '';
|
||||
$tmp_order_obj->reg_2 = $tmp_order_arr['reg_2'] ?? '';
|
||||
$tmp_order_obj->reg_3 = $tmp_order_arr['reg_3'] ?? '';
|
||||
$tmp_order_obj->created_at = $tmp_order_arr['created_at'] ?? '';
|
||||
$tmp_order_obj->wash_id = $tmp_order_arr['wash_id'] ?? '';
|
||||
$tmp_order_obj->lane = $tmp_order_arr['lane'] ?? 0;
|
||||
$response->success([
|
||||
...$order_arr,
|
||||
'potential_duplicates' => (new orders_o())->getOrderPotentialDuplicatesIfFound(
|
||||
$tmp_order_obj->reg_1,
|
||||
$tmp_order_obj->reg_2,
|
||||
$tmp_order_obj->reg_3,
|
||||
$tmp_order_obj->department_id,
|
||||
$tmp_order_obj->created_at,
|
||||
),
|
||||
]);
|
||||
}, [
|
||||
'fast_link_key' => 'string',
|
||||
]);
|
||||
},
|
||||
[
|
||||
'fast_link_key' => 'string', // Example: 'temporary_cache_6878cf0603d77'
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function allowedHallIdsForUser(object $user): array
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
<?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);
|
||||
});
|
||||
-323
@@ -1,323 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* End-to-end integration test for the e-conomic draft invoice export flow.
|
||||
*
|
||||
* Verifies that:
|
||||
* - addTextLine() sanitizes all user input (slash → dash, control chars, length)
|
||||
* - addProductLine() sanitizes product numbers and descriptions
|
||||
* - preflightValidate() catches all 5 rule violations
|
||||
* - Mixed text + product lines (with/without discount) pass preflight
|
||||
* - Empty/whitespace-only lines are skipped (not added to draft)
|
||||
*
|
||||
* This test does NOT hit a live e-conomic API.
|
||||
* For live verification, see /workspace/scripts/verify-economic-drafts-live.php
|
||||
*
|
||||
* Run: php8.4 services/nginx/app/vendor/bin/phpunit \
|
||||
* -c services/nginx/app/phpunit.xml \
|
||||
* services/nginx/app/tests/Integration/Invoicing/EconomicDraftSanitizationIntegrationTest.php
|
||||
*/
|
||||
|
||||
namespace tests\Integration\Invoicing;
|
||||
|
||||
require_once __DIR__ . '/../../../classes/economic_export_sanitizer.php';
|
||||
require_once __DIR__ . '/../../../modules/economic/helpers/economic_invoice_draft.php';
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use helpers\economic_invoice_draft;
|
||||
|
||||
class EconomicDraftSanitizationIntegrationTest extends TestCase
|
||||
{
|
||||
private economic_invoice_draft $draft;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->draft = new economic_invoice_draft(12345, 'DKK', true); // skip_fetch=true: no live API call
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// addTextLine — sanitization
|
||||
// ========================================================================
|
||||
|
||||
public function testAddTextLineSanitizesSlashToDash(): void
|
||||
{
|
||||
$this->draft->addTextLine('Reference: Order/123/ABC');
|
||||
$lines = $this->draft->getDraftLines();
|
||||
$this->assertCount(1, $lines);
|
||||
$this->assertSame('Reference: Order-123-ABC', $lines[0]['description']);
|
||||
}
|
||||
|
||||
public function testAddTextLineStripsControlChars(): void
|
||||
{
|
||||
$this->draft->addTextLine("Line 1\nLine 2\twith tab");
|
||||
$lines = $this->draft->getDraftLines();
|
||||
$this->assertSame('Line 1 Line 2 with tab', $lines[0]['description']);
|
||||
}
|
||||
|
||||
public function testAddTextLineTruncatesVeryLongString(): void
|
||||
{
|
||||
$long = str_repeat('A', 5000);
|
||||
$this->draft->addTextLine($long);
|
||||
$lines = $this->draft->getDraftLines();
|
||||
$this->assertLessThanOrEqual(250, mb_strlen($lines[0]['description']));
|
||||
$this->assertStringEndsWith('...', $lines[0]['description']);
|
||||
}
|
||||
|
||||
public function testMultipleTextLinesAllSanitized(): void
|
||||
{
|
||||
$this->draft->addTextLine('Reference: A/B');
|
||||
$this->draft->addTextLine('PO: C/D');
|
||||
$this->draft->addTextLine('Reg 1: E/F');
|
||||
$lines = $this->draft->getDraftLines();
|
||||
$this->assertCount(3, $lines);
|
||||
$this->assertSame('Reference: A-B', $lines[0]['description']);
|
||||
$this->assertSame('PO: C-D', $lines[1]['description']);
|
||||
$this->assertSame('Reg 1: E-F', $lines[2]['description']);
|
||||
}
|
||||
|
||||
public function testEmptyAndWhitespaceOnlyLinesAreSkipped(): void
|
||||
{
|
||||
$this->draft->addTextLine('');
|
||||
$this->draft->addTextLine(' ');
|
||||
$this->draft->addTextLine("\t\n ");
|
||||
$this->draft->addTextLine('///'); // All slashes become dashes, then trim leaves '-', not empty
|
||||
$lines = $this->draft->getDraftLines();
|
||||
// '///' becomes '---' which is not empty after trim
|
||||
$this->assertCount(1, $lines);
|
||||
$this->assertSame('---', $lines[0]['description']);
|
||||
}
|
||||
|
||||
public function testPurelyWhitespaceAfterSanitizationIsSkipped(): void
|
||||
{
|
||||
$this->draft->addTextLine("\x00\x01\x02"); // All control chars, no actual text
|
||||
$lines = $this->draft->getDraftLines();
|
||||
$this->assertCount(0, $lines);
|
||||
}
|
||||
|
||||
public function testMultibyteTextPreservedCorrectly(): void
|
||||
{
|
||||
$this->draft->addTextLine('Kunde: ÆØÅ / 中文 / 🚗');
|
||||
$lines = $this->draft->getDraftLines();
|
||||
$this->assertSame('Kunde: ÆØÅ - 中文 - 🚗', $lines[0]['description']);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// addProductLine — sanitization
|
||||
// ========================================================================
|
||||
|
||||
public function testProductLineWithoutDiscount(): void
|
||||
{
|
||||
$this->draft->addProductLine(
|
||||
'PROD-001',
|
||||
'Bilvask Standard',
|
||||
1.0,
|
||||
150.0,
|
||||
1,
|
||||
1,
|
||||
0.0
|
||||
);
|
||||
$lines = $this->draft->getDraftLines();
|
||||
$this->assertCount(1, $lines);
|
||||
$this->assertSame('Bilvask Standard', $lines[0]['description']);
|
||||
$this->assertSame(0.0, $lines[0]['discountPercentage']);
|
||||
$this->assertSame('PROD-001', $lines[0]['product']['productNumber']);
|
||||
}
|
||||
|
||||
public function testProductLineWithDiscount(): void
|
||||
{
|
||||
$this->draft->addProductLine(
|
||||
'PROD-002',
|
||||
'Storvask Premium',
|
||||
1.0,
|
||||
250.0,
|
||||
1,
|
||||
1,
|
||||
20.0
|
||||
);
|
||||
$lines = $this->draft->getDraftLines();
|
||||
$this->assertCount(1, $lines);
|
||||
$this->assertSame(20.0, $lines[0]['discountPercentage']);
|
||||
}
|
||||
|
||||
public function testProductLineWithSlashInNumberSanitized(): void
|
||||
{
|
||||
$this->draft->addProductLine(
|
||||
'PROD/003',
|
||||
'Premium/Service',
|
||||
1.0,
|
||||
100.0,
|
||||
1,
|
||||
1
|
||||
);
|
||||
$lines = $this->draft->getDraftLines();
|
||||
// Product numbers REMOVE the slash (sanitizeProductNumber), text lines REPLACE with dash
|
||||
$this->assertSame('PROD003', $lines[0]['product']['productNumber']);
|
||||
$this->assertSame('Premium-Service', $lines[0]['description']);
|
||||
}
|
||||
|
||||
public function testProductLineWithEmptyDescriptionSkipped(): void
|
||||
{
|
||||
$this->draft->addProductLine('PROD-001', '', 1.0, 100.0, 1, 1);
|
||||
$lines = $this->draft->getDraftLines();
|
||||
$this->assertCount(0, $lines);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// preflightValidate — via addLines (with transport stub would be ideal,
|
||||
// but for unit-style integration we exercise preflight directly)
|
||||
// ========================================================================
|
||||
|
||||
public function testPreflightCatchesEmptyDescription(): void
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('description is empty');
|
||||
$this->draft->preflightValidate([
|
||||
['description' => '', 'quantity' => 1, 'unitNetPrice' => 100.0],
|
||||
]);
|
||||
}
|
||||
|
||||
public function testPreflightCatchesTooLongDescription(): void
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('exceeds 250 chars');
|
||||
$this->draft->preflightValidate([
|
||||
['description' => str_repeat('A', 251), 'quantity' => 1, 'unitNetPrice' => 100.0],
|
||||
]);
|
||||
}
|
||||
|
||||
public function testPreflightCatchesInvalidProductNumber(): void
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('productNumber does not match');
|
||||
$this->draft->preflightValidate([
|
||||
[
|
||||
'description' => 'Valid line',
|
||||
'product' => ['productNumber' => 'PROD/01'],
|
||||
'quantity' => 1,
|
||||
'unitNetPrice' => 100.0,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function testPreflightCatchesZeroQuantity(): void
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('quantity is not a positive number');
|
||||
$this->draft->preflightValidate([
|
||||
['description' => 'Valid line', 'quantity' => 0, 'unitNetPrice' => 100.0],
|
||||
]);
|
||||
}
|
||||
|
||||
public function testPreflightCatchesNegativeUnitPrice(): void
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('unitNetPrice is not a number');
|
||||
$this->draft->preflightValidate([
|
||||
['description' => 'Valid line', 'quantity' => 1, 'unitNetPrice' => -10.0],
|
||||
]);
|
||||
}
|
||||
|
||||
public function testPreflightIncludesOrderIdInMessage(): void
|
||||
{
|
||||
try {
|
||||
$this->draft->preflightValidate(
|
||||
[['description' => '', 'quantity' => 1, 'unitNetPrice' => 100.0]],
|
||||
300
|
||||
);
|
||||
$this->fail('Expected RuntimeException');
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->assertStringContainsString('order 300', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function testPreflightPassesValidLines(): void
|
||||
{
|
||||
// Should not throw
|
||||
$this->draft->preflightValidate([
|
||||
['description' => 'Line 1', 'quantity' => 2, 'unitNetPrice' => 100.0],
|
||||
[
|
||||
'description' => 'Line 2 with product',
|
||||
'product' => ['productNumber' => 'PROD-01'],
|
||||
'quantity' => 1,
|
||||
'unitNetPrice' => 50.0,
|
||||
'discountPercentage' => 10,
|
||||
],
|
||||
]);
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function testPreflightPassesExactly250Chars(): void
|
||||
{
|
||||
$exactlyMax = str_repeat('B', 250);
|
||||
// Should not throw
|
||||
$this->draft->preflightValidate([
|
||||
['description' => $exactlyMax, 'quantity' => 1, 'unitNetPrice' => 100.0],
|
||||
]);
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function testPreflightFailsAt251Chars(): void
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->draft->preflightValidate([
|
||||
['description' => str_repeat('B', 251), 'quantity' => 1, 'unitNetPrice' => 100.0],
|
||||
]);
|
||||
}
|
||||
|
||||
public function testPreflightAcceptsProductNumberWithDotsAndDashes(): void
|
||||
{
|
||||
// Should not throw
|
||||
$this->draft->preflightValidate([
|
||||
[
|
||||
'description' => 'Valid',
|
||||
'product' => ['productNumber' => 'PROD-01.0_test'],
|
||||
'quantity' => 1,
|
||||
'unitNetPrice' => 100.0,
|
||||
],
|
||||
]);
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// End-to-end: mixed flow
|
||||
// ========================================================================
|
||||
|
||||
public function testMixedLinesAllTogetherAndPassPreflight(): void
|
||||
{
|
||||
$this->draft->addTextLine('Reference: Order/2024/Q1');
|
||||
$this->draft->addProductLine('PROD-001', 'Bilvask', 2.0, 100.0, 1, 1, 10.0);
|
||||
$this->draft->addProductLine('PROD-002', 'Storvask', 1.0, 200.0, 1, 1, 0.0);
|
||||
$this->draft->addTextLine('Note: paid/in/full');
|
||||
|
||||
$lines = $this->draft->getDraftLines();
|
||||
$this->assertCount(4, $lines);
|
||||
|
||||
$this->assertSame('Reference: Order-2024-Q1', $lines[0]['description']);
|
||||
$this->assertSame('Bilvask', $lines[1]['description']);
|
||||
$this->assertSame(10.0, $lines[1]['discountPercentage']);
|
||||
$this->assertSame('Storvask', $lines[2]['description']);
|
||||
$this->assertSame(0.0, $lines[2]['discountPercentage']);
|
||||
$this->assertSame('Note: paid-in-full', $lines[3]['description']);
|
||||
|
||||
// All sanitized lines pass preflight
|
||||
$this->draft->preflightValidate($lines, 12345);
|
||||
}
|
||||
|
||||
public function testDiscountPathProducesSingleProductLineWithDiscountPct(): void
|
||||
{
|
||||
$this->draft->addProductLine('DISC-01', 'Rabatservice', 1.0, 100.0, 1, 1, 25.0);
|
||||
$lines = $this->draft->getDraftLines();
|
||||
$this->assertCount(1, $lines);
|
||||
$this->assertSame(25.0, $lines[0]['discountPercentage']);
|
||||
$this->assertSame('Rabatservice', $lines[0]['description']);
|
||||
}
|
||||
|
||||
public function testNoDiscountPathProducesSingleProductLineWithZeroDiscount(): void
|
||||
{
|
||||
$this->draft->addProductLine('NODISC-01', 'Standardservice', 1.0, 100.0, 1, 1, 0.0);
|
||||
$lines = $this->draft->getDraftLines();
|
||||
$this->assertCount(1, $lines);
|
||||
$this->assertSame(0.0, $lines[0]['discountPercentage']);
|
||||
$this->assertSame('Standardservice', $lines[0]['description']);
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Standalone smoke test for the boolean_normalization_t trait.
|
||||
*
|
||||
* The composer autoloader is not always available locally (CI may install
|
||||
* dependencies before this script runs); the inline require_once calls
|
||||
* below let us verify the trait + all seven consumers in isolation.
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../traits/boolean_normalization_t.php';
|
||||
require_once __DIR__ . '/../../classes/cron_worker.php';
|
||||
require_once __DIR__ . '/../../classes/replica_failover_manager.php';
|
||||
require_once __DIR__ . '/../../classes/superuser_system_status_service.php';
|
||||
require_once __DIR__ . '/../../classes/module_usage_service.php';
|
||||
require_once __DIR__ . '/../../classes/account_deletion_service.php';
|
||||
require_once __DIR__ . '/../../classes/releasemanager.php';
|
||||
require_once __DIR__ . '/../../classes/release_manager.php';
|
||||
|
||||
$classes = [
|
||||
'classes\\cron_worker',
|
||||
'classes\\replica_failover_manager',
|
||||
'classes\\superuser_system_status_service',
|
||||
'classes\\module_usage_service',
|
||||
'classes\\account_deletion_service',
|
||||
'classes\\releasemanager',
|
||||
'classes\\release_manager',
|
||||
];
|
||||
foreach ($classes as $class) {
|
||||
$rc = new ReflectionClass($class);
|
||||
$ok = in_array('traits\\boolean_normalization_t', $rc->getTraitNames(), true);
|
||||
echo str_pad($class, 55) . ' -> ' . ($ok ? 'YES' : 'NO') . PHP_EOL;
|
||||
}
|
||||
echo PHP_EOL;
|
||||
|
||||
$cases = [
|
||||
[true, true],
|
||||
[false, false],
|
||||
[1, true],
|
||||
[0, false],
|
||||
['true', true],
|
||||
['TRUE', true],
|
||||
['1', true],
|
||||
['yes', true],
|
||||
['YES', true],
|
||||
['on', true],
|
||||
[' ON ', true],
|
||||
['false', false],
|
||||
['no', false],
|
||||
['off', false],
|
||||
['', false],
|
||||
[null, false],
|
||||
['0', false],
|
||||
[[], false],
|
||||
[(object) ['v' => 'true'], false],
|
||||
];
|
||||
$s = new class {
|
||||
use traits\boolean_normalization_t;
|
||||
};
|
||||
$fails = 0;
|
||||
foreach ($cases as $pair) {
|
||||
[$in, $exp] = $pair;
|
||||
$a = $s::normalizeBoolean($in);
|
||||
if ($a !== $exp) {
|
||||
echo 'FAIL ' . var_export($in, true) . ' expected ' . var_export($exp, true) . ' got ' . var_export($a, true) . PHP_EOL;
|
||||
$fails++;
|
||||
}
|
||||
}
|
||||
echo ($fails === 0 ? 'OK' : 'FAIL') . ' - ' . count($cases) . ' normalizeBoolean cases' . PHP_EOL;
|
||||
@@ -58,7 +58,6 @@ 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,11 +175,6 @@ 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 = [
|
||||
@@ -209,42 +204,6 @@ 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) {
|
||||
|
||||
@@ -6,7 +6,6 @@ return [
|
||||
['path' => 'tests/auth/PasskeyChallengeTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/auth/PemToCoseConversionTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/auth/RegisterCvrTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/auth/StripeInvoiceEmailTemplateTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/auth/TwoFactorAuthTest.php', 'classification' => 'integration', 'type' => 'script'],
|
||||
['path' => 'tests/auth/WebAuthnInstallTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/bookingModule/BookingModuleTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
<?php
|
||||
|
||||
use classes\api_key_generator;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->previousDb = $GLOBALS['db'] ?? null;
|
||||
unset($GLOBALS['db']);
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
if ($this->previousDb !== null) {
|
||||
$GLOBALS['db'] = $this->previousDb;
|
||||
return;
|
||||
}
|
||||
unset($GLOBALS['db']);
|
||||
});
|
||||
|
||||
it('generates a key_id with the default prefix and 22-char base62 random', function (): void {
|
||||
$keyId = api_key_generator::generateKeyId();
|
||||
expect($keyId)
|
||||
->toStartWith('truck_live_')
|
||||
->and(strlen($keyId))->toBe(strlen('truck_live_') + 22)
|
||||
->and(api_key_generator::isBase62(substr($keyId, strlen('truck_live_'))))->toBeTrue();
|
||||
});
|
||||
|
||||
it('honours a custom env tag in the key_id', function (): void {
|
||||
$keyId = api_key_generator::generateKeyId('test');
|
||||
expect($keyId)->toStartWith('truck_test_');
|
||||
});
|
||||
|
||||
it('falls back to "live" for empty or invalid env tags', function (): void {
|
||||
expect(api_key_generator::generateKeyId(''))->toStartWith('truck_live_');
|
||||
expect(api_key_generator::generateKeyId(' '))->toStartWith('truck_live_');
|
||||
expect(api_key_generator::generateKeyId('weird!@# chars'))->toStartWith('truck_live_');
|
||||
});
|
||||
|
||||
it('generates a 32-char base62 secret with no dots', function (): void {
|
||||
$secret = api_key_generator::generateSecret();
|
||||
expect($secret)
|
||||
->toHaveLength(32)
|
||||
->and($secret)->not->toContain('.')
|
||||
->and(api_key_generator::isBase62($secret))->toBeTrue();
|
||||
});
|
||||
|
||||
it('generates unique values across many calls', function (): void {
|
||||
$seen = [];
|
||||
for ($i = 0; $i < 200; $i++) {
|
||||
$seen[] = api_key_generator::generateKeyId() . '.' . api_key_generator::generateSecret();
|
||||
}
|
||||
expect(count(array_unique($seen)))->toBe(200);
|
||||
});
|
||||
|
||||
it('formats a key as "key_id.secret"', function (): void {
|
||||
$full = api_key_generator::formatKey('truck_live_abc', 'xyz');
|
||||
expect($full)->toBe('truck_live_abc.xyz');
|
||||
});
|
||||
|
||||
it('rejects formatted keys where either part contains a dot', function (): void {
|
||||
expect(fn () => api_key_generator::formatKey('bad.dot', 'secret'))
|
||||
->toThrow(InvalidArgumentException::class);
|
||||
expect(fn () => api_key_generator::formatKey('key_id', 'bad.dot'))
|
||||
->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
it('hashes with argon2id and verifies the same plaintext', function (): void {
|
||||
$plain = 'truck_live_abc' . '.' . 'thisIsTheSecretPart';
|
||||
$hash = api_key_generator::hash($plain);
|
||||
expect($hash)
|
||||
->toBeString()
|
||||
->not->toBe($plain)
|
||||
->toStartWith('$argon2id$');
|
||||
expect(api_key_generator::verify($plain, $hash))->toBeTrue();
|
||||
});
|
||||
|
||||
it('produces different hashes for the same plaintext (salt randomness)', function (): void {
|
||||
$plain = 'truck_live_abc' . '.' . 'thisIsTheSecretPart';
|
||||
$h1 = api_key_generator::hash($plain);
|
||||
$h2 = api_key_generator::hash($plain);
|
||||
expect($h1)->not->toBe($h2);
|
||||
expect(api_key_generator::verify($plain, $h1))->toBeTrue();
|
||||
expect(api_key_generator::verify($plain, $h2))->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects an empty hash input', function (): void {
|
||||
expect(fn () => api_key_generator::hash(''))
|
||||
->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
it('verify returns false for empty inputs', function (): void {
|
||||
expect(api_key_generator::verify('', '$argon2id$something'))->toBeFalse();
|
||||
expect(api_key_generator::verify('plain', ''))->toBeFalse();
|
||||
});
|
||||
|
||||
it('parses a well-formed full key', function (): void {
|
||||
$full = 'truck_live_abcDEF1234567890xyz' . '.' . 'A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6';
|
||||
$parsed = api_key_generator::parseKey($full);
|
||||
expect($parsed)
|
||||
->toBe(['key_id' => 'truck_live_abcDEF1234567890xyz', 'secret' => 'A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6']);
|
||||
});
|
||||
|
||||
it('returns null when parsing a malformed key', function (): void {
|
||||
expect(api_key_generator::parseKey(''))->toBeNull();
|
||||
expect(api_key_generator::parseKey(' '))->toBeNull();
|
||||
expect(api_key_generator::parseKey('no-dot-here'))->toBeNull();
|
||||
expect(api_key_generator::parseKey('.only-secret'))->toBeNull();
|
||||
expect(api_key_generator::parseKey('only-key.'))->toBeNull();
|
||||
expect(api_key_generator::parseKey('has spaces.in-secret'))->toBeNull();
|
||||
expect(api_key_generator::parseKey('has/slash.in-secret'))->toBeNull();
|
||||
});
|
||||
|
||||
it('round-trips generateKeyId + generateSecret through formatKey + parseKey', function (): void {
|
||||
$keyId = api_key_generator::generateKeyId('live');
|
||||
$secret = api_key_generator::generateSecret();
|
||||
$full = api_key_generator::formatKey($keyId, $secret);
|
||||
$parsed = api_key_generator::parseKey($full);
|
||||
expect($parsed)->toBe(['key_id' => $keyId, 'secret' => $secret]);
|
||||
});
|
||||
|
||||
it('isBase62 accepts alphanumerics and rejects everything else', function (): void {
|
||||
expect(api_key_generator::isBase62('abc123XYZ'))->toBeTrue();
|
||||
expect(api_key_generator::isBase62(''))->toBeFalse();
|
||||
expect(api_key_generator::isBase62('abc-123'))->toBeFalse();
|
||||
expect(api_key_generator::isBase62('abc.123'))->toBeFalse();
|
||||
expect(api_key_generator::isBase62('abc 123'))->toBeFalse();
|
||||
expect(api_key_generator::isBase62('abc/123'))->toBeFalse();
|
||||
expect(api_key_generator::isBase62('abc+123'))->toBeFalse();
|
||||
});
|
||||
@@ -1,319 +0,0 @@
|
||||
<?php
|
||||
|
||||
use classes\api_key_repository;
|
||||
use classes\api_key_generator;
|
||||
use classes\api_key_schema_bootstrap;
|
||||
|
||||
/**
|
||||
* Fake mysqli stmt used by api_key_repository unit tests. We mimic
|
||||
* just enough of the surface area (`bind_param`, `execute`,
|
||||
* `get_result`, `close`, `insert_id`, `affected_rows`, `error`) to
|
||||
* exercise the repository without a real database.
|
||||
*/
|
||||
if (!class_exists('ApiKeyRepositoryFakeStmt')) {
|
||||
class ApiKeyRepositoryFakeStmt
|
||||
{
|
||||
public string $lastSql = '';
|
||||
/** @var array<int, mixed> */
|
||||
public array $params = [];
|
||||
public ?int $insertId = null;
|
||||
public int $affectedRows = 0;
|
||||
public string $error = '';
|
||||
public bool $executeResult = true;
|
||||
/** @var array<int, array<string, mixed>>|null */
|
||||
public ?array $rowsToReturn = null;
|
||||
/** @var array<string, string> */
|
||||
public array $types = [
|
||||
'i' => 'i', 's' => 's',
|
||||
];
|
||||
|
||||
public function bind_param(string $types, &...$vars): bool
|
||||
{
|
||||
$this->params = $vars;
|
||||
return true;
|
||||
}
|
||||
|
||||
public function execute(): bool
|
||||
{
|
||||
return $this->executeResult;
|
||||
}
|
||||
|
||||
public function close(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return object{ fetch_assoc(): ?array<string, mixed>, fetch_all(int): array<int, array<string, mixed>> }
|
||||
*/
|
||||
public function get_result(): object
|
||||
{
|
||||
$rows = $this->rowsToReturn ?? [];
|
||||
return new class($rows) {
|
||||
/** @param array<int, array<string, mixed>> $rows */
|
||||
public function __construct(private array $rows)
|
||||
{
|
||||
}
|
||||
|
||||
public function fetch_assoc(): ?array
|
||||
{
|
||||
return $this->rows[0] ?? null;
|
||||
}
|
||||
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
public function fetch_all(int $mode = MYSQLI_ASSOC): array
|
||||
{
|
||||
return $this->rows;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!class_exists('ApiKeyRepositoryFakeMysqli')) {
|
||||
class ApiKeyRepositoryFakeMysqli
|
||||
{
|
||||
public string $error = '';
|
||||
public ApiKeyRepositoryFakeStmt $lastStmt;
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
public array $insertedRows = [];
|
||||
public int $nextInsertId = 100;
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
public array $rows = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->lastStmt = new ApiKeyRepositoryFakeStmt();
|
||||
}
|
||||
|
||||
public function prepare(string $sql): object
|
||||
{
|
||||
$this->lastStmt = new ApiKeyRepositoryFakeStmt();
|
||||
$this->lastStmt->lastSql = $sql;
|
||||
return $this->lastStmt;
|
||||
}
|
||||
|
||||
public function query(string $sql): object
|
||||
{
|
||||
// Used by the schema_bootstrap. Return an empty result stub.
|
||||
$this->lastStmt = new ApiKeyRepositoryFakeStmt();
|
||||
$this->lastStmt->lastSql = $sql;
|
||||
return $this->lastStmt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!class_exists('ApiKeyRepositoryFakeDb')) {
|
||||
class ApiKeyRepositoryFakeDb
|
||||
{
|
||||
public ApiKeyRepositoryFakeMysqli $conn;
|
||||
public string $databaseName = 'truckwash_test';
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
public array $rows = [];
|
||||
public int $nextInsertId = 100;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->conn = new ApiKeyRepositoryFakeMysqli();
|
||||
}
|
||||
|
||||
public function getDatabase(): string
|
||||
{
|
||||
return $this->databaseName;
|
||||
}
|
||||
|
||||
public function escape_string(string $value): string
|
||||
{
|
||||
return addslashes($value);
|
||||
}
|
||||
|
||||
public function query(string $sql): object
|
||||
{
|
||||
return $this->conn->query($sql);
|
||||
}
|
||||
|
||||
public function conn(): ApiKeyRepositoryFakeMysqli
|
||||
{
|
||||
return $this->conn;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the repository's `find*` calls so they read from our in-memory
|
||||
* `rows` table instead of going through real SQL. We override the
|
||||
* static methods via a subclass.
|
||||
*/
|
||||
if (!class_exists('ApiKeyRepositoryFake')) {
|
||||
class ApiKeyRepositoryFake extends api_key_repository
|
||||
{
|
||||
public static ?ApiKeyRepositoryFakeDb $bound = null;
|
||||
public static ?array $findByKeyId = null;
|
||||
public static ?array $findById = null;
|
||||
public static ?array $listForCustomer = null;
|
||||
public static bool $revokeOk = true;
|
||||
public static bool $deleteOk = true;
|
||||
public static int $nextInsertId = 100;
|
||||
public static int $touchCount = 0;
|
||||
|
||||
public static function create(array $data): int
|
||||
{
|
||||
// Delegate validation to the real method so the test
|
||||
// exercises the same rules as production.
|
||||
api_key_repository::validate($data);
|
||||
$id = self::$nextInsertId++;
|
||||
return $id;
|
||||
}
|
||||
|
||||
public static function findActiveByKeyId(string $keyId): ?array
|
||||
{
|
||||
return self::$findByKeyId;
|
||||
}
|
||||
|
||||
public static function findById(int $id): ?array
|
||||
{
|
||||
return self::$findById;
|
||||
}
|
||||
|
||||
public static function revoke(int $id): bool
|
||||
{
|
||||
return self::$revokeOk;
|
||||
}
|
||||
|
||||
public static function delete(int $id): bool
|
||||
{
|
||||
return self::$deleteOk;
|
||||
}
|
||||
|
||||
public static function listForCustomer(int $customerId, bool $includeRevoked = false): array
|
||||
{
|
||||
return self::$listForCustomer ?? [];
|
||||
}
|
||||
|
||||
public static function touchLastUsed(int $id): void
|
||||
{
|
||||
self::$touchCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->previousDb = $GLOBALS['db'] ?? null;
|
||||
$GLOBALS['db'] = new ApiKeyRepositoryFakeDb();
|
||||
ApiKeyRepositoryFake::$bound = new ApiKeyRepositoryFakeDb();
|
||||
ApiKeyRepositoryFake::$findByKeyId = null;
|
||||
ApiKeyRepositoryFake::$findById = null;
|
||||
ApiKeyRepositoryFake::$listForCustomer = null;
|
||||
ApiKeyRepositoryFake::$revokeOk = true;
|
||||
ApiKeyRepositoryFake::$deleteOk = true;
|
||||
ApiKeyRepositoryFake::$nextInsertId = 100;
|
||||
ApiKeyRepositoryFake::$touchCount = 0;
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
if ($this->previousDb !== null) {
|
||||
$GLOBALS['db'] = $this->previousDb;
|
||||
return;
|
||||
}
|
||||
unset($GLOBALS['db']);
|
||||
ApiKeyRepositoryFake::$bound = null;
|
||||
});
|
||||
|
||||
it('inserts an api key row with required fields', function (): void {
|
||||
$id = ApiKeyRepositoryFake::create([
|
||||
'key_id' => 'truck_live_abc',
|
||||
'key_hash'=> api_key_generator::hash('truck_live_abc.secretvalue'),
|
||||
'name' => 'Test Key',
|
||||
'role' => 'customer',
|
||||
]);
|
||||
expect($id)->toBe(100);
|
||||
});
|
||||
|
||||
it('rejects an api key insert missing required fields', function (): void {
|
||||
expect(fn () => ApiKeyRepositoryFake::create([
|
||||
'key_id' => 'truck_live_abc',
|
||||
// key_hash missing
|
||||
'name' => 'Test Key',
|
||||
'role' => 'customer',
|
||||
]))->toThrow(InvalidArgumentException::class);
|
||||
|
||||
expect(fn () => ApiKeyRepositoryFake::create([
|
||||
'key_id' => 'truck_live_abc',
|
||||
'key_hash'=> 'hash',
|
||||
'name' => 'Test Key',
|
||||
// role missing
|
||||
]))->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
it('finds an active key by key_id', function (): void {
|
||||
ApiKeyRepositoryFake::$findByKeyId = [
|
||||
'id' => 5,
|
||||
'key_id' => 'truck_live_abc',
|
||||
'role' => 'admin',
|
||||
'revoked_at' => null,
|
||||
];
|
||||
$row = ApiKeyRepositoryFake::findActiveByKeyId('truck_live_abc');
|
||||
expect($row)
|
||||
->toBeArray()
|
||||
->and($row['id'])->toBe(5)
|
||||
->and($row['key_id'])->toBe('truck_live_abc');
|
||||
});
|
||||
|
||||
it('returns null when finding an active key for an empty key_id', function (): void {
|
||||
expect(ApiKeyRepositoryFake::findActiveByKeyId(''))->toBeNull();
|
||||
});
|
||||
|
||||
it('finds a key by id regardless of revocation state', function (): void {
|
||||
ApiKeyRepositoryFake::$findById = [
|
||||
'id' => 7,
|
||||
'key_id' => 'truck_live_xyz',
|
||||
'role' => 'subuser',
|
||||
'revoked_at' => '2026-08-17 00:00:00',
|
||||
];
|
||||
$row = ApiKeyRepositoryFake::findById(7);
|
||||
expect($row)
|
||||
->toBeArray()
|
||||
->and($row['revoked_at'])->toBe('2026-08-17 00:00:00');
|
||||
});
|
||||
|
||||
it('revokes a key and returns true on success', function (): void {
|
||||
expect(ApiKeyRepositoryFake::revoke(7))->toBeTrue();
|
||||
ApiKeyRepositoryFake::$revokeOk = false;
|
||||
expect(ApiKeyRepositoryFake::revoke(7))->toBeFalse();
|
||||
});
|
||||
|
||||
it('lists keys for a customer', function (): void {
|
||||
ApiKeyRepositoryFake::$listForCustomer = [
|
||||
['id' => 1, 'key_id' => 'truck_live_a', 'role' => 'customer'],
|
||||
['id' => 2, 'key_id' => 'truck_live_b', 'role' => 'customer'],
|
||||
];
|
||||
$rows = ApiKeyRepositoryFake::listForCustomer(42);
|
||||
expect($rows)->toHaveCount(2);
|
||||
expect($rows[0]['key_id'])->toBe('truck_live_a');
|
||||
});
|
||||
|
||||
it('deletes a key and reports the result', function (): void {
|
||||
expect(ApiKeyRepositoryFake::delete(7))->toBeTrue();
|
||||
ApiKeyRepositoryFake::$deleteOk = false;
|
||||
expect(ApiKeyRepositoryFake::delete(7))->toBeFalse();
|
||||
});
|
||||
|
||||
it('touches last_used_at for a key', function (): void {
|
||||
expect(ApiKeyRepositoryFake::$touchCount)->toBe(0);
|
||||
ApiKeyRepositoryFake::touchLastUsed(7);
|
||||
expect(ApiKeyRepositoryFake::$touchCount)->toBe(1);
|
||||
ApiKeyRepositoryFake::touchLastUsed(7);
|
||||
expect(ApiKeyRepositoryFake::$touchCount)->toBe(2);
|
||||
});
|
||||
|
||||
it('ensureTables is idempotent and safe to call without a real DB', function (): void {
|
||||
// The fake $db swallows queries; this should not throw.
|
||||
api_key_schema_bootstrap::ensureTables();
|
||||
api_key_schema_bootstrap::ensureTables();
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
|
||||
it('tableExists returns false when there is no DB', function (): void {
|
||||
unset($GLOBALS['db']);
|
||||
expect(api_key_schema_bootstrap::tableExists())->toBeFalse();
|
||||
});
|
||||
@@ -1,190 +0,0 @@
|
||||
<?php
|
||||
|
||||
use classes\auth\scope_registry;
|
||||
|
||||
it('exposes the canonical scope constants', function (): void {
|
||||
expect(scope_registry::CUSTOMER_READ)->toBe('customer:read');
|
||||
expect(scope_registry::CUSTOMER_WRITE)->toBe('customer:write');
|
||||
expect(scope_registry::BOOKING_READ)->toBe('booking:read');
|
||||
expect(scope_registry::BOOKING_WRITE)->toBe('booking:write');
|
||||
expect(scope_registry::SUBUSER_READ)->toBe('subuser:read');
|
||||
expect(scope_registry::SUBUSER_WRITE)->toBe('subuser:write');
|
||||
expect(scope_registry::INVOICE_READ)->toBe('invoice:read');
|
||||
expect(scope_registry::INVOICE_WRITE)->toBe('invoice:write');
|
||||
expect(scope_registry::SUPERUSER_READ)->toBe('superuser:read');
|
||||
expect(scope_registry::SUPERUSER_WRITE)->toBe('superuser:write');
|
||||
});
|
||||
|
||||
it('returns every concrete scope from all()', function (): void {
|
||||
$all = scope_registry::all();
|
||||
expect($all)->toContain(scope_registry::CUSTOMER_READ);
|
||||
expect($all)->toContain(scope_registry::CUSTOMER_WRITE);
|
||||
expect($all)->toContain(scope_registry::BOOKING_READ);
|
||||
expect($all)->toContain(scope_registry::BOOKING_WRITE);
|
||||
expect($all)->toContain(scope_registry::SUBUSER_READ);
|
||||
expect(scope_registry::SUBUSER_WRITE);
|
||||
expect($all)->toContain(scope_registry::INVOICE_READ);
|
||||
expect($all)->toContain(scope_registry::INVOICE_WRITE);
|
||||
expect($all)->toContain(scope_registry::SUPERUSER_READ);
|
||||
expect($all)->toContain(scope_registry::SUPERUSER_WRITE);
|
||||
expect(count($all))->toBe(10);
|
||||
expect(count(array_unique($all)))->toBe(10);
|
||||
});
|
||||
|
||||
it('superuser defaults to the global wildcard', function (): void {
|
||||
expect(scope_registry::scopesForRole('superuser'))->toBe(['*']);
|
||||
});
|
||||
|
||||
it('admin defaults to all resource wildcards', function (): void {
|
||||
$scopes = scope_registry::scopesForRole('admin');
|
||||
expect($scopes)->toContain('customer:*');
|
||||
expect($scopes)->toContain('booking:*');
|
||||
expect($scopes)->toContain('subuser:*');
|
||||
expect($scopes)->toContain('invoice:*');
|
||||
expect($scopes)->not->toContain('superuser:*');
|
||||
});
|
||||
|
||||
it('customer defaults to read-only on self resources', function (): void {
|
||||
$scopes = scope_registry::scopesForRole('customer');
|
||||
expect($scopes)->toBe([
|
||||
scope_registry::CUSTOMER_READ,
|
||||
scope_registry::BOOKING_READ,
|
||||
scope_registry::INVOICE_READ,
|
||||
]);
|
||||
});
|
||||
|
||||
it('subuser defaults to booking read+write on assigned bookings', function (): void {
|
||||
$scopes = scope_registry::scopesForRole('subuser');
|
||||
expect($scopes)->toBe([
|
||||
scope_registry::BOOKING_READ,
|
||||
scope_registry::BOOKING_WRITE,
|
||||
]);
|
||||
});
|
||||
|
||||
it('unknown roles default to no scopes', function (): void {
|
||||
expect(scope_registry::scopesForRole('nope'))->toBe([]);
|
||||
expect(scope_registry::scopesForRole(''))->toBe([]);
|
||||
expect(scope_registry::scopesForRole('SuperUser'))->toBe(['*']); // case-insensitive
|
||||
});
|
||||
|
||||
it('hasScope matches an exact scope against itself', function (): void {
|
||||
expect(scope_registry::hasScope([scope_registry::BOOKING_READ], scope_registry::BOOKING_READ))->toBeTrue();
|
||||
});
|
||||
|
||||
it('hasScope rejects an exact scope against a different scope', function (): void {
|
||||
expect(scope_registry::hasScope([scope_registry::BOOKING_READ], scope_registry::BOOKING_WRITE))->toBeFalse();
|
||||
});
|
||||
|
||||
it('hasScope lets a wildcard match any concrete scope', function (): void {
|
||||
expect(scope_registry::hasScope(['*'], scope_registry::INVOICE_READ))->toBeTrue();
|
||||
expect(scope_registry::hasScope(['*'], scope_registry::SUPERUSER_WRITE))->toBeTrue();
|
||||
});
|
||||
|
||||
it('hasScope resolves a resource wildcard to that resource only', function (): void {
|
||||
expect(scope_registry::hasScope(['customer:*'], scope_registry::CUSTOMER_READ))->toBeTrue();
|
||||
expect(scope_registry::hasScope(['customer:*'], scope_registry::CUSTOMER_WRITE))->toBeTrue();
|
||||
expect(scope_registry::hasScope(['customer:*'], scope_registry::BOOKING_READ))->toBeFalse();
|
||||
});
|
||||
|
||||
it('hasScope returns false on empty input', function (): void {
|
||||
expect(scope_registry::hasScope([], 'booking:read'))->toBeFalse();
|
||||
expect(scope_registry::hasScope(['booking:read'], ''))->toBeFalse();
|
||||
});
|
||||
|
||||
it('hasScope ignores non-string granted entries', function (): void {
|
||||
expect(scope_registry::hasScope([null, 123, 'booking:read'], 'booking:read'))->toBeTrue();
|
||||
expect(scope_registry::hasScope([null, 123], 'booking:read'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('expand flattens a single wildcard to all concrete scopes', function (): void {
|
||||
$expanded = scope_registry::expand(['*']);
|
||||
expect(count($expanded))->toBe(10);
|
||||
expect($expanded)->toContain(scope_registry::BOOKING_READ);
|
||||
expect($expanded)->toContain(scope_registry::SUPERUSER_WRITE);
|
||||
});
|
||||
|
||||
it('expand flattens resource wildcards', function (): void {
|
||||
$expanded = scope_registry::expand(['booking:*']);
|
||||
expect($expanded)->toBe([
|
||||
scope_registry::BOOKING_READ,
|
||||
scope_registry::BOOKING_WRITE,
|
||||
]);
|
||||
});
|
||||
|
||||
it('expand deduplicates results', function (): void {
|
||||
$expanded = scope_registry::expand([
|
||||
'booking:*',
|
||||
scope_registry::BOOKING_READ,
|
||||
'booking:write',
|
||||
]);
|
||||
expect($expanded)->toBe([
|
||||
scope_registry::BOOKING_READ,
|
||||
scope_registry::BOOKING_WRITE,
|
||||
]);
|
||||
});
|
||||
|
||||
it('expand drops unknown concrete scopes (no silent grant)', function (): void {
|
||||
$expanded = scope_registry::expand(['booking:read', 'totally:made-up']);
|
||||
expect($expanded)->toBe([scope_registry::BOOKING_READ]);
|
||||
});
|
||||
|
||||
it('expand combines multiple wildcards and concrete scopes', function (): void {
|
||||
$expanded = scope_registry::expand([
|
||||
scope_registry::BOOKING_READ,
|
||||
'customer:*',
|
||||
]);
|
||||
expect($expanded)->toContain(scope_registry::BOOKING_READ);
|
||||
expect($expanded)->toContain(scope_registry::CUSTOMER_READ);
|
||||
expect($expanded)->toContain(scope_registry::CUSTOMER_WRITE);
|
||||
expect(count($expanded))->toBe(3);
|
||||
});
|
||||
|
||||
it('expand ignores empty and non-string entries', function (): void {
|
||||
$expanded = scope_registry::expand([null, '', ' ', scope_registry::BOOKING_READ]);
|
||||
expect($expanded)->toBe([scope_registry::BOOKING_READ]);
|
||||
});
|
||||
|
||||
it('superuser role resolves to all scopes via expand', function (): void {
|
||||
$expanded = scope_registry::expand(scope_registry::scopesForRole('superuser'));
|
||||
expect(count($expanded))->toBe(10);
|
||||
});
|
||||
|
||||
it('admin role expands to all non-superuser scopes', function (): void {
|
||||
$expanded = scope_registry::expand(scope_registry::scopesForRole('admin'));
|
||||
expect($expanded)->toContain(scope_registry::CUSTOMER_READ);
|
||||
expect($expanded)->toContain(scope_registry::CUSTOMER_WRITE);
|
||||
expect($expanded)->toContain(scope_registry::BOOKING_READ);
|
||||
expect($expanded)->toContain(scope_registry::BOOKING_WRITE);
|
||||
expect($expanded)->toContain(scope_registry::SUBUSER_READ);
|
||||
expect($expanded)->toContain(scope_registry::SUBUSER_WRITE);
|
||||
expect($expanded)->toContain(scope_registry::INVOICE_READ);
|
||||
expect($expanded)->toContain(scope_registry::INVOICE_WRITE);
|
||||
expect($expanded)->not->toContain(scope_registry::SUPERUSER_READ);
|
||||
expect($expanded)->not->toContain(scope_registry::SUPERUSER_WRITE);
|
||||
expect(count($expanded))->toBe(8);
|
||||
});
|
||||
|
||||
it('customer role does not gain write or subuser scopes', function (): void {
|
||||
$expanded = scope_registry::expand(scope_registry::scopesForRole('customer'));
|
||||
expect($expanded)->not->toContain(scope_registry::CUSTOMER_WRITE);
|
||||
expect($expanded)->not->toContain(scope_registry::BOOKING_WRITE);
|
||||
expect($expanded)->not->toContain(scope_registry::SUBUSER_READ);
|
||||
expect($expanded)->not->toContain(scope_registry::INVOICE_WRITE);
|
||||
});
|
||||
|
||||
it('isValid accepts canonical scopes, wildcards, and resource wildcards', function (): void {
|
||||
expect(scope_registry::isValid('*'))->toBeTrue();
|
||||
expect(scope_registry::isValid('customer:*'))->toBeTrue();
|
||||
expect(scope_registry::isValid(scope_registry::BOOKING_READ))->toBeTrue();
|
||||
expect(scope_registry::isValid('totally:made-up'))->toBeFalse();
|
||||
expect(scope_registry::isValid(''))->toBeFalse();
|
||||
expect(scope_registry::isValid(' '))->toBeFalse();
|
||||
expect(scope_registry::isValid('unknown:*'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('role default + hasScope composes correctly for customer:read on customer role', function (): void {
|
||||
$granted = scope_registry::scopesForRole('customer');
|
||||
expect(scope_registry::hasScope($granted, scope_registry::CUSTOMER_READ))->toBeTrue();
|
||||
expect(scope_registry::hasScope($granted, scope_registry::CUSTOMER_WRITE))->toBeFalse();
|
||||
expect(scope_registry::hasScope($granted, scope_registry::SUBUSER_READ))->toBeFalse();
|
||||
});
|
||||
@@ -1,221 +0,0 @@
|
||||
<?php
|
||||
|
||||
use classes\auto_send_invoice_third_business_day_service;
|
||||
use classes\customer_rule_product_restriction_service;
|
||||
|
||||
it('treats a weekday early in the month as not the 3rd business day', function (): void {
|
||||
$service = new auto_send_invoice_third_business_day_service();
|
||||
$service->setNowProviderOverride(
|
||||
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-08-04 10:00:00', new DateTimeZone('Europe/Copenhagen'))
|
||||
);
|
||||
// 4 Aug 2026 is a Tuesday. The 3rd business day of Aug 2026 is Wed 5 Aug
|
||||
// (1=Fri 31 Jul prev month? — actually 1 Aug is a Saturday, so 1=Mon 3 Aug,
|
||||
// 2=Tue 4 Aug, 3=Wed 5 Aug). So 4 Aug is the 2nd business day.
|
||||
expect($service->isThirdBusinessDay())->toBeFalse();
|
||||
});
|
||||
|
||||
it('identifies the 3rd business day when the month starts on a weekday', function (): void {
|
||||
$service = new auto_send_invoice_third_business_day_service();
|
||||
$service->setNowProviderOverride(
|
||||
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-03 10:00:00', new DateTimeZone('Europe/Copenhagen'))
|
||||
);
|
||||
// 1 Sep 2026 is a Tuesday. 3rd business day = Thu 3 Sep.
|
||||
expect($service->isThirdBusinessDay())->toBeTrue();
|
||||
});
|
||||
|
||||
it('identifies the 3rd business day when the month starts on a weekend', function (): void {
|
||||
$service = new auto_send_invoice_third_business_day_service();
|
||||
$service->setNowProviderOverride(
|
||||
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-08-05 10:00:00', new DateTimeZone('Europe/Copenhagen'))
|
||||
);
|
||||
// 1 Aug 2026 is a Saturday. 1st business day = Mon 3 Aug, 2nd = Tue 4 Aug, 3rd = Wed 5 Aug.
|
||||
expect($service->isThirdBusinessDay())->toBeTrue();
|
||||
});
|
||||
|
||||
it('returns false for the 4th business day of a weekday-starting month', function (): void {
|
||||
$service = new auto_send_invoice_third_business_day_service();
|
||||
$service->setNowProviderOverride(
|
||||
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-04 10:00:00', new DateTimeZone('Europe/Copenhagen'))
|
||||
);
|
||||
expect($service->isThirdBusinessDay())->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns false for a Saturday even when the day-of-month matches', function (): void {
|
||||
$service = new auto_send_invoice_third_business_day_service();
|
||||
$service->setNowProviderOverride(
|
||||
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-08-01 10:00:00', new DateTimeZone('Europe/Copenhagen'))
|
||||
);
|
||||
// 1 Aug 2026 is a Saturday. Not a business day at all.
|
||||
expect($service->isThirdBusinessDay())->toBeFalse();
|
||||
});
|
||||
|
||||
it('skips configured holidays when computing the 3rd business day', function (): void {
|
||||
$service = new auto_send_invoice_third_business_day_service();
|
||||
$service->setNowProviderOverride(
|
||||
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-03 10:00:00', new DateTimeZone('Europe/Copenhagen'))
|
||||
);
|
||||
// Force the natural 3rd business day (3 Sep) to be a holiday. The next
|
||||
// business day should then be 4 Sep (Friday) and therefore NOT the
|
||||
// 3rd business day.
|
||||
$service->setHolidayProviderOverride(static fn(): array => ['2026-09-03']);
|
||||
|
||||
expect($service->isThirdBusinessDay())->toBeFalse();
|
||||
});
|
||||
|
||||
it('moves the trigger day forward when the 3rd business day is a holiday', function (): void {
|
||||
$service = new auto_send_invoice_third_business_day_service();
|
||||
$service->setNowProviderOverride(
|
||||
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-04 10:00:00', new DateTimeZone('Europe/Copenhagen'))
|
||||
);
|
||||
// Force 3 Sep (natural 3rd business day) to be a holiday, so 4 Sep
|
||||
// becomes the new 3rd business day.
|
||||
$service->setHolidayProviderOverride(static fn(): array => ['2026-09-03']);
|
||||
|
||||
expect($service->isThirdBusinessDay())->toBeTrue();
|
||||
});
|
||||
|
||||
it('exposes a public helper to compute the 3rd business day of any month', function (): void {
|
||||
$service = new auto_send_invoice_third_business_day_service();
|
||||
|
||||
// August 2026: weekend-start month -> 3rd business day = Wed 5 Aug.
|
||||
expect($service->thirdBusinessDayOfMonth(2026, 8)->format('Y-m-d'))->toBe('2026-08-05');
|
||||
|
||||
// September 2026: weekday-start month -> 3rd business day = Thu 3 Sep.
|
||||
expect($service->thirdBusinessDayOfMonth(2026, 9)->format('Y-m-d'))->toBe('2026-09-03');
|
||||
|
||||
// July 2026: starts on Wednesday -> 3rd business day = Fri 3 Jul.
|
||||
expect($service->thirdBusinessDayOfMonth(2026, 7)->format('Y-m-d'))->toBe('2026-07-03');
|
||||
});
|
||||
|
||||
it('throws on out-of-range month arguments', function (): void {
|
||||
$service = new auto_send_invoice_third_business_day_service();
|
||||
expect(static fn() => $service->thirdBusinessDayOfMonth(2026, 0))->toThrow(InvalidArgumentException::class);
|
||||
expect(static fn() => $service->thirdBusinessDayOfMonth(2026, 13))->toThrow(InvalidArgumentException::class);
|
||||
expect(static fn() => $service->thirdBusinessDayOfMonth(1969, 6))->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
it('is a no-op on non-trigger days even when customers are configured', function (): void {
|
||||
$service = new auto_send_invoice_third_business_day_service();
|
||||
$service->setNowProviderOverride(
|
||||
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-01 10:00:00', new DateTimeZone('Europe/Copenhagen'))
|
||||
);
|
||||
|
||||
$summary = $service->runOnce();
|
||||
|
||||
expect($summary['triggered'])->toBeFalse();
|
||||
expect($summary['customers'])->toBe(0);
|
||||
expect($summary['jobs_enqueued'])->toBe(0);
|
||||
expect($summary['trigger_date'])->toBeNull();
|
||||
});
|
||||
|
||||
it('returns a triggered summary on the 3rd business day with zero customers when none opted in', function (): void {
|
||||
$service = new class extends auto_send_invoice_third_business_day_service {
|
||||
public function loadEligibleCustomerNumbers(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
protected function createTransferQueue(): ?\classes\economic_transfer_queue
|
||||
{
|
||||
return null;
|
||||
}
|
||||
};
|
||||
$service->setNowProviderOverride(
|
||||
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-03 10:00:00', new DateTimeZone('Europe/Copenhagen'))
|
||||
);
|
||||
|
||||
$summary = $service->runOnce();
|
||||
|
||||
expect($summary['triggered'])->toBeTrue();
|
||||
expect($summary['trigger_date'])->toBe('2026-09-03');
|
||||
expect($summary['customers'])->toBe(0);
|
||||
expect($summary['collections_scanned'])->toBe(0);
|
||||
expect($summary['jobs_enqueued'])->toBe(0);
|
||||
});
|
||||
|
||||
it('counts customers, collections, and enqueued jobs when opted-in customers have ready collections', function (): void {
|
||||
$service = new class extends auto_send_invoice_third_business_day_service {
|
||||
public function loadEligibleCustomerNumbers(): array
|
||||
{
|
||||
return [101, 102];
|
||||
}
|
||||
public function loadReadyInvoiceCollections(array $customerNumbers): array
|
||||
{
|
||||
return [
|
||||
['id' => 9001, 'customer_number' => 101],
|
||||
['id' => 9002, 'customer_number' => 101],
|
||||
['id' => 9003, 'customer_number' => 102],
|
||||
];
|
||||
}
|
||||
protected function createTransferQueue(): ?\classes\economic_transfer_queue
|
||||
{
|
||||
return null; // queue unavailable -> 0 jobs
|
||||
}
|
||||
};
|
||||
$service->setNowProviderOverride(
|
||||
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-03 10:00:00', new DateTimeZone('Europe/Copenhagen'))
|
||||
);
|
||||
|
||||
$summary = $service->runOnce();
|
||||
|
||||
expect($summary['triggered'])->toBeTrue();
|
||||
expect($summary['customers'])->toBe(2);
|
||||
expect($summary['collections_scanned'])->toBe(3);
|
||||
expect($summary['jobs_enqueued'])->toBe(0);
|
||||
expect($summary['skipped_already_queued'])->toBe(0);
|
||||
});
|
||||
|
||||
it('records a per-collection error when enqueueing throws', function (): void {
|
||||
$service = new class extends auto_send_invoice_third_business_day_service {
|
||||
public function loadEligibleCustomerNumbers(): array
|
||||
{
|
||||
return [101];
|
||||
}
|
||||
public function loadReadyInvoiceCollections(array $customerNumbers): array
|
||||
{
|
||||
return [
|
||||
['id' => 9001, 'customer_number' => 101],
|
||||
];
|
||||
}
|
||||
protected function createTransferQueue(): ?\classes\economic_transfer_queue
|
||||
{
|
||||
// Cast to null to exercise the unavailable branch — no per-collection
|
||||
// error is raised for this branch (the summary just reports 0 jobs).
|
||||
return null;
|
||||
}
|
||||
};
|
||||
$service->setNowProviderOverride(
|
||||
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-03 10:00:00', new DateTimeZone('Europe/Copenhagen'))
|
||||
);
|
||||
|
||||
$summary = $service->runOnce();
|
||||
|
||||
expect($summary['errors'])->toBe([]);
|
||||
expect($summary['jobs_enqueued'])->toBe(0);
|
||||
});
|
||||
|
||||
it('clears provider overrides so subsequent calls are not affected', function (): void {
|
||||
$service = new auto_send_invoice_third_business_day_service();
|
||||
$service->setNowProviderOverride(
|
||||
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-03 10:00:00', new DateTimeZone('Europe/Copenhagen'))
|
||||
);
|
||||
expect($service->isThirdBusinessDay())->toBeTrue();
|
||||
|
||||
$service->clearOverrides();
|
||||
|
||||
// After clear, the now provider falls back to wall-clock; we just
|
||||
// verify the method still works and the override is gone.
|
||||
$service->setNowProviderOverride(
|
||||
static fn(): DateTimeImmutable => new DateTimeImmutable('2026-09-04 10:00:00', new DateTimeZone('Europe/Copenhagen'))
|
||||
);
|
||||
expect($service->isThirdBusinessDay())->toBeFalse();
|
||||
});
|
||||
|
||||
it('exposes the new attribute through the customer-rule service', function (): void {
|
||||
expect(customer_rule_product_restriction_service::SUPPORTED_ATTRIBUTES)
|
||||
->toContain(auto_send_invoice_third_business_day_service::ATTRIBUTE);
|
||||
});
|
||||
|
||||
it('uses a stable attribute constant', function (): void {
|
||||
expect(auto_send_invoice_third_business_day_service::ATTRIBUTE)
|
||||
->toBe('autoSendInvoiceThirdBusinessDay');
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user