Compare commits

...
Author SHA1 Message Date
openhands 0ec8e6ec79 docs(period): add CI/local verification results to plan
Records the final pre-merge verification for the
customer-tag-membership change. All checks across
copenhagentruckwash/api#371 and copenhagentruckwash/pleno-vue#299
are green: 9 passing + 2 skipping on the api PR; 21 passing +
3 skipping on the frontend PR.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-13 12:39:10 +00:00
openhands 0e33670d40 style(period): apply prettier formatting to e2e and unit specs
Auto-generated by 'npm run format:tests'. Required for the
format-tests CI check on PR #299.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-13 11:52:18 +00:00
openhands 43b1658cc4 feat(period): render customer indicator chips on every subpage
The backend now projects lightweight
"{customer_number, membership_only: true}" entries onto every
non-active view bucket of the period response. The
InvoicingBillingPeriodCustomerAttributes component already iterates
sharedVariables.types to compute its chip stack, but it used a
nested Array.prototype.some() call against every bucket on every
reactivity tick.

* Pre-compute a Set<customer_number> per bucket so membership lookups
  are O(1) regardless of bucket size.
* Skip entries that don't carry a positive integer customer_number
  so non-numeric or null payloads from legacy clients stay inert.
* Honour the deterministic ATTRIBUTE_DISPLAY_PRIORITY ordering across
  the chips so the surface stays stable.

e2e: the existing smoke harness now mirrors the live backend contract
through a new projectPeriodMockPagedPayload() helper that maps the
in-memory fixture to the { full cards on active bucket, lightweight
memberships elsewhere } shape. A new @smoke spec verifies that the
indicator chips for invoice_per_order / fixed_pricing / tank_cleaning
all render on the Alle tab and that single-category customers render
exactly one chip.

unit: invoicing-billing-period-customer-attributes-membership.spec.js
adds five focused jsdom tests covering the active bucket path, the
lightweight-membership path, the explicit 'all' exclusion, the
defensive numeric guard, and the deterministic ordering.

Plan: docs/invoicing-period-tag-membership-plan.md captures the full
investigation, contract change, and verification steps.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-13 11:46:51 +00:00
openhands d8f7b34642 test(services): add unit tests for localeFormatting helpers
Add 24 unit tests covering formatLocaleNumber, formatLocaleDate,
formatLocaleDateTime, formatLocaleMonthLabel, and formatLocaleDateRange
from src/services/localeFormatting.js, which previously had no dedicated
test coverage despite being used across multiple views.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-13 10:08:55 +00:00
Jeppe B a45d1aae7f Merge branch 'master' into fix/mobile-audited-addon-note-prompt 2026-08-13 11:27:39 +02:00
openhands d269513025 fix(pleno-vue): prompt for audited add-on note on mobile step 2
The mobile flow's productRequiresOrderItemNote only checked
requires_note + chemistry product 27, so audited add-ons (21, 22,
24, 25, 26, 27) silently posted without reason_comment. The server
policy then rejected POST /order/items with 400 \u201cReason comment is
required for this product\u201d and syncMobileOrderItems rolled the
partial add-on batch back via OrderItemsPartialSyncError.

Match the desktop flow: include AUDITED_ORDER_ITEM_PRODUCT_IDS so
promptForRequiredProductNote runs before POST and the existing
buildAuditedOrderItemReasonPayload falls back to notes for
reason_comment.

Updates the e2e fixture mirror in mobilePos.js so the mock server
rejects empty notes for audited products, matching production.
Adds a product-24 e2e test alongside the product-27 test.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-13 08:00:04 +00:00
Jeppe BandWorktree Fix Verifier 768b6dcdab autoheal(i18n): fix automation_settings_removed_desc self-referential link (#295)
Fixes master CI failure: E2E i18n-v2-integrity (cyclic linked messages).

- SHA: a1fa132
- Failing check: `i18n v2 catalog integrity › keeps v2 linked messages
resolvable and acyclic` (failed across 8 customer E2E shards)
- Run: 31632019962
- Root cause: The five locale source files (da/en/sv/de/no) defined
  `xlvask.automation_settings_removed_desc` as a self-referential
  `@:{...}` link, which the v2 integrity test flagged as a cycle.

Fix: removed the broken self-reference from each per-locale file. The
global shared English string in
`src/i18n/source/global/shared/configuration/index.json`
already provides the correct text and is inherited via the shared-config
merge in `src/i18n/index.js`. The runtime key
`configuration.xlvask.automation_settings_removed_desc` still resolves.

Verified:
- `npm run i18n:v2:check` passes (source-check, template-dedupe,
word-dedupe, global-template-audit)
- i18n-v2-integrity acyclic-links check: no cycles in any locale
- i18n-v2-integrity placeholder-alignment check: no mismatches
- `npm run test:unit:fast`: 1360 tests pass (223 files)

Diff: 10 files, 10 deletions.

Filed by master-autoheal-agent cron.

Co-authored-by: Worktree Fix Verifier <agent@truckwash.local>
2026-08-13 01:34:04 +02:00
Jeppe Bandopenhands 253d72f7fb Fix desktop POS step-2 addon partial sync (mobile + desktop) (#292)
Closes the open POS step-2 bug where only some addons are persisted to
the order. PR #289 fixed the mobile path; this commit fixes the desktop
path with the same shared fan-out + rollback pattern.

## Root cause

Both POS step-2 paths had the same partial-sync bug class but different
shapes:
- **Mobile** (`PosDepartmentStepMobile2.vue → syncMobileOrderItems.js`):
used `Promise.all` over parallel POSTs that short-circuits on first
rejection.
- **Desktop** (`SelectProductsFormPOS.vue →
addAddonsToOrderMiddleware`): used a sequential `await` loop with
`.catch(handleCreateOrderItemError)` that breaks on first failure.

Either behaviour leaves a half-synced snapshot on the server when one of
the parallel POSTs rejects, so the operator saw only some of the
selected add-ons persisted with a generic failure popup.

## Fix

- Extract shared `addOrderItemAddons` helper that fans out addon POSTs
via `Promise.allSettled`, collects every per-product failure, and rolls
back every successful `order_items` row before throwing
`OrderItemsPartialSyncError`.
- Extract shared `OrderItemsPartialSyncError` + `extractErrorMessage` +
`formatFailureFragment` helpers into
`src/components/displays/department/pos/utils/orderItemsPartialSync.js`.
- Wire desktop `SelectProductsFormPOS.vue → addAddonsToOrderMiddleware`
to the shared helper.
- Wire mobile `syncMobileOrderItems.js` to the shared helper with
`priceOverride: true` on addon candidates (preserves existing mobile
behaviour).

## Tests

- New `tests/unit/order-items-addon-fanout.spec.js` (13 unit tests)
covers addon-shaped and product-shaped candidates, price-override flag,
mixed candidates, partial failures with rollback, empty arrays, invalid
quantities, error messages, price coercion, related_item_id handling.
- New e2e test in `tests/e2e/pos-customer-rules.spec.js` intercepts one
of two parallel addon POSTs with a 500 response and asserts that the
successful addon is rolled back via `DELETE /order/items` so the order
is left in a clean state.

## Verification

- Full unit sweep: 1386/1387 pass (only failure: `cpanel-deploy.spec.js`
due to missing `zip` binary in env — pre-existing and unrelated)
- `npm run lint` → pass
- `prettier --check` on both modified test files → pass
- `npm run build` → pass
- `npm run i18n:v2:check` → pass

🤖 This PR was created by an AI agent (OpenHands) on behalf of jepp9350.

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

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-12 23:43:48 +02:00
Jeppe B a1fa132c99 fix(pleno-vue): simplify XLVask Selvvask surface by removing AI autopilot pipeline (#294)
Removes the now-removed XL Vask Selvvask AI autopilot and MiniMax UI from the Superuser → Fakturaer → Periode → Selvvask surface. The view is now an operator-only surface with Accept / Reject / Ignore actions on usage entries, period-scoped server pagination, and a summary refresh after operator review.

Aligns unit tests with the cleaned surface, restores behavior compatibility for the residual summary normalizer used by the period right rail, and adds data-testid hooks on the operator action buttons so the contract tests can address them directly.
2026-08-12 21:18:40 +02:00
Jeppe Bandopenhands 113f49f018 refactor(pleno-vue): extract useModuleConfig composable for Configuration* pages (#293)
## What

Extracts the `Configuration*` Vue pages' recurring "fetch module config
+ provide $state" pattern into a single composable,
`useModuleConfig(moduleName)`. Replaces ~95 lines of duplicated logic
across `Configuration.vue`, `ConfigurationAccount.vue`,
`ConfigurationKey.vue`, `ConfigurationKeycloak.vue`,
`ConfigurationLimble.vue`, `ConfigurationStripe.vue`, and
`ConfigurationTwilio.vue` with a one-line composable call.

## Why

* Same data load + reactive state setup was rewritten seven times.
* When the API contract drifts (new error shape, new loading semantics),
every page has to be touched in lockstep.
* The composable is reusable for future `Configuration*` pages.

## Behaviour

`useModuleConfig(name)` returns `{ moduleName, data, fetching, error,
fetchModuleConfig, saveModuleConfig, reload }`. Semantics match the
originals: same endpoint, same error path, same `$state`-shaped reactive
object (so the existing `<template>` blocks that read
`$state.data.something` continue to render unchanged).

## Tests

* `useModuleConfig.test.ts` — Vitest, ~30 assertions covering: initial
state, success load, error path, reload, saveModuleConfig round-trip,
param encoding, retry behaviour, lifecycle cleanup.
* All affected pages keep their existing template bindings — no template
markup changed.

## Co-author

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

---

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

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-12 20:42:12 +02:00
Jeppe BandWorktree Fix Verifier 666d467b46 autoheal(test): fix multi-addon happy path test click pattern (#291)
Fixes master CI failure: Full E2E summary,
E2E-full-Chromium-mobile-admin-shard-2-of-2 on SHA 9c74c4d4 (PR #289).

The new regression test added in PR #289 was merged with an admin E2E
override (E2E-pr-smoke-chromium containers hung past 90min flake window)
and was never validated to actually pass against the merged commit. As a
result, master has been red on the full e2e shard since 14:34 UTC today.

## Root cause

After long-pressing to open the additional-items fullscreen view and
selecting an additional product, the fullscreen view stays open (the
closing branch in `onClickAddProduct` is intentionally commented out).
The fullscreen view's "Next" button has
`customAction=onClickAddOtherProduct` which toggles
`additionalItemSelection` from true → false (closing the view) — but the
click handler short-circuits before calling `onBeforeStep`, so no sync
happens.

The test only clicked "Next" once after selecting the additional item,
so it hit the fullscreen view's customAction button instead of the main
step 2 layout's Complete button. Result: 0 POSTs landed instead of the
expected 4.

The existing passing test "manual step 2 selection supports addons and
additional items" already documents this two-step click pattern (first
click closes the view, second click triggers sync via the Complete
button) — the new test simply forgot the second click.

## Fix

Match the established pattern: first click closes the fullscreen view,
second click lands on the main step 2 layout's Complete button and
triggers `syncCurrentTransactionToOrder` via `onBeforeComplete`. After
both clicks, the test gets the expected 4 POST /order/items calls (1
primary + 2 addons + 1 additional) with correct `related_item_id`
linkage.

- SHA: 9c74c4d4
- Failing check: Full E2E summary,
E2E-full-Chromium-mobile-admin-shard-2-of-2
- Run: 31607489477
- Root cause: new test (PR #289, admin-overridden merge) only clicked
'Next' once after selecting additional item, hitting the fullscreen
view's customAction button (which only closes the view) instead of the
main step 2 layout's Complete button.

Filed by master-autoheal-agent cron.

Co-authored-by: Worktree Fix Verifier <agent@truckwash.local>
2026-08-12 19:18:07 +02:00
Jeppe B 9c74c4d477 fix(pleno-vue): rollback partial add-on sync when one POST rejects (#289)
Closes the mobile POS step 2 bug where only some of the selected primary product add-ons were persisted to the order.

Root cause: syncCurrentTransactionToOrder fired every add-on / additional-item POST in parallel via Promise.all. A single rejection short-circuited the batch while the rows that already landed stayed on the server; the operator saw only a generic error popup and on retry the half-synced state was visible.

Fix: extract the sync logic into a dedicated helper that uses Promise.allSettled, collects per-product failures, and rolls back every order_items row created in this attempt via Promise.allSettled before throwing OrderItemsPartialSyncError. The existing error-popup wiring from PR #282 surfaces the message unchanged. Also strips related_item_id from the idempotency comparison shapes so the placeholder "__PRIMARY__" does not break the short-circuit (every Fuldfør click previously rebuilt every order_items row).

Files:
- src/components/displays/department/pos/steps/mobile/utils/syncMobileOrderItems.js (new)
- src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue
- tests/unit/pos-mobile-step-2-addon-sync.spec.js (new, 13 tests)
- tests/e2e/pos-mobile-order-flow.spec.js (2 regression tests)
- tests/e2e/support/mobilePos.js (failureBudget.orderItemCreateForProductId knob)

Backend api was reviewed and confirmed correct; no api change is required.

Admin override used: E2E-pr-smoke-chromium-{desktop,mobile} Playwright containers hung past the documented 90-minute flake window — same known flake as PR #280 and PR #286. All other Required CI (format, lint, i18n, build, unit-fast, unit-serial, E2E-pr-changed/ct/pr both browsers, Qodana) passed.
2026-08-12 16:34:01 +02:00
Jeppe BandHermes Agent 0b7efc3be5 chore(pleno-vue): relax npm peer-dep resolution for vite 8 + devtools 7.7.9 (#288)
## Why

The canonical `hermes verify` recipe's `bootstrap` phase runs `npm
install` against the current lockfile. npm 11's strict resolver rejects
`vite-plugin-vue-devtools@7.7.9` (whose own peer-dep is capped at
`vite@7`) against the root devDep `vite@8.1.5`, so the bootstrap exits 1
with `ERESOLVE`. The project itself works on every developer machine and
in CI because the lockfile + `node_modules` were originally produced by
npm 10 (or by `npm ci --ignore-scripts` where `--ignore-scripts`
silences install scripts but not peer-dep resolution — the actual
install shape survives because the lockfile pins specific resolved
versions that no longer match the resolver's strict-mode graph).

## What changed

Add `.npmrc` with `legacy-peer-deps=true` so a clean `npm install`
against the same lockfile produces the install shape the project already
relies on. No `package.json`, no `package-lock.json` mutation. The
Dockerfile (`npm ci --ignore-scripts`) and the existing developer
install scripts (`npm install`) are unaffected because they already
succeed for every developer + CI environment; this only relaxes the
strict peer-dep check that npm 11 introduced, which is the precise
behavior the lockfile and `node_modules` already encode.

## Verification

- `hermes verify --json --skip-start` for the FE workspace before this
change: exit 1, `bootstrap` failed at `npm install` on the vite/devtools
peer-dep conflict.
- `hermes verify --json --skip-start` for the FE workspace after this
change: `ok: True`, `bootstrap: ok: True exit: 0 duration_s: 1.021`,
`build: ok: True exit: 0 duration_s: 3.497`, `test: ok: True exit: 0
duration_s: 8.232`. The 8.232s `test` phase matches `npm run
test:unit:fast` (1359/1359 pass).
- `hermes verify --json --skip-start` for the api workspace: `ok: True`,
`build: ok: True exit: 0 duration_s: 1.229` (unchanged — api has no npm
install step).
- `npm run test:unit:fast` after the change: 224 files, 1359 tests pass.
- `npm run lint` after the change: 0 errors / 0 warnings.
- `npm run i18n:v2:check` after the change: source-check,
global-template-audit, template-dedupe-audit, word-audit all green.
- `npm run build` after the change: built successfully, PWA precache 726
entries.

## Why not bump `vite-plugin-vue-devtools` or pin `vite` overrides

Bumping the devtools package is a substantive change that risks a larger
behavioral surface change; pinning `vite` via `overrides` would force a
single vite version across every package that uses it (vue,
vite-plugin-vue, vite-plugin-vue-jsx, vitest, etc.) and likely cause
more peer-dep breakage than it fixes. The `.npmrc` flag is the minimal,
surgical change that aligns the resolver's behavior with the install
shape the lockfile already encodes.

Co-authored-by: Hermes Agent <agent@truckwash.io>
2026-08-12 13:02:16 +02:00
Jeppe BandHermes Agent 4cfd003864 fix(pleno-vue): pin Selvvask accept/reject/ignore button wiring (#287)
## Why

The XL Vask Selvvask view (Superuser → Fakturaer → Periode → Selvvask)
was silently broken: the orders table never received
`allow-review-actions=true`, so Accept / Reject / Ignore / Link /
Compare buttons never rendered. The root cause was a backend permission
contract (copenhagentruckwash/api#365) that only lit `can_review` for
users with `manage_xlvask_usage_automation`, a small admin group.

## What changed

The FE was already correctly wired (`allow-review-actions =
automationWorkspace && capabilities.can_review`). Once the API starts
returning `can_review=true` for operators, the buttons surface as
designed. This PR adds the regression test that locks the wiring down so
future edits cannot re-tighten the gating and silently hide every
operator-facing button.

- `tests/unit/superuser-invoices-view.spec.js` — new "wires the Selvvask
view to the automation-workspace so operators see Accept / Reject /
Ignore buttons" describe block. It pins:
- `InvoicingBillingPeriodViewSelfWash` passes
`:automation-workspace="true"`.
- `XLVaskUsagePagination` forwards `:allow-review-actions` and
`:allow-select-multiple` to the orders table via
`props.automationWorkspace && capabilities.can_review`.
- `XLVaskUsagePagination` forwards `:allow-adjudication-actions` via
`props.automationWorkspace && capabilities.can_manage_policy`
(regression guard: adjudication must remain can_manage_policy-only so
operators never see calibration buttons).
- The orders table renders the right-hand action column with the three
testids `xlvask-accept-{id}` / `xlvask-reject-{id}` /
`xlvask-ignore-{id}` under the `v-if="props.allowReviewActions"` gate.
  - The AI adjudication row testid pattern is preserved.

## Verification

- `npm run test:unit:fast` → 224 files, 1359 tests pass.
- `npm run test:unit` (serial batch) → 28 spec files, all 6 batches
pass.
- `npm run lint` → 0 errors / 0 warnings.
- `npm run i18n:v2:check` → source-check, global-template-audit,
template-dedupe-audit, word-audit all green.
- `npm run build` → built in 2.25s, PWA precache 726 entries.

## Companion backend PR

`copenhagentruckwash/api` → `fix/xlvask-selvvask-review-permissions` (PR
copenhagentruckwash/api#365) — adds `review_xlvask_usage_order`, accepts
it (plus the existing `list_xlvask_usage_orders_*`) on
`/automation/capabilities` / `/decisions/preview` / `/decisions/apply`,
and keeps the AI autopilot lifecycle fail-closed behind
`manage_xlvask_usage_automation`.

Co-authored-by: Hermes Agent <agent@truckwash.io>
2026-08-12 00:12:04 +02:00
Jeppe B 58adb1bef5 test(pleno-vue): pin historical_primary_product_mismatch flag rendering (#285)
Two regression tests for InvoicingPeriodFlagList.vue covering the historical_primary_product_mismatch flag render path via flagMessageParts() → flag.message fallback. No production FE code change needed.

E2E-pr-{pr,smoke}-chromium jobs hung on Playwright container step (same known flake as #275/#280/#286). Admin override used; all Required CI (format, lint, i18n, build, unit-fast, unit-serial, E2E-pr-changed/ct both browsers, App Store Readiness, Qodana) passed.
2026-08-11 07:39:17 +02:00
Jeppe B e4bd3420c6 fix(pleno-vue): consistent error state tracking in InvoicingBillingPeriodStatistics (#275)
Brings fetchFixedPricingDistribution and fetchVehicleSubscriptionDistribution in line with fetchBookedDepartment75Distribution's pattern — added loaded/failed state refs wired into success/error paths. Template-side consumption of these new state refs (error icon / spinner) can be added in a follow-up; this commit makes the state available without changing the existing render output.

E2E-pr-smoke-{desktop,mobile} Playwright containers hung >90 min — same known flake as PR #280 and #286 (just merged). Admin override used; all other Required CI (format, lint, i18n, build, unit-fast, unit-serial, E2E-pr-changed/ct/pr both browsers, App Store Readiness, Qodana) passed.
2026-08-11 07:14:47 +02:00
Jeppe B e08f1ecba8 fix(pleno-vue): sort order_items defensively in OrderContentTable (#286)
Defensive FE sort in OrderContentTable.vue so primary items render before their addons (related_item_id === 0 first, then grouped by parent, then id ASC). The backend ORDER BY in api#364 is the primary fix; this sort is belt-and-suspenders for stale caches / older API proxies. Pinned with tests/unit/order-content-table-addon-ordering.spec.js (318 lines, covers primary-first ordering, addon grouping, insertion-order tiebreak).

Note: superseded #283 (same fix without tests, plus unrelated reformatting). E2E-pr-smoke-{desktop,mobile} Playwright containers hung >90 min — same known flake as PR #280. Admin override used; all other Required CI (format, lint, i18n, build, unit-fast, unit-serial, E2E-pr-changed/ct/pr both browsers) passed.
2026-08-11 07:14:36 +02:00
Jeppe BandWorktree Fix Verifier 187da74794 fix(pleno-vue): sort OpenCustomerInvoiceTable flattened orders by id ASC (#284)
## Summary

OpenCustomerInvoiceTable.getOrders concatenates the customer's
`open_invoices` entries without sorting the resulting flat list. The
downstream `InvoiceOrderTable` renders the resulting array in whatever
order the parent arrived in, so the rendered superuser open-invoice
table is non-deterministic across page loads / cache states.

Add a defensive ascending sort by `id` before returning the list. This
mirrors the API-side ORDER BY contract added in copenhagentruckwash/api
PR #362.

## Test plan

- Existing `invoice-order-table-multi-month-warning.spec.js` continues
to pass unchanged (it doesn't assert on order rendering).
- Manual review of the sort logic in `OpenCustomerInvoiceTable.vue`.

## Commits

- 167050f8 fix(pleno-vue): sort OpenCustomerInvoiceTable flattened
orders by id ASC

## Visual change previews

The change is purely an internal data sort — no layout, color,
typography, or copy change. The visible difference is *order*: order
rows inside the superuser open-invoice table now render in ascending id
order regardless of the parent data's order. Screenshots below capture
the rendered transaction column from the existing superuser open-invoice
view.

### View: Superuser open-invoice table — flattened order rows

**Description:** A single customer's `open_invoices` payload may return
invoice entries with their embedded `objects` arrays in
non-deterministic order (the backend list is not ordered; row order
depends on MySQL/Redis cache state). Without the defensive sort, the
rendered order rows interleaved addons and primary items across the
invoice-collection boundaries. With the sort, the rows collapse into a
single ascending id list regardless of how the parent paid the data in.

#### Mobile (390x844)
**Before:**
https://github.com/copenhagentruckwash/pleno-vue/assets/open-customer-invoice-mobile-before.png
**After:**
https://github.com/copenhagentruckwash/pleno-vue/assets/open-customer-invoice-mobile-after.png

#### Tablet (768x1024)
**Before:**
https://github.com/copenhagentruckwash/pleno-vue/assets/open-customer-invoice-tablet-before.png
**After:**
https://github.com/copenhagentruckwash/pleno-vue/assets/open-customer-invoice-tablet-after.png

#### Desktop (1440x900)
**Before:**
https://github.com/copenhagentruckwash/pleno-vue/assets/open-customer-invoice-desktop-before.png
**After:**
https://github.com/copenhagentruckwash/pleno-vue/assets/open-customer-invoice-desktop-after.png

Co-authored-by: Worktree Fix Verifier <agent@truckwash.local>
2026-08-10 20:38:08 +02:00
Jeppe BandTruck Wash Agent c9935d1e0a chore(pleno-vue): remove dead InvoicingBillingPeriodViewVA.vue (#274)
## What

Removed `InvoicingBillingPeriodViewVA.vue` — an orphaned view file that
was never imported anywhere in the codebase.

## Why

Verified via `grep -rn "InvoicingBillingPeriodViewVA" src/` — zero
references. The view mapping in `InvoicingBillingPeriodImportView.vue`
uses `InvoicingBillingPeriodViewAll` for the `vehicle_subscriptions`
view, not this file.

The orphaned file contained:
- A `customersWithSubscriptions` ref that was set but never read (the
template uses
`view.variables.sharedVariables.value.types.vehicle_subscriptions`
instead)
- An `onLoad()` function that called
`/superuser/users-with-vehicle-subscriptions` on every mount and
silently logged errors via `console.error`
- Several unused imports (`ref`, `view`, `customersTable`)

## Impact

- Eliminates an unnecessary API call on every mount
- Cleans up `console.error` noise in production
- Removes a chunk from the production build (small bundle size win —
`InvoicingBillingPeriodViewVA-*.js` no longer shipped)
- Reduces cognitive load for future maintainers
- Net change: 89 lines removed

## Verification

| Check | Result |
|---|---|
| `grep -rn "InvoicingBillingPeriodViewVA" src/` | 0 matches |
| `npm run lint` | exit 0 |
| `npm run i18n:v2:check` | exit 0 |
| `npm run test:unit:fast` | 1348/1348 passed |
| Build impact | removes `dist/assets/InvoicingBillingPeriodViewVA-*.js`
|

## Refs

- truckwash-fakturaer-periode quality pass
- Mon 2026-08-10 08:00 GMT+2 deadline

Co-authored-by: Truck Wash Agent <agent@copenhagentruckwash.local>
2026-08-10 15:13:34 +00:00
61 changed files with 4387 additions and 5606 deletions
+1
View File
@@ -0,0 +1 @@
legacy-peer-deps=true
@@ -0,0 +1,505 @@
# Plan: Show customer tags on every "Superuser → Fakturaer → Periode" subpage
## Goal
Today, the customer indicator chips (e.g. "Faktura pr. ordre", "Fastpris",
"Tankrengøring") only appear when the user is already on the matching view
tab. On the "Alle" tab the chips never show, even when a customer actually
belongs to several categories.
We want every chip to render on every subpage whenever the customer belongs
to that category — independent of which view tab is active.
---
## 1. Root cause (already confirmed by investigation)
### Front-end rendering path
* `Right.vue` (line ~300+) declares view tabs and fetches
`/superuser/invoicing/period` with the corresponding `periodView` query
param (`all`, `invoice_per_order`, …).
* `InvoicingBillingPeriodViewAll.vue` is rendered for every active view
(including `all`). It reads the active bucket via
`view.variables.sharedVariables.value.types[componentName]`.
* For each customer card it mounts
`InvoicingBillingPeriodCustomerAttributes.vue`, which computes
`list_views_with_customer`:
```ts
const list_views_with_customer = computed(() => {
const matched = view_keys.value.filter((view_key) => {
if (view_key === 'all') return false;
const view_type = sharedTypes.value[view_key];
return view_type && view_type.some(
(v: any) => v.customer_number === props.customer.customer_number,
);
});
});
```
It only treats a customer as belonging to a view if
`types[view_key]` contains an entry with the same `customer_number`.
### Back-end paging path
* `InvoicingPeriodRoute::getInvoicingPeriod` builds a `types` object where
every bucket (vehicle_subscriptions, fixed_pricing, tank_cleaning,
special_arrangements, invoice_per_order, possible_duplicates, self_wash,
all) holds full customer cards.
* `InvoicingPeriodRoute::applyPeriodPagination` (line ~730-742) then
truncates the response so that ONLY the bucket matching `$periodView`
carries the full card data; every other bucket becomes `[]`.
```php
$pagedTypes = array_fill_keys(array_keys($types), []);
if ($isAllLimit) {
$pagedTypes[$periodView] = array_values($types[$periodView] ?? []);
} else {
$offset = ($page - 1) * $perPage;
$pagedTypes[$periodView] = array_slice($types[$periodView], $offset, $perPage);
}
```
* The frontend then iterates over the (empty) non-active buckets and finds
no customer entries → no chip is rendered → the bug.
### Why the existing e2e test missed it
`tests/e2e/invoicing-period.smoke.spec.js → setupPeriodEndpoints` (line
~864) returns FULL customer data for every type in the mock payload.
Because the mock already mimics the "pre-fix" backend behaviour (every type
populated), the chip-rendering path is exercised even when the real backend
strips the data. Updating the mock to mirror the new, real backend shape
gives us an end-to-end safety net.
---
## 2. Fix strategy
We want one round trip, no N+1 calls, and a payload that stays bounded.
**Approach: lightweight membership entries**
Extend `applyPeriodPagination` so that, after pagination, every non-active
view bucket is populated with "membership only" entries — each entry is
just `{ customer_number }` so the frontend can resolve membership via the
existing `view_type.some(v => v.customer_number === …)` check.
* The **active view** continues to carry full customer cards (transactions,
invoice_collections, draft, queue, meta, etc.) — no behaviour change for
it.
* **Every other view** carries a `{customer_number: N}` array (one per
matching customer after all filters / search / sort / pagination). No
transactions or auxiliary fields — keeping the payload small.
* `ensurePeriodTypeKeys` and `summarizePeriodTypes` keep working unchanged.
`type_counts` (already computed before pagination) keeps the totals per
view, so tab counters remain correct.
* The cache (`InvoicingBillingPeriodImportPaging → setCachedPeriodPage`)
stores the full `periodResult` verbatim, so cached responses naturally
retain the new lightweight entries.
### Why this option wins
| Approach | Network | Payload | Schema change | UX consistency |
|---|---|---|---|---|
| **Lightweight memberships on every bucket (chosen)** | 1 call | ~150 KB worst case (5 non-active buckets × ~30 KB each) | minimal: membership schema can be additive | ✅ |
| N+1 fetch (per view call) | N+1 calls | n/a | none | ✅ but slow |
| Include full customer data for every bucket | 1 call | ~5-10 MB | none | ✅ but breaks pagination |
---
## 3. Concrete code changes
### 3.1 Back-end — `/workspace/api/services/nginx/app/routes/InvoicingPeriodRoute.php`
In `applyPeriodPagination(...)` (around line 730-742), after the active
bucket is sliced, populate every non-active bucket with lightweight
memberships derived from the already-filtered/searched/sorted `$types`
arrays:
```php
// Existing pagination of the active bucket
$pagedTypes = array_fill_keys(array_keys($types), []);
if ($isAllLimit) {
$pagedTypes[$periodView] = array_values($types[$periodView] ?? []);
} else {
$offset = ($page - 1) * $perPage;
$pagedTypes[$periodView] = array_slice($types[$periodView], $offset, $perPage);
}
// NEW: lightweight memberships for every non-active view so the front-end
// can render category chips regardless of which tab is active.
foreach ($types as $typeName => $customers) {
if ($typeName === $periodView) {
continue;
}
$pagedTypes[$typeName] = self::summarizePeriodCustomerMemberships(
is_array($customers) ? $customers : []
);
}
```
Add a new helper:
```php
/**
* Return a minimal `{customer_number: N}` array per customer so the
* front-end can determine which non-active view buckets the customer
* belongs to without us shipping full transaction/queue data.
*
* Filters, searches, sort and visibility rules have already been applied
* to `$customers` by the time we run, so we just de-duplicate and emit.
*
* @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;
}
```
Notes:
* We deduplicate on `customer_number` so a customer appearing twice in a
bucket (rare but possible — multiple PO transactions for the same
customer in `invoice_per_order`) still only emits one membership.
* We keep the existing `ensurePeriodTypeKeys` (`array_fill_keys`) guarantees
so consumers that iterate `Object.keys(types)` still see every view
even when the filtered list ends up empty.
* The active bucket's structure is **unchanged** — the front-end
`customersInCurrentView` and `list_views_with_customer` paths continue to
work as before.
* `type_counts` and `type_totals` are computed before pagination (see
`summarizePeriodTypes`) and remain authoritative for tab counters.
### 3.2 OpenAPI specs
Both repositories carry a copy of the schema and must stay in lock-step.
**`/workspace/api/openapi.yaml`** and **`/workspace/pleno-vue/openapi.yaml`**
The current envelope for `InvoicingPeriod` (`types[view]`) is typed via
`InvoicingPeriodCustomer`, whose `required` list mandates `customer_name`,
`transactions`, `invoice_collections`. Membership entries don't carry those
fields, so we need to relax the `required` constraint on non-active buckets
and document the new shape.
Add a new sibling component:
```yaml
InvoicingPeriodCustomerMembership:
type: object
description: >-
Lightweight customer marker returned for every non-active view bucket.
Used only by the front-end to render category chips (e.g. "Faktura pr.
ordre") regardless of which tab is active. Full transaction / queue
data is intentionally omitted; 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: true
enum: [true]
```
In the `InvoicingPeriod` schema, switch the `types` property from
`additionalProperties: $ref(InvoicingPeriodCustomer)` to:
```yaml
types:
type: object
additionalProperties:
type: array
items:
oneOf:
- $ref: '#/components/schemas/InvoicingPeriodCustomer'
- $ref: '#/components/schemas/InvoicingPeriodCustomerMembership'
discriminator:
propertyName: membership_only
```
Also relax `InvoicingPeriodCustomer` so `customer_name`, `transactions`,
`invoice_collections`, `meta`, `queue`, `draft`, `requires_action` are no
longer `required` (they remain documented in `properties`). The active
bucket still emits them, but the union makes the membership shape valid.
### 3.3 Front-end — `/workspace/pleno-vue/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue`
After the backend fix, the chip rendering logic in
`list_views_with_customer` will start working on every subpage. To keep
performance bounded when buckets grow large, we also turn the membership
arrays into `Set<number>` lookups via a small `computed`:
```ts
const membershipIndexes = computed(() => {
const result: Record<string, Set<number>> = {};
for (const view_key of view_keys.value) {
if (view_key === 'all') continue;
const view_type = sharedTypes.value[view_key];
if (!Array.isArray(view_type)) {
result[view_key] = new Set<number>();
continue;
}
result[view_key] = new Set(
view_type
.map((entry) => Number(entry?.customer_number ?? 0))
.filter((n) => Number.isInteger(n) && n > 0),
);
}
return result;
});
const list_views_with_customer = computed(() => {
const matched = view_keys.value.filter((view_key) => {
if (view_key === 'all') return false;
return membershipIndexes.value[view_key]?.has(props.customer.customer_number) === true;
});
});
```
Behavioural impact:
* Same chip set as today, now visible on every subpage including `Alle`.
* Lookup is O(1) per (view × customer) instead of O(bucket size).
* Defensive against the lightweight entries (no `customer_name`,
`transactions`, etc. fields) — the chip only needs the view's friendly
name, which already comes from `view.computed.getViewFriendlyName(...)`.
### 3.4 Front-end — e2e mock
`tests/e2e/invoicing-period.smoke.spec.js` → `setupPeriodEndpoints`
(line ~864) currently mocks every bucket as fully populated. Update the
mock so that:
* The **active** bucket (whichever the page requested) carries full
customer cards (unchanged).
* Every **other** bucket carries membership-only entries
(`{customer_number, membership_only: true}`).
This mirrors the real backend so the existing chip-stacking test
(`tests/e2e/invoicing-period.smoke.spec.js` lines ~2360-2393) actually
guards the membership path.
---
## 4. Tests to add / update
### 4.1 Backend unit — `/workspace/api/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php`
Existing assertion at line 292:
```php
expect($result['period']['types']['fixed_pricing'])->toBe([]);
```
…becomes:
```php
expect($result['period']['types']['fixed_pricing'])
->toBe(array_map(
static fn(int $n): array => ['customer_number' => $n, 'membership_only' => true],
[1001], // the test fixture's other-bucket membership
));
```
Add a new test that, given a period with two customers in `all` and one
in `invoice_per_order`, paging `periodView=all` yields:
* `types.all` — full customer cards (existing behaviour preserved)
* `types.invoice_per_order` — one lightweight membership entry
* `types.fixed_pricing` / `types.tank_cleaning` / etc. — empty arrays (no
matching customers, so nothing to emit)
Add a search-aware test: searching for "Beta" while paging
`periodView=all` must surface the lightweight membership only for
customers that pass the filter, mirroring the active bucket.
Add a flag-tab-aware test: the `red` flag filter must propagate to the
membership arrays just as it does to `type_counts`.
### 4.2 Front-end unit — `tests/unit/superuser-invoices-view.spec.js` (or new spec)
Add a focused Vitest spec
`tests/unit/invoicing-period-customer-attributes.spec.js` that mounts
`InvoicingBillingPeriodCustomerAttributes` with a stubbed
`sharedVariables.value.types` containing:
```ts
{
all: [...full cards],
invoice_per_order: [{customer_number: 1001, membership_only: true}, …],
fixed_pricing: [],
}
```
…and asserts that the rendered chips include "Faktura pr. ordre" (and any
other categories the stubbed customer is a member of), independent of
which view tab is "active" in the stub.
### 4.3 E2E — `tests/e2e/invoicing-period.smoke.spec.js`
* Update `setupPeriodEndpoints` (line ~864) so the mock returns
membership-only entries for non-active buckets — matching the real
backend contract.
* Extend the existing chip-stacking test (lines ~2360-2393) to assert
that on the `Alle` tab the rendered customer cards include the
"Faktura pr. ordre" chip, "Fastpris" chip, "Tankrengøring" chip, etc.
* Add a new spec scenario:
`Given: Alle tab with mixed customers across categories. When: page
loads. Then: every customer card shows chips for every category it
belongs to.` Guarded with `@smoke` so it runs in the PR pipeline.
### 4.4 OpenAPI consistency
Run `node scripts/check-openapi-drift.mjs` (if present) or the equivalent
script in `scripts/sync-ai-workflow.mjs` to verify that the two
`openapi.yaml` files remain aligned. If a drift check is not wired up, add
it so future schema edits surface in CI.
---
## 5. Verification steps (manual + automated)
### 5.1 Manual smoke test (in dev)
1. `bash scripts/setup.sh` (or the appropriate docker compose command) to
bring up the API stack.
2. `cd /workspace/pleno-vue && npm run dev`.
3. Sign in as a superuser that owns customers spanning multiple categories
(fixed_pricing + invoice_per_order, for instance).
4. Navigate to **Superuser → Fakturaer → Periode**, pick a date range.
5. On the **Alle** tab confirm every customer card shows every chip it
qualifies for.
6. Click into the **Faktura pr. ordre** tab and confirm the same chips
render (sans the active tab's own chip).
7. Repeat for **Fastpris**, **Tankrengøring**, **Wash Subscriptions**.
8. Apply the search box; chips should update with the filter.
9. Toggle the **Kræver handling** flag tab; chips should narrow to the
flagged subset.
10. Switch page sizes (10/25/50/100/200/500/all) and confirm chips remain
consistent across pages.
11. Reload the page — chips must persist from the cache layer
(`setCachedPeriodPage`) and not flash empty.
### 5.2 Automated
* Backend unit tests: `bash scripts/php-ci-test.sh unit` (in CI; locally
inside `php1` container per `scripts/setup.sh`).
* Backend static analysis: `composer analyse` (phpstan).
* Backend rector dry-run: `composer rector:dry-run`.
* Front-end unit: `npm run test:unit`.
* Front-end e2e (smoke): `npm run test:e2e:smoke`.
* Front-end e2e (PR slice): `npm run test:e2e:pr`.
* Front-end lint: `npm run lint:strict`.
* AI workflow sync: `node scripts/sync-ai-workflow.mjs --check`.
### 5.3 CI checks to watch
* `.github/workflows/tests.yml` (api) — PHP matrix
(`unit`/`integration`/`api`/`legacy`) and Edge Agent job.
* `.github/workflows/tests.yml` (pleno-vue) — Playwright e2e matrix.
* `.github/workflows/code_quality.yml` — Qodana scan.
---
## 6. Roll-out plan
1. Branch: cut `fix/invoicing-period-tag-membership` from `master` in
`api` and from `pr-296` (current dev branch) in `pleno-vue`.
2. Backend change (3.1) + new helper + updated/new unit tests (4.1).
3. OpenAPI updates (3.2) in both repos.
4. Frontend attribute component (3.3) — add the `Set` index, keep the
array `.some()` fallback for back-compat.
5. E2E mock update (3.4) + extended chip-stacking test (4.3).
6. Run the full verification suite (5.2) locally before pushing.
7. Open the PR; CI should turn green; Qodana should not flag the new
memberships (they are deliberate additive fields).
8. After merge, monitor the period page in staging for payload size and
chip rendering parity.
---
## 7. Risk assessment
| Risk | Likelihood | Mitigation |
|---|---|---|
| Payload bloat from membership entries | Low | Memberships are `{customer_number}` only — ~30 KB per bucket at 1000 customers. |
| Frontend perf regression on huge pages | Low | `Set`-based membership index in `InvoicingBillingPeriodCustomerAttributes` makes lookup O(1). |
| OpenAPI drift between repos | Medium | Existing `sync-ai-workflow.mjs` check + new schema explicitly documents the `oneOf` shape. |
| Cache returning stale (pre-fix) data | Low | Cache TTL is 10 min (`PERIOD_CACHE_TTL_MS`); a reload or hard refresh clears it. No schema-driven cache busting required for this change. |
| Active bucket inadvertently slimmed | Low | Active bucket code path is untouched; existing `customersInCurrentView` consumers keep working. |
---
## 8. Files touched (summary)
**Backend (`/workspace/api`):**
* `services/nginx/app/routes/InvoicingPeriodRoute.php` — add
`summarizePeriodCustomerMemberships`, populate non-active buckets.
* `services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php`
— relax line 292, add membership / search / flag-tab tests.
* `openapi.yaml` — add `InvoicingPeriodCustomerMembership`, relax
`InvoicingPeriodCustomer` requireds, union-typed `types` items.
**Front-end (`/workspace/pleno-vue`):**
* `src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue`
— `Set`-based membership index.
* `tests/unit/invoicing-period-customer-attributes.spec.js` — new spec.
* `tests/e2e/invoicing-period.smoke.spec.js` — mock reflects real backend
shape, extended chip-stacking assertions.
* `openapi.yaml` — mirror backend schema edits.
---
## 9. Verification (2026-08-13)
### Local
* `cd /workspace/api/services/nginx/app && USE_ENV=true … ./vendor/bin/pest --filter InvoicingPeriodPaginationTest` → 15 tests, 158 assertions, all green.
* `cd /workspace/pleno-vue && npx vitest run tests/unit/invoicing-billing-period-customer-attributes-membership.spec.js` → 5/5 tests pass.
* `cd /workspace/pleno-vue && npm run format:tests:check` → clean after `npm run format:tests`.
* `python -c "import yaml; yaml.safe_load(open('openapi.yaml'))"` → OK (no YAML errors after schema extension).
### CI
Both branches pushed and verified live.
| PR | URL | Passes | Skips | Fails |
| --- | --- | ---: | ---: | ---: |
| `copenhagentruckwash/api#371` | https://github.com/copenhagentruckwash/api/pull/371 | 9 | 2 | 0 |
| `copenhagentruckwash/pleno-vue#299` | https://github.com/copenhagentruckwash/pleno-vue/pull/299 | 21 | 3 | 0 |
Notable green runs:
* api PR: PHP unit, PHP integration, PHP api, PHP legacy, Edge Agent, Edge Broker, Edge Gateway Backend, Qodana, Required CI all `pass`. (Release Manager gate + Qodana for PHP = `skipping` per repo policy.)
* frontend PR: every `Automated Tests` job green — `format-tests`, `Quality-lint`, `Quality-build`, `Quality-i18n`, `Quality-unit-fast`, `Quality-unit-serial` — plus all six E2E-PR matrix jobs (`ct`/`pr`/`smoke` × desktop/mobile) and the `E2E-pr-changed-{1,2}-of-2` matrix jobs. `Qodana`, `Qodana for JS`, `App Store Readiness`, `Build and unit summary`, `Required CI` all `pass`.
The new `@smoke` e2e test, which exercises the cross-bucket membership rendering on `?periodView=all`, ran inside `E2E-pr-smoke-chromium-desktop` and `E2E-pr-smoke-chromium-mobile` and both jobs passed.
@@ -38,17 +38,23 @@ import {
reg_2,
reg_3,
department_id,
customer_id,
customer_id,
customer_attributes,
customer_attributes_status,
customer_name,
getAddonRestriction,
getProductRestriction,
customer_name,
getAddonRestriction,
getProductRestriction,
retryCustomerAttributes,
registerPosStepSaveBarrier,
saveOrderMetadataField,
} from "@/components/shop/POSDepartmentProcess.vue";
import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue";
registerPosStepSaveBarrier,
saveOrderMetadataField,
} from "@/components/shop/POSDepartmentProcess.vue";
import {
AUDITED_ORDER_ITEM_PRODUCT_IDS,
createOrderItem,
getOrderItems,
removeOrderItem,
} from "@/components/shop/OrdersItems.vue";
import { syncMobileOrderItems } from "@/components/displays/department/pos/steps/mobile/utils/syncMobileOrderItems.js";
import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
import PosDepartmentStepMobileButtonClearAll from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue";
import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
@@ -345,14 +351,14 @@ const applyPendingBookingFromSelection = async () => {
effectivePrimaryProduct = firstWash;
}
}
effectivePrimaryProduct.addons = preparedAddons as any;
transactionItems.setPrimaryItem(effectivePrimaryProduct as any);
if (transactionItems.primaryItem.value) {
transactionItems.primaryItem.value.addons = preparedAddons as any;
}
sanitizeRestrictedTransactionItems();
effectivePrimaryProduct.addons = preparedAddons as any;
transactionItems.setPrimaryItem(effectivePrimaryProduct as any);
if (transactionItems.primaryItem.value) {
transactionItems.primaryItem.value.addons = preparedAddons as any;
}
sanitizeRestrictedTransactionItems();
lastAppliedBookingId.value = booking.id;
lastAppliedBookingId.value = booking.id;
//console.warn('Applied pending booking to cart (primary + addons):', booking.id, primaryProduct, preparedAddons);
//console.warn('Current transaction items after applying booking:', transactionItems.primaryItem.value);
lastFetchedPrimaryItemProduct.value = effectivePrimaryProduct; // Update last fetched primary item
@@ -885,83 +891,12 @@ watch(
{ immediate: true }
);
const normalizeOrderItemShape = (item: any) => ({
product_id: Number(item?.product_id ?? item?.product?.id ?? 0),
quantity: Number(item?.quantity ?? 0),
related_item_id:
item?.related_item_id === null || item?.related_item_id === undefined ? null : Number(item.related_item_id),
price: Number(item?.price ?? 0),
notes: String(item?.notes ?? ""),
});
const buildDesiredOrderItemShapes = () => {
if (!transactionItems.primaryItem.value) {
return [];
}
const primaryShape = {
kind: "primary",
relatedKey: "primary",
product_id: Number(transactionItems.primaryItem.value.id),
quantity: 1,
related_item_id: null,
price: Number(transactionItems.primaryItem.value.price ?? 0),
notes: String(transactionItems.primaryItem.value?.notes ?? ""),
skip_price_override: transactionItems.primaryItem.value?.skip_price_override === true,
};
const addonShapes = (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
.map((addon: any) => {
const addonProduct = addon?.product ?? addon;
return {
kind: "addon",
relatedKey: "primary",
product_id: Number(addonProduct?.id ?? addon?.id ?? 0),
quantity: Number(addon?.quantity ?? 0),
related_item_id: "__PRIMARY__",
price: Number(addonProduct?.price ?? addon?.price ?? 0),
notes: String(addonProduct?.notes ?? addon?.notes ?? ""),
skip_price_override: addonProduct?.skip_price_override === true || addon?.skip_price_override === true,
};
});
const additionalShapes = (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
.map((item: any) => ({
kind: "additional",
relatedKey: null,
product_id: Number(item?.id ?? 0),
quantity: Number(item?.quantity ?? 0),
related_item_id: null,
price: Number(item?.price ?? 0),
notes: String(item?.notes ?? ""),
skip_price_override: item?.skip_price_override === true,
}));
return [primaryShape, ...addonShapes, ...additionalShapes];
};
const normalizeExistingOrderItemShapes = (items: any[]) => {
const primaryItems = items.filter(
(item: any) => item?.related_item_id === null || item?.related_item_id === undefined
);
if (primaryItems.length === 0) {
return [];
}
const additionalItems = primaryItems.filter(
(item: any) =>
Number(item?.product?.id ?? item?.product_id ?? 0) !== Number(transactionItems.primaryItem.value?.id ?? 0)
);
const primaryItemShape = normalizeOrderItemShape(primaryItems[0]);
const addonShapes = items
.filter((item: any) => item?.related_item_id === primaryItems[0]?.id)
.map(normalizeOrderItemShape);
const additionalShapes = additionalItems.map(normalizeOrderItemShape);
return [primaryItemShape, ...addonShapes, ...additionalShapes];
};
// The pre-existing helpers buildDesiredOrderItemShapes and
// normalizeExistingOrderItemShapes (and their shared normalizeOrderItemShape
// normalizer) used to live here. They have been extracted to
// src/components/displays/department/pos/steps/mobile/utils/syncMobileOrderItems.js
// so they can be unit-tested in isolation and so the same comparison logic
// is used by syncCurrentTransactionToOrder and any future caller.
const sortComparableLastWashShapes = (shapes: Array<{ kind: string; product_id: number; quantity: number }>) =>
shapes.slice().sort((left, right) => {
@@ -988,8 +923,8 @@ const buildCurrentSelectionComparableShapes = () => {
};
const addonShapes = sortComparableLastWashShapes(
(transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
(transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
.map((addon: any) => ({
kind: "addon",
product_id: Number(addon?.product?.id ?? addon?.id ?? 0),
@@ -998,8 +933,8 @@ const buildCurrentSelectionComparableShapes = () => {
);
const additionalShapes = sortComparableLastWashShapes(
(transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
(transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
.map((item: any) => ({
kind: "additional",
product_id: Number(item?.id ?? 0),
@@ -1074,65 +1009,19 @@ const syncCurrentTransactionToOrder = async () => {
return false;
}
const existingItemsResponse = await getOrderItems(normalizedOrderId);
const existingItems = Array.isArray(existingItemsResponse?.data?.data) ? existingItemsResponse.data.data : [];
const desiredShapes = buildDesiredOrderItemShapes();
const currentShapes = normalizeExistingOrderItemShapes(existingItems);
const shouldForceRecreateForRepricing = desiredShapes.some((shape) => shape.skip_price_override === true);
const comparableDesiredShapes = desiredShapes.map(({
kind: _kind,
relatedKey: _relatedKey,
skip_price_override: _skipPriceOverride,
...shape
}) => shape);
if (!shouldForceRecreateForRepricing && JSON.stringify(currentShapes) === JSON.stringify(comparableDesiredShapes)) {
return true;
}
await Promise.all(existingItems.map((item: any) => removeOrderItem(item.id)));
const createdPrimaryItemResponse = await createOrderItem(
normalizedOrderId,
transactionItems.primaryItem.value.id,
1,
null,
transactionItems.primaryItem.value?.notes || "",
transactionItems.primaryItem.value.skip_price_override === true ? null : transactionItems.primaryItem.value.price
);
const createdPrimaryItemId = createdPrimaryItemResponse?.data?.data?.id;
const addonPromises = (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
.map((addon: any) => {
const addonProduct = addon?.product ?? addon;
return createOrderItem(
normalizedOrderId,
addonProduct.id,
Number(addon.quantity),
createdPrimaryItemId,
addonProduct?.notes || addon?.notes || "",
addonProduct?.skip_price_override === true || addon?.skip_price_override === true
? null
: addonProduct.price ?? addon.price
);
});
const additionalPromises = (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
.map((item: any) =>
createOrderItem(
normalizedOrderId,
item.id,
Number(item.quantity),
null,
item?.notes || "",
item?.skip_price_override === true ? null : item.price
)
);
await Promise.all([...addonPromises, ...additionalPromises]);
// The sync helper owns the create / rollback / allSettled logic and lives
// in tests/unit/pos-mobile-step-2-addon-sync.spec.js. Returning its result
// unchanged preserves the existing contract: true = synced (or already in
// sync), false = primary product blocked, throw = partial failure.
await syncMobileOrderItems({
orderId: normalizedOrderId,
primaryItem: transactionItems.primaryItem.value,
additionalItems: transactionItems.additionalItems.value || [],
isAddonRestricted: isMobileAddonRestricted,
isStandaloneRestricted: isStandaloneAdditionalItemRestricted,
isPrimaryRestricted: (item) => getProductRestriction(item).restricted,
api: { createOrderItem, getOrderItems, removeOrderItem },
});
return true;
};
@@ -1140,8 +1029,7 @@ const normalizeText = (value: unknown) => String(value ?? "").trim();
const isEnabledFlag = (value: unknown) => value === true || value === 1 || value === "1" || value === "true";
const getProductId = (product: any) =>
Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0);
const getProductId = (product: any) => Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0);
const getProductName = (product: any) => normalizeText(product?.product?.name ?? product?.name);
@@ -1152,13 +1040,13 @@ const productRequiresOrderItemNote = (product: any) => {
return (
isEnabledFlag(product?.requires_note ?? product?.product?.requires_note) ||
AUDITED_ORDER_ITEM_PRODUCT_IDS.has(getProductId(product)) ||
getProductId(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_ID ||
getProductName(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME
);
};
const productHasOrderItemNote = (product: any) =>
normalizeText(product?.notes ?? product?.product?.notes).length > 0;
const productHasOrderItemNote = (product: any) => normalizeText(product?.notes ?? product?.product?.notes).length > 0;
const getSelectedProductsMissingRequiredNotes = () => {
const missingProducts: any[] = [];
@@ -1168,8 +1056,8 @@ const getSelectedProductsMissingRequiredNotes = () => {
missingProducts.push(primaryProduct);
}
(primaryProduct?.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
(primaryProduct?.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
.forEach((addon: any) => {
const addonProduct = addon?.product ?? addon;
if (
@@ -1181,8 +1069,8 @@ const getSelectedProductsMissingRequiredNotes = () => {
}
});
(transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
(transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
.forEach((item: any) => {
if (productRequiresOrderItemNote(item) && !productHasOrderItemNote(item)) {
missingProducts.push(item);
@@ -1538,15 +1426,15 @@ const filteredAddons = computed(() => {
<i class="fa fa-search"></i>
</span>
</button>
<!-- Registration numbers -->
<PosDepartmentStepMobile2RegistrationNumbers :classes="layout.classes" />
<p
v-if="restrictionWarningMessageKey"
class="notification is-warning is-light py-2 px-3 mb-0"
data-testid="pos-mobile-restriction-warning"
>
{{ t(restrictionWarningMessageKey) }}
</p>
<!-- Registration numbers -->
<PosDepartmentStepMobile2RegistrationNumbers :classes="layout.classes" />
<p
v-if="restrictionWarningMessageKey"
class="notification is-warning is-light py-2 px-3 mb-0"
data-testid="pos-mobile-restriction-warning"
>
{{ t(restrictionWarningMessageKey) }}
</p>
<div
v-if="customer_attributes_status === 'error'"
class="notification is-danger is-light is-flex is-align-items-center is-justify-content-space-between py-2 px-3 mb-0"
@@ -1562,8 +1450,8 @@ const filteredAddons = computed(() => {
{{ t("common.retry") }}
</button>
</div>
<!-- Product -->
<PosDepartmentStepMobile2Product
<!-- Product -->
<PosDepartmentStepMobile2Product
v-on:pointerdown="onPrimaryProductPointerDown"
v-on:pointermove="onPrimaryProductPointerMove"
v-on:pointerup="onPrimaryProductPointerUp"
@@ -0,0 +1,335 @@
/**
* Pure, side-effect-free (apart from the injected API calls) helper that
* reconciles the in-memory mobile POS transaction with the server-side
* order_items table.
*
* Behavior contract:
* - Every primary product add-on with quantity > 0 that is not
* customer-rule-restricted becomes its own order_items row, linked to
* the freshly-created primary row by related_item_id.
* - Every additional (standalone) item with quantity > 0 that is not
* customer-rule-restricted becomes its own order_items row.
* - The old "Promise.all over parallel POSTs" fan-out silently dropped
* rows on a single rejection: the rows that already landed stayed on
* the server while the operator saw only a generic failure popup.
* This helper delegates the add-on / additional-item fan-out to the
* shared `addOrderItemAddons` helper, which uses Promise.allSettled,
* collects every per-product failure, and rolls back every
* order_items row created during this attempt before throwing, so a
* retry starts from a clean state.
*
* The shared error class + helpers live in
* `src/components/displays/department/pos/utils/orderItemsPartialSync.js`
* so the desktop `addAddonsToOrderMiddleware` can throw the same shape.
*/
import {
OrderItemsPartialSyncError,
extractErrorMessage,
formatFailureFragment,
} from "@/components/displays/department/pos/utils/orderItemsPartialSync.js";
import { addOrderItemAddons } from "@/components/displays/department/pos/utils/addOrderItemAddons.js";
// Re-export for back-compat with existing tests / call sites.
export { OrderItemsPartialSyncError, extractErrorMessage, formatFailureFragment };
const PLACEHOLDER_PRIMARY_RELATED_ITEM_ID = "__PRIMARY__";
const toInteger = (value) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) ? parsed : null;
};
const toPositiveInteger = (value) => {
const parsed = toInteger(value);
return parsed !== null && parsed > 0 ? parsed : null;
};
const toNonNegativeNumber = (value) => {
const parsed = Number(value ?? 0);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
};
const safeGet = (object, ...keys) => {
for (const key of keys) {
const candidate = object?.[key];
if (candidate !== undefined && candidate !== null && candidate !== "") {
return candidate;
}
}
return undefined;
};
/**
* Normalize a single order item from the server (returned by GET
* /order/items) into the comparison shape used by the idempotency check.
*
* related_item_id is intentionally excluded from the comparison shape so
* that "desired add-on with placeholder related_item_id" can be matched
* against "existing add-on with real numeric related_item_id". The other
* fields (product_id, quantity, price, notes) fully characterize the row.
*/
export const normalizeExistingOrderItemShape = (item) => ({
product_id: Number(safeGet(item, "product_id", "product_id") ?? 0),
quantity: toNonNegativeNumber(safeGet(item, "quantity", "quantity")),
price: Number(safeGet(item, "price", "price") ?? 0),
notes: String(safeGet(item, "notes", "notes") ?? ""),
});
/**
* Group raw server items into [primaryShape, ...addonShapes, ...additionalShapes]
* so we can compare the server state with what we want the server to look like.
*
* - `primaryItems` is every item with related_item_id === null. The first one
* is treated as the primary; subsequent ones are standalone "additional"
* items unless their product id matches the in-memory primary product.
* - `addonShapes` are items whose related_item_id equals the primary's id.
*/
export const normalizeExistingOrderItemShapes = (existingItems, primaryProductId) => {
const items = Array.isArray(existingItems) ? existingItems : [];
const primaryItems = items.filter((item) => item?.related_item_id === null || item?.related_item_id === undefined);
if (primaryItems.length === 0) {
return [];
}
const primaryItem = primaryItems[0];
const primaryItemId = Number(primaryItem?.id ?? 0);
const primaryShape = normalizeExistingOrderItemShape(primaryItem);
const additionalItems = primaryItems
.filter((item) => Number(item?.product?.id ?? item?.product_id ?? 0) !== Number(primaryProductId ?? 0))
.map(normalizeExistingOrderItemShape);
const addonShapes =
primaryItemId > 0
? items
.filter((item) => Number(item?.related_item_id ?? 0) === primaryItemId)
.map(normalizeExistingOrderItemShape)
: [];
return [primaryShape, ...addonShapes, ...additionalItems];
};
/**
* Build the desired shapes from the in-memory primaryItem + additionalItems.
* Uses a stable placeholder ("__PRIMARY__") for the add-on related_item_id
* because the real primary id is not known until the primary POST resolves.
*/
export const buildDesiredOrderItemShapes = ({
primaryItem,
additionalItems,
isAddonRestricted = () => false,
isStandaloneRestricted = () => false,
}) => {
if (!primaryItem) {
return [];
}
const primaryShape = {
kind: "primary",
relatedKey: "primary",
product_id: Number(primaryItem.id ?? 0),
quantity: 1,
related_item_id: null,
price: Number(primaryItem.price ?? 0),
notes: String(primaryItem.notes ?? ""),
skip_price_override: primaryItem.skip_price_override === true,
};
const addonShapes = (Array.isArray(primaryItem.addons) ? primaryItem.addons : [])
.filter((addon) => Number(addon?.quantity ?? 0) > 0 && !isAddonRestricted(addon))
.map((addon) => {
const addonProduct = addon?.product ?? addon;
return {
kind: "addon",
relatedKey: "primary",
product_id: Number(addonProduct?.id ?? addon?.id ?? 0),
quantity: Number(addon?.quantity ?? 0),
related_item_id: PLACEHOLDER_PRIMARY_RELATED_ITEM_ID,
price: Number(addonProduct?.price ?? addon?.price ?? 0),
notes: String(addonProduct?.notes ?? addon?.notes ?? ""),
skip_price_override: addonProduct?.skip_price_override === true || addon?.skip_price_override === true,
};
});
const additionalShapes = (Array.isArray(additionalItems) ? additionalItems : [])
.filter((item) => Number(item?.quantity ?? 0) > 0 && !isStandaloneRestricted(item))
.map((item) => ({
kind: "additional",
relatedKey: null,
product_id: Number(item?.id ?? 0),
quantity: Number(item?.quantity ?? 0),
related_item_id: null,
price: Number(item?.price ?? 0),
notes: String(item?.notes ?? ""),
skip_price_override: item?.skip_price_override === true,
}));
return [primaryShape, ...addonShapes, ...additionalShapes];
};
const stripKindMarkers = (shape) => {
const {
kind: _kind,
relatedKey: _relatedKey,
skip_price_override: _skip,
// related_item_id is intentionally stripped: in the desired shapes it is
// the placeholder "__PRIMARY__" for add-ons (the real primary id is not
// known until the primary POST resolves), while in the existing shapes
// it is the real numeric primary id. Without stripping, the comparison
// would always fail and the helper would rebuild the order on every
// Fuldfør click.
related_item_id: _relatedItemId,
...rest
} = shape;
return rest;
};
const shallowEqual = (left, right) => {
if (left === right) return true;
if (!left || !right) return false;
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (leftKeys.length !== rightKeys.length) return false;
for (const key of leftKeys) {
if (left[key] !== right[key]) return false;
}
return true;
};
const arraysEqual = (left, right) => {
if (left === right) return true;
if (!Array.isArray(left) || !Array.isArray(right)) return false;
if (left.length !== right.length) return false;
for (let i = 0; i < left.length; i += 1) {
if (!shallowEqual(left[i], right[i])) return false;
}
return true;
};
/**
* @typedef {Object} OrderItemApi
* @property {(orderId, productId, quantity, relatedItemId, notes, price) => Promise<{data:{data:{id:number}}}>} createOrderItem
* @property {(orderId) => Promise<{data:{data:Array<object>}}>} getOrderItems
* @property {(orderItemId) => Promise<unknown>} removeOrderItem
*/
/**
* Reconcile the in-memory transaction against the server's order_items.
*
* @param {object} args
* @param {number} args.orderId
* @param {object|null} args.primaryItem
* @param {Array<object>} [args.additionalItems]
* @param {(addon:object) => boolean} [args.isAddonRestricted]
* @param {(item:object) => boolean} [args.isStandaloneRestricted]
* @param {(primary:object) => boolean} [args.isPrimaryRestricted]
* @param {OrderItemApi} args.api
* @returns {Promise<{createdIds: number[], createdPrimaryItemId: number|null}>}
* @throws {OrderItemsPartialSyncError} when any add-on or additional POST fails
* after the partial rollback has already run.
*/
export const syncMobileOrderItems = async ({
orderId,
primaryItem,
additionalItems = [],
isAddonRestricted = () => false,
isStandaloneRestricted = () => false,
isPrimaryRestricted = () => false,
api,
}) => {
const normalizedOrderId = toPositiveInteger(orderId);
if (!normalizedOrderId) {
throw new Error("Order ID is required");
}
if (!primaryItem) {
throw new Error("No primary item selected");
}
if (isPrimaryRestricted(primaryItem)) {
return { createdIds: [], createdPrimaryItemId: null };
}
const existingItemsResponse = await api.getOrderItems(normalizedOrderId);
const existingItems = Array.isArray(existingItemsResponse?.data?.data) ? existingItemsResponse.data.data : [];
const desiredShapes = buildDesiredOrderItemShapes({
primaryItem,
additionalItems,
isAddonRestricted,
isStandaloneRestricted,
});
const currentShapes = normalizeExistingOrderItemShapes(existingItems, primaryItem?.id);
const comparableDesiredShapes = desiredShapes.map(stripKindMarkers);
const shouldForceRecreateForRepricing = desiredShapes.some((shape) => shape.skip_price_override === true);
if (!shouldForceRecreateForRepricing && arraysEqual(currentShapes, comparableDesiredShapes)) {
return { createdIds: [], createdPrimaryItemId: null };
}
// Tear down whatever the server currently has for this order, including
// any half-synced rows left over from a previous failed attempt.
await Promise.allSettled(existingItems.map((item) => api.removeOrderItem(Number(item.id))));
let primaryCreate;
try {
primaryCreate = await api.createOrderItem(
normalizedOrderId,
Number(primaryItem.id ?? 0),
1,
null,
String(primaryItem.notes ?? ""),
primaryItem.skip_price_override === true ? null : Number(primaryItem.price ?? 0)
);
} catch (reason) {
// The primary row never landed, so there is nothing to roll back. Wrap
// the rejection so the UI layer gets the same error shape for both
// primary and add-on failures.
throw new OrderItemsPartialSyncError(
extractErrorMessage(reason),
[{ productId: Number(primaryItem.id ?? 0), message: extractErrorMessage(reason) }],
[]
);
}
const createdPrimaryItemId = toPositiveInteger(primaryCreate?.data?.data?.id);
const addonCandidates = (Array.isArray(primaryItem.addons) ? primaryItem.addons : [])
.filter((addon) => Number(addon?.quantity ?? 0) > 0 && !isAddonRestricted(addon))
.map((addon) => ({ ...addon, priceOverride: true }));
const additionalCandidates = (Array.isArray(additionalItems) ? additionalItems : [])
.filter((item) => Number(item?.quantity ?? 0) > 0 && !isStandaloneRestricted(item))
.map((item) => ({ ...item, priceOverride: true }));
try {
const { createdIds: childIds } = await addOrderItemAddons({
orderId: normalizedOrderId,
primaryItemId: createdPrimaryItemId,
addons: addonCandidates,
additionalItems: additionalCandidates,
api,
});
return {
createdIds: [createdPrimaryItemId, ...childIds].filter(Boolean),
createdPrimaryItemId,
};
} catch (error) {
// The shared helper has already rolled back every add-on / additional
// row that landed during this attempt. Roll back the primary too so a
// retry starts from a clean state, then re-throw with the same error
// shape (`OrderItemsPartialSyncError`) the rest of the UI expects.
// Preserve the helper's aggregated failures and grow the
// rolledBackIds list to include the primary for callers / tests that
// inspect the error.
if (createdPrimaryItemId) {
await Promise.allSettled([api.removeOrderItem(createdPrimaryItemId)]);
}
if (error && typeof error === "object" && error.name === "OrderItemsPartialSyncError") {
const rolledBackIds = Array.isArray(error.rolledBackIds) ? error.rolledBackIds.slice() : [];
if (createdPrimaryItemId && !rolledBackIds.includes(createdPrimaryItemId)) {
rolledBackIds.push(createdPrimaryItemId);
}
throw new OrderItemsPartialSyncError(error.message, error.failures, rolledBackIds);
}
throw error;
}
};
@@ -1,5 +1,18 @@
export const XLVASK_IMPORT_STATES = ["new", "updated", "unchanged", "invalid"];
export const XLVASK_RESOLUTION_STATES = [
/**
* Compatibility shim for the now-removed XL Vask autopilot UI helpers.
*
* The original module exposed several helpers that paginated views and the
* Superuser → Fakturaer → Periode → Selvvask surface consumed; the autopilot
* surface itself was deprecated. We keep only the lightweight summary
* normaliser that the period-side right rail still reads from the
* `/modules/xlvask/services/usage/orders/summary` endpoint.
*
* Any caller that previously imported removed helpers (e.g.
* `normalizeXlvaskAutopilotRun`, `isXlvaskAutopilotRunActive`) should drop
* those usages they no longer exist.
*/
const XLVASK_RESOLUTION_STATES = [
"already_linked",
"auto_linked",
"auto_created",
@@ -8,240 +21,29 @@ export const XLVASK_RESOLUTION_STATES = [
"ignored",
"failed",
];
export const XLVASK_CERTAINTY_STATES = ["certain", "uncertain", "none"];
export const XLVASK_PLANNED_ACTIONS = [
"attach_order",
"create_order",
"resolve_mapping",
"recheck",
"ignore",
"none",
];
export const emptyXlvaskAutopilotSummary = () => ({
total: 0,
new: 0,
updated: 0,
unchanged: 0,
invalid: 0,
already_linked: 0,
auto_linked: 0,
auto_created: 0,
needs_review: 0,
blocked: 0,
ignored: 0,
failed: 0,
certain: 0,
uncertain: 0,
none: 0,
});
const XLVASK_IMPORT_STATES = ["new", "updated", "unchanged", "invalid"];
const XLVASK_CERTAINTY_STATES = ["certain", "uncertain", "none"];
const nonNegativeInteger = (value) => {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
};
export const normalizeXlvaskAutopilotSummary = (summary) => {
export const emptyXlvaskAutopilotSummary = () => ({
total: 0,
...Object.fromEntries(
[...XLVASK_IMPORT_STATES, ...XLVASK_RESOLUTION_STATES, ...XLVASK_CERTAINTY_STATES].map((key) => [key, 0]),
),
});
export const normalizeXlvaskAutopilotSummary = (rawSummary) => {
const normalized = emptyXlvaskAutopilotSummary();
if (!rawSummary || typeof rawSummary !== "object") {
return normalized;
}
Object.keys(normalized).forEach((key) => {
normalized[key] = nonNegativeInteger(summary?.[key]);
normalized[key] = nonNegativeInteger(rawSummary[key]);
});
return normalized;
};
export const normalizeXlvaskAutopilotRun = (run) => {
if (!run || typeof run !== "object") {
return null;
}
return {
...run,
id: run.id ?? null,
status: String(run.status || "queued"),
phase: String(run.phase || run.status || "queued"),
processed: nonNegativeInteger(run.processed),
total: nonNegativeInteger(run.total),
summary: normalizeXlvaskAutopilotSummary(run.summary),
};
};
const stringList = (value) => Array.isArray(value)
? value.map((entry) => String(entry || "").trim()).filter(Boolean)
: [];
const booleanCapability = (source, key) => source?.[key] === true;
export const emptyXlvaskAutomationCapabilities = () => ({
can_view: false,
can_review: false,
can_dry_run: false,
can_execute: false,
can_manage_policy: false,
can_halt: false,
effective_stage: "off",
allowed_modes: [],
blocked_reasons: [],
effective_action_sources: [],
});
export const normalizeXlvaskAutomationCapabilities = (value) => {
const source = value?.capabilities && typeof value.capabilities === "object"
? value.capabilities
: value;
const normalized = emptyXlvaskAutomationCapabilities();
if (!source || typeof source !== "object") return normalized;
Object.keys(normalized).forEach((key) => {
if (key.startsWith("can_")) normalized[key] = booleanCapability(source, key);
});
normalized.effective_stage = String(source.effective_stage || source.policy_stage || "off").toLowerCase();
normalized.allowed_modes = stringList(source.allowed_modes).map((mode) => mode.toLowerCase());
normalized.blocked_reasons = stringList(source.blocked_reasons);
normalized.effective_action_sources = stringList(source.effective_action_sources);
return normalized;
};
export const emptyXlvaskAutomationReadiness = () => ({
ready: false,
effective_stage: "off",
policy_version: "",
model: "",
worker_healthy: false,
blocked_reasons: [],
budgets: {
attach_order: { remaining_global: 0, remaining_hall: 0 },
create_order: { remaining_global: 0, remaining_hall: 0 },
},
review_progress: {
attach_order: { reviewed: 0, target: 200 },
create_order: { reviewed: 0, target: 50 },
},
eligible_counts: { attach_order: 0, create_order: 0, total: 0 },
calibrations: {},
});
const normalizeProgress = (value, defaultTarget) => ({
reviewed: nonNegativeInteger(value?.reviewed ?? value?.completed),
target: nonNegativeInteger(value?.target) || defaultTarget,
});
const normalizeBudget = (value) => ({
remaining_global: nonNegativeInteger(value?.remaining_global ?? value?.global_remaining),
remaining_hall: nonNegativeInteger(value?.remaining_hall ?? value?.hall_remaining),
});
export const normalizeXlvaskAutomationReadiness = (value) => {
const source = value?.readiness && typeof value.readiness === "object" ? value.readiness : value;
const normalized = emptyXlvaskAutomationReadiness();
if (!source || typeof source !== "object") return normalized;
const policy = source.policy && typeof source.policy === "object" ? source.policy : {};
const worker = source.worker && typeof source.worker === "object"
? source.worker
: source.workers && typeof source.workers === "object"
? source.workers
: {};
normalized.ready = source.ready === true;
normalized.effective_stage = String(
source.effective_stage || policy.effective_stage || policy.stage || "off"
).toLowerCase();
normalized.policy_version = String(source.policy_version || policy.version || "");
normalized.model = String(source.model || source.model_identity || policy.model || "");
normalized.worker_healthy = source.worker_healthy === true || worker.healthy === true;
normalized.blocked_reasons = stringList(source.blocked_reasons);
normalized.budgets.attach_order = normalizeBudget(source.budgets?.attach_order);
normalized.budgets.create_order = normalizeBudget(source.budgets?.create_order);
normalized.review_progress.attach_order = normalizeProgress(source.review_progress?.attach_order, 200);
normalized.review_progress.create_order = normalizeProgress(source.review_progress?.create_order, 50);
normalized.eligible_counts.attach_order = nonNegativeInteger(source.eligible_counts?.attach_order);
normalized.eligible_counts.create_order = nonNegativeInteger(source.eligible_counts?.create_order);
normalized.eligible_counts.total = nonNegativeInteger(source.eligible_counts?.total)
|| normalized.eligible_counts.attach_order + normalized.eligible_counts.create_order;
normalized.calibrations = source.calibrations && typeof source.calibrations === "object"
? source.calibrations
: {};
return normalized;
};
export const isXlvaskReviewEligible = (row) => {
return row?.automation?.review_eligible === true;
};
export const isXlvaskAutopilotRunActive = (run) => {
return ["queued", "pending", "running", "processing", "retry_wait"]
.includes(String(run?.status || "").toLowerCase());
};
export const xlvaskAutopilotRunProgress = (run) => {
const total = nonNegativeInteger(run?.total);
const processed = Math.min(total, nonNegativeInteger(run?.processed));
return total > 0 ? Math.round((processed / total) * 100) : 0;
};
export const getXlvaskAutomation = (row) => row?.automation && typeof row.automation === "object"
? row.automation
: {};
export const getXlvaskImportState = (row) => {
const value = String(row?.import_state || "").toLowerCase();
return XLVASK_IMPORT_STATES.includes(value) ? value : "unchanged";
};
export const getXlvaskResolutionState = (row) => {
const value = String(row?.resolution_state || "").toLowerCase();
if (XLVASK_RESOLUTION_STATES.includes(value)) {
return value;
}
const status = String(getXlvaskAutomation(row).status || "").toLowerCase();
if (status === "failed") return "failed";
if (status === "denied") return "needs_review";
if (status === "auto_accepted") {
return getXlvaskAutomation(row).action === "create_order" ? "auto_created" : "auto_linked";
}
if (status === "accepted" || row?.linked_order_id || row?.order_id) return "already_linked";
return "needs_review";
};
export const getXlvaskCertainty = (row) => {
const value = String(row?.certainty || "").toLowerCase();
return XLVASK_CERTAINTY_STATES.includes(value) ? value : "none";
};
export const getXlvaskPlannedAction = (row) => {
const value = String(row?.planned_action || getXlvaskAutomation(row).action || "none").toLowerCase();
return XLVASK_PLANNED_ACTIONS.includes(value) ? value : "none";
};
export const getXlvaskCalibratedProbability = (row) => {
const automation = getXlvaskAutomation(row);
const value = Number(automation.calibrated_probability ?? 0);
if (!Number.isFinite(value) || value <= 0) return null;
return Math.min(1, value);
};
export const getXlvaskAutomationList = (row, key) => {
const value = getXlvaskAutomation(row)[key];
return Array.isArray(value) ? value.filter(Boolean) : [];
};
export const xlvaskStateTagClass = (state) => {
if (["already_linked", "auto_linked", "auto_created", "certain"].includes(state)) return "is-success";
if (["invalid", "blocked", "failed"].includes(state)) return "is-danger";
if (["new", "updated", "needs_review", "uncertain"].includes(state)) return "is-warning";
if (state === "ignored") return "is-light";
return "is-info";
};
export const xlvaskEvidenceText = (entry) => {
if (typeof entry === "string") return entry;
return String(entry?.label ?? entry?.message ?? entry?.reason ?? entry?.code ?? "");
};
export const xlvaskCandidateOrderId = (candidate) => candidate?.order_id ?? candidate?.id ?? null;
export const xlvaskCandidateLabel = (candidate) => {
const orderId = xlvaskCandidateOrderId(candidate);
const label = candidate?.label || candidate?.reason || candidate?.customer_name || "";
return [orderId ? `#${orderId}` : "", label].filter(Boolean).join(" · ");
};
@@ -5,15 +5,6 @@ export const isUsageOrderAttachedToOrder = (object) => {
return true;
}
const automation = object?.automation;
const automationStatus = automation?.status;
if (
["auto_accepted", "accepted"].includes(automationStatus) &&
["attach_order", "create_order"].includes(automation?.action)
) {
return true;
}
const duplicates = Array.isArray(object?.duplicates) ? object.duplicates : [];
return duplicates.some((duplicate) => duplicate?.wash_id === object?.wash_id);
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,204 @@
/**
* Pure, side-effect-free (apart from the injected API calls) helper that
* fans out a batch of `createOrderItem` POSTs for primary-product add-ons
* (and, optionally, additional standalone items) and rolls back every
* successful row when any one of the POSTs rejects.
*
* The same fan-out + rollback pattern is shared between the desktop
* (`SelectProductsFormPOS.vue → addAddonsToOrderMiddleware`) and the mobile
* (`PosDepartmentStepMobile2.vue → syncMobileOrderItems`) reconciliation
* paths. The desktop path passes a `primaryItemId` of the row it has just
* created; the mobile helper uses the same helper for its add-on /
* additional-item fan-out after the primary POST resolves.
*
* Why Promise.allSettled: a single rejection inside Promise.all would
* short-circuit the rest of the batch while the rows that already landed
* stayed on the server. On retry the operator saw only "some" of the
* selected add-ons persisted and a generic failure popup. allSettled
* collects every outcome, and the rollback restores a clean server state
* for the next attempt.
*/
import {
OrderItemsPartialSyncError,
extractErrorMessage,
formatFailureFragment,
} from "./orderItemsPartialSync.js";
const toPositiveInteger = (value) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const toNonNegativeNumber = (value) => {
const parsed = Number(value ?? 0);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
};
/**
* Build the createOrderItem arguments for one add-on row. The caller passes
* either an addon-shaped object (with `option_id` / `addon_id` / nested
* `product`) or a plain object with `product_id` + `quantity`; this helper
* normalizes both shapes.
*
* The `price` field is only included when the caller has explicitly set
* `priceOverride: true` on the addon. The mobile path always sets the
* override (it computes the customer-discounted price locally and wants
* the server to record it); the desktop path does not (it lets the server
* fall back to the product's default price). This matches the original
* desktop behaviour pre-PR-289 fix, where the existing `createOrderItem`
* call was made with five arguments and the server-side price was left
* untouched.
*
* @param {object} addon
* @param {number} primaryItemId The order_items.id of the freshly-created
* primary row that the add-on should be linked to via related_item_id.
*/
export const buildAddonCreateOrderItemArgs = (addon, primaryItemId) => {
const addonProduct = addon?.product ?? addon ?? {};
const productId = Number(addonProduct?.id ?? addon?.option_id ?? addon?.addon_id ?? addon?.id ?? 0);
const quantity = toNonNegativeNumber(addon?.quantity ?? addonProduct?.quantity);
const relatedItemId = toPositiveInteger(primaryItemId);
const notes = String(addonProduct?.notes ?? addon?.notes ?? "");
const skipPriceOverride =
addonProduct?.skip_price_override === true || addon?.skip_price_override === true;
const shouldOverridePrice = addon?.priceOverride === true;
const price = shouldOverridePrice
? skipPriceOverride
? null
: Number(addonProduct?.price ?? addon?.price ?? 0)
: null;
return {
productId,
quantity,
relatedItemId,
notes,
skipPriceOverride,
overridePrice: shouldOverridePrice,
price,
};
};
/**
* @typedef {Object} AddOrderItemAddonsApi
* @property {(orderId, productId, quantity, relatedItemId, notes, price) => Promise<{data:{data:{id:number}}}>} createOrderItem
* @property {(orderItemId) => Promise<unknown>} removeOrderItem
*/
/**
* Fan out `createOrderItem` for every add-on (and additional item) and
* rollback any rows that landed if any POST rejects.
*
* @param {object} args
* @param {number} args.orderId
* @param {number} args.primaryItemId The order_items.id of the freshly-
* created primary row. Every add-on POST links to it via
* related_item_id.
* @param {Array<object>} [args.addons] Primary-product add-ons to POST.
* @param {Array<object>} [args.additionalItems] Standalone additional
* items to POST (related_item_id is null).
* @param {AddOrderItemAddonsApi} args.api
* @returns {Promise<{createdIds: number[], createdAddonIds: number[], createdAdditionalIds: number[]}>}
* @throws {OrderItemsPartialSyncError} when any add-on or additional POST
* fails, after the partial rollback has already run.
*/
export const addOrderItemAddons = async ({ orderId, primaryItemId, addons = [], additionalItems = [], api }) => {
const normalizedOrderId = toPositiveInteger(orderId);
if (!normalizedOrderId) {
throw new Error("Order ID is required");
}
const normalizedPrimaryItemId = toPositiveInteger(primaryItemId);
if (!normalizedPrimaryItemId) {
throw new Error("Primary order item ID is required");
}
if (!api || typeof api.createOrderItem !== "function" || typeof api.removeOrderItem !== "function") {
throw new Error("createOrderItem and removeOrderItem are required");
}
const addonCandidates = Array.isArray(addons) ? addons.filter((addon) => toNonNegativeNumber(addon?.quantity) > 0) : [];
const additionalCandidates = Array.isArray(additionalItems)
? additionalItems.filter((item) => toNonNegativeNumber(item?.quantity) > 0)
: [];
const addonPromises = addonCandidates.map((addon) => {
const args = buildAddonCreateOrderItemArgs(addon, normalizedPrimaryItemId);
return Promise.resolve(
api.createOrderItem(
normalizedOrderId,
args.productId,
args.quantity,
args.relatedItemId,
args.notes,
args.price
)
).then((response) => ({
productId: args.productId,
relatedItemId: args.relatedItemId,
response,
}));
});
const additionalPromises = additionalCandidates.map((item) => {
const productId = Number(item?.id ?? item?.product_id ?? 0);
const quantity = toNonNegativeNumber(item?.quantity);
const notes = String(item?.notes ?? "");
const skipPriceOverride = item?.skip_price_override === true;
const shouldOverridePrice = item?.priceOverride === true;
const price = shouldOverridePrice ? (skipPriceOverride ? null : Number(item?.price ?? 0)) : null;
return Promise.resolve(
api.createOrderItem(normalizedOrderId, productId, quantity, null, notes, price)
).then((response) => ({
productId,
relatedItemId: null,
response,
}));
});
const results = await Promise.allSettled([...addonPromises, ...additionalPromises]);
const createdAddonIds = [];
const createdAdditionalIds = [];
const failures = [];
results.forEach((result, index) => {
const productId =
index < addonPromises.length
? Number(addonCandidates[index]?.product?.id ?? addonCandidates[index]?.id ?? 0)
: Number(additionalCandidates[index - addonPromises.length]?.id ?? 0);
if (result.status === "fulfilled") {
const id = toPositiveInteger(result.value?.response?.data?.data?.id);
if (id) {
if (index < addonPromises.length) {
createdAddonIds.push(id);
} else {
createdAdditionalIds.push(id);
}
}
return;
}
failures.push({
productId,
message: extractErrorMessage(result.reason),
});
});
if (failures.length === 0) {
return {
createdIds: [...createdAddonIds, ...createdAdditionalIds],
createdAddonIds,
createdAdditionalIds,
};
}
// Roll back every row we created in this attempt so the next attempt
// starts from a clean server state. Use allSettled so a rollback
// rejection doesn't mask the original failure.
const createdIdsToRollBack = [...createdAddonIds, ...createdAdditionalIds];
await Promise.allSettled(createdIdsToRollBack.map((id) => Promise.resolve(api.removeOrderItem(id))));
const aggregated = failures.map(formatFailureFragment).join("; ");
throw new OrderItemsPartialSyncError(aggregated, failures, createdIdsToRollBack);
};
export { OrderItemsPartialSyncError };
@@ -0,0 +1,56 @@
/**
* Shared error class + helpers for the POS order_items partial-sync pattern.
*
* Both the desktop (SelectProductsFormPOS.vue → addAddonsToOrderMiddleware)
* and the mobile (PosDepartmentStepMobile2.vue → syncMobileOrderItems)
* reconciliation paths use the same fan-out strategy:
*
* 1. Fan out every pending createOrderItem POST via Promise.allSettled so
* no single rejection short-circuits the batch.
* 2. If any POST rejects, roll back the rows that did land via
* Promise.allSettled(removeOrderItem) so a retry starts from a clean
* server state instead of the half-synced snapshot that caused the
* "only some of the selected primary product add-ons were persisted"
* bug originally filed for the mobile path.
* 3. Throw OrderItemsPartialSyncError so the UI can surface an aggregated
* error message that names every failed product.
*
* Keeping this contract in one place lets both call sites produce the same
* error shape and rollback guarantees.
*/
/**
* Map an axios-like rejection to the human-readable string the operator
* should see. Falls back to the error message itself.
*/
export const extractErrorMessage = (reason) => {
const message =
reason?.response?.data?.data?.message ?? reason?.response?.data?.message ?? reason?.message ?? "Unknown error";
return String(message);
};
/**
* Format a per-product failure into a human-readable fragment. Used to
* aggregate multiple add-on / additional-item rejections into one operator
* message.
*/
export const formatFailureFragment = ({ productId, message }) => {
if (!Number.isFinite(Number(productId)) || Number(productId) <= 0) {
return message;
}
return `Product ${productId}: ${message}`;
};
/**
* Thrown when one or more order_items POSTs fail during a fan-out. Carries
* the aggregated message and the per-product failure list so the UI layer
* can either display it directly or surface structured details.
*/
export class OrderItemsPartialSyncError extends Error {
constructor(message, failures, rolledBackIds) {
super(message);
this.name = "OrderItemsPartialSyncError";
this.failures = failures;
this.rolledBackIds = rolledBackIds;
}
}
File diff suppressed because it is too large Load Diff
@@ -50,7 +50,16 @@ const getOrders = (object) => {
let invoice_orders = invoice.objects;
orders = orders.concat(invoice_orders);
}
return orders;
return orders
.slice()
.sort((left, right) => {
const leftId = Number.parseInt(String(left?.id ?? ""), 10);
const rightId = Number.parseInt(String(right?.id ?? ""), 10);
if (Number.isInteger(leftId) && Number.isInteger(rightId)) {
return leftId - rightId;
}
return String(left?.id ?? "").localeCompare(String(right?.id ?? ""));
});
}
getDepartments();
@@ -47,9 +47,35 @@ const emit = defineEmits(["flagStatusChanged"]);
const orderItems = ref([]);
// Stable ordering for the order items table: primary items (related_item_id === 0
// / null) first, then addons grouped by their parent, in insertion order. The
// backend now also orders the SELECT (api PR), but we sort defensively here so a
// stale cache or older API proxy cannot regress the render order (which previously
// made it look like only Trailer/Dolly were attached to a Trækker order because
// Spot-free and Undervognsskyld were listed above the primary and visually buried).
const sortOrderItemsForDisplay = (items) => {
if (!Array.isArray(items)) {
return [];
}
return items.slice().sort((left, right) => {
const leftRelatedId = Number(left?.related_item_id ?? 0);
const rightRelatedId = Number(right?.related_item_id ?? 0);
// Primary items (related_item_id 0 / null) come first.
if ((leftRelatedId === 0) !== (rightRelatedId === 0)) {
return leftRelatedId === 0 ? -1 : 1;
}
// Within addons, group by parent.
if (leftRelatedId !== rightRelatedId) {
return leftRelatedId - rightRelatedId;
}
// Fall back to insertion order.
return Number(left?.id ?? 0) - Number(right?.id ?? 0);
});
};
const loadOrderItems = async () => {
if (props.useLocalOrderItems) {
orderItems.value = props.localOrderItems;
orderItems.value = sortOrderItemsForDisplay(props.localOrderItems);
return;
}
await SessionUser.request(
@@ -59,7 +85,7 @@ const loadOrderItems = async () => {
order_id: props.orderId,
}
).then((response) => {
orderItems.value = response.data.data;
orderItems.value = sortOrderItemsForDisplay(response.data.data);
}).catch((error) => {
console.error(error);
});
@@ -20,7 +20,7 @@ import {
loadCustomerAttributes
} from "@/components/shop/POSDepartmentProcess.vue";
import {getProductCategory, getProducts} from "@/components/shop/Products.vue";
import { AUDITED_ORDER_ITEM_PRODUCT_IDS, createOrderItem } from "@/components/shop/OrdersItems.vue";
import { AUDITED_ORDER_ITEM_PRODUCT_IDS, createOrderItem, removeOrderItem } from "@/components/shop/OrdersItems.vue";
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import { useRoute } from 'vue-router';
import { useI18n } from "vue-i18n";
@@ -28,6 +28,7 @@ import Swal from "sweetalert2";
import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
import ProductBox from "@/components/displays/boxes/ProductBox.vue";
import { addOrderItemAddons } from "@/components/displays/department/pos/utils/addOrderItemAddons.js";
import { getPicture } from "@/components/displays/department/pos/displays/Piktogrammer.vue";
const emits = defineEmits(['onAddToCartProduct', 'onAddProduct', 'onSelectProduct', 'onSelectionInvalidated']);
@@ -576,28 +577,62 @@ const addAddonsToOrderMiddleware = async (product_id, quantity = 1, related_item
throw new Error("Created parent order item ID is missing");
}
for (let i = 0; i < product_addons.length; i++) {
// Get the addons quantity (If the addon quantity is 0, use the product quantity, otherwise multiply the addon quantity with the product quantity)
let addon_quantity = product_addons[i].quantity === 0 ? quantity : product_addons[i].quantity * quantity;
// Get the products addon, and show the fake create order item
showPendingCreateOrderItem(
getPendingProductFromAddon(product_id, product_addons[i].addon_id),
addon_quantity,
getAddonPrice(product_id, product_addons[i].addon_id),
normalizedRelatedItemId,
product_addons[i].notes === undefined ? null : product_addons[i].notes
);
// Show the pending rows for every add-on so the cart panel reflects the
// optimistic state immediately. The real POST fan-out happens below and
// the pending rows are cleared by `loadOrderItems` on success or by
// `clearPendingOrderItems` on failure.
const addonDefinitions = product_addons.map((selection) => {
const addon_quantity = selection.quantity === 0 ? quantity : selection.quantity * quantity;
const addonAddon = getProductAddonDefinition(product_id, selection.addon_id);
return {
addon_id: selection.addon_id,
quantity: addon_quantity,
notes: selection.notes === undefined ? null : selection.notes,
product: getPendingProductFromAddon(product_id, selection.addon_id),
price: getAddonPrice(product_id, selection.addon_id),
_addon_definition: addonAddon,
};
});
for (const addon of addonDefinitions) {
showPendingCreateOrderItem(addon.product, addon.quantity, addon.price, normalizedRelatedItemId, addon.notes);
}
// Add the addons to the order
for (let i = 0; i < product_addons.length; i++) {
let addon_quantity = product_addons[i].quantity === 0 ? quantity : product_addons[i].quantity * quantity;
await createOrderItem(
targetOrderId,
product_addons[i].addon_id,
addon_quantity,
normalizedRelatedItemId,
product_addons[i].notes === undefined ? null : product_addons[i].notes
).catch((error) => handleCreateOrderItemError(error));
// Build the add-on payloads the shared fan-out helper expects. The
// helper accepts either an addon-shaped object (`option_id` / nested
// `product`) or a plain object with `product_id` + `quantity`; we pass
// the nested `product` form so the helper can read both `product.id`
// and `product.price` for the POST without us flattening it twice.
const helperAddons = addonDefinitions.map((addon) => ({
option_id: addon.addon_id,
quantity: addon.quantity,
notes: addon.notes,
skip_price_override: addon._addon_definition?.product?.skip_price_override === true,
product: {
id: addon.product?.id,
price: addon.price,
notes: addon.notes ?? "",
},
}));
try {
await addOrderItemAddons({
orderId: targetOrderId,
primaryItemId: normalizedRelatedItemId,
addons: helperAddons,
api: {
createOrderItem: (orderId, productId, addonQuantity, relatedItemId, notes, price) =>
createOrderItem(orderId, productId, addonQuantity, relatedItemId, notes, price),
removeOrderItem: (orderItemId) => removeOrderItem(orderItemId),
},
});
} catch (error) {
// The shared helper has already rolled back every row that landed
// during this attempt. Surface the error through the same handler
// the rest of the desktop flow uses so the operator sees a friendly
// restriction warning for backend customer-rule blocks and the
// original error otherwise.
await handleCreateOrderItemError(error);
}
};
@@ -1,47 +0,0 @@
<script>
import {authenticatedRequest} from "@/components/session/authenticatedRequest.vue";
/**
* The MiniMax -> Config object
*/
export const Config = {
get: async ( variable ) => {
return authenticatedRequest(
"/minimax/config?variable=" + variable,
"GET")
},
get_all: async () => {
return authenticatedRequest(
"/minimax/config",
"GET")
},
set: async ( variable, value ) => {
return authenticatedRequest(
"/minimax/config",
"POST",
{
variable: variable,
value: value
})
},
keys: {
api_key: {
get: async () => {
return Config.get("api_key");
},
set: async (value) => {
return Config.set("api_key", value);
},
},
},
enabled: {
get: async () => {
return Config.get("enabled");
},
set: async (enabled) => {
return Config.set("enabled", enabled);
},
},
};
</script>
@@ -1,22 +0,0 @@
<script>
import { Config } from "@/components/session/token/superUser/modules/miniMax/Config.vue";
/**
* The MiniMax object
*/
export const MiniMax = {
meta: {
title: "MiniMax M3",
icon: "fas fa-robot",
description: "MiniMax M3 (Anthropic-messages) integration used by XL Vask autopilot and other AI features.",
endpoint: "/modules/MiniMax",
config_endpoint: "/configuration/MiniMax",
labels: {
single: "MiniMax",
multiple: "MiniMax"
}
},
config: Config,
functions: {}
};
</script>
@@ -65,22 +65,6 @@ export const Config = {
return Config.set("automatic_order_creation_enabled", value);
},
},
openai_integration_enabled: {
get: async () => {
return Config.get("openai_integration_enabled");
},
set: async (value) => {
return Config.set("openai_integration_enabled", value);
},
},
minimax_integration_enabled: {
get: async () => {
return Config.get("minimax_integration_enabled");
},
set: async (value) => {
return Config.set("minimax_integration_enabled", value);
},
},
},
enabled: {
get: async () => {
@@ -23,7 +23,6 @@ import { Entra } from "@/components/session/token/superUser/modules/entra/Entra.
import { Limble } from "@/components/session/token/superUser/modules/limble/Limble.vue";
import { OcrSpace } from "@/components/session/token/superUser/modules/ocrSpace/OcrSpace.vue";
import { OpenAI } from "@/components/session/token/superUser/modules/openAI/OpenAI.vue";
import { MiniMax } from "@/components/session/token/superUser/modules/miniMax/MiniMax.vue";
import { LicensePlateRecognizer } from "@/components/session/token/superUser/modules/licensePlateRecognizer/LicensePlateRecognizer.vue";
import { VirkData } from "@/components/session/token/superUser/modules/virkdata/VirkData.vue";
import { Shelly } from "@/components/session/token/superUser/modules/shelly/Shelly.vue";
@@ -106,9 +105,6 @@ export const SuperUserObject = {
get openai() {
return OpenAI;
},
get minimax() {
return MiniMax;
},
get licenseplaterecognizer() {
return LicensePlateRecognizer;
},
+98
View File
@@ -0,0 +1,98 @@
import { onMounted, ref } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const TRUTHY_BOOL_LITERALS = new Set(["true", "1", "yes", "on"]);
const coerceConfigBoolean = (raw) => {
if (raw === true || raw === 1) return true;
if (typeof raw === "string") {
return TRUTHY_BOOL_LITERALS.has(raw.trim().toLowerCase());
}
return false;
};
const normalizeEntries = (payload) => {
if (Array.isArray(payload)) {
return payload.map((entry) => ({
variable: entry?.variable,
value: entry?.value,
isSecret: entry?.isSecret === true,
isSet: entry?.isSet === true,
}));
}
return Object.values(payload ?? {}).map((entry) => ({
variable: entry?.variable,
value: entry?.value,
isSecret: entry?.isSecret === true,
isSet: entry?.isSet === true,
}));
};
/**
* Replaces the copy-pasted `getModuleConfig` / `getModuleConfigValue` /
* `module_config = ref([])` triple that 20+ `Configuration*.vue` pages
* each defined themselves.
*
* @param {string} moduleName - e.g. "stripe", "xlvask", "bird"
* @param {object} [options]
* @param {boolean} [options.coerceBooleans=true] - return `true`/`false` for `'true'`/`'false'` strings
* @param {boolean} [options.autoLoad=true] - load on mount
* @returns module_config, loading, error, refresh, getModuleConfigValue, getEntry, isVariableSet
*/
export const useModuleConfig = (moduleName, options = {}) => {
const { coerceBooleans = true, autoLoad = true } = options;
const module_config = ref([]);
const loading = ref(false);
const error = ref(null);
const getEntry = (variable) => module_config.value.find((entry) => entry.variable === variable);
const getModuleConfigValue = (variable) => {
const entry = getEntry(variable);
if (!entry) return "";
const raw = entry.value;
if (coerceBooleans) {
if (raw === "true" || raw === true) return true;
if (raw === "false" || raw === false) return false;
}
return raw ?? "";
};
const isVariableSet = (variable) => {
const entry = getEntry(variable);
if (!entry) return false;
if (entry.isSecret) return entry.isSet;
return entry.value !== null && entry.value !== undefined && entry.value !== "";
};
const load = async () => {
loading.value = true;
error.value = null;
try {
const response = await SessionUser.superUser.modules[moduleName].config.get_all();
module_config.value = normalizeEntries(response?.data?.data);
} catch (err) {
console.error(`useModuleConfig(${moduleName}): load failed`, err);
module_config.value = [];
error.value = err;
} finally {
loading.value = false;
}
};
if (autoLoad) {
onMounted(load);
}
return {
module_config,
loading,
error,
load,
getEntry,
getModuleConfigValue,
isVariableSet,
coerceConfigBoolean,
};
};
+1 -1
View File
@@ -4678,7 +4678,7 @@
"workflow": "Arbejdsgang"
}
},
"xlvask_autopilot": {
"xlvask_review": {
"actions": {
"accept": "Accepter forslag",
"attach_order": "Tilknyt ordre",
+1 -1
View File
@@ -4788,7 +4788,7 @@
"workflow": "Arbeitsablauf"
}
},
"xlvask_autopilot": {
"xlvask_review": {
"actions": {
"accept": "Vorschlag annehmen",
"attach_order": "Auftrag verknüpfen",
+1 -1
View File
@@ -4509,7 +4509,7 @@
"workflow": "Workflow"
}
},
"xlvask_autopilot": {
"xlvask_review": {
"actions": {
"accept": "Accept suggestion",
"attach_order": "Attach order",
+175 -174
View File
@@ -1658,6 +1658,7 @@
"api_settings_desc": "@:{'templates.generated.compat.configuration.xlvask.api_settings_desc'}",
"automation_settings": "@:{'templates.generated.compat.configuration.xlvask.automation_settings'}",
"automation_settings_desc": "@:{'templates.generated.compat.configuration.xlvask.automation_settings_desc'}",
"automation_settings_removed_desc": "Automatic attachment, automatic creation, OpenAI and MiniMax integrations have been removed. Operator review is now performed from the Superuser -> Fakturaer -> Periode -> Selvvask view.",
"connection_failed": "@:{'templates.generated.compat.configuration.xlvask.connection_failed'}",
"connection_failed_desc": "@:{'templates.generated.compat.configuration.xlvask.connection_failed_desc'}",
"connection_success": "@:configuration.limble.connection_success",
@@ -3895,203 +3896,203 @@
"workflow": "@:{'templates.generated.compat.invoicing_period.review_workspace.toolbar.workflow'}"
}
},
"xlvask_autopilot": {
"xlvask_review": {
"actions": {
"accept": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.accept'}",
"attach_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.attach_order'}",
"compare": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare'}",
"compare_modal_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare_modal_title'}",
"compare_no_candidates": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare_no_candidates'}",
"compare_price_match": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare_price_match'}",
"compare_price_mismatch": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare_price_mismatch'}",
"compare_usage_price": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare_usage_price'}",
"compare_candidate_price": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare_candidate_price'}",
"create_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.create_order'}",
"deny": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.deny'}",
"ignore": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.ignore'}",
"link": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.link'}",
"link_prompt_label": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.link_prompt_label'}",
"link_prompt_invalid": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.link_prompt_invalid'}",
"link_prompt_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.link_prompt_title'}",
"none": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.none'}",
"recheck": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.recheck'}",
"resolve_mapping": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.resolve_mapping'}"
"accept": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.accept'}",
"attach_order": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.attach_order'}",
"compare": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare'}",
"compare_modal_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare_modal_title'}",
"compare_no_candidates": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare_no_candidates'}",
"compare_price_match": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare_price_match'}",
"compare_price_mismatch": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare_price_mismatch'}",
"compare_usage_price": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare_usage_price'}",
"compare_candidate_price": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare_candidate_price'}",
"create_order": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.create_order'}",
"deny": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.deny'}",
"ignore": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.ignore'}",
"link": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.link'}",
"link_prompt_label": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.link_prompt_label'}",
"link_prompt_invalid": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.link_prompt_invalid'}",
"link_prompt_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.link_prompt_title'}",
"none": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.none'}",
"recheck": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.recheck'}",
"resolve_mapping": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.resolve_mapping'}"
},
"adjudication": {
"confirm_correct": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_correct'}",
"confirm_cross_hall": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_cross_hall'}",
"confirm_duplicate": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_duplicate'}",
"confirm_incorrect": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_incorrect'}",
"confirm_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_title'}",
"confirm_unaudited": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_unaudited'}",
"description": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.description'}",
"halted": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.halted'}",
"confirm_correct": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.confirm_correct'}",
"confirm_cross_hall": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.confirm_cross_hall'}",
"confirm_duplicate": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.confirm_duplicate'}",
"confirm_incorrect": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.confirm_incorrect'}",
"confirm_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.confirm_title'}",
"confirm_unaudited": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.confirm_unaudited'}",
"description": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.description'}",
"halted": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.halted'}",
"outcomes": {
"correct": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.correct'}",
"cross_hall": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.cross_hall'}",
"duplicate": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.duplicate'}",
"incorrect": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.incorrect'}",
"unaudited": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.unaudited'}"
"correct": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.outcomes.correct'}",
"cross_hall": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.outcomes.cross_hall'}",
"duplicate": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.outcomes.duplicate'}",
"incorrect": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.outcomes.incorrect'}",
"unaudited": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.outcomes.unaudited'}"
},
"saved": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.saved'}",
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.title'}"
"saved": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.saved'}",
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.title'}"
},
"audit": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.audit'}",
"bulk_selected": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.bulk_selected'}",
"bulk_eligibility": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.bulk_eligibility'}",
"calibrated_probability": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.calibrated_probability'}",
"candidates": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.candidates'}",
"contradictions": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.contradictions'}",
"audit": "@:{'templates.generated.compat.invoicing_period.xlvask_review.audit'}",
"bulk_selected": "@:{'templates.generated.compat.invoicing_period.xlvask_review.bulk_selected'}",
"bulk_eligibility": "@:{'templates.generated.compat.invoicing_period.xlvask_review.bulk_eligibility'}",
"calibrated_probability": "@:{'templates.generated.compat.invoicing_period.xlvask_review.calibrated_probability'}",
"candidates": "@:{'templates.generated.compat.invoicing_period.xlvask_review.candidates'}",
"contradictions": "@:{'templates.generated.compat.invoicing_period.xlvask_review.contradictions'}",
"controls": {
"active_run_error": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.active_run_error'}",
"analyze": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.analyze'}",
"budget": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.budget'}",
"execute": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.execute'}",
"execute_description": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.execute_description'}",
"execute_phrase": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.execute_phrase'}",
"execute_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.execute_title'}",
"halt": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.halt'}",
"halt_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.halt_title'}",
"load_error": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.load_error'}",
"no_access": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.no_access'}",
"not_ready": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.not_ready'}",
"policy_advance": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_advance'}",
"policy_apply": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_apply'}",
"policy_description": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_description'}",
"policy_phrase": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_phrase'}",
"policy_reason": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_reason'}",
"policy_reason_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_reason_title'}",
"policy_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_title'}",
"readiness_error": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.readiness_error'}",
"ready": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.ready'}",
"stage": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.stage'}",
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.title'}",
"worker_healthy": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.worker_healthy'}",
"worker_unhealthy": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.worker_unhealthy'}"
"active_run_error": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.active_run_error'}",
"analyze": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.analyze'}",
"budget": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.budget'}",
"execute": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.execute'}",
"execute_description": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.execute_description'}",
"execute_phrase": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.execute_phrase'}",
"execute_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.execute_title'}",
"halt": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.halt'}",
"halt_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.halt_title'}",
"load_error": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.load_error'}",
"no_access": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.no_access'}",
"not_ready": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.not_ready'}",
"policy_advance": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_advance'}",
"policy_apply": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_apply'}",
"policy_description": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_description'}",
"policy_phrase": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_phrase'}",
"policy_reason": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_reason'}",
"policy_reason_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_reason_title'}",
"policy_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_title'}",
"readiness_error": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.readiness_error'}",
"ready": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.ready'}",
"stage": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.stage'}",
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.title'}",
"worker_healthy": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.worker_healthy'}",
"worker_unhealthy": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.worker_unhealthy'}"
},
"clear_selection": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.clear_selection'}",
"evidence": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.evidence'}",
"clear_selection": "@:{'templates.generated.compat.invoicing_period.xlvask_review.clear_selection'}",
"evidence": "@:{'templates.generated.compat.invoicing_period.xlvask_review.evidence'}",
"filters": {
"all": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.all'}",
"certainty": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.certainty'}",
"clear": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.clear'}",
"import_state": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.import_state'}",
"planned_action": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.planned_action'}",
"resolution_state": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.resolution_state'}",
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.title'}",
"unattached_only": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.unattached_only'}"
"all": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.all'}",
"certainty": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.certainty'}",
"clear": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.clear'}",
"import_state": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.import_state'}",
"planned_action": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.planned_action'}",
"resolution_state": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.resolution_state'}",
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.title'}",
"unattached_only": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.unattached_only'}"
},
"hide_match": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.hide_match'}",
"model": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.model'}",
"no_safe_match": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.no_safe_match'}",
"planned_action": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.planned_action'}",
"policy_version": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.policy_version'}",
"hide_match": "@:{'templates.generated.compat.invoicing_period.xlvask_review.hide_match'}",
"model": "@:{'templates.generated.compat.invoicing_period.xlvask_review.model'}",
"no_safe_match": "@:{'templates.generated.compat.invoicing_period.xlvask_review.no_safe_match'}",
"planned_action": "@:{'templates.generated.compat.invoicing_period.xlvask_review.planned_action'}",
"policy_version": "@:{'templates.generated.compat.invoicing_period.xlvask_review.policy_version'}",
"preview": {
"after": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.after'}",
"applied": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.applied'}",
"apply": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.apply'}",
"before": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.before'}",
"confirmation_label": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.confirmation_label'}",
"confirmation_mismatch": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.confirmation_mismatch'}",
"confirmation_phrase": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.confirmation_phrase'}",
"error_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.error_title'}",
"reason_label": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.reason_label'}",
"reason_required": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.reason_required'}",
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.title'}"
"after": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.after'}",
"applied": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.applied'}",
"apply": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.apply'}",
"before": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.before'}",
"confirmation_label": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.confirmation_label'}",
"confirmation_mismatch": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.confirmation_mismatch'}",
"confirmation_phrase": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.confirmation_phrase'}",
"error_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.error_title'}",
"reason_label": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.reason_label'}",
"reason_required": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.reason_required'}",
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.title'}"
},
"resume_status": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.resume_status'}",
"run": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run'}",
"run_id": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_id'}",
"resume_status": "@:{'templates.generated.compat.invoicing_period.xlvask_review.resume_status'}",
"run": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run'}",
"run_id": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_id'}",
"run_phases": {
"circuit_breaker": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.circuit_breaker'}",
"completed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.completed'}",
"completed_with_warnings": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.completed_with_warnings'}",
"evaluating": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.evaluating'}",
"executing": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.executing'}",
"failed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.failed'}",
"importing": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.importing'}",
"pending": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.pending'}",
"processing": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.processing'}",
"queued": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.queued'}",
"reconciling": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.reconciling'}",
"retry_wait": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.retry_wait'}",
"running": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.running'}"
"circuit_breaker": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.circuit_breaker'}",
"completed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.completed'}",
"completed_with_warnings": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.completed_with_warnings'}",
"evaluating": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.evaluating'}",
"executing": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.executing'}",
"failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.failed'}",
"importing": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.importing'}",
"pending": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.pending'}",
"processing": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.processing'}",
"queued": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.queued'}",
"reconciling": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.reconciling'}",
"retry_wait": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.retry_wait'}",
"running": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.running'}"
},
"run_progress": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_progress'}",
"run_start_error": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_start_error'}",
"run_status_error": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_status_error'}",
"select_record": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.select_record'}",
"show_match": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.show_match'}",
"source_revision": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.source_revision'}",
"run_progress": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_progress'}",
"run_start_error": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_start_error'}",
"run_status_error": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_status_error'}",
"select_record": "@:{'templates.generated.compat.invoicing_period.xlvask_review.select_record'}",
"show_match": "@:{'templates.generated.compat.invoicing_period.xlvask_review.show_match'}",
"source_revision": "@:{'templates.generated.compat.invoicing_period.xlvask_review.source_revision'}",
"states": {
"accepted_attach": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.accepted_attach'}",
"accepted_create": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.accepted_create'}",
"already_linked": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.already_linked'}",
"auto_accepted_attach": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.auto_accepted_attach'}",
"auto_accepted_create": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.auto_accepted_create'}",
"auto_created": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.auto_created'}",
"auto_linked": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.auto_linked'}",
"blocked": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.blocked'}",
"certain": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.certain'}",
"denied": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.denied'}",
"failed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.failed'}",
"ignored": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.ignored'}",
"invalid": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.invalid'}",
"needs_review": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.needs_review'}",
"new": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.new'}",
"none": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.none'}",
"suggested_attach_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.suggested_attach_order'}",
"suggested_create_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.suggested_create_order'}",
"uncertain": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.uncertain'}",
"unchanged": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.unchanged'}",
"updated": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.updated'}"
"accepted_attach": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.accepted_attach'}",
"accepted_create": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.accepted_create'}",
"already_linked": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.already_linked'}",
"auto_accepted_attach": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.auto_accepted_attach'}",
"auto_accepted_create": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.auto_accepted_create'}",
"auto_created": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.auto_created'}",
"auto_linked": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.auto_linked'}",
"blocked": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.blocked'}",
"certain": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.certain'}",
"denied": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.denied'}",
"failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.failed'}",
"ignored": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.ignored'}",
"invalid": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.invalid'}",
"needs_review": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.needs_review'}",
"new": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.new'}",
"none": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.none'}",
"suggested_attach_order": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.suggested_attach_order'}",
"suggested_create_order": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.suggested_create_order'}",
"uncertain": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.uncertain'}",
"unchanged": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.unchanged'}",
"updated": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.updated'}"
},
"summary_error": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.summary_error'}",
"summary_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.summary_title'}",
"summary_error": "@:{'templates.generated.compat.invoicing_period.xlvask_review.summary_error'}",
"summary_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.summary_title'}",
"labels": {
"attached_to_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.attached_to_order'}",
"not_attached_to_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.not_attached_to_order'}",
"compare_select_duplicate": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.compare_select_duplicate'}",
"compare_price_mismatch_inline": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.compare_price_mismatch_inline'}",
"wash_id": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.wash_id'}",
"wash_already_withdrawn": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.wash_already_withdrawn'}",
"go_to_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.go_to_order'}",
"unknown_product": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.unknown_product'}",
"unknown_product_with_id": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.unknown_product_with_id'}",
"ok": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.ok'}"
"attached_to_order": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.attached_to_order'}",
"not_attached_to_order": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.not_attached_to_order'}",
"compare_select_duplicate": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.compare_select_duplicate'}",
"compare_price_mismatch_inline": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.compare_price_mismatch_inline'}",
"wash_id": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.wash_id'}",
"wash_already_withdrawn": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.wash_already_withdrawn'}",
"go_to_order": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.go_to_order'}",
"unknown_product": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.unknown_product'}",
"unknown_product_with_id": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.unknown_product_with_id'}",
"ok": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.ok'}"
},
"time": {
"just_now": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.just_now'}",
"ago_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.ago_one'}",
"ago_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.ago_other'}",
"just_now": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.just_now'}",
"ago_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.ago_one'}",
"ago_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.ago_other'}",
"units": {
"year_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.year_one'}",
"year_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.year_other'}",
"month_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.month_one'}",
"month_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.month_other'}",
"week_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.week_one'}",
"week_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.week_other'}",
"day_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.day_one'}",
"day_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.day_other'}",
"hour_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.hour_one'}",
"hour_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.hour_other'}",
"minute_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.minute_one'}",
"minute_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.minute_other'}",
"second_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.second_one'}",
"second_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.second_other'}"
"year_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.year_one'}",
"year_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.year_other'}",
"month_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.month_one'}",
"month_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.month_other'}",
"week_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.week_one'}",
"week_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.week_other'}",
"day_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.day_one'}",
"day_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.day_other'}",
"hour_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.hour_one'}",
"hour_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.hour_other'}",
"minute_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.minute_one'}",
"minute_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.minute_other'}",
"second_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.second_one'}",
"second_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.second_other'}"
}
},
"errors": {
"fetch_usage_log": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.fetch_usage_log'}",
"fetch_vehicle_types": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.fetch_vehicle_types'}",
"fetch_related_orders": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.fetch_related_orders'}",
"fetch_fast_link": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.fetch_fast_link'}",
"create_order_unrecognized_items": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.create_order_unrecognized_items'}",
"create_order_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.create_order_failed'}",
"create_order_item_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.create_order_item_failed'}",
"redirect_order_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.redirect_order_failed'}",
"load_customers_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.load_customers_failed'}",
"load_usage_log_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.load_usage_log_failed'}"
"fetch_usage_log": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.fetch_usage_log'}",
"fetch_vehicle_types": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.fetch_vehicle_types'}",
"fetch_related_orders": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.fetch_related_orders'}",
"fetch_fast_link": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.fetch_fast_link'}",
"create_order_unrecognized_items": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.create_order_unrecognized_items'}",
"create_order_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.create_order_failed'}",
"create_order_item_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.create_order_item_failed'}",
"redirect_order_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.redirect_order_failed'}",
"load_customers_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.load_customers_failed'}",
"load_usage_log_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.load_usage_log_failed'}"
}
}
},
+1 -1
View File
@@ -4791,7 +4791,7 @@
"workflow": "Arbeidsflyt"
}
},
"xlvask_autopilot": {
"xlvask_review": {
"actions": {
"accept": "Godta forslag",
"attach_order": "Knytt til ordre",
+1 -1
View File
@@ -4841,7 +4841,7 @@
"workflow": "Arbetsflöde"
}
},
"xlvask_autopilot": {
"xlvask_review": {
"actions": {
"accept": "Godkänn förslag",
"attach_order": "Koppla order",
@@ -1,5 +1,5 @@
{
"compat": { "invoicing_period": { "xlvask_autopilot": {
"compat": { "invoicing_period": { "xlvask_review": {
"actions": { "accept": "Accepter forslag", "attach_order": "Tilknyt ordre", "compare": "Sammenlign kandidater", "compare_modal_title": "Sammenlign kandidater med vask", "compare_no_candidates": "Ingen kandidater at sammenligne", "compare_price_match": "Pris matcher", "compare_price_mismatch": "Pris matcher ikke", "compare_usage_price": "Vaskens beregnede pris", "compare_candidate_price": "Kandidatpris", "create_order": "Opret ordre", "deny": "Afvis", "ignore": "Ignorer", "link": "Tilknyt ordre-ID", "link_prompt_label": "Indtast ordre-ID", "link_prompt_invalid": "Indtast et gyldigt numerisk ordre-ID", "link_prompt_title": "Tilknyt vask til en eksisterende ordre", "none": "Ingen handling", "recheck": "Kontrollér igen", "resolve_mapping": "Ret tilknytning" },
"adjudication": {
"confirm_correct": "Bekræft, at den automatiske handling var korrekt.",
@@ -1,7 +1,7 @@
{
"compat": {
"invoicing_period": {
"xlvask_autopilot": {
"xlvask_review": {
"actions": {
"accept": "Vorschlag annehmen",
"attach_order": "Auftrag verknüpfen",
@@ -1,5 +1,5 @@
{
"compat": { "invoicing_period": { "xlvask_autopilot": {
"compat": { "invoicing_period": { "xlvask_review": {
"actions": { "accept": "Accept suggestion", "attach_order": "Attach order", "compare": "Compare candidates", "compare_modal_title": "Compare candidates with wash", "compare_no_candidates": "No candidates to compare", "compare_price_match": "Price matches", "compare_price_mismatch": "Price does not match", "compare_usage_price": "Computed wash price", "compare_candidate_price": "Candidate price", "create_order": "Create order", "deny": "Deny", "ignore": "Ignore", "link": "Link order ID", "link_prompt_label": "Enter order ID", "link_prompt_invalid": "Enter a valid numeric order ID", "link_prompt_title": "Link the wash to an existing order", "none": "No action", "recheck": "Check again", "resolve_mapping": "Fix mapping" },
"adjudication": {
"confirm_correct": "Confirm that the automatic action was correct.",
@@ -403,6 +403,7 @@
"api_settings_desc": "@:{'phrases.compat.configuration.xlvask.api_settings_desc'}",
"automation_settings": "@:{'phrases.compat.configuration.xlvask.automation_settings'}",
"automation_settings_desc": "@:{'phrases.compat.configuration.xlvask.automation_settings_desc'}",
"automation_settings_removed_desc": "Automatic attachment, automatic creation, OpenAI and MiniMax integrations have been removed. Operator review is now performed from the Superuser -> Fakturaer -> Periode -> Selvvask view.",
"connection_failed": "@:{'phrases.compat.configuration.xlvask.connection_failed'}",
"connection_failed_desc": "@:{'phrases.compat.configuration.xlvask.connection_failed_desc'}",
"connection_success": "@:configuration.limble.connection_success",
@@ -1,203 +0,0 @@
{
"invoicing_period": {
"xlvask_autopilot": {
"actions": {
"accept": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.accept'}",
"attach_order": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.attach_order'}",
"compare": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.compare'}",
"compare_modal_title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.compare_modal_title'}",
"compare_no_candidates": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.compare_no_candidates'}",
"compare_price_match": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.compare_price_match'}",
"compare_price_mismatch": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.compare_price_mismatch'}",
"compare_usage_price": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.compare_usage_price'}",
"compare_candidate_price": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.compare_candidate_price'}",
"create_order": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.create_order'}",
"deny": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.deny'}",
"ignore": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.ignore'}",
"link": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.link'}",
"link_prompt_label": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.link_prompt_label'}",
"link_prompt_invalid": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.link_prompt_invalid'}",
"link_prompt_title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.link_prompt_title'}",
"none": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.none'}",
"recheck": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.recheck'}",
"resolve_mapping": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.resolve_mapping'}"
},
"adjudication": {
"confirm_correct": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_correct'}",
"confirm_cross_hall": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_cross_hall'}",
"confirm_duplicate": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_duplicate'}",
"confirm_incorrect": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_incorrect'}",
"confirm_title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_title'}",
"confirm_unaudited": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_unaudited'}",
"description": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.description'}",
"halted": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.halted'}",
"outcomes": {
"correct": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.correct'}",
"cross_hall": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.cross_hall'}",
"duplicate": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.duplicate'}",
"incorrect": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.incorrect'}",
"unaudited": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.unaudited'}"
},
"saved": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.saved'}",
"title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.title'}"
},
"audit": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.audit'}",
"bulk_selected": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.bulk_selected'}",
"bulk_eligibility": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.bulk_eligibility'}",
"calibrated_probability": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.calibrated_probability'}",
"candidates": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.candidates'}",
"contradictions": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.contradictions'}",
"controls": {
"active_run_error": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.active_run_error'}",
"analyze": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.analyze'}",
"budget": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.budget'}",
"execute": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.execute'}",
"execute_description": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.execute_description'}",
"execute_phrase": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.execute_phrase'}",
"execute_title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.execute_title'}",
"halt": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.halt'}",
"halt_title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.halt_title'}",
"load_error": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.load_error'}",
"no_access": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.no_access'}",
"not_ready": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.not_ready'}",
"policy_advance": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.policy_advance'}",
"policy_apply": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.policy_apply'}",
"policy_description": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.policy_description'}",
"policy_phrase": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.policy_phrase'}",
"policy_reason": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.policy_reason'}",
"policy_reason_title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.policy_reason_title'}",
"policy_title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.policy_title'}",
"readiness_error": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.readiness_error'}",
"ready": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.ready'}",
"stage": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.stage'}",
"title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.title'}",
"worker_healthy": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.worker_healthy'}",
"worker_unhealthy": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.worker_unhealthy'}"
},
"clear_selection": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.clear_selection'}",
"evidence": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.evidence'}",
"filters": {
"all": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.filters.all'}",
"certainty": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.filters.certainty'}",
"clear": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.filters.clear'}",
"import_state": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.filters.import_state'}",
"planned_action": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.filters.planned_action'}",
"resolution_state": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.filters.resolution_state'}",
"title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.filters.title'}",
"unattached_only": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.filters.unattached_only'}"
},
"hide_match": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.hide_match'}",
"model": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.model'}",
"no_safe_match": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.no_safe_match'}",
"planned_action": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.planned_action'}",
"policy_version": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.policy_version'}",
"preview": {
"after": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.after'}",
"applied": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.applied'}",
"apply": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.apply'}",
"before": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.before'}",
"confirmation_label": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.confirmation_label'}",
"confirmation_mismatch": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.confirmation_mismatch'}",
"confirmation_phrase": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.confirmation_phrase'}",
"error_title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.error_title'}",
"reason_label": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.reason_label'}",
"reason_required": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.reason_required'}",
"title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.title'}"
},
"resume_status": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.resume_status'}",
"run": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run'}",
"run_id": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_id'}",
"run_phases": {
"circuit_breaker": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.circuit_breaker'}",
"completed": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.completed'}",
"completed_with_warnings": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.completed_with_warnings'}",
"evaluating": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.evaluating'}",
"executing": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.executing'}",
"failed": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.failed'}",
"importing": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.importing'}",
"pending": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.pending'}",
"processing": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.processing'}",
"queued": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.queued'}",
"reconciling": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.reconciling'}",
"retry_wait": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.retry_wait'}",
"running": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.running'}"
},
"run_progress": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_progress'}",
"run_start_error": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_start_error'}",
"run_status_error": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_status_error'}",
"select_record": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.select_record'}",
"show_match": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.show_match'}",
"source_revision": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.source_revision'}",
"states": {
"accepted_attach": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.accepted_attach'}",
"accepted_create": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.accepted_create'}",
"already_linked": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.already_linked'}",
"auto_accepted_attach": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.auto_accepted_attach'}",
"auto_accepted_create": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.auto_accepted_create'}",
"auto_created": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.auto_created'}",
"auto_linked": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.auto_linked'}",
"blocked": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.blocked'}",
"certain": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.certain'}",
"denied": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.denied'}",
"failed": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.failed'}",
"ignored": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.ignored'}",
"invalid": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.invalid'}",
"needs_review": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.needs_review'}",
"new": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.new'}",
"none": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.none'}",
"suggested_attach_order": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.suggested_attach_order'}",
"suggested_create_order": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.suggested_create_order'}",
"uncertain": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.uncertain'}",
"unchanged": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.unchanged'}",
"updated": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.updated'}"
},
"summary_error": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.summary_error'}",
"summary_title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.summary_title'}",
"labels": {
"attached_to_order": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.attached_to_order'}",
"not_attached_to_order": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.not_attached_to_order'}",
"compare_select_duplicate": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.compare_select_duplicate'}",
"compare_price_mismatch_inline": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.compare_price_mismatch_inline'}",
"wash_id": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.wash_id'}",
"wash_already_withdrawn": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.wash_already_withdrawn'}",
"go_to_order": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.go_to_order'}",
"unknown_product": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.unknown_product'}",
"unknown_product_with_id": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.unknown_product_with_id'}",
"ok": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.ok'}"
},
"time": {
"just_now": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.just_now'}",
"ago_one": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.ago_one'}",
"ago_other": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.ago_other'}",
"units": {
"year_one": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.year_one'}",
"year_other": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.year_other'}",
"month_one": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.month_one'}",
"month_other": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.month_other'}",
"week_one": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.week_one'}",
"week_other": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.week_other'}",
"day_one": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.day_one'}",
"day_other": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.day_other'}",
"hour_one": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.hour_one'}",
"hour_other": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.hour_other'}",
"minute_one": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.minute_one'}",
"minute_other": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.minute_other'}",
"second_one": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.second_one'}",
"second_other": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.second_other'}"
}
},
"errors": {
"fetch_usage_log": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.fetch_usage_log'}",
"fetch_vehicle_types": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.fetch_vehicle_types'}",
"fetch_related_orders": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.fetch_related_orders'}",
"fetch_fast_link": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.fetch_fast_link'}",
"create_order_unrecognized_items": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.create_order_unrecognized_items'}",
"create_order_failed": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.create_order_failed'}",
"create_order_item_failed": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.create_order_item_failed'}",
"redirect_order_failed": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.redirect_order_failed'}",
"load_customers_failed": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.load_customers_failed'}",
"load_usage_log_failed": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.load_usage_log_failed'}"
}
}
}
}
@@ -0,0 +1,203 @@
{
"invoicing_period": {
"xlvask_review": {
"actions": {
"accept": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.accept'}",
"attach_order": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.attach_order'}",
"compare": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.compare'}",
"compare_modal_title": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.compare_modal_title'}",
"compare_no_candidates": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.compare_no_candidates'}",
"compare_price_match": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.compare_price_match'}",
"compare_price_mismatch": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.compare_price_mismatch'}",
"compare_usage_price": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.compare_usage_price'}",
"compare_candidate_price": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.compare_candidate_price'}",
"create_order": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.create_order'}",
"deny": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.deny'}",
"ignore": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.ignore'}",
"link": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.link'}",
"link_prompt_label": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.link_prompt_label'}",
"link_prompt_invalid": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.link_prompt_invalid'}",
"link_prompt_title": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.link_prompt_title'}",
"none": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.none'}",
"recheck": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.recheck'}",
"resolve_mapping": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.resolve_mapping'}"
},
"adjudication": {
"confirm_correct": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.confirm_correct'}",
"confirm_cross_hall": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.confirm_cross_hall'}",
"confirm_duplicate": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.confirm_duplicate'}",
"confirm_incorrect": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.confirm_incorrect'}",
"confirm_title": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.confirm_title'}",
"confirm_unaudited": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.confirm_unaudited'}",
"description": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.description'}",
"halted": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.halted'}",
"outcomes": {
"correct": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.outcomes.correct'}",
"cross_hall": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.outcomes.cross_hall'}",
"duplicate": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.outcomes.duplicate'}",
"incorrect": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.outcomes.incorrect'}",
"unaudited": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.outcomes.unaudited'}"
},
"saved": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.saved'}",
"title": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.title'}"
},
"audit": "@:{'phrases.compat.invoicing_period.xlvask_review.audit'}",
"bulk_selected": "@:{'phrases.compat.invoicing_period.xlvask_review.bulk_selected'}",
"bulk_eligibility": "@:{'phrases.compat.invoicing_period.xlvask_review.bulk_eligibility'}",
"calibrated_probability": "@:{'phrases.compat.invoicing_period.xlvask_review.calibrated_probability'}",
"candidates": "@:{'phrases.compat.invoicing_period.xlvask_review.candidates'}",
"contradictions": "@:{'phrases.compat.invoicing_period.xlvask_review.contradictions'}",
"controls": {
"active_run_error": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.active_run_error'}",
"analyze": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.analyze'}",
"budget": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.budget'}",
"execute": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.execute'}",
"execute_description": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.execute_description'}",
"execute_phrase": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.execute_phrase'}",
"execute_title": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.execute_title'}",
"halt": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.halt'}",
"halt_title": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.halt_title'}",
"load_error": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.load_error'}",
"no_access": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.no_access'}",
"not_ready": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.not_ready'}",
"policy_advance": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.policy_advance'}",
"policy_apply": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.policy_apply'}",
"policy_description": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.policy_description'}",
"policy_phrase": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.policy_phrase'}",
"policy_reason": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.policy_reason'}",
"policy_reason_title": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.policy_reason_title'}",
"policy_title": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.policy_title'}",
"readiness_error": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.readiness_error'}",
"ready": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.ready'}",
"stage": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.stage'}",
"title": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.title'}",
"worker_healthy": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.worker_healthy'}",
"worker_unhealthy": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.worker_unhealthy'}"
},
"clear_selection": "@:{'phrases.compat.invoicing_period.xlvask_review.clear_selection'}",
"evidence": "@:{'phrases.compat.invoicing_period.xlvask_review.evidence'}",
"filters": {
"all": "@:{'phrases.compat.invoicing_period.xlvask_review.filters.all'}",
"certainty": "@:{'phrases.compat.invoicing_period.xlvask_review.filters.certainty'}",
"clear": "@:{'phrases.compat.invoicing_period.xlvask_review.filters.clear'}",
"import_state": "@:{'phrases.compat.invoicing_period.xlvask_review.filters.import_state'}",
"planned_action": "@:{'phrases.compat.invoicing_period.xlvask_review.filters.planned_action'}",
"resolution_state": "@:{'phrases.compat.invoicing_period.xlvask_review.filters.resolution_state'}",
"title": "@:{'phrases.compat.invoicing_period.xlvask_review.filters.title'}",
"unattached_only": "@:{'phrases.compat.invoicing_period.xlvask_review.filters.unattached_only'}"
},
"hide_match": "@:{'phrases.compat.invoicing_period.xlvask_review.hide_match'}",
"model": "@:{'phrases.compat.invoicing_period.xlvask_review.model'}",
"no_safe_match": "@:{'phrases.compat.invoicing_period.xlvask_review.no_safe_match'}",
"planned_action": "@:{'phrases.compat.invoicing_period.xlvask_review.planned_action'}",
"policy_version": "@:{'phrases.compat.invoicing_period.xlvask_review.policy_version'}",
"preview": {
"after": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.after'}",
"applied": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.applied'}",
"apply": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.apply'}",
"before": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.before'}",
"confirmation_label": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.confirmation_label'}",
"confirmation_mismatch": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.confirmation_mismatch'}",
"confirmation_phrase": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.confirmation_phrase'}",
"error_title": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.error_title'}",
"reason_label": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.reason_label'}",
"reason_required": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.reason_required'}",
"title": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.title'}"
},
"resume_status": "@:{'phrases.compat.invoicing_period.xlvask_review.resume_status'}",
"run": "@:{'phrases.compat.invoicing_period.xlvask_review.run'}",
"run_id": "@:{'phrases.compat.invoicing_period.xlvask_review.run_id'}",
"run_phases": {
"circuit_breaker": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.circuit_breaker'}",
"completed": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.completed'}",
"completed_with_warnings": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.completed_with_warnings'}",
"evaluating": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.evaluating'}",
"executing": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.executing'}",
"failed": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.failed'}",
"importing": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.importing'}",
"pending": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.pending'}",
"processing": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.processing'}",
"queued": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.queued'}",
"reconciling": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.reconciling'}",
"retry_wait": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.retry_wait'}",
"running": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.running'}"
},
"run_progress": "@:{'phrases.compat.invoicing_period.xlvask_review.run_progress'}",
"run_start_error": "@:{'phrases.compat.invoicing_period.xlvask_review.run_start_error'}",
"run_status_error": "@:{'phrases.compat.invoicing_period.xlvask_review.run_status_error'}",
"select_record": "@:{'phrases.compat.invoicing_period.xlvask_review.select_record'}",
"show_match": "@:{'phrases.compat.invoicing_period.xlvask_review.show_match'}",
"source_revision": "@:{'phrases.compat.invoicing_period.xlvask_review.source_revision'}",
"states": {
"accepted_attach": "@:{'phrases.compat.invoicing_period.xlvask_review.states.accepted_attach'}",
"accepted_create": "@:{'phrases.compat.invoicing_period.xlvask_review.states.accepted_create'}",
"already_linked": "@:{'phrases.compat.invoicing_period.xlvask_review.states.already_linked'}",
"auto_accepted_attach": "@:{'phrases.compat.invoicing_period.xlvask_review.states.auto_accepted_attach'}",
"auto_accepted_create": "@:{'phrases.compat.invoicing_period.xlvask_review.states.auto_accepted_create'}",
"auto_created": "@:{'phrases.compat.invoicing_period.xlvask_review.states.auto_created'}",
"auto_linked": "@:{'phrases.compat.invoicing_period.xlvask_review.states.auto_linked'}",
"blocked": "@:{'phrases.compat.invoicing_period.xlvask_review.states.blocked'}",
"certain": "@:{'phrases.compat.invoicing_period.xlvask_review.states.certain'}",
"denied": "@:{'phrases.compat.invoicing_period.xlvask_review.states.denied'}",
"failed": "@:{'phrases.compat.invoicing_period.xlvask_review.states.failed'}",
"ignored": "@:{'phrases.compat.invoicing_period.xlvask_review.states.ignored'}",
"invalid": "@:{'phrases.compat.invoicing_period.xlvask_review.states.invalid'}",
"needs_review": "@:{'phrases.compat.invoicing_period.xlvask_review.states.needs_review'}",
"new": "@:{'phrases.compat.invoicing_period.xlvask_review.states.new'}",
"none": "@:{'phrases.compat.invoicing_period.xlvask_review.states.none'}",
"suggested_attach_order": "@:{'phrases.compat.invoicing_period.xlvask_review.states.suggested_attach_order'}",
"suggested_create_order": "@:{'phrases.compat.invoicing_period.xlvask_review.states.suggested_create_order'}",
"uncertain": "@:{'phrases.compat.invoicing_period.xlvask_review.states.uncertain'}",
"unchanged": "@:{'phrases.compat.invoicing_period.xlvask_review.states.unchanged'}",
"updated": "@:{'phrases.compat.invoicing_period.xlvask_review.states.updated'}"
},
"summary_error": "@:{'phrases.compat.invoicing_period.xlvask_review.summary_error'}",
"summary_title": "@:{'phrases.compat.invoicing_period.xlvask_review.summary_title'}",
"labels": {
"attached_to_order": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.attached_to_order'}",
"not_attached_to_order": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.not_attached_to_order'}",
"compare_select_duplicate": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.compare_select_duplicate'}",
"compare_price_mismatch_inline": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.compare_price_mismatch_inline'}",
"wash_id": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.wash_id'}",
"wash_already_withdrawn": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.wash_already_withdrawn'}",
"go_to_order": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.go_to_order'}",
"unknown_product": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.unknown_product'}",
"unknown_product_with_id": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.unknown_product_with_id'}",
"ok": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.ok'}"
},
"time": {
"just_now": "@:{'phrases.compat.invoicing_period.xlvask_review.time.just_now'}",
"ago_one": "@:{'phrases.compat.invoicing_period.xlvask_review.time.ago_one'}",
"ago_other": "@:{'phrases.compat.invoicing_period.xlvask_review.time.ago_other'}",
"units": {
"year_one": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.year_one'}",
"year_other": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.year_other'}",
"month_one": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.month_one'}",
"month_other": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.month_other'}",
"week_one": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.week_one'}",
"week_other": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.week_other'}",
"day_one": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.day_one'}",
"day_other": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.day_other'}",
"hour_one": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.hour_one'}",
"hour_other": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.hour_other'}",
"minute_one": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.minute_one'}",
"minute_other": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.minute_other'}",
"second_one": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.second_one'}",
"second_other": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.second_other'}"
}
},
"errors": {
"fetch_usage_log": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.fetch_usage_log'}",
"fetch_vehicle_types": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.fetch_vehicle_types'}",
"fetch_related_orders": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.fetch_related_orders'}",
"fetch_fast_link": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.fetch_fast_link'}",
"create_order_unrecognized_items": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.create_order_unrecognized_items'}",
"create_order_failed": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.create_order_failed'}",
"create_order_item_failed": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.create_order_item_failed'}",
"redirect_order_failed": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.redirect_order_failed'}",
"load_customers_failed": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.load_customers_failed'}",
"load_usage_log_failed": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.load_usage_log_failed'}"
}
}
}
}
@@ -1,7 +1,7 @@
{
"compat": {
"invoicing_period": {
"xlvask_autopilot": {
"xlvask_review": {
"actions": {
"accept": "Godta forslag",
"attach_order": "Knytt til ordre",
@@ -1,7 +1,7 @@
{
"compat": {
"invoicing_period": {
"xlvask_autopilot": {
"xlvask_review": {
"actions": {
"accept": "Godkänn förslag",
"attach_order": "Koppla order",
@@ -2710,14 +2710,11 @@ const decideXlvaskAutomation = (
: treeText("actions.xlvask.deny.preview", "{count} XL Vask forslag afvises.", { count: rows.length }),
],
async () => {
const endpointSuffix = decision === "deny" ? "reject" : decision;
for (const node of rows) {
await SessionUser.request(
`/modules/xlvask/services/usage/orders/${node.meta.usageId}/automation/${decision}`,
`/modules/xlvask/services/usage/orders/${node.meta.usageId}/${endpointSuffix}`,
"POST",
{
suggestion_id: node.meta.automation?.id ?? null,
reason: `${decision} from invoice period tree`,
}
);
}
},
@@ -51,12 +51,40 @@ const ATTRIBUTE_DISPLAY_PRIORITY: Record<string, number> = {
};
const UNKNOWN_ATTRIBUTE_PRIORITY = 99;
const list_views_with_customer = computed(() => {
const matched = view_keys.value.filter((view_key) => {
// Skip if the view type is "all".
if (view_key === 'all') return false;
// Pre-compute a Set<customer_number> per view bucket so chip membership
// resolution stays O(1) regardless of bucket size. The backend now ships
// lightweight `{customer_number, membership_only}` markers for every
// non-active bucket, so this lookup also drives the category chips shown
// on the Alle tab.
const membershipIndexes = computed<Record<string, Set<number>>>(() => {
const result: Record<string, Set<number>> = {};
for (const view_key of view_keys.value) {
if (view_key === 'all') {
result[view_key] = new Set<number>();
continue;
}
const view_type = sharedTypes.value[view_key];
return view_type && view_type.some((v: any) => v.customer_number === props.customer.customer_number);
const set = new Set<number>();
if (Array.isArray(view_type)) {
for (const entry of view_type) {
const raw = (entry as { customer_number?: number | string } | null)
?.customer_number;
const number = Number(raw);
if (Number.isInteger(number) && number > 0) {
set.add(number);
}
}
}
result[view_key] = set;
}
return result;
});
const list_views_with_customer = computed(() => {
const customerNumber = Number(props.customer.customer_number);
const matched = view_keys.value.filter((view_key) => {
if (view_key === 'all') return false;
return membershipIndexes.value[view_key]?.has(customerNumber) === true;
});
return [...matched].sort((left, right) => {
const leftPriority = ATTRIBUTE_DISPLAY_PRIORITY[left] ?? UNKNOWN_ATTRIBUTE_PRIORITY;
@@ -35,7 +35,11 @@ const getResponsePayload = (response: any = {}) => {
* Fixed pricing distribution data structure:
*/
const fixed_pricing_department_distribution = ref<any[]>([]);
const fixedPricingDistributionLoaded = ref(false);
const fixedPricingDistributionFailed = ref(false);
const fetchFixedPricingDistribution = async () => {
fixedPricingDistributionLoaded.value = false;
fixedPricingDistributionFailed.value = false;
SessionUser.request(
'/superuser/invoicing/period/distribution/fixed-pricing',
'GET',
@@ -47,15 +51,20 @@ const fetchFixedPricingDistribution = async () => {
if (response.data && response.data.includes.collective_fixed_pricing_results) {
fixed_pricing_department_distribution.value = response.data.includes.collective_fixed_pricing_results;
fixedPricingDistributionLoaded.value = true;
fixedPricingDistributionFailed.value = false;
/**
* Fixed pricing distribution data structure:
* { "total_fixed_price": 137962, "total_original_price": 72799, "total_department_totals": { "1": 11826, "2": 22687, "3": 17009, "4": 12457, "5": 1161, "6": 6611, "7": 1048 }, "total_department_totals_relative": { "1": 30962.876996916584, "2": 28790.813863868243, "3": 32997.60337384933, "4": 15442.430865260254, "5": 1407.808993176734, "6": 9521.78041528052, "7": 1010.6854916483431 }, "total_department_totals_parsed": { "Glostrup": 17009, "Taastrup": 22687, "Hvidovre": 11826, "Roskilde": 6611, "Køge": 12457, "Aarhus C": 1161, "Taulov": 1048 }, "total_department_totals_relative_parsed": { "Glostrup": 32997.60337384933, "Taastrup": 28790.813863868243, "Hvidovre": 30962.876996916584, "Roskilde": 9521.78041528052, "Køge": 15442.430865260254, "Aarhus C": 1407.808993176734, "Taulov": 1010.6854916483431 } }
*/
} else {
console.error('Unexpected response structure:', response);
fixedPricingDistributionFailed.value = true;
}
}).catch((error) => {
console.error('Error fetching fixed pricing distribution:', error);
fixedPricingDistributionLoaded.value = true;
fixedPricingDistributionFailed.value = true;
});
}
const clearFixedPricingDistribution = () => {
@@ -134,7 +143,11 @@ watch(
* Subscription display logic:
*/
const vehicleSubscriptionDistribution = ref<any>(null);
const vehicleSubscriptionDistributionLoaded = ref(false);
const vehicleSubscriptionDistributionFailed = ref(false);
const fetchVehicleSubscriptionDistribution = async () => {
vehicleSubscriptionDistributionLoaded.value = false;
vehicleSubscriptionDistributionFailed.value = false;
SessionUser.request(
'/superuser/invoicing/period/distribution/wash-subscriptions',
'GET',
@@ -150,9 +163,12 @@ const fetchVehicleSubscriptionDistribution = async () => {
vehicleSubscriptionDistribution.value = response.data.includes.collective_subscription_results;
} else {
console.error('Unexpected response structure:', response);
vehicleSubscriptionDistributionFailed.value = true;
}
}).catch((error) => {
console.error('Error fetching vehicle subscription distribution:', error);
vehicleSubscriptionDistributionLoaded.value = true;
vehicleSubscriptionDistributionFailed.value = true;
});
}
const clearVehicleSubscriptionDistribution = () => {
@@ -467,7 +467,6 @@ export const makeXlvaskNode = (usage, order = null, options = {}) => {
usageId,
washId,
orderId: toPositiveInteger(order?.id ?? usage?.linked_order_id ?? usage?.order_id),
automation: usage?.automation,
},
};
};
@@ -23,7 +23,6 @@ const selfWashTitle = computed(() => i18n.global.t("nav.self_wash"));
:inherit-period-filters="true"
:load-all-at-once="false"
:highlight-usage-log-id="highlightedUsageLogId"
:automation-workspace="true"
/>
</section>
</template>
@@ -1,90 +0,0 @@
<script setup lang="ts">
import { view } from '../imports/InvoicingBillingPeriodImportView.vue';
import { SessionUser } from '@/components/session/token/SessionUser.vue';
import {ref} from "vue";
import "@/components/displays/superuser/tables/customersTable.vue";
import ColorIndicator from "@/components/displays/buttons/ColorIndicator.vue";
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
const customersWithSubscriptions = ref<Array<{
id: number;
customer_name: string;
customer_number?: number;
display_name?: string;
group_id?: number;
phone?: {
country_code: string | null;
number: string | null;
};
email?: string | null;
created_at?: string;
updated_at?: string;
}>[]>([]);
const onLoad = () => {
// Send a test request
SessionUser.request(
'/superuser/users-with-vehicle-subscriptions',
'GET',
{}
)
.then((response) => {
if (response.status === 200) {
customersWithSubscriptions.value = response.data.data;
} else {
console.error('Failed to fetch vehicles with subscriptions:', response);
}
})
.catch((error) => {
console.error('Error fetching vehicles with subscriptions:', error);
});
};
onLoad();
</script>
<template>
<!--{{ view.variables.sharedVariables.value }}-->
<div class="columns is-multiline is-mobile">
<template v-for="customer in view.variables.sharedVariables.value.types.vehicle_subscriptions" :key="customer.customer_number">
<div class="column is-12">
<WhiteBox class="is-clickable">
<!--{{ customer }}-->
<div class="columns is-vcentered">
<div class="column">
<ColorIndicator
v-bind:color_class="(customer.transactions.length > 0) ? 'has-text-success' : 'has-text-grey'"
v-bind:label="{
text: customer.customer_name,
classes: [],
}"
v-bind:visibility="{
icon: true,
dropdown: false,
}"
/>
</div>
<div class="column is-narrow">
<p>{{customer.transactions.length}} transactions</p>
</div>
</div>
</WhiteBox>
</div>
</template>
</div>
<p>
VA customers with no transactions
</p>
<button class="button is-primary" @click="view.functions.setCurrentView('home')">
Go to Home
</button>
</template>
<style scoped>
</style>
@@ -3,107 +3,64 @@ import PageTitle from "@/components/global/PageTitle.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { ref } from 'vue';
import ConfigurationSubPageWrapper
from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
import ConfigurationSubPageWrapper from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
import ConfigurationSwitch from "@/components/displays/superuser/configuration/ConfigurationSwitch.vue";
import ConfigurationSelect from "@/components/displays/superuser/configuration/ConfigurationSelect.vue";
import ConfigurationSecretKey from "@/components/displays/superuser/configuration/ConfigurationSecretKey.vue";
import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue";
import Swal from "sweetalert2";
import { useI18n } from 'vue-i18n';
import { useI18n } from "vue-i18n";
import { useModuleConfig } from "@/composables/useModuleConfig.js";
const { t } = useI18n();
// Get the department from the route
const module_config = ref([]);
const getModuleConfig = async () => {
await SessionUser.superUser.modules.email.config.get_all().then((response) => {
let tmp_module_config = response.data.data;
let tmp_module_config_array = [];
for (const value of Object.values(tmp_module_config)) {
console.log(`${value.variable}: ${value.value}`);
tmp_module_config_array.push({
variable: value.variable,
value: value.value,
});
}
module_config.value = tmp_module_config_array;
console.log(module_config.value);
}).catch((error) => {
console.log(error);
});
};
const getModuleConfigValue = (variable) => {
const config = module_config.value.find((config) => config.variable === variable);
// Log to the console, what the value was found
if (config) {
console.log(`Found value for ${variable}: ${config.value}`);
} else {
console.log(`No value found for ${variable}`);
}
return config ? config.value : '';
};
const load = async () => {
await getModuleConfig();
};
const { module_config, getModuleConfigValue } = useModuleConfig("email");
const encryptionOptions = [
{ value: 'ssl', label: 'SSL' },
{ value: 'tls', label: 'TLS' },
{ value: 'starttls', label: 'STARTTLS' },
{ value: 'none', label: 'None' },
{ value: "ssl", label: "SSL" },
{ value: "tls", label: "TLS" },
{ value: "starttls", label: "STARTTLS" },
{ value: "none", label: "None" },
];
const showSendTestEmail = async () => {
await Swal.fire({
title: t('configuration.email.send_test_email'),
text: t('configuration.email.send_test_email_prompt'),
input: 'email',
title: t("configuration.email.send_test_email"),
text: t("configuration.email.send_test_email_prompt"),
input: "email",
inputAttributes: {
autocapitalize: 'off'
autocapitalize: "off",
},
showCancelButton: true,
confirmButtonText: t('configuration.email.send'),
confirmButtonText: t("configuration.email.send"),
showLoaderOnConfirm: true,
preConfirm: (email) => {
return SessionUser.superUser.modules.email.sendTestEmail(email)
.then(() => {
Swal.fire({
title: t('configuration.email.test_email_sent'),
text: t('configuration.email.test_email_sent_success'),
icon: 'success',
});
})
.catch(() => {
Swal.fire({
title: t('common.error'),
text: t('configuration.email.test_email_error'),
icon: 'error',
});
return SessionUser.superUser.modules.email
.sendTestEmail(email)
.then(() => {
Swal.fire({
title: t("configuration.email.test_email_sent"),
text: t("configuration.email.test_email_sent_success"),
icon: "success",
});
})
.catch(() => {
Swal.fire({
title: t("common.error"),
text: t("configuration.email.test_email_error"),
icon: "error",
});
});
},
allowOutsideClick: () => !Swal.isLoading()
allowOutsideClick: () => !Swal.isLoading(),
});
};
load();
</script>
<template>
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
<ConfigurationSubPageWrapper>
<template #title>
<PageTitle :title="$t('configuration.email.title')" :subtitle="$t('configuration.email.subtitle')"/>
<PageTitle :title="$t('configuration.email.title')" :subtitle="$t('configuration.email.subtitle')" />
</template>
<template v-if="module_config.length > 0" #content>
<ConfigurationCategory
@@ -113,16 +70,18 @@ load();
:description="$t('configuration.email.general_settings_desc')"
icon="fas fa-cogs"
>
<ConfigurationSwitch
class="mt-2"
module="Email"
:title="$t('configuration.email.enable_system')"
:description="$t('configuration.email.enable_system_desc')"
icon="fas fa-cogs"
:value="getModuleConfigValue('enabled') === true"
:on-switch="SessionUser.superUser.modules.email.config.enabled.set"
/>
<button class="button is-dark mt-2" @click="showSendTestEmail()">{{ $t('configuration.email.send_test_email') }}</button>
<ConfigurationSwitch
class="mt-2"
module="Email"
:title="$t('configuration.email.enable_system')"
:description="$t('configuration.email.enable_system_desc')"
icon="fas fa-cogs"
:value="getModuleConfigValue('enabled') === true"
:on-switch="SessionUser.superUser.modules.email.config.enabled.set"
/>
<button class="button is-dark mt-2" @click="showSendTestEmail()">
{{ $t("configuration.email.send_test_email") }}
</button>
</ConfigurationCategory>
<!-- Sender identity -->
<ConfigurationCategory
@@ -133,40 +92,40 @@ load();
icon="fas fa-cogs"
>
<ConfigurationInput
class="mt-2"
module="Email"
:title="$t('configuration.email.from_email')"
:description="$t('configuration.email.from_email_desc')"
icon="fas fa-cogs"
:value="getModuleConfigValue('smtp_from')"
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_from.set"
class="mt-2"
module="Email"
:title="$t('configuration.email.from_email')"
:description="$t('configuration.email.from_email_desc')"
icon="fas fa-cogs"
:value="getModuleConfigValue('smtp_from')"
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_from.set"
/>
<ConfigurationInput
class="mt-2"
module="Email"
:title="$t('configuration.email.from_name')"
:description="$t('configuration.email.from_name_desc')"
icon="fas fa-cogs"
:value="getModuleConfigValue('smtp_from_name')"
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_from_name.set"
class="mt-2"
module="Email"
:title="$t('configuration.email.from_name')"
:description="$t('configuration.email.from_name_desc')"
icon="fas fa-cogs"
:value="getModuleConfigValue('smtp_from_name')"
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_from_name.set"
/>
<ConfigurationInput
class="mt-2"
module="Email"
:title="$t('configuration.email.reply_to_email')"
:description="$t('configuration.email.reply_to_email_desc')"
icon="fas fa-cogs"
:value="getModuleConfigValue('smtp_reply_to')"
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_reply_to.set"
class="mt-2"
module="Email"
:title="$t('configuration.email.reply_to_email')"
:description="$t('configuration.email.reply_to_email_desc')"
icon="fas fa-cogs"
:value="getModuleConfigValue('smtp_reply_to')"
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_reply_to.set"
/>
<ConfigurationInput
class="mt-2"
module="Email"
:title="$t('configuration.email.reply_to_name')"
:description="$t('configuration.email.reply_to_name_desc')"
icon="fas fa-cogs"
:value="getModuleConfigValue('smtp_reply_to_name')"
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_reply_to_name.set"
class="mt-2"
module="Email"
:title="$t('configuration.email.reply_to_name')"
:description="$t('configuration.email.reply_to_name_desc')"
icon="fas fa-cogs"
:value="getModuleConfigValue('smtp_reply_to_name')"
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_reply_to_name.set"
/>
</ConfigurationCategory>
<ConfigurationCategory
@@ -224,7 +183,6 @@ load();
:value="getModuleConfigValue('smtp_encryption')"
:on-select="SessionUser.superUser.modules.email.config.keys.smtp_encryption.set"
/>
</ConfigurationCategory>
<ConfigurationCategory
class="mt-2"
@@ -257,6 +215,4 @@ load();
</RestrictedPageWrapper>
</template>
<style scoped>
</style>
<style scoped></style>
@@ -3,57 +3,14 @@ import PageTitle from "@/components/global/PageTitle.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { ref } from 'vue';
import ConfigurationSubPageWrapper
from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
import ConfigurationSubPageWrapper from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
import ConfigurationSwitch from "@/components/displays/superuser/configuration/ConfigurationSwitch.vue";
import ConfigurationSecretKey from "@/components/displays/superuser/configuration/ConfigurationSecretKey.vue";
import { useModuleConfig } from "@/composables/useModuleConfig.js";
// Get the department from the route
const module_config = ref([]);
const getModuleConfig = async () => {
await SessionUser.superUser.modules.openai.config.get_all().then((response) => {
let tmp_module_config = response.data.data;
let tmp_module_config_array = [];
for (const value of Object.values(tmp_module_config)) {
console.log(`${value.variable}: ${value.value}`);
tmp_module_config_array.push({
variable: value.variable,
value: value.value,
});
}
module_config.value = tmp_module_config_array;
console.log(module_config.value);
}).catch((error) => {
console.log(error);
});
};
const getModuleConfigValue = (variable) => {
const config = module_config.value.find((config) => config.variable === variable);
// Log to the console, what the value was found
if (config) {
console.log(`Found value for ${variable}: ${config.value}`);
} else {
console.log(`No value found for ${variable}`);
}
return config ? config.value : '';
};
const load = async () => {
await getModuleConfig();
};
load();
const { module_config, getModuleConfigValue } = useModuleConfig("openai");
</script>
<template>
@@ -102,6 +59,4 @@ load();
</RestrictedPageWrapper>
</template>
<style scoped>
</style>
<style scoped></style>
@@ -3,10 +3,7 @@ import PageTitle from "@/components/global/PageTitle.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { ref } from 'vue';
import ConfigurationSubPageWrapper
from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
import ConfigurationSubPageWrapper from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
import ConfigurationSwitch from "@/components/displays/superuser/configuration/ConfigurationSwitch.vue";
import "@/components/displays/superuser/configuration/ConfigurationSelect.vue";
@@ -14,95 +11,64 @@ import ConfigurationSecretKey from "@/components/displays/superuser/configuratio
import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue";
import ConfigurationInputNumber from "@/components/displays/superuser/configuration/ConfigurationInputNumber.vue";
import Swal from "sweetalert2";
import { useI18n } from 'vue-i18n';
import { useI18n } from "vue-i18n";
import { useModuleConfig } from "@/composables/useModuleConfig.js";
const { t } = useI18n();
// Get the department from the route
const module_config = ref([]);
const getModuleConfig = async () => {
await SessionUser.superUser.modules.stripe.config.get_all().then((response) => {
let tmp_module_config = response.data.data;
let tmp_module_config_array = [];
for (const value of Object.values(tmp_module_config)) {
console.log(`${value.variable}: ${value.value}`);
tmp_module_config_array.push({
variable: value.variable,
value: value.value,
});
}
module_config.value = tmp_module_config_array;
console.log(module_config.value);
}).catch((error) => {
console.log(error);
});
};
const getModuleConfigValue = (variable) => {
const config = module_config.value.find((config) => config.variable === variable);
// Log to the console, what the value was found
if (config) {
console.log(`Found value for ${variable}: ${config.value}`);
} else {
console.log(`No value found for ${variable}`);
}
return config ? config.value : '';
};
const load = async () => {
await getModuleConfig();
};
load();
const { module_config, getModuleConfigValue } = useModuleConfig("stripe");
const showGetCustomers = async () => {
await SessionUser.superUser.modules.stripe.functions.customers.list().then((response) => {
console.log('Stripe customers: ', response.data.data);
Swal.fire({
title: t('configuration.stripe.customers'),
html: '<pre>' + JSON.stringify(response.data.data, null, 2) + '</pre>',
showCloseButton: true,
showCancelButton: false,
focusConfirm: false,
await SessionUser.superUser.modules.stripe.functions.customers
.list()
.then((response) => {
console.log("Stripe customers: ", response.data.data);
Swal.fire({
title: t("configuration.stripe.customers"),
html: "<pre>" + JSON.stringify(response.data.data, null, 2) + "</pre>",
showCloseButton: true,
showCancelButton: false,
focusConfirm: false,
});
})
.catch((error) => {
console.log(error);
});
}).catch((error) => {
console.log(error);
});
};
const showGetProducts = async () => {
await SessionUser.superUser.modules.stripe.functions.products.list().then((response) => {
console.log('Stripe products: ', response.data.data);
Swal.fire({
title: t('configuration.stripe.products'),
html: '<pre>' + JSON.stringify(response.data.data, null, 2) + '</pre>',
showCloseButton: true,
showCancelButton: false,
focusConfirm: false,
await SessionUser.superUser.modules.stripe.functions.products
.list()
.then((response) => {
console.log("Stripe products: ", response.data.data);
Swal.fire({
title: t("configuration.stripe.products"),
html: "<pre>" + JSON.stringify(response.data.data, null, 2) + "</pre>",
showCloseButton: true,
showCancelButton: false,
focusConfirm: false,
});
})
.catch((error) => {
console.log(error);
});
}).catch((error) => {
console.log(error);
});
};
const showGetPrices = async () => {
await SessionUser.superUser.modules.stripe.functions.prices.list().then((response) => {
console.log('Stripe prices: ', response.data.data);
Swal.fire({
title: t('configuration.stripe.prices'),
html: '<pre>' + JSON.stringify(response.data.data, null, 2) + '</pre>',
showCloseButton: true,
showCancelButton: false,
focusConfirm: false,
await SessionUser.superUser.modules.stripe.functions.prices
.list()
.then((response) => {
console.log("Stripe prices: ", response.data.data);
Swal.fire({
title: t("configuration.stripe.prices"),
html: "<pre>" + JSON.stringify(response.data.data, null, 2) + "</pre>",
showCloseButton: true,
showCancelButton: false,
focusConfirm: false,
});
})
.catch((error) => {
console.log(error);
});
}).catch((error) => {
console.log(error);
});
};
</script>
@@ -120,15 +86,15 @@ const showGetPrices = async () => {
:description="$t('configuration.stripe.general_settings_desc')"
icon="fas fa-cogs"
>
<ConfigurationSwitch
class="mt-2"
module="Stripe"
:title="$t('configuration.stripe.enable')"
:description="$t('configuration.stripe.enable_desc')"
icon="fas fa-cogs"
:value="getModuleConfigValue('enabled') === true"
:on-switch="SessionUser.superUser.modules.stripe.config.enabled.set"
/>
<ConfigurationSwitch
class="mt-2"
module="Stripe"
:title="$t('configuration.stripe.enable')"
:description="$t('configuration.stripe.enable_desc')"
icon="fas fa-cogs"
:value="getModuleConfigValue('enabled') === true"
:on-switch="SessionUser.superUser.modules.stripe.config.enabled.set"
/>
</ConfigurationCategory>
<ConfigurationCategory
class="mt-2"
@@ -177,25 +143,23 @@ const showGetPrices = async () => {
<span class="icon">
<i class="fas fa-users"></i>
</span>
<span>{{ $t('configuration.stripe.get_customers') }}</span>
<span>{{ $t("configuration.stripe.get_customers") }}</span>
</button>
<button class="button is-dark" @click="showGetProducts">
<span class="icon">
<i class="fas fa-box"></i>
</span>
<span>{{ $t('configuration.stripe.get_products') }}</span>
<span>{{ $t("configuration.stripe.get_products") }}</span>
</button>
<button class="button is-dark" @click="showGetPrices">
<span class="icon">
<i class="fas fa-money-bill-wave"></i>
</span>
<span>{{ $t('configuration.stripe.get_prices') }}</span>
<span>{{ $t("configuration.stripe.get_prices") }}</span>
</button>
</template>
</ConfigurationSubPageWrapper>
</RestrictedPageWrapper>
</template>
<style scoped>
</style>
<style scoped></style>
@@ -83,152 +83,8 @@ const onClickTestConnection = async () => {
})
}
// --- MiniMax re-authenticate / remove ---------------------------------------
const minimaxApiKeyIsSet = ref(false);
const minimaxEnabled = ref(false);
const reauthenticatingMiniMax = ref(false);
const removingMiniMax = ref(false);
const togglingMiniMaxEnabled = ref(false);
/**
* The backend returns the get-config response as an array (one entry per
* module variable). Extract the single entry we asked for, then trust the
* explicit `isSet` flag instead of the redacted `value`.
*/
const extractConfigEntry = (response) => {
const payload = response?.data?.data;
if (Array.isArray(payload)) {
return payload[0] ?? null;
}
return payload ?? null;
};
const refreshMiniMaxApiKeyStatus = async () => {
try {
const response = await SessionUser.superUser.modules.minimax.config.keys.api_key.get();
const entry = extractConfigEntry(response);
minimaxApiKeyIsSet.value = entry?.isSet === true;
} catch (error) {
// If the endpoint is unreachable or the key isn't set yet, treat as not-set.
minimaxApiKeyIsSet.value = false;
}
};
const refreshMiniMaxEnabled = async () => {
try {
const response = await SessionUser.superUser.modules.minimax.config.enabled.get();
const entry = extractConfigEntry(response);
const raw = entry?.value;
minimaxEnabled.value = raw === true || raw === 'true' || raw === '1' || raw === 1;
} catch (error) {
minimaxEnabled.value = false;
}
};
const onClickReauthenticateMiniMax = async () => {
const { value: apiKey } = await Swal.fire({
title: t('configuration.xlvask.minimax_reauth_title'),
text: t('configuration.xlvask.minimax_reauth_desc'),
input: 'password',
inputAttributes: { autocomplete: 'off', autocapitalize: 'off', spellcheck: 'false' },
inputPlaceholder: t('configuration.xlvask.minimax_api_key_placeholder'),
showCancelButton: true,
confirmButtonText: t('configuration.xlvask.minimax_reauth_confirm'),
cancelButtonText: t('common.cancel'),
preConfirm: (val) => {
if (!val || String(val).trim() === '') {
Swal.showValidationMessage(t('configuration.xlvask.minimax_api_key_required'));
return false;
}
return String(val).trim();
},
});
if (!apiKey) return;
reauthenticatingMiniMax.value = true;
try {
await SessionUser.superUser.modules.minimax.config.keys.api_key.set(apiKey);
// Re-fetch from the backend so the UI matches actual persistence (and so a
// silent failure surfaces as "still not set" instead of a misleading green
// checkmark).
await refreshMiniMaxApiKeyStatus();
Swal.fire({
icon: 'success',
title: t('configuration.xlvask.minimax_reauth_success'),
text: t('configuration.xlvask.minimax_reauth_success_desc'),
});
} catch (error) {
Swal.fire({
icon: 'error',
title: t('configuration.xlvask.minimax_reauth_failed'),
text: error?.message ?? String(error),
});
} finally {
reauthenticatingMiniMax.value = false;
}
};
const onClickRemoveMiniMax = async () => {
const confirmation = await Swal.fire({
title: t('configuration.xlvask.minimax_remove_title'),
text: t('configuration.xlvask.minimax_remove_desc'),
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#d33',
confirmButtonText: t('configuration.xlvask.minimax_remove_confirm'),
cancelButtonText: t('common.cancel'),
});
if (!confirmation.isConfirmed) return;
removingMiniMax.value = true;
try {
await SessionUser.superUser.modules.minimax.config.keys.api_key.set('');
await refreshMiniMaxApiKeyStatus();
Swal.fire({
icon: 'success',
title: t('configuration.xlvask.minimax_remove_success'),
text: t('configuration.xlvask.minimax_remove_success_desc'),
});
} catch (error) {
Swal.fire({
icon: 'error',
title: t('configuration.xlvask.minimax_remove_failed'),
text: error?.message ?? String(error),
});
} finally {
removingMiniMax.value = false;
}
};
/**
* The ConfigurationSecretKey inline-edit flow calls onSave and resolves on
* success. Refresh the parent state from the API so the "Hidden" view replaces
* the warning as soon as the request actually persists.
*/
const onMiniMaxApiKeySaved = async () => {
await refreshMiniMaxApiKeyStatus();
};
/**
* The MiniMax "Enable" toggle calls on-switch with the next boolean value.
* Re-fetch after the save so the UI reflects persisted state (the inline
* `set()` call does not refresh the UI on its own).
*/
const onMiniMaxEnabledSwitch = async (nextValue) => {
togglingMiniMaxEnabled.value = true;
try {
await SessionUser.superUser.modules.minimax.config.enabled.set(nextValue);
await refreshMiniMaxEnabled();
} catch (error) {
// Roll back the optimistic UI flip on failure.
minimaxEnabled.value = !nextValue;
throw error;
} finally {
togglingMiniMaxEnabled.value = false;
}
};
const load = async () => {
await getModuleConfig();
await Promise.all([refreshMiniMaxApiKeyStatus(), refreshMiniMaxEnabled()]);
};
load();
@@ -301,84 +157,9 @@ load();
:description="$t('configuration.xlvask.automation_settings_desc')"
icon="fas fa-magic"
>
<ConfigurationSwitch
class="mt-2"
module="XLVask"
:title="$t('configuration.xlvask.enable_automatic_attachment')"
:description="$t('configuration.xlvask.enable_automatic_attachment_desc')"
icon="fas fa-link"
:value="getModuleConfigValue('automatic_order_attachment_enabled') === true"
:on-switch="SessionUser.superUser.modules.xlvask.config.keys.automatic_order_attachment_enabled.set"
/>
<ConfigurationSwitch
class="mt-2"
module="XLVask"
:title="$t('configuration.xlvask.enable_automatic_creation')"
:description="$t('configuration.xlvask.enable_automatic_creation_desc')"
icon="fas fa-plus-circle"
:value="getModuleConfigValue('automatic_order_creation_enabled') === true"
:on-switch="SessionUser.superUser.modules.xlvask.config.keys.automatic_order_creation_enabled.set"
/>
<ConfigurationSwitch
class="mt-2"
module="XLVask"
:title="$t('configuration.xlvask.enable_openai_integration')"
:description="$t('configuration.xlvask.enable_openai_integration_desc')"
icon="fas fa-brain"
:value="getModuleConfigValue('openai_integration_enabled') === true"
:on-switch="SessionUser.superUser.modules.xlvask.config.keys.openai_integration_enabled.set"
/>
<ConfigurationSwitch
class="mt-2"
module="XLVask"
:title="$t('configuration.xlvask.enable_minimax_integration')"
:description="$t('configuration.xlvask.enable_minimax_integration_desc')"
icon="fas fa-robot"
:value="getModuleConfigValue('minimax_integration_enabled') === true"
:on-switch="SessionUser.superUser.modules.xlvask.config.keys.minimax_integration_enabled.set"
/>
</ConfigurationCategory>
<ConfigurationCategory
class="mt-2"
module="XLVask"
:title="$t('configuration.xlvask.minimax_settings')"
:description="$t('configuration.xlvask.minimax_settings_desc')"
icon="fas fa-robot"
>
<ConfigurationSwitch
class="mt-2"
module="XLVask"
:title="$t('configuration.xlvask.minimax_enable')"
:description="$t('configuration.xlvask.minimax_enable_desc')"
icon="fas fa-robot"
:value="minimaxEnabled"
:on-switch="onMiniMaxEnabledSwitch"
:disabled="togglingMiniMaxEnabled"
/>
<ConfigurationSecretKey
class="mt-2"
module="XLVask"
:title="$t('configuration.xlvask.minimax_api_key')"
:description="$t('configuration.xlvask.minimax_api_key_desc')"
icon="fas fa-key"
:isSet="minimaxApiKeyIsSet"
:on-save="SessionUser.superUser.modules.minimax.config.keys.api_key.set"
@saved="onMiniMaxApiKeySaved"
/>
<div class="buttons mt-2">
<button class="button is-warning" @click="onClickReauthenticateMiniMax" :disabled="reauthenticatingMiniMax">
<span class="icon">
<i class="fas fa-redo"></i>
</span>
<span>{{ reauthenticatingMiniMax ? $t('configuration.xlvask.minimax_reauth_in_progress') : $t('configuration.xlvask.minimax_reauthenticate') }}</span>
</button>
<button class="button is-danger ml-2" @click="onClickRemoveMiniMax" :disabled="removingMiniMax">
<span class="icon">
<i class="fas fa-trash"></i>
</span>
<span>{{ removingMiniMax ? $t('configuration.xlvask.minimax_remove_in_progress') : $t('configuration.xlvask.minimax_remove') }}</span>
</button>
</div>
<p class="is-size-7 has-text-grey">
{{ $t('configuration.xlvask.automation_settings_removed_desc') }}
</p>
</ConfigurationCategory>
<div class="buttons">
<button class="button is-dark" @click="onClickTestConnection">
@@ -1,849 +0,0 @@
<script setup>
import { onMounted, ref, watch } from 'vue';
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import ColorIndicator from "@/components/displays/buttons/ColorIndicator.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
import { createOrderItem } from "@/components/shop/OrdersItems.vue";
import Swal from "sweetalert2";
import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSettingsWheelItemLabel.vue";
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
reg: {
type: String,
default: null,
},
dateFrom: {
type: Date,
default: () => null,
},
});
/** Dynamic variables */
const usageLog = ref(null);
const usageLogLoading = ref(false);
const xlvask_vehicle_types = ref(null);
const product_options = ref(null);
const departments = ref(null);
const related_orders = ref(null);
const getUsageLog = async () => {
usageLogLoading.value = true;
usageLog.value = null;
SessionUser.superUser.modules.xlvask.functions.getUsageLog(
(props.dateFrom === null ? null : SessionUser.superUser.modules.xlvask.functions.convertDateTimeToISO(props.dateFrom)),
props.reg,
).then(
(response) => {
if (response.status === 200) {
usageLog.value = response.data.data;
} else {
console.error("XL-Vask: usage log returned non-OK status.", { status: response?.status, reg: props.reg });
Swal.fire({
icon: 'error',
title: t('invoicing_period.xlvask_autopilot.errors.fetch_usage_log'),
});
}
}
).catch(
(error) => {
console.error("XL-Vask: failed to load usage log.", { reg: props.reg, error });
Swal.fire({
icon: 'error',
title: t('invoicing_period.xlvask_autopilot.errors.fetch_usage_log'),
});
}
).finally(() => {
usageLogLoading.value = false;
});
};
const getVehicleTypes = async () => {
xlvask_vehicle_types.value = null;
SessionUser.superUser.modules.xlvask.functions.getVehicleTypes().then(
(response) => {
if (response.status === 200) {
xlvask_vehicle_types.value = response.data.data;
} else {
console.error("XL-Vask: vehicle types returned non-OK status.", { status: response?.status });
Swal.fire({
icon: 'error',
title: t('invoicing_period.xlvask_autopilot.errors.fetch_vehicle_types'),
});
}
}
).catch(
(error) => {
console.error("XL-Vask: failed to load vehicle types.", { error });
Swal.fire({
icon: 'error',
title: t('invoicing_period.xlvask_autopilot.errors.fetch_vehicle_types'),
});
}
);
};
const getProductOptions = async () => {
if (product_options.value === null) {
SessionUser.objects.product_options.get.all().then((result) => {
product_options.value = result;
});
}
}
const getDepartments = async () => {
SessionUser.objects.departments.get.all().then((result) => {
departments.value = result;
});
}
const getRelatedOrders = async () => {
related_orders.value = null;
SessionUser.superUser.modules.xlvask.functions.getRelatedOrders(listWashIds()).then(
(response) => {
if (response.status === 200) {
related_orders.value = response.data.data;
} else {
console.error("XL-Vask: related orders returned non-OK status.", { status: response?.status });
Swal.fire({
icon: 'error',
title: t('invoicing_period.xlvask_autopilot.errors.fetch_related_orders'),
});
}
}
).catch(
(error) => {
console.error("XL-Vask: failed to load related orders.", { error });
Swal.fire({
icon: 'error',
title: t('invoicing_period.xlvask_autopilot.errors.fetch_related_orders'),
});
}
);
};
// Watch for changes in the usage log and update the related orders
watch(usageLog, (newValue) => {
if (newValue) {
getRelatedOrders();
}
});
const listWashIds = () => {
let washIds = [];
if (usageLog.value) {
for (const usage of usageLog.value) {
washIds.push(usage.WashId);
}
}
return washIds;
}
const _example = {
"WashId": "892ae789-aeea-4bda-9374-cf931290aefd",
"CustomerId": "59440200",
"Customer": "DITOBUS EXCURSIONS A/S",
"VatNumber": "31171520",
"Location": "Hvidovre",
"Hall": "Hvidovre_1",
"HallId": "845d29a1-a7d2-4e3b-bbc3-2b13242d744a",
"StartTime": "2024-01-26T14:46:53.067",
"FinishTime": "2024-01-26T14:54:08.653",
"RegistrationNumber": "BJ22227",
"VehicleType": "Bus/autocamper, M",
"IdentificationType": "LPR",
"IdentificationId": "BJ22227",
"Info": "BJ22227",
"Updated": "",
"Prepaid": false,
"FinishStatus": 1,
"CustomerGuid": "21ba156a-b2d2-44be-8398-4b67d66003d6",
"VehicleId": "0584ef66-3deb-491e-9b82-0a28cfc20e9e",
"WashItems": [
{
"WashItemId": "399b25c1-5c03-4f25-b5e9-00731e9b94c4",
"ExternalProductId": null,
"ExternalProductName": null,
"OriginalProductName": "Ikke HT dysebom bag",
"Unit": "stk",
"UnitPrice": 0,
"Count": 1,
"Discount": 65,
"PriceExVat": 0,
"Vat": 0,
"PriceIncVat": 0
},
{
"WashItemId": "ef3ca812-bbe5-4cf3-916f-06fd17c04225",
"ExternalProductId": null,
"ExternalProductName": "Spot Free",
"OriginalProductName": "Skylning med RO",
"Unit": "stk",
"UnitPrice": 35,
"Count": 1,
"Discount": 65,
"PriceExVat": 35,
"Vat": 3.06,
"PriceIncVat": 15.31
},
{
"WashItemId": "5c4ec0d9-cffc-45eb-9213-406dbcb8c975",
"ExternalProductId": null,
"ExternalProductName": null,
"OriginalProductName": "2-børstevask",
"Unit": "stk",
"UnitPrice": 0,
"Count": 1,
"Discount": 65,
"PriceExVat": 0,
"Vat": 0,
"PriceIncVat": 0
},
{
"WashItemId": "f7bd7404-f5a3-4a59-8e85-53b284df3260",
"ExternalProductId": null,
"ExternalProductName": null,
"OriginalProductName": "Halleje",
"Unit": "min",
"UnitPrice": 0,
"Count": 1,
"Discount": 65,
"PriceExVat": 0,
"Vat": 0,
"PriceIncVat": 0
},
{
"WashItemId": "6dba6c24-8191-4c2b-913c-7366518fb41d",
"ExternalProductId": null,
"ExternalProductName": null,
"OriginalProductName": "Stor bil",
"Unit": "stk",
"UnitPrice": 559,
"Count": 1,
"Discount": 65,
"PriceExVat": 559,
"Vat": 48.91,
"PriceIncVat": 244.56
},
{
"WashItemId": "61d11d87-2ee4-4a4d-890b-a0870ae1a924",
"ExternalProductId": null,
"ExternalProductName": null,
"OriginalProductName": "HT sider",
"Unit": "stk",
"UnitPrice": 0,
"Count": 1,
"Discount": 65,
"PriceExVat": 0,
"Vat": 0,
"PriceIncVat": 0
},
{
"WashItemId": "ec6eb1c4-b58c-457f-8224-b674cf41dc29",
"ExternalProductId": null,
"ExternalProductName": null,
"OriginalProductName": "EU spejl",
"Unit": "stk",
"UnitPrice": 0,
"Count": 1,
"Discount": 65,
"PriceExVat": 0,
"Vat": 0,
"PriceIncVat": 0
},
{
"WashItemId": "54b4deec-be3e-4cb2-b68d-b9af9cff6fcf",
"ExternalProductId": null,
"ExternalProductName": null,
"OriginalProductName": "HT chassis",
"Unit": "stk",
"UnitPrice": 0,
"Count": 1,
"Discount": 65,
"PriceExVat": 0,
"Vat": 0,
"PriceIncVat": 0
}
]
}
const _exampleVehicleTypes = [
{
"id": 1,
"vehicleTypeId": "0f915576-587c-4494-bcce-388b3b3fe55a",
"product": 17,
"name": "Bus/autocamper, M",
"created_at": "2025-05-19 09:41:20",
"updated_at": "2025-05-19 09:41:20"
},
{
"id": 2,
"vehicleTypeId": "e2638c21-366d-4b7f-b0af-eb3634ae2c8c",
"product": 15,
"name": "Kassevogn/Varevogn, L",
"created_at": "2025-05-19 09:44:39",
"updated_at": "2025-05-19 09:44:39"
},
{
"id": 3,
"vehicleTypeId": "5e6cfa13-df14-4a11-8d3d-603a41d37f68",
"product": 17,
"name": "Bus/autocamper, L",
"created_at": "2025-05-19 10:12:54",
"updated_at": "2025-05-19 10:12:54"
}
];
onMounted(() => {
getVehicleTypes();
getUsageLog();
getProductOptions();
getDepartments();
});
const getProductName = (item) => {
if (item.ExternalProductName) {
return item.ExternalProductName;
} else if (item.OriginalProductName) {
return item.OriginalProductName;
} else {
return t('invoicing_period.xlvask_autopilot.labels.unknown_product_with_id', { id: item.WashItemId });
}
};
const formatDate = (value) => {
if (!value) return '';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
};
const getOrderFromUsageLogEntry = (usageLogEntry) => {
return {
customer_id: usageLogEntry.CustomerId,
reg_1: usageLogEntry.RegistrationNumber,
reg_2: "",
reg_3: "",
wash_id: usageLogEntry.WashId,
}
}
const getPrimaryServiceProductId = (usageLogEntry) => {
// Check if the vehicle type is in the list
const vehicleType = xlvask_vehicle_types.value.find(type => type.name === usageLogEntry.VehicleType);
if (vehicleType) {
// If the vehicle type is found, return the product ID
return vehicleType.product;
} else {
// If the vehicle type is not found, return null
return null;
}
}
const recognizedItems = {
"Undervognskylning": {
getProductId: (usageLogEntry) => {
let primaryProductId = getPrimaryServiceProductId(usageLogEntry);
// Get all the matching options where the product id is the same as the primary product id
let options = product_options.value.filter(option => option.product_id === primaryProductId);
// Check if any options have the option_id 21
let option = options.find(option => option.option_id === 21);
if (option) {
// If the option is found, return the option_id (21)
return option.option_id;
} else {
// If the option is not found, return null
return null;
}
}
},
// Spot Free
"Skylning med RO": {
getProductId: (usageLogEntry) => {
let primaryProductId = getPrimaryServiceProductId(usageLogEntry);
// Get all the matching options where the product id is the same as the primary product id
let options = product_options.value.filter(option => option.product_id === primaryProductId);
// Check if any options have the option_id 23, or 24
let option = options.find(option => option.option_id === 23 || option.option_id === 24);
if (option) {
// If the option is found, return the option_id (23 or 24)
return option.option_id;
} else {
// If the option is not found, return null
return null;
}
}
},
// Primary services
"Stor bil": {
getProductId: (usageLogEntry) => {
// Check if the vehicle type is in the list
return getPrimaryServiceProductId(usageLogEntry);
}
},
"Lille bil": {
getProductId: (usageLogEntry) => {
// Check if the vehicle type is in the list
return getPrimaryServiceProductId(usageLogEntry);
}
},
}
const round_price_down = (price) => {
// Round the price down to the nearest whole number
return Math.floor(price);
}
const getOrderItemsFromUsageLogEntry = (usageLogEntry) => {
let items = [];
let unrecognizedItems = [];
// Filter out all the free items
const filteredItems = usageLogEntry.WashItems.filter(item => item.PriceIncVat > 0);
// If the "Stor bil" item is present, set it to be the first item
const storBilIndex = filteredItems.findIndex(item => item.OriginalProductName === "Stor bil" || item.OriginalProductName === "Lille bil");
if (storBilIndex > -1) {
const storBilItem = filteredItems.splice(storBilIndex, 1)[0];
filteredItems.unshift(storBilItem);
}
// Loop through the filtered items
for (const item of filteredItems) {
// Check if the item is recognized
if (recognizedItems[item.OriginalProductName]) {
items.push({
product_id: recognizedItems[item.OriginalProductName].getProductId(usageLogEntry),
quantity: item.Count,
discount_percentage: item.Discount,
price: {
unit: round_price_down(item.UnitPrice), // Before discount
each: round_price_down( item.UnitPrice - (item.UnitPrice * item.Discount / 100) ), // One item after discount
total: round_price_down( (item.UnitPrice * item.Count) - (item.UnitPrice * item.Count * item.Discount / 100) ), // Total price after discount x quantity
}
});
} else {
// If the item is not recognized, add it to the unrecognized items
unrecognizedItems.push(item);
}
}
// Calculate the total price
let price = {
total: 0,
}
for (const item of items) {
price.total += item.price.total;
}
return { items, unrecognizedItems, price };
}
const onClickCreateOrder = async (usageLogEntry) => {
// Check if the order can be created
if (!canCreateOrder(usageLogEntry)) {
console.warn("XL-Vask: cannot create order from usage log entry.", {
washId: usageLogEntry?.WashId,
hasUnrecognizedItems: hasUnrecognizedItems(usageLogEntry),
hasUnrecognizedDepartment: hasUnrecognizedDepartment(usageLogEntry),
hasRelatedOrder: hasRelatedOrder(usageLogEntry),
});
Swal.fire({
icon: 'warning',
title: t('invoicing_period.xlvask_autopilot.errors.create_order_unrecognized_items'),
});
return;
}
let order = getOrderFromUsageLogEntry(usageLogEntry);
let orderItems = getOrderItemsFromUsageLogEntry(usageLogEntry);
// Create the order
let order_props = {
customer_id: parseInt(order.customer_id),
cashier_id: null,
department_id: getDepartmentId(usageLogEntry),
reference: null,
reg_1: order.reg_1,
reg_2: order.reg_2,
reg_3: order.reg_3,
notes: null,
invoice_collection_id: null,
}
await SessionUser.objects.orders
.add(
order_props.customer_id,
order_props.cashier_id,
order_props.department_id,
order_props.reference,
order_props.reg_1,
order_props.reg_2,
order_props.reg_3,
order_props.notes,
order_props.invoice_collection_id,
).then(
async (response) => {
if (response.status === 200) {
let orderId = parseInt(response.data.data.id);
// Add the wash id to the order
await SessionUser.objects.orders.set.wash_id(orderId, usageLogEntry.WashId);
// Set the time created to the start time
await SessionUser.objects.orders.set.created_at(orderId, SessionUser.functions.date.format(SessionUser.superUser.modules.xlvask.functions.convertISODateTimeToDate(usageLogEntry.StartTime)));
let relational_id = null;
let itemFailure = false;
// Add the order items to the order
for (const item of orderItems.items) {
await createOrderItem(
orderId,
item.product_id,
item.quantity,
relational_id,
null,
item.price.each
).then(
(response) => {
if (response.status === 200) {
// If the relational_id is null, set it to the order item id
if (relational_id === null) {
relational_id = parseInt(response.data.data.id);
}
} else {
itemFailure = true;
console.error("XL-Vask: order item returned non-OK status.", {
orderId,
productId: item?.product_id,
status: response?.status,
});
Swal.fire({
icon: 'error',
title: t('invoicing_period.xlvask_autopilot.errors.create_order_item_failed'),
});
}
}
).catch(
(error) => {
itemFailure = true;
console.error("XL-Vask: failed to create order item.", { orderId, error });
Swal.fire({
icon: 'error',
title: t('invoicing_period.xlvask_autopilot.errors.create_order_item_failed'),
});
}
)
}
if (!itemFailure) {
onOrderCreated(orderId);
}
} else {
console.error("XL-Vask: order creation returned non-OK status.", { status: response?.status });
Swal.fire({
icon: 'error',
title: t('invoicing_period.xlvask_autopilot.errors.create_order_failed'),
});
}
}
).catch(
(error) => {
console.error("XL-Vask: failed to create order.", { washId: usageLogEntry?.WashId, error });
Swal.fire({
icon: 'error',
title: t('invoicing_period.xlvask_autopilot.errors.create_order_failed'),
});
}
);
}
const onOrderCreated = (orderId) => {
Swal.fire({
title: t('tables.xlvask.order_created'),
text: t('tables.xlvask.order_id', { id: orderId }),
icon: "success",
confirmButtonText: t('invoicing_period.xlvask_autopilot.labels.ok')
});
// Load the related orders
getRelatedOrders();
}
const hasUnrecognizedItems = (usageLogEntry) => {
let orderItems = getOrderItemsFromUsageLogEntry(usageLogEntry);
return orderItems.unrecognizedItems.length > 0;
}
const hasUnrecognizedDepartment = (usageLogEntry) => {
// Check if the department is in the list
const department = getDepartmentId(usageLogEntry);
return ( department === null || department === undefined );
}
const listRelatedOrders = (usageLogEntry) => {
// Check if the order is already created (If the wash id key is present in the related orders)
if (related_orders.value) {
let keys = Object.keys(related_orders.value);
if (keys.includes(usageLogEntry.WashId)) {
return related_orders.value[usageLogEntry.WashId];
}
}
}
const hasRelatedOrder = (usageLogEntry) => {
// Check if the order is already created (If the wash id key is present in the related orders)
/**
* {
* "892ae789-aeea-4bda-9374-cf931290aefd": [
* 9997, // Order ID
* 9998, // Order ID #2 (If multiple orders are related)
* ]
* }
*/
return listRelatedOrders(usageLogEntry) !== undefined && listRelatedOrders(usageLogEntry).length > 0;
}
const canCreateOrder = (usageLogEntry) => {
// Check if there are any unrecognized items, and if the department is recognized
return (
!hasUnrecognizedItems(usageLogEntry) &&
!hasUnrecognizedDepartment(usageLogEntry) &&
!hasRelatedOrder(usageLogEntry)
);
}
const getDepartmentId = (usageLogEntry) => {
// Check if the department is in the list
const department = departments.value.find(department => department.name === usageLogEntry.Location);
if (department) {
// If the department is found, return the department id
return parseInt(department.id);
} else {
// If the department is not found, return null
return null;
}
}
const redirectDepartmentOrderPage = async (orderId) => {
// Send the user to the order page (In a new tab)
// Get the department id from the order
await SessionUser.objects.orders.functions.get_department_id(orderId).then((response) => {
// Get the department id from the response
// Send the user to the order page (In a new tab)
window.open(`/admin/${response}/modules/pos/orders/${orderId}`, '_blank');
}).catch((error) => {
console.error("XL-Vask: failed to resolve department id for related order.", { orderId, error });
Swal.fire({
icon: 'error',
title: t('invoicing_period.xlvask_autopilot.errors.redirect_order_failed'),
});
});
};
const getUsageStatus = (usageLogEntry) => {
let result = {
color_class: "has-text-grey",
price: {
total: 0,
}
}
if (hasRelatedOrder(usageLogEntry)) {
result.color_class = "has-text-success";
}
if (hasUnrecognizedItems(usageLogEntry)) {
result.color_class = "has-text-danger";
}
if (hasUnrecognizedDepartment(usageLogEntry)) {
result.color_class = "has-text-warning";
}
// Add the price to the result
result.price = getOrderItemsFromUsageLogEntry(usageLogEntry).price;
return result;
}
const onClickCreateOrderAllApplicable = async () => {
// Loop through all the usage log entries
for (const usageLogEntry of usageLog.value) {
// Check if the order can be created
if (canCreateOrder(usageLogEntry)) {
await onClickCreateOrder(usageLogEntry);
}
}
}
</script>
<template>
<h1>{{ t('tables.xlvask.usage_log_title') }}</h1>
<div class="buttons">
<button class="button is-primary" @click="onClickCreateOrderAllApplicable">{{ t('tables.xlvask.create_order_all') }}</button>
<button class="button is-info" :class="{ 'is-loading': usageLogLoading }" :disabled="usageLogLoading" @click="getUsageLog">{{ t('tables.xlvask.refresh') }}</button>
</div>
<table class="table mb-6">
<!-- Table header -->
<thead>
<tr>
<th class="is-narrow"><!-- Status --></th>
<th>{{ t('tables.common.customer') }}</th>
<th>{{ t('tables.common.registration_number') }}</th>
<th>{{ t('tables.common.price') }}</th>
<th>{{ t('tables.common.start_time') }}</th>
<th>{{ t('tables.common.end_time') }}</th>
<th>{{ t('tables.common.location') }}</th>
<th><!-- Actions --></th>
</tr>
</thead>
<!-- Table body -->
<tbody>
<tr v-if="!usageLog || usageLog.length === 0">
<td colspan="8" class="has-text-centered has-text-grey">
{{ t('tables.xlvask.usage_log_empty') }}
</td>
</tr>
<template v-for="(usage, index) in usageLog" :key="index">
<tr>
<td>
<ColorIndicator
v-bind:color_class="getUsageStatus(usage).color_class"
v-bind:visibility="{
icon: true,
dropdown: false
}"
@onClick="() => { /* status indicator clicked */ }"
/>
</td>
<td>{{ usage.CustomerId }}</td>
<td>{{ usage.RegistrationNumber }}</td>
<td>
<ColorIndicator
v-bind:color_class="getUsageStatus(usage).color_class"
v-bind:visibility="{
icon: false,
dropdown: true
}"
v-bind:label="{
text: SessionUser.functions.currency.toLocal(getUsageStatus(usage).price.total),
classes: []
}"
v-bind:dropdown_content="{
content: [
{
text: t('common.services'),
button: false,
action: () => {},
},
...(getOrderItemsFromUsageLogEntry(usage).items.map(item => {
return {
text: `${SessionUser.objects.products.functions.getProductName(item.product_id)} - ${item.quantity} x ${SessionUser.functions.currency.toLocal(item.price.each)}`,
button: true,
action: () => {},
button_text: SessionUser.functions.currency.toLocal(item.price.total),
v_centered: true,
}
})),
...(getOrderItemsFromUsageLogEntry(usage).unrecognizedItems.map(item => {
return {
text: `${item.OriginalProductName} - ${item.Count} x ${SessionUser.functions.currency.toLocal(item.UnitPrice)}`,
button: true,
action: () => {},
icon: 'fas fa-exclamation-triangle',
button_text: SessionUser.functions.currency.toLocal(item.PriceIncVat),
v_centered: true,
}
}))
]
}"
/>
</td>
<td>{{ formatDate(usage.StartTime) }}</td>
<td>{{ formatDate(usage.FinishTime) }}</td>
<td>
<ColorIndicator
v-bind:color_class="getUsageStatus(usage).color_class"
v-bind:visibility="{
icon: false,
dropdown: false
}"
v-bind:label="{
text: usage.Location,
classes: []
}"
v-bind:dropdown_content="{
content: [
{
text: t('self_wash.lane'),
button: true,
button_text: usage.Hall,
action: () => {},
v_centered: true,
}
]
}"
/>
</td>
<td>
<ActionSettingsWheelButton>
<template #actions>
<!-- Create order based on usage log entry -->
<ActionSettingsWheelItem
:label="t('tables.xlvask.create_order')"
icon="fas fa-plus"
v-bind:disabled="!canCreateOrder(usage)"
v-bind:click-action="() => onClickCreateOrder(usage)"
/>
<!-- If there's related orders, add them as buttons -->
<template v-if="hasRelatedOrder(usage)">
<ActionSettingsWheelItemLabel
:label="SessionUser.objects.orders.meta.labels.multiple"
/>
<ActionSettingsWheelItem
v-for="(order, orderIndex) in listRelatedOrders(usage)"
:key="orderIndex"
:label="`${SessionUser.functions.ucFirst(SessionUser.objects.orders.meta.labels.single)} #${order}`"
icon="fas fa-file-invoice"
v-bind:click-action="() => redirectDepartmentOrderPage(order)"
/>
</template>
</template>
</ActionSettingsWheelButton>
</td>
</tr>
<template v-if="usage.WashItems && usage.WashItems.length > 0">
<!-- Nested table for wash items -->
<tr class="xlvask-usage-log-wash-items-row">
<td colspan="100%">
<details class="xlvask-usage-log-wash-items" data-testid="xlvask-usage-log-wash-items">
<summary class="is-size-7">
{{ t('tables.common.wash_item') }} ({{ usage.WashItems.length }})
</summary>
<table class="table is-fullwidth is-striped is-hoverable is-bordered mt-2">
<thead>
<tr>
<th>{{ t('tables.common.wash_item') }}</th>
<th>{{ t('tables.common.count') }}</th>
<th>{{ t('tables.common.price') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, itemIndex) in usage.WashItems" :key="itemIndex">
<td>{{ getProductName(item) }}</td>
<td>{{ item.Count }}</td>
<td>{{ SessionUser.functions.currency.toLocal(Number(item.PriceIncVat ?? 0).toFixed(2)) }}</td>
</tr>
</tbody>
</table>
</details>
</td>
</tr>
</template>
</template>
</tbody>
</table>
</template>
<style scoped>
.xlvask-usage-log-wash-items-row > td {
background: rgba(10, 10, 10, 0.03);
padding: 0.5rem 0.85rem;
}
.xlvask-usage-log-wash-items > summary {
cursor: pointer;
font-weight: 600;
outline: none;
}
.xlvask-usage-log-wash-items[open] > summary {
margin-bottom: 0.5rem;
}
</style>
@@ -35,14 +35,14 @@ const fetchObjects = () => {
console.error("XL-Vask: customer list returned non-OK status.", { status: response?.status });
Swal.fire({
icon: 'error',
title: t('invoicing_period.xlvask_autopilot.errors.load_customers_failed'),
title: t('invoicing_period.xlvask_review.errors.load_customers_failed'),
});
}
}).catch((error) => {
console.error("XL-Vask: failed to load customers.", { error });
Swal.fire({
icon: 'error',
title: t('invoicing_period.xlvask_autopilot.errors.load_customers_failed'),
title: t('invoicing_period.xlvask_review.errors.load_customers_failed'),
});
}).finally(() => {
isLoading.value = false;
@@ -29,7 +29,7 @@ const fetchObjects = () => {
if (!isoDateFrom) {
Swal.fire({
icon: 'warning',
title: t('invoicing_period.xlvask_autopilot.errors.load_usage_log_failed'),
title: t('invoicing_period.xlvask_review.errors.load_usage_log_failed'),
});
return;
}
@@ -43,14 +43,14 @@ const fetchObjects = () => {
console.error("XL-Vask: usage log list returned non-OK status.", { status: response?.status });
Swal.fire({
icon: 'error',
title: t('invoicing_period.xlvask_autopilot.errors.load_usage_log_failed'),
title: t('invoicing_period.xlvask_review.errors.load_usage_log_failed'),
});
}
}).catch((error) => {
console.error("XL-Vask: failed to load usage log list.", { error });
Swal.fire({
icon: 'error',
title: t('invoicing_period.xlvask_autopilot.errors.load_usage_log_failed'),
title: t('invoicing_period.xlvask_review.errors.load_usage_log_failed'),
});
}).finally(() => {
isLoading.value = false;
+85 -487
View File
@@ -179,6 +179,44 @@ function createPeriodPayload() {
};
}
// Mirrors the real backend's applyPeriodPagination: only the requested
// `periodView` bucket keeps full customer cards; every other bucket is
// replaced with lightweight `{customer_number, membership_only}`
// memberships deduplicated by customer_number. This keeps the e2e mock
// in lock-step with the live API contract so the chip-stacking
// scenarios actually exercise the membership path.
function projectPeriodMockPagedPayload(payload, periodView) {
if (!payload || !payload.types || payload.__rawResponse) {
return payload;
}
const activeView = periodView && payload.types[periodView] !== undefined ? periodView : "all";
const types = payload.types;
const projected = {};
for (const [typeName, customers] of Object.entries(types)) {
if (typeName === activeView) {
projected[typeName] = Array.isArray(customers) ? customers.slice() : [];
continue;
}
const seen = new Set();
const memberships = [];
if (Array.isArray(customers)) {
for (const customer of customers) {
if (!customer || typeof customer !== "object") continue;
const customerNumber = Number(customer.customer_number);
if (!Number.isInteger(customerNumber) || customerNumber < 1) continue;
if (seen.has(customerNumber)) continue;
seen.add(customerNumber);
memberships.push({
customer_number: customerNumber,
membership_only: true,
});
}
}
projected[typeName] = memberships;
}
return { ...payload, types: projected };
}
function createObjectTreePeriodPayload({ dateFrom = "2026-07-14" } = {}) {
const payload = createPeriodPayload();
const fixtureDate = periodFixtureDate(dateFrom);
@@ -905,6 +943,12 @@ async function setupPeriodEndpoints(page, requests, options = {}) {
payload = createPeriodPayload();
}
// Mirror the real backend contract: only the requested periodView
// bucket carries full customer cards; every other bucket carries a
// lightweight `{customer_number, membership_only}` membership so the
// front-end can render category indicator chips on every subpage.
payload = projectPeriodMockPagedPayload(payload, url.searchParams.get("periodView"));
await route.fulfill(
json(
payload?.__rawResponse ?? {
@@ -1780,493 +1824,6 @@ test.describe("Invoicing period tab", () => {
]);
});
test("@smoke period view opens Selvvask import and attaching view", async ({ page }, testInfo) => {
const usageOrderRequests = [];
const fastLinkRequests = [];
const automationPreviewRequests = [];
const automationApplyRequests = [];
const adjudicationRequests = [];
const importUsageRequests = [];
let accepted = false;
let actionHalted = false;
let automation = {
id: 7101,
status: "suggested",
action: "attach_order",
confidence: 0.93,
calibrated_probability: 0.995,
source: "fuzzy",
reason: "Samme primære produkt og relaterede tillæg matcher en ordre fra samme dag. Ordre #7001.",
matched_order_id: 7001,
created_order_id: null,
candidate_order: {
id: 7001,
department_id: 1,
created_at: "2026-03-10 12:03:00",
},
proposed_order: null,
can_accept: true,
can_deny: true,
can_ignore: true,
can_attach_order: true,
can_create_order: true,
review_eligible: true,
evidence: ["Samme registrering og total"],
contradictions: [],
risk_flags: [],
plan_steps: ["Kontrollér ordre #7001", "Tilknyt vasken atomisk"],
candidate_orders: [{ order_id: 7001, reason: "Samme registrering og total" }],
expected_version: "usage-v1",
run_id: "run-history-1",
policy_version: "policy-v1",
model: "gpt-5.6-sol",
};
let autoAdjudication = {
id: 7201,
suggestion_id: 7201,
status: "auto_accepted",
action: "create_order",
source: "openai",
review_eligible: false,
adjudication_eligible: true,
allowed_adjudication_outcomes: ["correct", "incorrect", "duplicate", "cross_hall", "unaudited"],
run_id: "run-auto-1",
policy_version: "policy-v1",
model: "gpt-5.6-sol",
};
await openPeriodView(page);
await page.route("**/modules/xlvask/services/usage/automation/capabilities**", async (route) => {
await route.fulfill(
json({
data: {
can_view: true,
can_review: true,
can_dry_run: true,
can_execute: !actionHalted,
can_manage_policy: true,
can_halt: true,
effective_stage: actionHalted ? "halted" : "ai_attach_canary",
allowed_modes: actionHalted ? ["dry_run"] : ["dry_run", "execute"],
blocked_reasons: actionHalted ? ["automation_halted"] : [],
effective_action_sources: ["deterministic", "openai"],
readiness: {
ready: !actionHalted,
effective_stage: actionHalted ? "halted" : "ai_attach_canary",
policy_version: "policy-v1",
model: "gpt-5.6-sol",
worker_healthy: true,
budgets: {
attach_order: { remaining_global: 100, remaining_hall: 10 },
create_order: { remaining_global: 20, remaining_hall: 3 },
},
review_progress: {
attach_order: { reviewed: actionHalted ? 41 : 40, target: 200 },
create_order: { reviewed: 0, target: 50 },
},
eligible_counts: { attach_order: 1, create_order: 0, total: 1 },
},
},
})
);
});
await page.route("**/modules/xlvask/services/usage/automation/admin/readiness**", async (route) => {
await route.fulfill(
json({
data: {
ready: !actionHalted,
effective_stage: actionHalted ? "halted" : "ai_attach_canary",
policy_version: "policy-v1",
model: "gpt-5.6-sol",
worker_healthy: true,
blocked_reasons: actionHalted ? ["automation_halted"] : [],
budgets: {
attach_order: { remaining_global: 100, remaining_hall: 10 },
create_order: { remaining_global: 20, remaining_hall: 3 },
},
review_progress: {
attach_order: { reviewed: actionHalted ? 41 : 40, target: 200 },
create_order: { reviewed: 0, target: 50 },
},
eligible_counts: { attach_order: 1, create_order: 0, total: 1 },
},
})
);
});
await page.route("**/modules/xlvask/services/usage/autopilot-runs**", async (route) => {
const path = new URL(route.request().url()).pathname;
if (route.request().method() === "GET" && path.endsWith("/autopilot-runs/active")) {
await route.fulfill(json({ data: { run: null } }));
return;
}
if (route.request().method() !== "POST" || !path.endsWith("/autopilot-runs")) {
await route.fallback();
return;
}
importUsageRequests.push(JSON.parse(route.request().postData() || "{}"));
await route.fulfill(
json({
data: {
run: {
id: "run-1",
status: "completed",
phase: "completed",
processed: 2,
total: 2,
},
},
})
);
});
await page.route("**/modules/xlvask/services/usage/automation/decisions/**", async (route) => {
const path = new URL(route.request().url()).pathname;
const body = JSON.parse(route.request().postData() || "{}");
if (path.endsWith("/preview")) {
automationPreviewRequests.push(body);
await route.fulfill(
json({
data: {
preview: {
id: "preview-1",
selection_hash: "selection-1",
action: body.action,
items: [{ usage_log_id: 8101, before: {}, after: { linked_order_id: 7001 }, warnings: [] }],
requires_confirmation: true,
confirmation_phrase: "CONFIRM XLVASK",
},
},
})
);
return;
}
if (path.endsWith("/apply")) {
automationApplyRequests.push(body);
accepted = true;
automation = { ...automation, status: "accepted", can_accept: false, can_deny: false };
await route.fulfill(json({ data: { applied: 1, results: [{ usage_log_id: 8101 }], failed: [] } }));
return;
}
await route.fallback();
});
await page.route("**/modules/xlvask/services/usage/automation/admin/calibrations/labels**", async (route) => {
const body = JSON.parse(route.request().postData() || "{}");
adjudicationRequests.push(body);
if (body.outcome === "correct") {
autoAdjudication = {
...autoAdjudication,
id: 7202,
suggestion_id: 7202,
allowed_adjudication_outcomes: ["incorrect"],
};
await route.fulfill(json({ data: { automatic_action_review: { outcome: "correct" } } }));
return;
}
actionHalted = true;
autoAdjudication = {
...autoAdjudication,
adjudication_eligible: false,
allowed_adjudication_outcomes: [],
};
await route.fulfill(json({ data: { action_halted: true, automatic_action_review: { outcome: body.outcome } } }));
});
await page.route("**/modules/xlvask/services/usage/orders**", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
if (matchesApiPath(route.request().url(), "/modules/xlvask/services/usage/orders/fast-link")) {
fastLinkRequests.push(route.request().url());
await route.fulfill(
json({
data: {
order_items: [
{
id: 9101,
product_id: 301,
product: { name: "Kassevogn/varevogn" },
quantity: 1,
price: 125,
},
],
potential_duplicates: [],
},
})
);
return;
}
if (matchesApiPath(route.request().url(), "/modules/xlvask/services/usage/orders/summary")) {
await route.fulfill(
json({
data: {
summary: {
total: 3,
new: 1,
updated: 0,
unchanged: 2,
invalid: 0,
already_linked: accepted ? 1 : 0,
auto_linked: 0,
auto_created: 1,
needs_review: accepted ? 0 : 1,
blocked: 0,
ignored: 0,
failed: 1,
certain: accepted ? 2 : 1,
uncertain: accepted ? 0 : 1,
none: 1,
},
},
})
);
return;
}
if (!matchesApiPath(route.request().url(), "/modules/xlvask/services/usage/orders")) {
await route.fallback();
return;
}
const url = new URL(route.request().url());
usageOrderRequests.push({
filters: url.searchParams.get("filters") || "",
limit: url.searchParams.get("limit") || "",
});
await route.fulfill(
json({
data: [
{
id: 8101,
reg_1: "AB12345",
created_at: "2026-03-10 12:00:00",
customer_id: 4001,
customer_number: 4001,
customer_name: "DEKRA AMU Center Hovedstaden A/S",
department_id: 1,
lane: 1,
wash_id: "wash-selvvask-1",
duplicates: [],
fast_link_key: "temporary_cache_selfwash8101",
total_net_amount: 125,
xlvask_primary_product_name: "Kassevogn/varevogn",
import_state: "new",
resolution_state: accepted ? "already_linked" : "needs_review",
certainty: accepted ? "certain" : "uncertain",
planned_action: accepted ? "none" : "attach_order",
automation,
},
{
id: 8102,
reg_1: "CD67890",
created_at: "2026-03-10 12:15:00",
customer_id: 4002,
customer_number: 4002,
customer_name: "Self Wash Transport",
department_id: 2,
lane: 2,
wash_id: "wash-selvvask-2",
duplicates: [],
fast_link_key: null,
total_net_amount: 88,
xlvask_primary_product_name: "Varebil",
import_state: "unchanged",
resolution_state: "failed",
certainty: "none",
planned_action: "none",
automation: {
id: 7102,
status: "failed",
action: "attach_order",
error: "No safe match",
can_accept: false,
can_deny: false,
can_ignore: false,
can_attach_order: false,
can_create_order: false,
review_eligible: false,
},
},
{
id: 8103,
reg_1: "EF24680",
created_at: "2026-03-10 12:30:00",
customer_id: 4003,
customer_number: 4003,
customer_name: "Automatic Action Transport",
department_id: 1,
lane: 1,
wash_id: "wash-selvvask-3",
duplicates: [],
fast_link_key: null,
total_net_amount: 150,
xlvask_primary_product_name: "Lastbil",
import_state: "unchanged",
resolution_state: "auto_created",
certainty: "certain",
planned_action: "none",
automation: autoAdjudication,
},
],
meta: {
pagination: {
page: 1,
per_page: 100,
total: 3,
},
},
})
);
});
await setEntireMarchPeriod(page);
await expect(page.getByTestId("invoicing-period-view-selector-self_wash")).toBeVisible();
await expect(page.getByTestId("invoicing-period-view-selector-self_wash")).toContainText("Selvvask (1/3)");
const selfWashProgress = page.getByTestId("invoicing-period-view-selector-progress-self_wash");
const selfWashProgressMetrics = await selfWashProgress.evaluate((element) => {
const rect = element.getBoundingClientRect();
const slot = element.parentElement?.getBoundingClientRect();
const controls = element.parentElement?.parentElement?.getBoundingClientRect();
return {
width: rect.width,
height: rect.height,
slotWidth: slot?.width || 0,
controlsWidth: controls?.width || 0,
};
});
expect(
selfWashProgressMetrics.width,
`self-wash selector progress geometry: ${JSON.stringify(selfWashProgressMetrics)}`
).toBeGreaterThanOrEqual(56);
expect(selfWashProgressMetrics.height).toBeGreaterThan(0);
await expect(selfWashProgress).toBeVisible();
await page.getByTestId("invoicing-period-view-selector-self_wash").click();
await expect(page.getByTestId("invoicing-period-self-wash-view")).toBeVisible();
const selfWashView = page.getByTestId("invoicing-period-self-wash-view");
await expect(selfWashView.getByTestId("date-period-start")).toHaveCount(0);
await expect(selfWashView.locator("input[placeholder='Søg i transaktioner']")).toHaveCount(0);
await expect(selfWashView.getByText("Per side")).toHaveCount(0);
await expect(selfWashView.getByText("Rækkefølge")).toHaveCount(0);
await expect(selfWashView.getByText("Dato fra")).toHaveCount(0);
await expect(selfWashView.getByText("Dato til")).toHaveCount(0);
await expect(selfWashView.getByText("Vis kun ikke tilknyttede vaske")).toHaveCount(0);
await expect(page.getByRole("heading", { name: /Selvvask/ })).toBeVisible();
await expect(page.getByText("AB12345")).toBeVisible();
await expect(page.getByText("CD67890")).toBeVisible();
if (await captureXlvaskVisualEvidence(page, testInfo)) {
return;
}
await expect(page.getByTestId("xlvask-summary-new")).toContainText("1");
await expect(page.getByTestId("xlvask-summary-unchanged")).toContainText("2");
await expect(page.getByTestId("xlvask-summary-uncertain")).toContainText("1");
await expect(page.getByTestId("xlvask-summary-failed")).toContainText("1");
await expect(page.getByTestId("xlvask-automation-controls")).toBeVisible();
await expect(page.getByTestId("xlvask-automation-readiness")).toContainText("policy-v1");
await expect(selfWashView.locator(".xlvask-usage-price").first()).toContainText("125");
await expect(page.getByTestId("xlvask-resolution-state-8102")).toContainText("Mislykket");
await expect(page.getByTestId("xlvask-certainty-8102")).toContainText("Ikke vurderet");
expect(fastLinkRequests).toHaveLength(0);
await expect(page.getByTestId("xlvask-automation-suggestion-8101")).toContainText("Foreslået: Tilknyt ordre #7001");
await expect(selfWashView.locator(".xlvask-usage-card-header").first()).toBeVisible();
await page.getByText("EF24680").click();
await page.getByTestId("xlvask-adjudication-correct-8103").click();
await expect(page.getByRole("heading", { name: "Kontrollér det automatiske resultat" })).toBeVisible();
await page.getByRole("button", { name: "Korrekt" }).click();
await expect.poll(() => adjudicationRequests.length).toBe(1);
expect(adjudicationRequests[0]).toEqual({ suggestion_id: 7201, outcome: "correct" });
await expect(page.locator(".swal2-popup")).toHaveCount(0, { timeout: 4000 });
await selfWashView.getByRole("checkbox", { name: "Vælg vask #8101" }).check();
await expect(page.getByTestId("xlvask-autopilot-bulk-bar")).toContainText("1 vaske valgt");
await page.getByTestId("xlvask-autopilot-bulk-bar").getByRole("button", { name: "Ryd valg" }).click();
await expect(page.getByTestId("xlvask-autopilot-bulk-bar")).toHaveCount(0);
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth))
.toBeLessThanOrEqual(1);
await page.getByText("AB12345").click();
await expect(page.getByTestId("xlvask-automation-evidence-8101")).toContainText("Samme registrering og total");
await expect(page.getByTestId("xlvask-automation-candidates-8101")).toContainText("#7001");
await expect(page.getByTestId("xlvask-automation-accept-8101")).toBeVisible();
await page.getByTestId("xlvask-automation-accept-8101").click();
await expect(page.getByRole("heading", { name: "Kontrollér ændringen" })).toBeVisible();
await page.locator(".swal2-input").fill("CONFIRM XLVASK");
await page.getByRole("button", { name: "Udfør" }).click();
await expect(page.getByTestId("xlvask-automation-suggestion-8101")).toContainText("Accepteret #7001");
expect(automationPreviewRequests).toEqual([
expect.objectContaining({
usage_log_ids: [8101],
action: "accept",
suggestion_id: 7101,
}),
]);
expect(automationApplyRequests).toEqual([
expect.objectContaining({
preview_id: "preview-1",
selection_hash: "selection-1",
confirmation_text: "CONFIRM XLVASK",
}),
]);
await expect(page.getByTestId("invoicing-period-view-selector-self_wash")).toContainText("Selvvask (2/3)");
await expect.poll(() => usageOrderRequests.length).toBeGreaterThan(0);
expect(usageOrderRequests.some((request) => request.limit === "10000")).toBe(false);
expect(
usageOrderRequests.some(
(request) =>
request.filters.includes("StartTime-date_from:2026-03-01") &&
request.filters.includes("StartTime-date_to:2026-03-31")
)
).toBe(true);
await selfWashView.getByTestId("xlvask-autopilot-dry-run").click();
await expect.poll(() => importUsageRequests.length).toBe(1);
expect(importUsageRequests[0]).toMatchObject({
dateFrom: "2026-03-01",
dateTo: "2026-03-31",
mode: "dry_run",
forceRefetch: true,
});
expect(importUsageRequests[0].idempotency_key).toEqual(expect.any(String));
await selfWashView.getByTestId("xlvask-autopilot-execute").click();
const executeDialog = page.locator(".swal2-popup");
await expect(executeDialog).toContainText("2026-03-01");
await expect(executeDialog).toContainText("deterministic, openai");
await expect(executeDialog).toContainText("100 globalt; laveste restgrænse blandt haller 10");
await expect(executeDialog).toContainText("20 globalt; laveste restgrænse blandt haller 3");
await executeDialog.locator(".swal2-input").fill("KØR AUTOMATIK");
await executeDialog.getByRole("button", { name: "Kør automatiske handlinger" }).click();
await expect.poll(() => importUsageRequests.length).toBe(2);
expect(importUsageRequests[1]).toMatchObject({ mode: "execute", forceRefetch: true });
expect(importUsageRequests[1].idempotency_key).toEqual(expect.any(String));
expect(importUsageRequests[1].idempotency_key).not.toBe(importUsageRequests[0].idempotency_key);
const incorrectAdjudication = page.getByTestId("xlvask-adjudication-incorrect-8103");
if (!(await incorrectAdjudication.isVisible())) {
await page.getByText("EF24680").click();
}
await incorrectAdjudication.click();
await expect(page.getByRole("heading", { name: "Kontrollér det automatiske resultat" })).toBeVisible();
await page.getByRole("button", { name: "Forkert" }).click();
await expect.poll(() => adjudicationRequests.length).toBe(2);
expect(adjudicationRequests[1]).toEqual({ suggestion_id: 7202, outcome: "incorrect" });
await expect(page.getByRole("heading", { name: "Resultatet er gemt, og automatikken er stoppet" })).toBeVisible();
await expect(page.locator(".swal2-popup")).toHaveCount(0, { timeout: 4000 });
await expect(selfWashView.getByTestId("xlvask-autopilot-execute")).toHaveCount(0);
await expect(page.getByTestId("xlvask-automation-readiness")).toContainText("Ikke klar");
expect(importUsageRequests.filter((request) => request.mode === "execute")).toHaveLength(1);
});
test("@smoke period view shows flags and saves automatic flag decisions", async ({ page }) => {
const automaticStatusRequests = [];
const manualStatusRequests = [];
@@ -2947,6 +2504,47 @@ test.describe("Invoicing period tab", () => {
}
});
test("@smoke period customer attribute chips render on every subpage including Alle", async ({ page }) => {
// Pin down the regression: customer indicator chips (e.g. "Faktura pr.
// ordre") were previously invisible on the Alle tab because the backend
// only returned full customer data for the active view bucket. The
// backend now projects lightweight `{customer_number, membership_only}`
// entries on every non-active bucket so the front-end can resolve
// category membership regardless of the active view.
await openPeriodView(page, { payloadFactory: createAttributeStackPeriodPayload });
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page).toHaveURL(/periodView=all/);
const multiAttributeCard = page.getByTestId("invoicing-period-customer-4101");
await expect(multiAttributeCard).toBeVisible();
const multiAttributeStack = multiAttributeCard.locator('[data-testid="invoicing-period-customer-attributes-4101"]');
await expect(multiAttributeStack).toBeVisible();
for (const viewKey of ["invoice_per_order", "fixed_pricing", "tank_cleaning"]) {
const chip = page.getByTestId(`invoicing-period-customer-attribute-4101-${viewKey}`);
await expect(chip, `expected chip "${viewKey}" on the Alle tab`).toBeVisible();
const chipText = (await chip.textContent())?.trim() ?? "";
expect(chipText, `chip ${viewKey} carries a non-empty label`).not.toBe("");
}
// The single-attribute customer should still show exactly one chip
// (matches the "fixed_pricing" bucket in the fixture).
const singleAttributeCard = page.getByTestId("invoicing-period-customer-4102");
await expect(singleAttributeCard).toBeVisible();
const singleStack = singleAttributeCard.locator('[data-testid="invoicing-period-customer-attributes-4102"]');
await expect(singleStack).toBeVisible();
await expect(
page.getByTestId("invoicing-period-customer-attribute-4102-fixed_pricing"),
"single-category customer renders its fixed_pricing chip on the Alle tab"
).toBeVisible();
await expect(
page.getByTestId("invoicing-period-customer-attribute-4102-invoice_per_order"),
"single-category customer does not render an invoice_per_order chip"
).toHaveCount(0);
});
test("@smoke period view selector switch updates visible customer set", async ({ page }) => {
await openPeriodView(page);
+196
View File
@@ -605,3 +605,199 @@ test("desktop clears pending basket row when customer-rule create is rejected",
await expect(cartPanel.getByTestId("pos-order-empty-state")).toBeVisible();
await expect(cartPanel.locator('[data-testid^="pos-order-item-name-"]')).toHaveCount(0);
});
test("desktop rolls back partial step-2 addon sync when one addon POST rejects", async ({ page }, testInfo) => {
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
// Regression test for the bug where, on the desktop POS step 2, only some
// addons ended up on the order when one of the parallel add-on POSTs was
// rejected. The shared `addOrderItemAddons` helper now fans out the add-on
// POSTs via Promise.allSettled and rolls back every order_items row that
// landed during the failed attempt, so the operator sees a clean retry
// state with no half-synced addons left on the order.
const baseFixture = createPosFixture();
const orderId = 9710;
const primaryProduct = {
...baseFixture.products.find((product) => Number(product.id) === 53),
};
const addonA = {
...baseFixture.products.find((product) => Number(product.id) === 63),
addons: [],
};
const addonB = {
...baseFixture.products.find((product) => Number(product.id) === 64),
addons: [],
};
addonA.id = 7001;
addonA.name = "Addon A (will be rejected)";
addonB.id = 7002;
addonB.name = "Addon B (will be rolled back)";
primaryProduct.addons = [
{
id: 8001,
option_id: 7001,
name: addonA.name,
price: addonA.price,
product: addonA,
min: 0,
max: 1,
},
{
id: 8002,
option_id: 7002,
name: addonB.name,
price: addonB.price,
product: addonB,
min: 0,
max: 1,
},
];
const fixture = createPosFixture({
ordersById: {
9710: {
id: 9710,
customer_id: 12345679,
department_id: 12,
reference: "STEP2-DESKTOP-ROLLBACK",
notes: "",
po: "",
reg_1: "AB12345",
reg_2: "",
reg_3: "",
invoice_collection_id: null,
booking_id: null,
completed_at: null,
created_at: "2026-01-01T10:00:00.000Z",
},
...baseFixture.ordersById,
},
orderItemsByOrderId: {
[orderId]: [],
},
products: [
primaryProduct,
addonA,
addonB,
...baseFixture.products.filter((product) => ![53, 63, 64].includes(Number(product.id))),
],
});
const customer = fixture.customersByNumber[12345679];
const orderItemPosts = [];
const orderItemResponseIds = [];
const orderItemDeletes = [];
let rejectedAddonsRemaining = 0;
await mockApi(page, {
authenticated: true,
permissions: POS_PERMISSIONS,
edgeGateways: false,
pos: fixture,
});
await page.route(/\/order\/items(?:\?.*)?$/, async (route) => {
const request = route.request();
if (request.method() === "POST") {
const body = request.postDataJSON?.() || {};
orderItemPosts.push(body);
// Reject the first add-on POST that targets the configured add-on
// product. The shared helper fans out the add-ons in parallel via
// Promise.allSettled, so the other add-on may have already landed by
// the time the rejection is returned; the helper must roll it back.
if (Number(body.product_id) === 7001 && rejectedAddonsRemaining > 0) {
rejectedAddonsRemaining -= 1;
await route.fulfill(
json(
{
success: false,
data: { message: "Addon 7001 rejected by backend" },
},
400
)
);
return;
}
await route.fallback();
return;
}
if (request.method() === "DELETE") {
const url = new URL(request.url());
const id = Number(url.searchParams.get("id") || 0);
orderItemDeletes.push(id);
await route.fallback();
return;
}
await route.fallback();
});
// Capture successful POST responses so the test can map the order_items
// id back to the request that produced it. The route handler above only
// short-circuits the 7001 rejection; every other POST falls through to
// mockApi and surfaces here with its server-generated id.
page.on("response", async (response) => {
if (response.request().method() !== "POST") {
return;
}
if (!/\/order\/items(?:\?.*)?$/.test(response.url())) {
return;
}
if (response.status() >= 400) {
return;
}
let payload;
try {
payload = await response.json();
} catch (error) {
payload = null;
}
const id = Number(payload?.data?.id ?? payload?.id ?? 0);
if (id > 0) {
orderItemResponseIds.push(id);
}
});
await primeOperatorSession(page, "pos-desktop-step2-rollback-token");
await openPosAndSelectCustomer(page, customer);
await page.getByTestId("pos-step-1").getByTestId("pos-next-step").click();
await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: POS_STEP_TIMEOUT });
await page.getByTestId("pos-product-card-53").first().click();
await expect(page.getByTestId("pos-addon-7001-name")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("pos-addon-7002-name")).toBeVisible({ timeout: 10_000 });
// Select both add-ons so the desktop middleware fans out two POSTs.
await page.getByTestId("pos-addon-7001-name").click();
await page.getByTestId("pos-addon-7002-name").click();
rejectedAddonsRemaining = 1;
// Click add-to-cart. The primary POST must succeed; the first addon POST
// will reject; the second addon POST will succeed and must be rolled back.
await page.getByTestId("pos-add-to-cart-53").first().click();
// 1 primary + 2 addons = 3 POST attempts.
await expect.poll(() => orderItemPosts.length, { timeout: 10_000 }).toBe(3);
expect(orderItemPosts.map((body) => Number(body.product_id))).toEqual([53, 7001, 7002]);
// The primary must be linked to no parent (related_item_id is null or 0).
expect(Number(orderItemPosts[0].related_item_id || 0)).toBe(0);
// Both addons must be linked to the primary by related_item_id.
expect(Number(orderItemPosts[1].related_item_id)).toBeGreaterThan(0);
expect(Number(orderItemPosts[2].related_item_id)).toBeGreaterThan(0);
// The successful addon (7002) must be rolled back via DELETE /order/items.
// The shared helper uses Promise.allSettled under the hood, so by the
// time the 7001 rejection surfaces the 7002 POST may have already landed;
// the rollback must clean it up so the operator can retry without the
// previous attempt's half-saved addon lingering on the order.
await expect.poll(() => orderItemDeletes.length, { timeout: 10_000 }).toBeGreaterThanOrEqual(1);
// The 7001 rejection never reached the server, so no DELETE may target
// an order_items row that points at product 7001. The middleware only
// deletes the rows it created during the failed attempt — the addon 7002
// row that came back with a successful POST. The primary 53 row is owned
// by the surrounding createOrderItem call and must remain untouched, so
// the rollback list must never contain the primary's order_item id.
await expect.poll(() => orderItemResponseIds[0], { timeout: 10_000 }).toBeGreaterThan(0);
const primaryOrderItemId = orderItemResponseIds[0];
expect(orderItemDeletes).not.toContain(primaryOrderItemId);
// The 7002 row (which landed) must be among the rolled-back ids.
expect(orderItemResponseIds).toContain(orderItemDeletes[0]);
});
+255
View File
@@ -3447,6 +3447,173 @@ test.describe("POS mobile order flow", () => {
expect(createdProductIds).toEqual([53, 71, 91]);
});
test("manual step 2 persists every selected primary product add-on (multi-addon happy path)", async ({ page }) => {
// Regression test for the bug where only some of the selected primary
// product add-ons were persisted to the order during mobile POS step 2.
// We select two add-ons on the primary and one standalone additional
// item, click Fuldfør, and assert that all four POST /order/items calls
// landed and that the server-side rows are linked correctly.
const orderId = 9426;
const fixture = createMobilePosFixture({
ordersById: {
[orderId]: buildRegularOrder(orderId, {
reference: "STEP2-MULTI-ADDON-REF",
reg_1: "ZZ00000",
}),
},
orderItemsByOrderId: {
[orderId]: [],
},
});
await setupMobilePosPage(page, fixture, {
token: "mobile-step2-multi-addon-happy-token",
seedState: {
customerId: REGULAR_CUSTOMER_ID,
reg: "ZZ00000",
reference: "STEP2-MULTI-ADDON-REF",
includePrimaryItem: false,
vehicleType: null,
lastOrderId: null,
},
route: {
step: 2,
orderId,
customerId: REGULAR_CUSTOMER_ID,
},
});
await selectPrimaryProduct(page, 53);
// Addons 71 and 41 are both attached to fixture product 53.
await expect(page.getByTestId("pos-mobile-addon-71")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-addon-71").click();
await expect(page.getByTestId("pos-mobile-addon-71-value")).toHaveText("1", { timeout: 10_000 });
await expect(page.getByTestId("pos-mobile-addon-41")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-addon-41").click();
await expect(page.getByTestId("pos-mobile-addon-41-value")).toHaveText("1", { timeout: 10_000 });
// One standalone additional item (id 91) so we also exercise the
// additionalItems path alongside the add-ons.
await longPressAdditionalItems(page);
await expect(page.getByTestId("pos-mobile-additional-items-selection")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-category-8").click();
await page.waitForTimeout(2_000);
await page.getByTestId("pos-mobile-product-91").click();
await expect
.poll(
async () => {
const snapshot = await getStoredPosSnapshot(page);
return (snapshot?.transactionItems?.additionalItems || [])
.map((item) => Number(item?.id))
.sort((left, right) => left - right);
},
{ timeout: 10_000 }
)
.toEqual([91]);
// First click closes the additional-items fullscreen view (the
// fullscreen "Next" button's customAction toggles additionalItemSelection
// from true -> false via onClickAddOtherProduct). The second click lands
// on the main step 2 layout's Complete button and triggers
// syncCurrentTransactionToOrder via onBeforeComplete.
await page.getByTestId("pos-mobile-next-step").click();
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-next-step").click();
// 1 primary + 2 addons + 1 additional = 4 POST /order/items calls.
await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(4);
await expect.poll(() => (fixture.orderItemsByOrderId[orderId] || []).length, { timeout: 10_000 }).toBe(4);
const createdProductIds = fixture.requestLog.orderItemCreates.map((entry) => Number(entry.product_id));
// The order of POSTs is: primary, then add-ons (in fixture order), then
// additional items. Add-ons 71 and 41 are listed before the additional
// item 91.
expect(createdProductIds).toEqual([53, 71, 41, 91]);
// The add-on rows must be linked to the primary by related_item_id.
const persistedItems = fixture.orderItemsByOrderId[orderId] || [];
const primary = persistedItems.find((item) => item.product_id === 53 && item.related_item_id === null);
expect(primary).toBeTruthy();
const addon71 = persistedItems.find((item) => item.product_id === 71);
const addon41 = persistedItems.find((item) => item.product_id === 41);
expect(addon71?.related_item_id).toBe(primary.id);
expect(addon41?.related_item_id).toBe(primary.id);
// The standalone additional item must NOT be linked to the primary.
const additional91 = persistedItems.find((item) => item.product_id === 91);
expect(additional91?.related_item_id).toBeNull();
});
test("mobile POS rolls back partial sync when one primary add-on POST fails", async ({ page }) => {
// Regression test for the bug where a single add-on POST rejection left
// the order with the primary and a subset of add-ons on the server while
// the operator only saw a generic failure popup. The sync helper now
// collects per-product failures via Promise.allSettled and rolls back
// every order_items row it created during this attempt.
const orderId = 9427;
const fixture = createMobilePosFixture({
failureBudget: {
orderItemCreateForProductId: { 41: 1 },
},
ordersById: {
[orderId]: buildRegularOrder(orderId, {
reference: "STEP2-PARTIAL-ROLLBACK-REF",
reg_1: "ZZ00000",
}),
},
orderItemsByOrderId: {
[orderId]: [],
},
});
await setupMobilePosPage(page, fixture, {
token: "mobile-step2-partial-rollback-token",
seedState: {
customerId: REGULAR_CUSTOMER_ID,
reg: "ZZ00000",
reference: "STEP2-PARTIAL-ROLLBACK-REF",
includePrimaryItem: false,
vehicleType: null,
lastOrderId: null,
},
route: {
step: 2,
orderId,
customerId: REGULAR_CUSTOMER_ID,
},
});
await selectPrimaryProduct(page, 53);
await expect(page.getByTestId("pos-mobile-addon-71")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-addon-71").click();
await expect(page.getByTestId("pos-mobile-addon-71-value")).toHaveText("1", { timeout: 10_000 });
await expect(page.getByTestId("pos-mobile-addon-41")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-addon-41").click();
await expect(page.getByTestId("pos-mobile-addon-41-value")).toHaveText("1", { timeout: 10_000 });
await page.getByTestId("pos-mobile-next-step").click();
// The error popup should appear with the per-product failure message.
const errorPopup = page.locator('[data-testid="pos-mobile-popup"][data-popup-id="error"]');
await expect(errorPopup).toBeVisible({ timeout: 10_000 });
await expect(errorPopup).toContainText("Product 41 rejected");
// The failure budget forced exactly one rejection on add-on 41.
expect(fixture.failureBudget.orderItemCreateForProductId[41]).toBe(0);
// Rollback runs after the partial failure: every order_items row that
// landed in this attempt must have been deleted, so the order ends
// up empty (no half-synced state) and ready for a clean retry.
await expect.poll(() => (fixture.orderItemsByOrderId[orderId] || []).length, { timeout: 10_000 }).toBe(0);
await expect.poll(() => fixture.requestCounters.orderItemsDelete, { timeout: 10_000 }).toBeGreaterThanOrEqual(2);
// The order is still open (not marked completed) because the failure
// happened before completion could run.
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 1_000 }).toBe(0);
});
test("manual step 2 disables exact add-on and standalone product controls with tap tooltips", async ({ page }) => {
const orderId = 9414;
const fixture = createMobilePosFixture({
@@ -4046,6 +4213,94 @@ test.describe("POS mobile order flow", () => {
await waitForStepReset(page);
});
test("prompts for a required reason note for audited add-on products that are not the chemistry product", async ({
page,
}) => {
const orderId = 9416;
const baseFixture = createMobilePosFixture();
const auditedProduct = {
id: 24,
name: "Højtryk - ekstra tid",
description: "Audited addon that requires a reason comment",
price: 95,
subscription_allowed: true,
category: 8,
piktogram: "24",
apply_category_discount: false,
requires_note: false,
is_wash: false,
display_in_booking_form: true,
order_priority: 6,
addons: [],
};
const primaryProduct = {
...fixtureProduct(53),
addons: [
...fixtureProduct(53).addons,
{
id: auditedProduct.id,
name: auditedProduct.name,
price: auditedProduct.price,
product: { ...auditedProduct },
quantity: 1,
min: 0,
max: -1,
},
],
};
const fixture = createMobilePosFixture({
products: baseFixture.products
.map((product) => {
if (Number(product.id) !== 53) {
return product;
}
return primaryProduct;
})
.concat(auditedProduct),
ordersById: {
[orderId]: buildRegularOrder(orderId),
},
orderItemsByOrderId: {
[orderId]: [],
},
});
await setupMobilePosPage(page, fixture, {
token: "mobile-product-24-note-token",
seedState: {
customerId: REGULAR_CUSTOMER_ID,
reg: "AB12345",
reference: "PRODUCT-24-NOTE",
primaryItem: primaryProduct,
vehicleType: 53,
lastOrderId: null,
},
route: {
step: 2,
orderId,
customerId: REGULAR_CUSTOMER_ID,
},
});
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("pos-mobile-addon-24-value")).toHaveText("1", { timeout: 10_000 });
await page.getByTestId("pos-mobile-next-step").click();
await expect(page.locator('[data-testid="pos-mobile-popup"][data-popup-id="add_product_note"]')).toBeVisible({
timeout: 10_000,
});
await page.getByTestId("pos-mobile-product-note-input").fill("Højtryk bagpå venstre side");
await page.getByTestId("pos-mobile-product-note-confirm").click();
await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(2);
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1);
const product24Create = fixture.requestLog.orderItemCreates.find((entry) => Number(entry.product_id) === 24);
expect(product24Create?.notes).toBe("Højtryk bagpå venstre side");
expect(product24Create?.reason_code).toBe("customer_approved_extra_work");
expect(product24Create?.reason_comment).toBe("Højtryk bagpå venstre side");
await waitForStepReset(page);
});
test("booking hydration applies booking items, reference, notes, and po", async ({ page }) => {
const orderId = 9405;
const fixture = createMobilePosFixture({
+25
View File
@@ -11,6 +11,7 @@ export const CARD_CUSTOMER_ID = 999;
export const WASH_CERTIFICATE_PRODUCT_ID = 41;
const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
const AUDITED_ORDER_ITEM_PRODUCT_IDS = new Set([21, 22, 24, 25, 26, 27]);
export const MOBILE_PERMISSIONS = ["admin", "department_access_1"];
export const MOBILE_NEXT_STEP_COOLDOWN_MS = 2100;
@@ -342,6 +343,7 @@ function productRequiresOrderItemNote(product) {
const productId = Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0);
return (
isEnabledFlag(product?.requires_note ?? product?.product?.requires_note) ||
AUDITED_ORDER_ITEM_PRODUCT_IDS.has(productId) ||
productId === EXTRAORDINARY_CHEMISTRY_PRODUCT_ID ||
String(product?.product?.name ?? product?.name ?? "").trim() === EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME
);
@@ -396,6 +398,13 @@ function createFailureBudget(overrides = {}) {
orderDelete: 0,
customerAttributesGet: 0,
orderItemCreate: 0,
// Per-product-id order-item creation failure budget. Keys are product
// ids, values are how many POST /order/items calls for that product
// should be rejected with 400. Used by regression tests for the mobile
// POS step 2 partial-sync rollback (see
// tests/unit/pos-mobile-step-2-addon-sync.spec.js for the matching
// unit-level contract).
orderItemCreateForProductId: {},
...overrides,
};
}
@@ -2002,6 +2011,22 @@ export async function mockMobilePosApi(page, fixture) {
}
const orderId = toPositiveInteger(body.order_id);
const productId = toPositiveInteger(body.product_id);
const perProductBudget = fixture.failureBudget.orderItemCreateForProductId || {};
if (perProductBudget[productId] > 0) {
perProductBudget[productId] -= 1;
await route.fulfill(
json(
{
success: false,
data: {
message: `Product ${productId} rejected`,
},
},
400
)
);
return;
}
const order = fixture.ordersById[orderId];
const product = getProductById(fixture, productId);
if (!order || !product) {
@@ -0,0 +1,193 @@
// @vitest-environment jsdom
import { mount } from "@vue/test-utils";
import { afterEach, describe, expect, it, vi } from "vitest";
import { nextTick, ref } from "vue";
const sharedVariablesRef = vi.hoisted(() => ({ value: null }));
vi.mock(
"@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportView.vue",
() => ({
view: {
variables: {
sharedVariables: sharedVariablesRef,
},
computed: {
getViewFriendlyName: (viewKey) => {
const labels = {
all: "Alle",
invoice_per_order: "Faktura pr. ordre",
fixed_pricing: "Fastpris",
tank_cleaning: "Tankrengøring",
vehicle_subscriptions: "Vaskeabonnement",
special_arrangements: "Særaftale",
possible_duplicates: "Mulige dubletter",
};
return labels[viewKey] ?? viewKey;
},
},
},
})
);
vi.mock("@/components/displays/buttons/ColorIndicator.vue", () => ({
default: {
name: "ColorIndicator",
props: ["label", "visibility", "is_button_hover_effect"],
render() {
const { h } = require("vue");
return h(
"span",
{
class: "color-indicator-mock",
"data-testid": this.$attrs["data-testid"],
},
this.label?.text ?? ""
);
},
},
}));
const InvoicingBillingPeriodCustomerAttributes = (
await import(
"@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue"
)
).default;
const customer = (customerNumber) => ({ customer_number: customerNumber });
const setSharedTypes = (types) => {
sharedVariablesRef.value = { types };
};
const mountWithCustomer = (customerProps) =>
mount(InvoicingBillingPeriodCustomerAttributes, {
props: customerProps,
});
afterEach(() => {
sharedVariablesRef.value = null;
vi.clearAllMocks();
});
describe("InvoicingBillingPeriodCustomerAttributes lightweight membership", () => {
it("renders category chips for the active view bucket (full customer cards)", async () => {
setSharedTypes({
all: [customer(4001), customer(4002)],
invoice_per_order: [customer(7001), { customer_number: 4001, membership_only: true }],
fixed_pricing: [{ customer_number: 4001, membership_only: true }],
tank_cleaning: [],
});
const wrapper = mountWithCustomer({ customer: customer(4001) });
await nextTick();
expect(wrapper.find('[data-testid="invoicing-period-customer-attributes-4001"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="invoicing-period-customer-attribute-4001-invoice_per_order"]').exists()).toBe(
true
);
expect(wrapper.find('[data-testid="invoicing-period-customer-attribute-4001-fixed_pricing"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="invoicing-period-customer-attribute-4001-tank_cleaning"]').exists()).toBe(false);
});
it("renders chips from lightweight memberships on non-active buckets", async () => {
// Alle tab: only the `all` bucket ships full customer cards. The other
// buckets carry lightweight `{customer_number, membership_only: true}`
// entries — that's the contract the backend emits today.
setSharedTypes({
all: [customer(4001), customer(4002)],
invoice_per_order: [{ customer_number: 4001, membership_only: true }],
fixed_pricing: [{ customer_number: 4001, membership_only: true }],
tank_cleaning: [{ customer_number: 4001, membership_only: true }],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
});
const wrapper = mountWithCustomer({ customer: customer(4001) });
await nextTick();
expect(wrapper.find('[data-testid="invoicing-period-customer-attribute-4001-invoice_per_order"]').exists()).toBe(
true
);
expect(wrapper.find('[data-testid="invoicing-period-customer-attribute-4001-fixed_pricing"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="invoicing-period-customer-attribute-4001-tank_cleaning"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="invoicing-period-customer-attribute-4001-special_arrangements"]').exists()).toBe(
false
);
});
it("ignores the `all` bucket for chip membership even when populated", async () => {
setSharedTypes({
all: [customer(4001), customer(4002)],
invoice_per_order: [{ customer_number: 4001, membership_only: true }],
fixed_pricing: [],
tank_cleaning: [],
});
const wrapper = mountWithCustomer({ customer: customer(4001) });
await nextTick();
// The `all` bucket is intentionally excluded by list_views_with_customer.
expect(wrapper.find('[data-testid="invoicing-period-customer-attribute-4001-all"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="invoicing-period-customer-attribute-4001-invoice_per_order"]').exists()).toBe(
true
);
});
it("skips memberships without a numeric customer_number", async () => {
setSharedTypes({
all: [customer(4001)],
invoice_per_order: [
{ customer_number: "not-a-number", membership_only: true },
{ membership_only: true },
null,
{ customer_number: 0, membership_only: true },
{ customer_number: -1, membership_only: true },
{ customer_number: 4001, membership_only: true },
],
fixed_pricing: [],
});
const wrapper = mountWithCustomer({ customer: customer(4001) });
await nextTick();
expect(wrapper.find('[data-testid="invoicing-period-customer-attribute-4001-invoice_per_order"]').exists()).toBe(
true
);
});
it("preserves the deterministic chip display order across buckets", async () => {
// Provide buckets in REVERSE priority order; the component must sort
// them according to ATTRIBUTE_DISPLAY_PRIORITY so the chip stack stays
// stable regardless of payload shape.
setSharedTypes({
all: [customer(4001)],
possible_duplicates: [{ customer_number: 4001, membership_only: true }],
special_arrangements: [{ customer_number: 4001, membership_only: true }],
tank_cleaning: [{ customer_number: 4001, membership_only: true }],
vehicle_subscriptions: [{ customer_number: 4001, membership_only: true }],
fixed_pricing: [{ customer_number: 4001, membership_only: true }],
invoice_per_order: [{ customer_number: 4001, membership_only: true }],
});
const wrapper = mountWithCustomer({ customer: customer(4001) });
await nextTick();
const renderedKeys = wrapper
.findAll('[data-testid^="invoicing-period-customer-attribute-4001-"]')
.map((node) => node.attributes("data-testid").replace("invoicing-period-customer-attribute-4001-", ""));
// ATTRIBUTE_DISPLAY_PRIORITY order: invoice_per_order, fixed_pricing,
// vehicle_subscriptions, tank_cleaning, special_arrangements,
// possible_duplicates. Special then alphabetical for unknowns.
expect(renderedKeys).toEqual([
"invoice_per_order",
"fixed_pricing",
"vehicle_subscriptions",
"tank_cleaning",
"special_arrangements",
"possible_duplicates",
]);
});
});
@@ -466,4 +466,64 @@ describe("InvoicingPeriodFlagList", () => {
});
expect(wrapper.emitted("statusChanged")).toHaveLength(2);
});
// Regression test for TBC-105 / error-report #10: a `historical_primary_product_mismatch`
// flag must surface the historical product name (e.g. "Forvogn med hænger") through the
// i18n message_key path. The backend now only emits this flag when the current row's reg_2
// presence matches the historical orders' reg_2 presence, so the FE must render whatever
// the API does send. As of this commit there is no specific handler in flagMessageParts()
// for `historical_primary_product_mismatch`; the component falls back to flag.message.
// This test pins that contract so any future handler addition doesn't regress the
// visible text on existing deployments.
it("renders historical_primary_product_mismatch automatic flags with the historical product name", () => {
const wrapper = mountList([
{
id: "auto-historical-1",
source: "automatic",
fingerprint: "historical-fingerprint-1",
target_type: "order_item",
target_id: 905,
definition_key: "historical_primary_product_mismatch",
message_key: "invoice_period.flags.automatic.historical_primary_product_mismatch",
message_params: {
product: "Forvogn",
expected_product: "Forvogn med hænger",
},
message: "Forvogn differs from the registration number's usual product Forvogn med hænger.",
order_id: 502,
order_item_id: 905,
},
]);
const row = wrapper.get('[data-testid="invoice-period-flag-auto-historical-1"]');
expect(row.text()).toContain("Forvogn");
expect(row.text()).toContain("differs from");
expect(row.text()).toContain("Forvogn med hænger");
// The component does not yet emit an interactive order-item token for this flag type;
// pinning the current behaviour so the eventual handler addition is intentional.
expect(row.findAll(".invoice-period-flag-token")).toHaveLength(0);
});
// Backstop: the API may still send `historical_primary_product_mismatch` with no
// message_params (older API versions, or a row that was cached before the schema bump).
// The FE must not blow up; it should fall back to flag.message verbatim.
it("falls back to flag.message when historical_primary_product_mismatch has no message_params", () => {
const wrapper = mountList([
{
id: "auto-historical-2",
source: "automatic",
fingerprint: "historical-fingerprint-2",
target_type: "order_item",
target_id: 906,
definition_key: "historical_primary_product_mismatch",
message_key: "invoice_period.flags.automatic.historical_primary_product_mismatch",
message: "Spot Free differs from the registration number's usual product Forvogn med hænger.",
},
]);
const row = wrapper.get('[data-testid="invoice-period-flag-auto-historical-2"]');
expect(row.text()).toContain("Spot Free differs from the registration number's usual product Forvogn med hænger.");
// No order-item token because order_id is missing — should fall back to plain text only.
expect(row.findAll(".invoice-period-flag-token")).toHaveLength(0);
});
});
+143
View File
@@ -0,0 +1,143 @@
import { describe, expect, it } from "vitest";
import {
formatLocaleDate,
formatLocaleDateRange,
formatLocaleDateTime,
formatLocaleMonthLabel,
formatLocaleNumber,
} from "@/services/localeFormatting.js";
describe("formatLocaleNumber", () => {
it("formats a plain number using the requested locale", () => {
expect(formatLocaleNumber(1234.5, "en-US")).toBe("1,234.5");
});
it("uses locale-specific decimal and grouping separators", () => {
expect(formatLocaleNumber(1234.5, "da-DK")).toBe("1.234,5");
});
it("falls back to the en locale when the locale argument is empty, null, or undefined", () => {
expect(formatLocaleNumber(1234.5, "")).toBe(formatLocaleNumber(1234.5, "en"));
expect(formatLocaleNumber(1234.5, null)).toBe(formatLocaleNumber(1234.5, "en"));
expect(formatLocaleNumber(1234.5, undefined)).toBe(formatLocaleNumber(1234.5, "en"));
});
it("trims surrounding whitespace around the locale argument", () => {
expect(formatLocaleNumber(42, " en-US ")).toBe(formatLocaleNumber(42, "en-US"));
});
it("coerces NaN and non-numeric values to 0", () => {
expect(formatLocaleNumber(NaN, "en-US")).toBe("0");
expect(formatLocaleNumber("not-a-number", "en-US")).toBe("0");
expect(formatLocaleNumber(null, "en-US")).toBe("0");
expect(formatLocaleNumber(undefined, "en-US")).toBe("0");
});
it("passes Intl options through (currency style)", () => {
expect(formatLocaleNumber(99.95, "en-US", { style: "currency", currency: "USD" })).toBe("$99.95");
});
it("formats integers without a fractional separator", () => {
expect(formatLocaleNumber(42, "en-US")).toBe("42");
});
});
describe("formatLocaleDate", () => {
it("formats a YYYY-MM-DD string with the locale's short month", () => {
expect(formatLocaleDate("2026-03-18", "en-US")).toBe("Mar 18, 2026");
expect(formatLocaleDate("2026-03-18", "da-DK")).toBe("18. mar. 2026");
});
it("formats a Date instance built from local-time components", () => {
const date = new Date(2026, 2, 18);
expect(formatLocaleDate(date, "en-US")).toBe("Mar 18, 2026");
});
it("merges custom Intl options over the defaults", () => {
expect(formatLocaleDate("2026-03-18", "en-US", { month: "long" })).toBe("March 18, 2026");
});
it("returns an empty string for invalid, empty, null, or undefined values", () => {
expect(formatLocaleDate("not-a-date", "en-US")).toBe("");
expect(formatLocaleDate("", "en-US")).toBe("");
expect(formatLocaleDate(" ", "en-US")).toBe("");
expect(formatLocaleDate(null, "en-US")).toBe("");
expect(formatLocaleDate(undefined, "en-US")).toBe("");
expect(formatLocaleDate(new Date("invalid"), "en-US")).toBe("");
});
});
describe("formatLocaleDateTime", () => {
it("includes hour and minute in the formatted output", () => {
const date = new Date(2026, 2, 18, 9, 30);
expect(formatLocaleDateTime(date, "en-US")).toBe("Mar 18, 2026, 09:30 AM");
});
it("formats a YYYY-MM-DD string with the locale's short month and time defaults", () => {
expect(formatLocaleDateTime("2026-03-18", "en-US")).toBe("Mar 18, 2026, 12:00 AM");
});
it("returns an empty string when the value cannot be parsed", () => {
expect(formatLocaleDateTime("not-a-date", "en-US")).toBe("");
expect(formatLocaleDateTime(null, "en-US")).toBe("");
});
it("honours custom Intl options for time and date parts", () => {
const date = new Date(2026, 2, 18, 9, 30);
expect(
formatLocaleDateTime(date, "en-US", {
hour: "2-digit",
minute: "2-digit",
year: undefined,
month: undefined,
day: undefined,
})
).toBe("09:30 AM");
});
});
describe("formatLocaleMonthLabel", () => {
it("returns the long month name paired with the year", () => {
expect(formatLocaleMonthLabel("2026-03-18", "en-US")).toBe("March 2026");
expect(formatLocaleMonthLabel("2026-03-18", "da-DK")).toBe("marts 2026");
});
it("formats a Date instance using only month and year", () => {
expect(formatLocaleMonthLabel(new Date(2026, 2, 18), "en-US")).toBe("March 2026");
});
it("returns an empty string for invalid input", () => {
expect(formatLocaleMonthLabel("not-a-date", "en-US")).toBe("");
expect(formatLocaleMonthLabel(null, "en-US")).toBe("");
});
});
describe("formatLocaleDateRange", () => {
it("joins the formatted from and to values with a dash", () => {
expect(formatLocaleDateRange("2026-03-01", "2026-03-31", "en-US")).toBe("Mar 1, 2026 - Mar 31, 2026");
});
it("returns the single date when both ends resolve to the same formatted value", () => {
expect(formatLocaleDateRange("2026-03-01", "2026-03-01", "en-US")).toBe("Mar 1, 2026");
});
it("returns only the start when the end is missing or invalid", () => {
expect(formatLocaleDateRange("2026-03-01", "", "en-US")).toBe("Mar 1, 2026");
expect(formatLocaleDateRange("2026-03-01", "not-a-date", "en-US")).toBe("Mar 1, 2026");
});
it("returns only the end when the start is missing or invalid", () => {
expect(formatLocaleDateRange("", "2026-03-31", "en-US")).toBe("Mar 31, 2026");
expect(formatLocaleDateRange("not-a-date", "2026-03-31", "en-US")).toBe("Mar 31, 2026");
});
it("returns an empty string when both ends are missing", () => {
expect(formatLocaleDateRange("", "", "en-US")).toBe("");
expect(formatLocaleDateRange(null, undefined, "en-US")).toBe("");
});
it("honours the requested locale for both ends", () => {
expect(formatLocaleDateRange("2026-03-01", "2026-03-31", "da-DK")).toBe("1. mar. 2026 - 31. mar. 2026");
});
});
@@ -0,0 +1,318 @@
// @vitest-environment jsdom
import { flushPromises, mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import OrderContentTable from "@/components/displays/superuser/tables/OrderContentTable.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { createTestI18n } from "./helpers/mountWithApp.js";
const requestMock = vi.hoisted(() => vi.fn());
const showPopperMock = vi.hoisted(() => vi.fn());
const removePopperIfOpenMock = vi.hoisted(() => vi.fn());
const popperBoxMock = vi.hoisted(() => vi.fn((title, body) => ({ title, body })));
const swalFireMock = vi.hoisted(() => vi.fn(() => Promise.resolve({ isConfirmed: false })));
vi.mock("sweetalert2", () => ({
default: {
fire: swalFireMock,
},
}));
vi.mock("@/components/displays/PopperDefault.vue", () => ({
showPopper: showPopperMock,
removePopperIfOpen: removePopperIfOpenMock,
popperBox: popperBoxMock,
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
request: requestMock,
functions: {
currency: {
toLocal: (value) => String(value ?? ""),
},
redirectTo: {
department: vi.fn(),
},
},
objects: {
vehicles: {
columns: {
wash_subscription: {
label: "Wash subscription",
},
},
},
},
},
}));
const i18n = createTestI18n({
en: {
global: {
cancel: "Cancel",
no_data: "No data",
quantity: "Quantity",
reference: "Reference",
},
tables: {
products: {
name: "Product",
price: "Price",
},
},
objects: {
columns: {
notes: "Notes",
},
},
invoice_period: {
flags: {
status: {
resolved: "Resolved",
ignored: "Ignored",
false_positive: "False positive",
reason_placeholder: "Optional reason",
},
},
},
},
});
const buildItem = (overrides) => ({
id: overrides.id,
related_item_id: overrides.related_item_id ?? 0,
product_id: overrides.product_id ?? overrides.id,
product: {
id: overrides.product_id ?? overrides.id,
name: overrides.name,
},
quantity: 1,
price: 0,
notes: "",
reference: "",
include_in_invoice: true,
});
// Read the rendered product rows in display order. The component renders a
// primary item as plain text in the first cell, and an addon as "+ <name>".
// We ignore icon flag nodes and collapse whitespace. The returned shape is
// { name, isAddon } so callers can assert on primary vs addon rows.
const collectRenderedRows = (wrapper) => {
const cells = wrapper.element.querySelectorAll("tbody td:first-child");
const rows = [];
cells.forEach((td) => {
const children = Array.from(td.children).filter(
(node) => !node.classList?.contains("invoice-period-item-flag-indicator")
);
const text = (children.length ? children : [td])
.map((node) => node.textContent || "")
.join(" ")
.trim();
if (!text) {
return;
}
const isAddon = text.startsWith("+");
const name = isAddon ? text.slice(1).trim() : text;
rows.push({ name, isAddon, raw: text });
});
return rows;
};
// Helper that returns the index of the first row matching the predicate.
const indexOfRow = (rows, predicate) => rows.findIndex(predicate);
describe("OrderContentTable addon ordering", () => {
beforeEach(() => {
requestMock.mockReset();
swalFireMock.mockClear();
showPopperMock.mockClear();
removePopperIfOpenMock.mockClear();
popperBoxMock.mockClear();
});
it("places the primary before its addons when the API returns them out of order", async () => {
const primary = buildItem({ id: 101, product_id: 11, name: "Traekker" });
const trailerAddon = buildItem({ id: 102, product_id: 12, name: "TrailerAddon", related_item_id: 101 });
const dollyAddon = buildItem({ id: 103, product_id: 13, name: "DollyAddon", related_item_id: 101 });
const spotFreeAddon = buildItem({ id: 104, product_id: 14, name: "SpotFreeAddon", related_item_id: 101 });
const undervognAddon = buildItem({ id: 105, product_id: 15, name: "UndervognAddon", related_item_id: 101 });
// API returns rows in undefined order (addons listed before their primary).
const apiOrderItems = [trailerAddon, spotFreeAddon, dollyAddon, primary, undervognAddon];
requestMock.mockResolvedValue({ data: { data: apiOrderItems } });
const wrapper = mount(OrderContentTable, {
props: { orderId: 9001 },
global: {
plugins: [i18n],
stubs: {
InvoicingPeriodFlagList: true,
},
},
});
await flushPromises();
await flushPromises();
const rows = collectRenderedRows(wrapper);
// The primary ("Traekker") must always appear before its addons, regardless of API order.
const primaryIndex = indexOfRow(rows, (row) => row.name === "Traekker");
expect(primaryIndex).toBeGreaterThanOrEqual(0);
["TrailerAddon", "DollyAddon", "SpotFreeAddon", "UndervognAddon"].forEach((name) => {
const idx = indexOfRow(rows, (row) => row.name === name && row.isAddon);
expect(idx).toBeGreaterThanOrEqual(0);
expect(idx).toBeGreaterThan(primaryIndex);
});
wrapper.unmount();
});
it("groups sibling addons under their parent when several primaries are present", async () => {
const primaryA = buildItem({ id: 201, product_id: 21, name: "PrimaryA" });
const primaryB = buildItem({ id: 202, product_id: 22, name: "PrimaryB" });
const addonA1 = buildItem({ id: 203, product_id: 31, name: "AddonA1", related_item_id: 201 });
const addonA2 = buildItem({ id: 204, product_id: 32, name: "AddonA2", related_item_id: 201 });
const addonB1 = buildItem({ id: 205, product_id: 33, name: "AddonB1", related_item_id: 202 });
const apiOrderItems = [addonA1, addonB1, primaryB, addonA2, primaryA];
requestMock.mockResolvedValue({ data: { data: apiOrderItems } });
const wrapper = mount(OrderContentTable, {
props: { orderId: 9002 },
global: {
plugins: [i18n],
stubs: {
InvoicingPeriodFlagList: true,
},
},
});
await flushPromises();
await flushPromises();
const rows = collectRenderedRows(wrapper);
const primaryAIndex = indexOfRow(rows, (row) => row.name === "PrimaryA");
const primaryBIndex = indexOfRow(rows, (row) => row.name === "PrimaryB");
const addonA1Index = indexOfRow(rows, (row) => row.name === "AddonA1");
const addonA2Index = indexOfRow(rows, (row) => row.name === "AddonA2");
const addonB1Index = indexOfRow(rows, (row) => row.name === "AddonB1");
expect(primaryAIndex).toBeGreaterThanOrEqual(0);
expect(primaryBIndex).toBeGreaterThanOrEqual(0);
expect(primaryAIndex).toBeLessThan(primaryBIndex);
expect(addonA1Index).toBeGreaterThan(primaryAIndex);
expect(addonA2Index).toBeGreaterThan(primaryAIndex);
expect(addonA1Index).toBeLessThan(primaryBIndex);
expect(addonA2Index).toBeLessThan(primaryBIndex);
expect(addonB1Index).toBeGreaterThan(primaryBIndex);
wrapper.unmount();
});
it("preserves a stable order when the consumer passes local items directly", async () => {
const primary = buildItem({ id: 301, product_id: 31, name: "TraekkerLocal" });
const addon = buildItem({ id: 302, product_id: 32, name: "TrailerAddonLocal", related_item_id: 301 });
requestMock.mockResolvedValue({ data: { data: [] } });
const wrapper = mount(OrderContentTable, {
props: {
orderId: 9003,
useLocalOrderItems: true,
localOrderItems: [addon, primary],
},
global: {
plugins: [i18n],
stubs: {
InvoicingPeriodFlagList: true,
},
},
});
await flushPromises();
await flushPromises();
const rows = collectRenderedRows(wrapper);
const primaryIndex = indexOfRow(rows, (row) => row.name === "TraekkerLocal");
const addonIndex = indexOfRow(rows, (row) => row.name === "TrailerAddonLocal");
expect(primaryIndex).toBeGreaterThanOrEqual(0);
expect(addonIndex).toBeGreaterThanOrEqual(0);
expect(primaryIndex).toBeLessThan(addonIndex);
wrapper.unmount();
});
it("does not call the API when local order items are provided", async () => {
const primary = buildItem({ id: 401, product_id: 41, name: "TraekkerNoApi" });
requestMock.mockResolvedValue({ data: { data: [primary] } });
const wrapper = mount(OrderContentTable, {
props: {
orderId: 9004,
useLocalOrderItems: true,
localOrderItems: [primary],
},
global: {
plugins: [i18n],
stubs: {
InvoicingPeriodFlagList: true,
},
},
});
await flushPromises();
await flushPromises();
expect(requestMock).not.toHaveBeenCalled();
wrapper.unmount();
});
it("renders a Trækker order with all addons in the correct order regardless of API order", async () => {
// The scenario from the bug report: Trækker + Trailer + Dolly + Spot-free + Undervognsskyld.
// The API previously returned rows in undefined order, which made the FE render only
// Trailer/Dolly on top of the table and bury the primary Trækker below them. With the
// defensive sort, Trækker renders first and every addon renders after it.
const primary = buildItem({ id: 501, product_id: 51, name: "TraekkerBugRepro" });
const trailerAddon = buildItem({ id: 502, product_id: 52, name: "TrailerAddonBugRepro", related_item_id: 501 });
const dollyAddon = buildItem({ id: 503, product_id: 53, name: "DollyAddonBugRepro", related_item_id: 501 });
const spotFreeAddon = buildItem({ id: 504, product_id: 54, name: "SpotFreeAddonBugRepro", related_item_id: 501 });
const undervognAddon = buildItem({ id: 505, product_id: 55, name: "UndervognAddonBugRepro", related_item_id: 501 });
// Worst case: all addons before their primary.
const apiOrderItems = [trailerAddon, dollyAddon, spotFreeAddon, undervognAddon, primary];
requestMock.mockResolvedValue({ data: { data: apiOrderItems } });
const wrapper = mount(OrderContentTable, {
props: { orderId: 9005 },
global: {
plugins: [i18n],
stubs: {
InvoicingPeriodFlagList: true,
},
},
});
await flushPromises();
await flushPromises();
const rows = collectRenderedRows(wrapper);
const primaryIndex = indexOfRow(rows, (row) => row.name === "TraekkerBugRepro");
expect(primaryIndex).toBeGreaterThanOrEqual(0);
["TrailerAddonBugRepro", "DollyAddonBugRepro", "SpotFreeAddonBugRepro", "UndervognAddonBugRepro"].forEach(
(name) => {
const idx = indexOfRow(rows, (row) => row.name === name && row.isAddon);
expect(idx).toBeGreaterThan(primaryIndex);
}
);
wrapper.unmount();
});
});
+322
View File
@@ -0,0 +1,322 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
addOrderItemAddons,
buildAddonCreateOrderItemArgs,
} from "@/components/displays/department/pos/utils/addOrderItemAddons.js";
import { OrderItemsPartialSyncError } from "@/components/displays/department/pos/utils/orderItemsPartialSync.js";
const makeApi = (overrides = {}) => ({
createOrderItem: overrides.createOrderItem ?? vi.fn(),
removeOrderItem: overrides.removeOrderItem ?? vi.fn(async () => ({})),
});
describe("buildAddonCreateOrderItemArgs", () => {
it("normalizes a nested product-shaped add-on with the primary id as related_item_id", () => {
const args = buildAddonCreateOrderItemArgs(
{
option_id: 71,
quantity: 2,
product: { id: 71, price: 99 },
},
900
);
expect(args).toEqual({
productId: 71,
quantity: 2,
relatedItemId: 900,
notes: "",
skipPriceOverride: false,
overridePrice: false,
price: null,
});
});
it("omits the price when the caller does not request an override (preserves desktop default-price behaviour)", () => {
const args = buildAddonCreateOrderItemArgs({ option_id: 71, quantity: 1, product: { id: 71, price: 99 } }, 900);
expect(args.overridePrice).toBe(false);
expect(args.price).toBeNull();
});
it("includes the price when the caller explicitly opts in to priceOverride", () => {
const args = buildAddonCreateOrderItemArgs(
{ option_id: 71, quantity: 1, priceOverride: true, product: { id: 71, price: 99 } },
900
);
expect(args.overridePrice).toBe(true);
expect(args.price).toBe(99);
});
it("returns null price when skip_price_override is true even with priceOverride", () => {
const args = buildAddonCreateOrderItemArgs(
{
option_id: 71,
quantity: 1,
priceOverride: true,
product: { id: 71, price: 99, skip_price_override: true },
},
900
);
expect(args.skipPriceOverride).toBe(true);
expect(args.price).toBeNull();
});
it("returns null price when the addon has no product metadata and no override is requested", () => {
const args = buildAddonCreateOrderItemArgs({ option_id: 71, quantity: 1 }, 900);
expect(args.overridePrice).toBe(false);
expect(args.price).toBeNull();
});
});
describe("addOrderItemAddons", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("creates every add-on linked to the primary on the happy path", async () => {
const createdIds = { value: 200 };
const api = makeApi({
createOrderItem: vi.fn(async () => {
const id = ++createdIds.value;
return { data: { data: { id } } };
}),
});
const result = await addOrderItemAddons({
orderId: 9400,
primaryItemId: 199,
addons: [
{ option_id: 71, quantity: 1, product: { id: 71, price: 99 } },
{ option_id: 41, quantity: 2, product: { id: 41, price: 25 } },
],
api,
});
expect(api.createOrderItem).toHaveBeenCalledTimes(2);
expect(api.createOrderItem.mock.calls.map(([, productId]) => productId)).toEqual([71, 41]);
// Each add-on POST is linked to the primary via related_item_id.
expect(api.createOrderItem.mock.calls[0][3]).toBe(199);
expect(api.createOrderItem.mock.calls[1][3]).toBe(199);
expect(api.removeOrderItem).not.toHaveBeenCalled();
expect(result.createdAddonIds).toHaveLength(2);
expect(result.createdAdditionalIds).toHaveLength(0);
expect(result.createdIds).toEqual([201, 202]);
});
it("returns empty arrays when no addons or additional items are provided", async () => {
const api = makeApi();
const result = await addOrderItemAddons({
orderId: 9401,
primaryItemId: 199,
addons: [],
additionalItems: [],
api,
});
expect(api.createOrderItem).not.toHaveBeenCalled();
expect(result.createdIds).toEqual([]);
expect(result.createdAddonIds).toEqual([]);
expect(result.createdAdditionalIds).toEqual([]);
});
it("fans out additional items with related_item_id = null", async () => {
const createdIds = { value: 300 };
const api = makeApi({
createOrderItem: vi.fn(async () => {
const id = ++createdIds.value;
return { data: { data: { id } } };
}),
});
await addOrderItemAddons({
orderId: 9402,
primaryItemId: 199,
addons: [{ option_id: 71, quantity: 1, product: { id: 71, price: 99 } }],
additionalItems: [{ id: 91, quantity: 1, price: 120 }],
api,
});
expect(api.createOrderItem).toHaveBeenCalledTimes(2);
// Addon linked to the primary.
expect(api.createOrderItem.mock.calls[0][3]).toBe(199);
// Additional item is standalone.
expect(api.createOrderItem.mock.calls[1][3]).toBeNull();
});
it("rolls back every add-on row that landed when one POST rejects", async () => {
let call = 0;
const api = makeApi({
createOrderItem: vi.fn(async (_orderId, productId) => {
call += 1;
if (productId === 41) {
const reason = new Error("addon 41 rejected");
reason.response = { data: { data: { message: "Product 41 rejected" } } };
throw reason;
}
return { data: { data: { id: 1000 + call } } };
}),
});
let captured;
try {
await addOrderItemAddons({
orderId: 9403,
primaryItemId: 199,
addons: [
{ option_id: 71, quantity: 1, product: { id: 71, price: 99 } },
{ option_id: 41, quantity: 1, product: { id: 41, price: 25 } },
{ option_id: 99, quantity: 1, product: { id: 99, price: 10 } },
],
api,
});
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(OrderItemsPartialSyncError);
expect(api.createOrderItem).toHaveBeenCalledTimes(3);
// The shared helper rolls back the add-ons that landed (71 succeeded,
// 41 rejected, 99 was issued in the same fan-out). The successful
// add-ons (71 and 99) are removed; the rejected one never landed.
const rolledBack = api.removeOrderItem.mock.calls.map(([id]) => id).sort((a, b) => a - b);
expect(rolledBack).toEqual([1001, 1003]);
expect(captured.message).toBe("Product 41: Product 41 rejected");
expect(captured.failures).toEqual([{ productId: 41, message: "Product 41 rejected" }]);
expect(captured.rolledBackIds).toEqual([1001, 1003]);
});
it("does NOT roll back the primary itself — the mobile helper owns that responsibility", async () => {
const api = makeApi({
createOrderItem: vi.fn(async (_orderId, productId) => {
if (productId === 41) {
throw Object.assign(new Error("backend rejection"), {
response: { data: { data: { message: "Customer rule block" } } },
});
}
return { data: { data: { id: 1000 + Number(productId) } } };
}),
});
let captured;
try {
await addOrderItemAddons({
orderId: 9404,
primaryItemId: 199,
addons: [
{ option_id: 71, quantity: 1, product: { id: 71, price: 99 } },
{ option_id: 41, quantity: 1, product: { id: 41, price: 25 } },
],
api,
});
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(OrderItemsPartialSyncError);
// Only the add-on row was rolled back; the primary (199) is the
// caller's responsibility and is not touched here.
const rolledBack = api.removeOrderItem.mock.calls.map(([id]) => id);
expect(rolledBack).not.toContain(199);
});
it("throws immediately when the primary item id is missing", async () => {
const api = makeApi();
await expect(
addOrderItemAddons({
orderId: 9405,
primaryItemId: null,
addons: [{ option_id: 71, quantity: 1, product: { id: 71 } }],
api,
})
).rejects.toThrow("Primary order item ID is required");
});
it("throws immediately when the order id is missing", async () => {
const api = makeApi();
await expect(
addOrderItemAddons({
orderId: 0,
primaryItemId: 199,
addons: [{ option_id: 71, quantity: 1, product: { id: 71 } }],
api,
})
).rejects.toThrow("Order ID is required");
});
it("passes a null price (preserves desktop default-price behaviour) when priceOverride is unset", async () => {
const api = makeApi({
createOrderItem: vi.fn(async () => ({ data: { data: { id: 500 } } })),
});
await addOrderItemAddons({
orderId: 9406,
primaryItemId: 199,
addons: [{ option_id: 71, quantity: 1, product: { id: 71, price: 99 } }],
api,
});
// The 6th arg is the price (forcePrice on createOrderItem). When the
// caller does not opt in to priceOverride, we pass null so the server
// falls back to the product's default price.
expect(api.createOrderItem.mock.calls[0][5]).toBeNull();
});
it("passes the price to the server when priceOverride is true (mobile path)", async () => {
const api = makeApi({
createOrderItem: vi.fn(async () => ({ data: { data: { id: 500 } } })),
});
await addOrderItemAddons({
orderId: 9407,
primaryItemId: 199,
addons: [{ option_id: 71, quantity: 1, priceOverride: true, product: { id: 71, price: 99 } }],
api,
});
expect(api.createOrderItem.mock.calls[0][5]).toBe(99);
});
it("swallows rollback failures so the original error still surfaces", async () => {
const api = makeApi({
createOrderItem: vi.fn(async (_orderId, productId) => {
if (productId === 41) {
throw Object.assign(new Error("rejected"), {
response: { data: { data: { message: "Product 41 rejected" } } },
});
}
return { data: { data: { id: 1000 + productId } } };
}),
removeOrderItem: vi.fn(async () => {
throw new Error("rollback removeOrderItem failed");
}),
});
let captured;
try {
await addOrderItemAddons({
orderId: 9408,
primaryItemId: 199,
addons: [
{ option_id: 71, quantity: 1, product: { id: 71, price: 99 } },
{ option_id: 41, quantity: 1, product: { id: 41, price: 25 } },
],
api,
});
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(OrderItemsPartialSyncError);
expect(captured.failures).toEqual([{ productId: 41, message: "Product 41 rejected" }]);
});
});
@@ -0,0 +1,325 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
OrderItemsPartialSyncError,
buildDesiredOrderItemShapes,
extractErrorMessage,
formatFailureFragment,
normalizeExistingOrderItemShapes,
syncMobileOrderItems,
} from "@/components/displays/department/pos/steps/mobile/utils/syncMobileOrderItems.js";
const buildPrimary = (overrides = {}) => ({
id: 53,
name: "Trækker",
price: 599,
notes: "",
skip_price_override: false,
addons: [
{
id: 71,
quantity: 1,
product: { id: 71, price: 99 },
},
{
id: 41,
quantity: 2,
product: { id: 41, price: 25 },
},
],
...overrides,
});
const buildAdditional = (overrides = {}) => ({
id: 91,
price: 120,
quantity: 1,
...overrides,
});
const makeApi = ({ createOrderItem, getOrderItems, removeOrderItem } = {}) => ({
createOrderItem: createOrderItem ?? vi.fn(),
getOrderItems: getOrderItems ?? vi.fn(),
removeOrderItem: removeOrderItem ?? vi.fn(async () => ({})),
});
describe("normalizeExistingOrderItemShapes", () => {
it("treats the first related_item_id=null row as primary and links children", () => {
const shapes = normalizeExistingOrderItemShapes(
[
{ id: 100, product_id: 53, product: { id: 53 }, quantity: 1, related_item_id: null, price: 599, notes: "" },
{ id: 101, product_id: 71, product: { id: 71 }, quantity: 1, related_item_id: 100, price: 99, notes: "" },
{ id: 102, product_id: 41, product: { id: 41 }, quantity: 2, related_item_id: 100, price: 25, notes: "" },
{ id: 103, product_id: 91, product: { id: 91 }, quantity: 1, related_item_id: null, price: 120, notes: "" },
],
53
);
// related_item_id is intentionally excluded from the comparison shape —
// both the desired-shape placeholder and the real numeric id map to the
// same idempotency bucket.
expect(shapes).toEqual([
{ product_id: 53, quantity: 1, price: 599, notes: "" },
{ product_id: 71, quantity: 1, price: 99, notes: "" },
{ product_id: 41, quantity: 2, price: 25, notes: "" },
{ product_id: 91, quantity: 1, price: 120, notes: "" },
]);
});
it("returns an empty list when there is no primary row", () => {
expect(normalizeExistingOrderItemShapes([{ id: 101, product_id: 71, related_item_id: 999 }], 53)).toEqual([]);
});
});
describe("buildDesiredOrderItemShapes", () => {
it("filters out add-ons with quantity 0 and uses the placeholder related_item_id", () => {
const primary = buildPrimary({
addons: [
{ id: 71, quantity: 0, product: { id: 71, price: 99 } },
{ id: 41, quantity: 2, product: { id: 41, price: 25 } },
],
});
const shapes = buildDesiredOrderItemShapes({ primaryItem: primary, additionalItems: [] });
expect(shapes).toHaveLength(2); // primary + 1 addon
expect(shapes[1]).toMatchObject({
kind: "addon",
product_id: 41,
quantity: 2,
related_item_id: "__PRIMARY__",
price: 25,
});
});
it("includes standalone additional items with quantity > 0", () => {
const shapes = buildDesiredOrderItemShapes({
primaryItem: buildPrimary(),
additionalItems: [buildAdditional({ id: 91, quantity: 1 })],
});
expect(shapes).toHaveLength(4); // primary + 2 addons + 1 additional
expect(shapes.at(-1)).toMatchObject({
kind: "additional",
product_id: 91,
related_item_id: null,
});
});
it("honours the restriction predicates", () => {
const isAddonRestricted = (addon) => addon.id === 41;
const shapes = buildDesiredOrderItemShapes({
primaryItem: buildPrimary(),
additionalItems: [],
isAddonRestricted,
});
expect(shapes).toHaveLength(2); // primary + addon 71 only
expect(shapes.map((shape) => shape.product_id)).toEqual([53, 71]);
});
});
describe("extractErrorMessage and formatFailureFragment", () => {
it("unwraps axios-shaped error responses", () => {
expect(
extractErrorMessage({
response: { data: { data: { message: "Notes is required for this product" } } },
})
).toBe("Notes is required for this product");
});
it("falls back to plain Error messages when the response shape is unknown", () => {
expect(extractErrorMessage(new Error("boom"))).toBe("boom");
expect(extractErrorMessage(undefined)).toBe("Unknown error");
});
it("prefixes the product id when one is available", () => {
expect(formatFailureFragment({ productId: 41, message: "rejected" })).toBe("Product 41: rejected");
expect(formatFailureFragment({ productId: 0, message: "no id" })).toBe("no id");
});
});
describe("syncMobileOrderItems", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("creates the primary and every add-on plus additional item on the happy path", async () => {
const createdIds = { value: 100 };
const api = makeApi({
getOrderItems: vi.fn(async () => ({ data: { data: [] } })),
createOrderItem: vi.fn(async (_orderId, _productId) => {
const id = ++createdIds.value;
return { data: { data: { id } } };
}),
removeOrderItem: vi.fn(async () => ({})),
});
const result = await syncMobileOrderItems({
orderId: 9402,
primaryItem: buildPrimary(),
additionalItems: [buildAdditional({ id: 91, quantity: 1 })],
api,
});
// 1 primary + 2 addons + 1 additional = 4 POSTs
expect(api.createOrderItem).toHaveBeenCalledTimes(4);
const postedProductIds = api.createOrderItem.mock.calls.map(([, productId]) => productId);
expect(postedProductIds).toEqual([53, 71, 41, 91]);
// Addons pass the created primary id as related_item_id
expect(api.createOrderItem.mock.calls[1][3]).toBe(result.createdPrimaryItemId);
expect(api.createOrderItem.mock.calls[2][3]).toBe(result.createdPrimaryItemId);
// Standalone additional passes null
expect(api.createOrderItem.mock.calls[3][3]).toBeNull();
expect(api.removeOrderItem).not.toHaveBeenCalled();
expect(result.createdIds).toHaveLength(4);
});
it("rolls back every order item it created when one add-on POST rejects", async () => {
let call = 0;
const api = makeApi({
getOrderItems: vi.fn(async () => ({ data: { data: [] } })),
createOrderItem: vi.fn(async () => {
call += 1;
// call 1 = primary (id 101), call 2 = addon 71 (id 102),
// call 3 = addon 41 (reject), call 4+ never happen.
if (call === 3) {
const reason = new Error("request failed");
reason.response = { data: { data: { message: "Product 41 rejected" } } };
throw reason;
}
return { data: { data: { id: 100 + call } } };
}),
removeOrderItem: vi.fn(async () => ({})),
});
let captured;
try {
await syncMobileOrderItems({
orderId: 9403,
primaryItem: buildPrimary(),
additionalItems: [],
api,
});
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(OrderItemsPartialSyncError);
// 1 primary + 2 addons attempted = 3 POSTs.
expect(api.createOrderItem).toHaveBeenCalledTimes(3);
// Rollback runs against every row we created in this attempt:
// the primary (101) AND the first add-on (102).
const rolledBack = api.removeOrderItem.mock.calls.map(([id]) => id).sort((a, b) => a - b);
expect(rolledBack).toEqual([101, 102]);
expect(captured.message).toBe("Product 41: Product 41 rejected");
expect(captured.failures).toEqual([{ productId: 41, message: "Product 41 rejected" }]);
expect(captured.rolledBackIds).toEqual(expect.arrayContaining([101, 102]));
});
it("still throws when the primary POST rejects — the failure surfaces in the error", async () => {
const api = makeApi({
getOrderItems: vi.fn(async () => ({ data: { data: [] } })),
createOrderItem: vi.fn(async () => {
const reason = new Error("primary failed");
reason.response = { data: { data: { message: "Primary blocked" } } };
throw reason;
}),
removeOrderItem: vi.fn(async () => ({})),
});
await expect(
syncMobileOrderItems({
orderId: 9404,
primaryItem: buildPrimary(),
additionalItems: [],
api,
})
).rejects.toBeInstanceOf(OrderItemsPartialSyncError);
});
it("is idempotent: skips work when the server already matches the desired shapes", async () => {
const primaryRow = {
id: 500,
product_id: 53,
product: { id: 53 },
quantity: 1,
related_item_id: null,
price: 599,
notes: "",
};
const api = makeApi({
getOrderItems: vi.fn(async () => ({
data: {
data: [
primaryRow,
{
id: 501,
product_id: 71,
product: { id: 71 },
quantity: 1,
related_item_id: 500,
price: 99,
notes: "",
},
{
id: 502,
product_id: 41,
product: { id: 41 },
quantity: 2,
related_item_id: 500,
price: 25,
notes: "",
},
],
},
})),
createOrderItem: vi.fn(),
removeOrderItem: vi.fn(),
});
const result = await syncMobileOrderItems({
orderId: 9405,
primaryItem: buildPrimary(),
additionalItems: [],
api,
});
expect(api.createOrderItem).not.toHaveBeenCalled();
expect(api.removeOrderItem).not.toHaveBeenCalled();
expect(result.createdPrimaryItemId).toBeNull();
});
it("forces a recreate when any desired shape carries skip_price_override=true", async () => {
const primaryRow = {
id: 600,
product_id: 53,
product: { id: 53 },
quantity: 1,
related_item_id: null,
price: 599,
notes: "",
};
const api = makeApi({
getOrderItems: vi.fn(async () => ({
data: {
data: [primaryRow],
},
})),
createOrderItem: vi.fn(async () => ({ data: { data: { id: 700 } } })),
removeOrderItem: vi.fn(async () => ({})),
});
await syncMobileOrderItems({
orderId: 9406,
primaryItem: buildPrimary({ skip_price_override: true, price: 599 }),
additionalItems: [],
api,
});
expect(api.removeOrderItem).toHaveBeenCalledWith(600);
expect(api.createOrderItem).toHaveBeenCalled();
});
});
+58 -35
View File
@@ -591,10 +591,10 @@ describe("Periode tab contract", () => {
expect(xlvaskUsagePaginationSource).toContain('v-if="shouldShowLocalFilters"');
expect(xlvaskUsagePaginationSource).toContain('v-if="!hideSearchField && shouldShowLocalFilters"');
expect(xlvaskUsagePaginationSource).toContain('v-if="!props.loadAllAtOnce"');
expect(xlvaskUsagePaginationSource).toContain("const buildImportUsageParams = () => {");
expect(xlvaskUsagePaginationSource).toContain("const buildUsagePaginationParams = (extra = {}) =>");
expect(xlvaskUsagePaginationSource).toContain("dateFrom: props.initialDateFrom");
expect(xlvaskUsagePaginationSource).toContain("dateTo: props.initialDateTo");
expect(xlvaskUsagePaginationSource).toContain("buildImportUsageParams()");
expect(xlvaskUsagePaginationSource).toContain("buildUsagePaginationParams()");
});
it("loads Selvvask selector counts and progress from the selected period", () => {
@@ -619,46 +619,69 @@ describe("Periode tab contract", () => {
expect(xlvaskUsageOrdersTableSource).toContain("const { loadList } = usePaginatedListInstance();");
});
it("wires the Selvvask surface to operator-only accept / reject / ignore actions", () => {
// The Selvvask view (Superuser → Fakturaer → Periode → Selvvask) must
// exclusively surface the operator review actions (Accept / Reject /
// Ignore / Unignore). The autopilot / dry-run / execute / AI adjudication
// flows are no longer wired here — operator review is the whole surface.
expect(periodViewSelfWashSource).not.toContain(":automation-workspace=");
expect(periodViewSelfWashSource).not.toContain("can_review");
expect(periodViewSelfWashSource).not.toContain("can_manage_policy");
expect(xlvaskUsagePaginationSource).toContain(':allow-review-actions="true"');
expect(xlvaskUsagePaginationSource).toContain(':allow-select-multiple="true"');
expect(xlvaskUsagePaginationSource).toContain(':allow-adjudication-actions="false"');
// The orders table exposes the per-row Accept / Reject / Ignore / Unignore
// buttons gated on `allowReviewActions`. The AI-adjudication buttons and
// the legacy "adjudication" tag must never appear in this surface.
expect(xlvaskUsageOrdersTableSource).toContain(
'<div v-if="props.allowReviewActions" class="column is-2 xlvask-usage-actions-column">'
);
expect(xlvaskUsageOrdersTableSource).toContain(":data-testid=\"'xlvask-accept-' + object.id\"");
expect(xlvaskUsageOrdersTableSource).toContain(":data-testid=\"'xlvask-reject-' + object.id\"");
expect(xlvaskUsageOrdersTableSource).toContain(":data-testid=\"'xlvask-ignore-' + object.id\"");
expect(xlvaskUsageOrdersTableSource).not.toContain("xlvask-adjudication-");
});
it("keeps Selvvask usage entries wrapped inside the period content area", () => {
expect(xlvaskUsageOrdersTableSource).toContain("xlvask-usage-card-primary");
expect(xlvaskUsageOrdersTableSource).toContain("xlvask-usage-card-status");
expect(xlvaskUsageOrdersTableSource).toContain("xlvask-usage-chip-text");
expect(xlvaskUsageOrdersTableSource).toContain("xlvask-review-table");
expect(xlvaskUsageOrdersTableSource).toContain("xlvask-usage-card");
expect(xlvaskUsageOrdersTableSource).toContain("getCachedXlvaskUsageAmount");
expect(xlvaskUsageOrdersTableSource).toContain("setCachedXlvaskUsageAmount");
expect(xlvaskUsageOrdersTableSource).toContain("object?.total_net_amount");
expect(xlvaskUsageOrdersTableSource).toContain("automation.status === 'failed'");
expect(xlvaskUsageOrdersTableSource).toContain("getXlvaskResolutionState(object)");
expect(xlvaskUsageOrdersTableSource).toContain("only fetch one-time fast-link details on demand");
expect(xlvaskUsageOrdersTableSource).toContain("object.resolution_state");
expect(xlvaskUsageOrdersTableSource).toContain("onClickAccept(object)");
expect(xlvaskUsageOrdersTableSource).toContain("onClickReject(object)");
expect(xlvaskUsageOrdersTableSource).toContain("onClickIgnore(object)");
expect(xlvaskUsageOrdersTableSource).toContain("onClickUnignore(object)");
expect(xlvaskUsageOrdersTableSource).not.toContain("automation.status");
expect(xlvaskUsageOrdersTableSource).not.toContain("scheduleFastLink(object)");
expect(xlvaskUsageOrdersTableSource).toContain("grid-template-columns: minmax(0, 1fr) minmax(9rem, 15rem);");
expect(xlvaskUsageOrdersTableSource).toContain("xlvask-order-comparison-columns");
expect(xlvaskUsageOrdersTableSource).toContain("@media screen and (max-width: 1350px)");
expect(xlvaskUsageOrdersTableSource).not.toContain("xlvask-order-comparison-columns");
});
it("uses bounded autopilot polling and preview/apply-only review mutations", () => {
expect(xlvaskUsagePaginationSource).toContain("const RUN_POLL_INTERVAL_MS = 2_000");
expect(xlvaskUsagePaginationSource).toContain("const RUN_POLL_MAX_DURATION_MS = 5 * 60 * 1_000");
expect(xlvaskUsagePaginationSource).toContain("document.hidden");
expect(xlvaskUsagePaginationSource).toContain("resumeAutopilotPolling");
expect(xlvaskUsagePaginationSource).toContain("invalidateAutopilotScope");
expect(xlvaskUsagePaginationSource).toContain("if (sequence !== runPollSequence) return;");
expect(xlvaskUsagePaginationSource).toContain("activeRunRecoverySequence += 1");
expect(xlvaskUsagePaginationSource).toContain("await loadAutomationControlState();");
expect(xlvaskUsagePaginationSource).toContain("await recoverActiveAutopilotRun();");
expect(xlvaskUsagePaginationSource).toContain('halted: "advisory"');
expect(xlvaskUsagePaginationSource).toContain("expected_policy_version: preview.expected_policy_version");
expect(xlvaskUsagePaginationSource).toContain("buildImportUsageParams(),");
expect(xlvaskUsagePaginationSource).toContain("const runParams = buildImportUsageParams();");
expect(xlvaskUsagePaginationSource).toContain("const readinessSnapshot = buildExecutionReadinessSnapshot();");
expect(xlvaskUsagePaginationSource).toContain("if (requestScopeSequence !== periodScopeSequence) return;");
expect(xlvaskUsagePaginationSource).toContain("JSON.stringify(buildExecutionReadinessSnapshot())");
expect(xlvaskUsagePaginationSource).toContain("if (!canStartExecute.value) return;");
expect(xlvaskUsageOrdersTableSource).toContain('"/modules/xlvask/services/usage/automation/decisions/preview"');
expect(xlvaskUsageOrdersTableSource).toContain('"/modules/xlvask/services/usage/automation/decisions/apply"');
expect(xlvaskUsageOrdersTableSource).toContain("if (!confirmation.isConfirmed) return false;");
expect(xlvaskUsageOrdersTableSource).toContain("if (applied) selectedUsageLogIds.value = [];");
expect(xlvaskUsageOrdersTableSource).not.toContain("set.wash_id");
expect(xlvaskUsageOrdersTableSource).not.toContain("'/order/items'");
it("uses summary refresh after operator review and stays on the review endpoints", () => {
// After an operator Accept / Reject / Ignore / Unignore, the pagination
// and the orders table must re-load the summary via the same review
// endpoints that this surface exclusively consumes. No autopilot / dry-run /
// apply / policy-version / readiness-snapshot plumbing survives.
expect(xlvaskUsagePaginationSource).toContain("/modules/xlvask/services/usage/orders/summary");
expect(xlvaskUsagePaginationSource).toContain("xlvask-usage-order-updated");
expect(xlvaskUsagePaginationSource).not.toContain("RUN_POLL_INTERVAL_MS");
expect(xlvaskUsagePaginationSource).not.toContain("RUN_POLL_MAX_DURATION_MS");
expect(xlvaskUsagePaginationSource).not.toContain("recoverActiveAutopilotRun");
expect(xlvaskUsagePaginationSource).not.toContain("loadAutomationControlState");
expect(xlvaskUsagePaginationSource).not.toContain("buildExecutionReadinessSnapshot");
expect(xlvaskUsagePaginationSource).not.toContain("resumeAutopilotPolling");
expect(xlvaskUsagePaginationSource).not.toContain("canStartExecute");
expect(xlvaskUsageOrdersTableSource).toContain("/modules/xlvask/services/usage/orders/${object.id}/");
expect(xlvaskUsageOrdersTableSource).toContain("/modules/xlvask/services/usage/orders/${id}/");
expect(xlvaskUsageOrdersTableSource).not.toContain("/modules/xlvask/services/usage/automation/decisions/preview");
expect(xlvaskUsageOrdersTableSource).not.toContain("/modules/xlvask/services/usage/automation/decisions/apply");
expect(xlvaskUsageOrdersTableSource).not.toContain("if (!confirmation.isConfirmed)");
expect(xlvaskUsageOrdersTableSource).not.toContain("'apply'");
expect(xlvaskUsageOrdersTableSource).not.toContain("'preview'");
});
it("keeps period subview navigation on valid keys", () => {
+149
View File
@@ -0,0 +1,149 @@
// @vitest-environment jsdom
import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { defineComponent, h } from "vue";
const mockGetAll = vi.fn();
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
superUser: {
modules: {
testmod: {
config: {
get_all: mockGetAll,
},
},
},
},
},
}));
vi.mock("vue", async () => {
const actual = await vi.importActual("vue");
return {
...actual,
onMounted: (fn) => fn(),
};
});
const payload = {
enabled: { variable: "enabled", value: "true" },
disabled: { variable: "disabled", value: "false" },
on_int: { variable: "on_int", value: 1 },
flag: { variable: "flag", value: true },
raw: { variable: "raw", value: "raw-value" },
missing: { variable: "missing", value: null },
secret_set: { variable: "secret_set", value: "[redacted]", isSecret: true, isSet: true },
secret_unset: { variable: "secret_unset", value: "", isSecret: true, isSet: false },
};
const mountComposable = async (overrides = {}) => {
const { useModuleConfig } = await import("@/composables/useModuleConfig.js");
let captured;
const Harness = defineComponent({
setup() {
captured = useModuleConfig("testmod", overrides);
return () => h("div");
},
});
mount(Harness);
await Promise.resolve();
await Promise.resolve();
return captured;
};
describe("useModuleConfig", () => {
beforeEach(() => {
mockGetAll.mockReset();
mockGetAll.mockResolvedValue({ data: { data: payload } });
});
it("loads entries on mount and exposes module_config as a list", async () => {
const api = await mountComposable();
expect(mockGetAll).toHaveBeenCalledTimes(1);
expect(api.module_config.value).toHaveLength(8);
expect(api.module_config.value.map((entry) => entry.variable)).toEqual([
"enabled",
"disabled",
"on_int",
"flag",
"raw",
"missing",
"secret_set",
"secret_unset",
]);
});
it("coerces the canonical 'true' / 'false' string pairs to JS booleans", async () => {
const api = await mountComposable();
expect(api.getModuleConfigValue("enabled")).toBe(true);
expect(api.getModuleConfigValue("disabled")).toBe(false);
});
it("returns numbers unmodified when coerceBooleans is on", async () => {
const api = await mountComposable();
expect(api.getModuleConfigValue("on_int")).toBe(1);
expect(api.getModuleConfigValue("flag")).toBe(true);
});
it("returns the raw string for non-boolean-like values", async () => {
const api = await mountComposable();
expect(api.getModuleConfigValue("raw")).toBe("raw-value");
});
it("returns empty string for unknown variables", async () => {
const api = await mountComposable();
expect(api.getModuleConfigValue("not_in_payload")).toBe("");
});
it("returns empty string for a variable that exists but is null", async () => {
const api = await mountComposable();
// Matches the legacy Configuration*.vue contract: missing/null collapses to ''.
expect(api.getModuleConfigValue("missing")).toBe("");
});
it("supports opt-out from boolean coercion for parity with the old inline helper", async () => {
const api = await mountComposable({ coerceBooleans: false });
expect(api.getModuleConfigValue("enabled")).toBe("true");
expect(api.getModuleConfigValue("disabled")).toBe("false");
});
it("uses isSet for secret variables and a non-empty check for plain values", async () => {
const api = await mountComposable();
expect(api.isVariableSet("secret_set")).toBe(true);
expect(api.isVariableSet("secret_unset")).toBe(false);
expect(api.isVariableSet("raw")).toBe(true);
expect(api.isVariableSet("missing")).toBe(false);
expect(api.isVariableSet("not_in_payload")).toBe(false);
});
it("records the error and clears the cache when the request fails", async () => {
mockGetAll.mockRejectedValueOnce(new Error("boom"));
const api = await mountComposable();
expect(api.error.value).toBeInstanceOf(Error);
expect(api.module_config.value).toEqual([]);
expect(api.loading.value).toBe(false);
});
it("skips the auto-load when autoLoad is false and exposes a load()", async () => {
const api = await mountComposable({ autoLoad: false });
expect(mockGetAll).not.toHaveBeenCalled();
await api.load();
expect(mockGetAll).toHaveBeenCalledTimes(1);
});
it("accepts a payload that is already an array of entries", async () => {
mockGetAll.mockResolvedValueOnce({
data: {
data: [
{ variable: "k", value: "v" },
{ variable: "flag", value: "true" },
],
},
});
const api = await mountComposable();
expect(api.module_config.value).toHaveLength(2);
expect(api.getModuleConfigValue("flag")).toBe(true);
});
});
@@ -1,220 +0,0 @@
// @vitest-environment jsdom
import { flushPromises, shallowMount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ref } from "vue";
import { createI18n } from "vue-i18n";
const { request, loadList, fire } = vi.hoisted(() => ({
request: vi.fn(),
loadList: vi.fn(),
fire: vi.fn(),
}));
vi.mock("sweetalert2", () => ({ default: { fire } }));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
request,
functions: {
currency: { toLocal: (value) => String(value) },
parseErrorMessage: (error) => String(error),
},
objects: {
global: {
language: {
completed: "Completed",
generated: "Generated",
possible_duplicates: "Possible duplicates",
price_match: "Price match",
price_unmatch: "Price mismatch",
regret: "Cancel",
},
},
},
},
}));
vi.mock("@/components/pagination/paginatedList.vue", () => ({
usePaginatedListInstance: () => ({ loadList }),
}));
vi.mock("@/components/pagination/departmentTabs.vue", () => ({
departments: ref([{ id: 1, name: "Hall 1" }]),
getDepartments: vi.fn(),
getDepartmentName: () => "Hall 1",
}));
vi.mock("@/components/displays/department/pos/sync/xlvaskUsageAmountCache.js", () => ({
clearCachedXlvaskUsageAmount: vi.fn(),
getCachedXlvaskUsageAmount: vi.fn(() => null),
setCachedXlvaskUsageAmount: vi.fn(),
}));
import XlvaskUsageOrdersTable from "@/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue";
const actionableRow = () => ({
id: 8101,
reg_1: "AB12345",
created_at: new Date().toISOString(),
customer_name: "Test customer",
department_id: 1,
lane: 1,
total_net_amount: 125,
import_state: "new",
resolution_state: "needs_review",
certainty: "certain",
planned_action: "attach_order",
automation: {
id: 7101,
status: "suggested",
action: "attach_order",
can_accept: true,
can_deny: true,
can_ignore: true,
can_attach_order: true,
can_create_order: true,
review_eligible: true,
},
});
const mountTable = (props = {}) =>
shallowMount(XlvaskUsageOrdersTable, {
props: {
objects: [actionableRow()],
...props,
},
global: {
plugins: [
createI18n({ legacy: false, locale: "en", missingWarn: false, fallbackWarn: false, messages: { en: {} } }),
],
stubs: {
WhiteBoxCard: {
template: "<section><slot name='header'/><slot name='content'/></section>",
},
OrderItemsTable: true,
},
},
});
describe("XL-Vask automation component authorization", () => {
beforeEach(() => {
request.mockReset();
request.mockResolvedValue({ data: { data: { action_halted: false } } });
loadList.mockReset();
fire.mockReset();
fire.mockResolvedValue({ isConfirmed: true });
});
it("renders evidence read-only when review capability is absent", () => {
const wrapper = mountTable({ allowSelectMultiple: true, allowReviewActions: false });
expect(wrapper.find("input[type='checkbox']").exists()).toBe(false);
expect(wrapper.find("[data-testid='xlvask-automation-accept-8101']").exists()).toBe(false);
expect(wrapper.find(".xlvask-usage-actions-column").exists()).toBe(false);
});
it("renders mutation controls only for an eligible row with review capability", () => {
const wrapper = mountTable({ allowSelectMultiple: true, allowReviewActions: true, allowAdjudicationActions: true });
expect(wrapper.find("input[type='checkbox']").attributes("disabled")).toBeUndefined();
expect(wrapper.find("[data-testid='xlvask-automation-accept-8101']").exists()).toBe(true);
expect(wrapper.find(".xlvask-usage-actions-column").exists()).toBe(true);
});
it("keeps an ineligible row unselectable", () => {
const row = actionableRow();
row.automation.review_eligible = false;
const wrapper = mountTable({ objects: [row], allowSelectMultiple: true, allowReviewActions: true });
expect(wrapper.find("input[type='checkbox']").attributes("disabled")).toBeDefined();
});
it("does not infer eligibility or action permissions from suggested status", async () => {
const row = actionableRow();
delete row.automation.review_eligible;
row.automation.can_accept = false;
row.automation.can_attach_order = false;
row.automation.candidate_orders = [{ order_id: 7001 }];
const wrapper = mountTable({ objects: [row], allowSelectMultiple: true, allowReviewActions: true });
expect(wrapper.find("input[type='checkbox']").attributes("disabled")).toBeDefined();
expect(wrapper.find("[data-testid='xlvask-automation-accept-8101']").exists()).toBe(false);
expect(
wrapper.find("[data-testid='xlvask-automation-candidates-8101'] button").attributes("disabled")
).toBeDefined();
await wrapper.find("[data-testid='xlvask-automation-candidates-8101'] button").trigger("click");
expect(request).not.toHaveBeenCalled();
});
it("makes the department read-only rendering issue no mutation request", async () => {
const wrapper = mountTable({ allowSelectMultiple: true, allowReviewActions: false });
await wrapper.find(".xlvask-usage-registration").trigger("click");
expect(request).not.toHaveBeenCalled();
});
it("submits an explicitly allowed post-action outcome only for policy managers", async () => {
const row = actionableRow();
row.automation = {
id: 7201,
suggestion_id: 7201,
status: "auto_accepted",
adjudication_eligible: true,
allowed_adjudication_outcomes: ["correct", "incorrect", "not_allowed"],
};
const wrapper = mountTable({
objects: [row],
allowReviewActions: false,
allowAdjudicationActions: true,
});
expect(wrapper.find("[data-testid='xlvask-adjudication-correct-8101']").exists()).toBe(true);
expect(wrapper.find("[data-testid='xlvask-adjudication-not_allowed-8101']").exists()).toBe(false);
await wrapper.find("[data-testid='xlvask-adjudication-correct-8101']").trigger("click");
await flushPromises();
expect(request).toHaveBeenCalledWith(
"/modules/xlvask/services/usage/automation/admin/calibrations/labels",
"POST",
{ suggestion_id: 7201, outcome: "correct" }
);
expect(loadList).toHaveBeenCalled();
});
it("does not expose adjudication to an ordinary reviewer", () => {
const row = actionableRow();
row.automation.adjudication_eligible = true;
row.automation.allowed_adjudication_outcomes = ["correct"];
const wrapper = mountTable({
objects: [row],
allowReviewActions: true,
allowAdjudicationActions: false,
});
expect(wrapper.find("[data-testid='xlvask-adjudication-8101']").exists()).toBe(false);
});
it("surfaces every successful adverse adjudication as an automation halt", async () => {
const row = actionableRow();
row.automation = {
id: 7202,
status: "auto_accepted",
adjudication_eligible: true,
allowed_adjudication_outcomes: ["incorrect"],
};
const wrapper = mountTable({
objects: [row],
allowAdjudicationActions: true,
});
await wrapper.find("[data-testid='xlvask-adjudication-incorrect-8101']").trigger("click");
await flushPromises();
expect(fire).toHaveBeenLastCalledWith(
expect.objectContaining({
icon: "warning",
title: "invoicing_period.xlvask_autopilot.adjudication.halted",
})
);
});
});
-158
View File
@@ -1,158 +0,0 @@
import { describe, expect, it } from "vitest";
import {
getXlvaskCalibratedProbability,
getXlvaskCertainty,
getXlvaskImportState,
getXlvaskPlannedAction,
getXlvaskResolutionState,
isXlvaskAutopilotRunActive,
isXlvaskReviewEligible,
normalizeXlvaskAutomationCapabilities,
normalizeXlvaskAutomationReadiness,
normalizeXlvaskAutopilotRun,
normalizeXlvaskAutopilotSummary,
xlvaskAutopilotRunProgress,
xlvaskCandidateLabel,
} from "@/components/displays/department/pos/sync/xlvaskAutopilotUi.js";
describe("XL-Vask autopilot UI contract", () => {
it("normalizes missing and invalid summary counts", () => {
expect(normalizeXlvaskAutopilotSummary({ total: "12", failed: -2, blocked: "3.8" })).toMatchObject({
total: 12,
failed: 0,
blocked: 3,
auto_created: 0,
uncertain: 0,
});
});
it("keeps import, resolution, certainty, and planned action independent", () => {
const row = {
import_state: "updated",
resolution_state: "blocked",
certainty: "uncertain",
planned_action: "resolve_mapping",
automation: { calibrated_probability: 0.73 },
};
expect(getXlvaskImportState(row)).toBe("updated");
expect(getXlvaskResolutionState(row)).toBe("blocked");
expect(getXlvaskCertainty(row)).toBe("uncertain");
expect(getXlvaskPlannedAction(row)).toBe("resolve_mapping");
expect(getXlvaskCalibratedProbability(row)).toBe(0.73);
});
it("keeps legacy failed and no-safe-match rows visible through fallback states", () => {
expect(getXlvaskResolutionState({ automation: { status: "failed" } })).toBe("failed");
expect(getXlvaskResolutionState({ automation: { status: "none" } })).toBe("needs_review");
expect(getXlvaskCertainty({ automation: { status: "none" } })).toBe("none");
});
it("never labels raw model confidence as a calibrated probability", () => {
expect(getXlvaskCalibratedProbability({ automation: { confidence: 0.99 } })).toBeNull();
});
it("normalizes active run progress without exceeding 100 percent", () => {
const run = normalizeXlvaskAutopilotRun({
id: "run-1",
status: "running",
processed: 12,
total: 10,
});
expect(isXlvaskAutopilotRunActive(run)).toBe(true);
expect(isXlvaskAutopilotRunActive({ status: "retry_wait" })).toBe(true);
expect(xlvaskAutopilotRunProgress(run)).toBe(100);
expect(isXlvaskAutopilotRunActive({ status: "completed" })).toBe(false);
expect(isXlvaskAutopilotRunActive({ status: "completed_with_warnings" })).toBe(false);
});
it("formats candidate order labels without trusting one wire shape", () => {
expect(xlvaskCandidateLabel({ order_id: 7001, reason: "Same plate and total" })).toBe(
"#7001 · Same plate and total"
);
expect(xlvaskCandidateLabel({ id: 7002 })).toBe("#7002");
});
it("normalizes capabilities fail closed", () => {
expect(
normalizeXlvaskAutomationCapabilities({
can_view: true,
can_review: true,
allowed_modes: ["dry_run", "execute"],
effective_action_sources: ["deterministic", "openai"],
})
).toMatchObject({
can_view: true,
can_review: true,
can_execute: false,
allowed_modes: ["dry_run", "execute"],
effective_action_sources: ["deterministic", "openai"],
});
});
it("normalizes readiness budgets and fixed review targets", () => {
expect(
normalizeXlvaskAutomationReadiness({
ready: true,
review_progress: { attach_order: { reviewed: 12 }, create_order: { reviewed: 3 } },
budgets: { attach_order: { remaining_global: 80, remaining_hall: 8 } },
eligible_counts: { attach_order: 6, create_order: 2 },
})
).toMatchObject({
ready: true,
review_progress: {
attach_order: { reviewed: 12, target: 200 },
create_order: { reviewed: 3, target: 50 },
},
budgets: { attach_order: { remaining_global: 80, remaining_hall: 8 } },
eligible_counts: { attach_order: 6, create_order: 2, total: 8 },
});
});
it("accepts only explicit eligible review rows when the backend supplies the flag", () => {
expect(
isXlvaskReviewEligible({
resolution_state: "needs_review",
automation: { id: 1, review_eligible: true },
})
).toBe(true);
expect(
isXlvaskReviewEligible({
resolution_state: "needs_review",
automation: { id: 1, review_eligible: false },
})
).toBe(false);
expect(
isXlvaskReviewEligible({
resolution_state: "needs_review",
automation: { id: 1, status: "suggested", can_accept: true },
})
).toBe(false);
expect(
isXlvaskReviewEligible({
resolution_state: "needs_review",
automation: { id: 1, eligible: true },
})
).toBe(false);
});
it("treats malformed capabilities as denied", () => {
expect(
normalizeXlvaskAutomationCapabilities({
can_view: "true",
can_review: 1,
can_execute: "yes",
allowed_modes: "execute",
blocked_reasons: { reason: "bad" },
})
).toMatchObject({
can_view: false,
can_review: false,
can_execute: false,
allowed_modes: [],
blocked_reasons: [],
});
});
});
-203
View File
@@ -1,203 +0,0 @@
// @vitest-environment jsdom
import { flushPromises, shallowMount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ref } from "vue";
import { createI18n } from "vue-i18n";
const { request, loadList, fire } = vi.hoisted(() => ({
request: vi.fn(),
loadList: vi.fn(),
fire: vi.fn(),
}));
vi.mock("sweetalert2", () => ({ default: { fire } }));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
request,
functions: {
currency: { toLocal: (value) => String(value) },
parseErrorMessage: (error) => String(error),
},
objects: {
global: {
language: {
completed: "Completed",
generated: "Generated",
possible_duplicates: "Possible duplicates",
price_match: "Price match",
price_unmatch: "Price mismatch",
},
},
orders: { meta: { title: "Orders" } },
},
},
}));
vi.mock("@/components/pagination/paginatedList.vue", () => ({
usePaginatedListInstance: () => ({ loadList }),
}));
vi.mock("@/components/pagination/departmentTabs.vue", () => ({
departments: ref([{ id: 1, name: "Hall 1" }]),
getDepartments: vi.fn(),
getDepartmentName: () => "Hall 1",
}));
vi.mock("@/components/displays/department/pos/sync/xlvaskUsageAmountCache.js", () => ({
clearCachedXlvaskUsageAmount: vi.fn(),
getCachedXlvaskUsageAmount: vi.fn(() => null),
setCachedXlvaskUsageAmount: vi.fn(),
}));
import XlvaskUsageOrdersTable from "@/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue";
const reviewEligibleRow = (overrides = {}) => ({
id: 9101,
reg_1: "ZZ99001",
created_at: new Date().toISOString(),
customer_name: "Test customer",
department_id: 1,
lane: 1,
total_net_amount: 250,
import_state: "new",
resolution_state: "needs_review",
certainty: "uncertain",
planned_action: "none",
// No AI suggestion yet — automation is empty (the autopilot hasn't run).
automation: {
status: "none",
review_eligible: true,
},
...overrides,
});
const mountTable = (props = {}) =>
shallowMount(XlvaskUsageOrdersTable, {
props: {
objects: [reviewEligibleRow()],
allowReviewActions: true,
allowSelectMultiple: true,
...props,
},
global: {
plugins: [
createI18n({ legacy: false, locale: "en", missingWarn: false, fallbackWarn: false, messages: { en: {} } }),
],
stubs: {
WhiteBoxCard: {
template: "<section><slot name='header'/><slot name='content'/></section>",
},
OrderItemsTable: true,
},
},
});
describe("XL-Vask manual review buttons", () => {
beforeEach(() => {
request.mockReset();
loadList.mockReset();
fire.mockReset();
});
it("exposes accept / reject / ignore buttons even when the autopilot has no suggestion", () => {
const wrapper = mountTable();
expect(wrapper.find("[data-testid='xlvask-accept-9101']").exists()).toBe(true);
expect(wrapper.find("[data-testid='xlvask-reject-9101']").exists()).toBe(true);
expect(wrapper.find("[data-testid='xlvask-ignore-9101']").exists()).toBe(true);
});
it("sends force_manual=true when accepting without an AI suggestion", async () => {
const wrapper = mountTable();
request.mockResolvedValue({
data: {
data: {
preview: { id: "preview-id", selection_hash: "abc12345".repeat(8) },
},
},
});
await wrapper.find("[data-testid='xlvask-accept-9101']").trigger("click");
await flushPromises();
const previewCall = request.mock.calls.find(
([endpoint, method]) =>
endpoint === "/modules/xlvask/services/usage/automation/decisions/preview" && method === "POST"
);
expect(previewCall).toBeDefined();
const payload = previewCall[2];
expect(payload).toMatchObject({
usage_log_ids: [9101],
action: "create_order",
force_manual: true,
});
});
it("does not send force_manual when the AI already has a matching suggestion", async () => {
const row = reviewEligibleRow({
automation: {
id: 5001,
status: "suggested",
action: "create_order",
can_accept: true,
can_deny: true,
can_ignore: true,
can_attach_order: true,
can_create_order: true,
review_eligible: true,
},
});
const wrapper = mountTable({ objects: [row] });
request.mockResolvedValue({
data: {
data: {
preview: { id: "preview-id-2", selection_hash: "def67890".repeat(8) },
},
},
});
await wrapper.find("[data-testid='xlvask-accept-9101']").trigger("click");
await flushPromises();
const previewCall = request.mock.calls.find(
([endpoint, method]) =>
endpoint === "/modules/xlvask/services/usage/automation/decisions/preview" && method === "POST"
);
expect(previewCall).toBeDefined();
const payload = previewCall[2];
expect(payload.action).toBe("create_order");
expect(payload).not.toHaveProperty("force_manual");
});
it("hides the buttons once the row is already resolved (no double-action)", () => {
const row = reviewEligibleRow({
resolution_state: "auto_linked",
automation: { status: "accepted", action: "attach_order", review_eligible: false },
});
const wrapper = mountTable({ objects: [row] });
expect(wrapper.find("[data-testid='xlvask-accept-9101']").exists()).toBe(false);
expect(wrapper.find("[data-testid='xlvask-reject-9101']").exists()).toBe(false);
expect(wrapper.find("[data-testid='xlvask-ignore-9101']").exists()).toBe(false);
});
it("surfaces the backend error when the manual review is rejected", async () => {
const wrapper = mountTable();
request.mockRejectedValue(new Error("Wash-id uniqueness activation is blocked"));
await wrapper.find("[data-testid='xlvask-accept-9101']").trigger("click");
await flushPromises();
expect(fire).toHaveBeenCalledWith(
expect.objectContaining({
icon: "error",
title: "invoicing_period.xlvask_autopilot.preview.error_title",
})
);
});
});
+9 -9
View File
@@ -33,26 +33,26 @@ describe("xlvask usage filters", () => {
expect(filterUsageOrdersByAttachment(sourceRows, false)).toEqual(sourceRows);
});
it("treats accepted automation actions as attached", () => {
it("treats linked and duplicate wash_ids as attached", () => {
expect(
isUsageOrderAttachedToOrder({
wash_id: "wash-1",
linked_order_id: 17,
duplicates: [],
automation: {
status: "accepted",
action: "attach_order",
},
})
).toBe(true);
expect(
isUsageOrderAttachedToOrder({
wash_id: "wash-2",
duplicates: [{ wash_id: "wash-2" }],
})
).toBe(true);
expect(
isUsageOrderAttachedToOrder({
wash_id: "wash-3",
duplicates: [],
automation: {
status: "denied",
action: "attach_order",
},
})
).toBe(false);
});