Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a37e925dc6 | ||
|
|
2e1d7f3e8f | ||
|
|
7c9eb8b644 | ||
|
|
e2cc76091f | ||
|
|
4db3be34f8 | ||
|
|
b1e0c61df0 | ||
|
|
eb8482585b | ||
|
|
c207fea61e | ||
|
|
01c5864382 | ||
|
|
c01596aeb5 | ||
|
|
5d4de1d932 | ||
|
|
768b6dcdab | ||
|
|
253d72f7fb | ||
|
|
a1fa132c99 | ||
|
|
113f49f018 | ||
|
|
666d467b46 | ||
|
|
9c74c4d477 | ||
|
|
0b7efc3be5 | ||
|
|
4cfd003864 | ||
|
|
58adb1bef5 | ||
|
|
e4bd3420c6 | ||
|
|
e08f1ecba8 | ||
|
|
187da74794 | ||
|
|
c9935d1e0a |
@@ -0,0 +1,4 @@
|
|||||||
|
# AGENT MCP SMOKE
|
||||||
|
|
||||||
|
Generated 20260813-091957 by hermes agent to verify GitHub MCP wiring.
|
||||||
|
Safe to close.
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
# 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.
|
||||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"kind": "VisualEvidenceManifestV1",
|
||||||
|
"taskId": "workboard-94209138-31f6-422e-ac8c-181ad391b8a7",
|
||||||
|
"view": "POS extra sale audit",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"device": "mobile",
|
||||||
|
"state": "before",
|
||||||
|
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-mobile-before.png",
|
||||||
|
"width": 390,
|
||||||
|
"height": 844
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"device": "mobile",
|
||||||
|
"state": "after",
|
||||||
|
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-mobile-after.png",
|
||||||
|
"width": 390,
|
||||||
|
"height": 844
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"device": "tablet",
|
||||||
|
"state": "before",
|
||||||
|
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-tablet-before.png",
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"device": "tablet",
|
||||||
|
"state": "after",
|
||||||
|
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-tablet-after.png",
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"device": "desktop",
|
||||||
|
"state": "before",
|
||||||
|
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-desktop-before.png",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"device": "desktop",
|
||||||
|
"state": "after",
|
||||||
|
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-desktop-after.png",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// AUT-1 smoke run — capture the before/after visual diff for the
|
||||||
|
// `primary-button hover state` change in `src/assets/main.css`.
|
||||||
|
//
|
||||||
|
// Renders tests/visual-previews/AUT-1/preview.html in a real Chromium
|
||||||
|
// against the project's bundled Bulma stylesheet, then takes two
|
||||||
|
// screenshots:
|
||||||
|
//
|
||||||
|
// desktop-before.png — Bulma defaults, button idle (no hover effect).
|
||||||
|
// desktop-after.png — With the AUT-1 CSS rules applied, button
|
||||||
|
// hovered (lift + brightness shift visible).
|
||||||
|
// mobile-before.png — Same as desktop-before, captured at the
|
||||||
|
// Pixel 5 viewport.
|
||||||
|
// mobile-after.png — Same as desktop-after, captured at the
|
||||||
|
// Pixel 5 viewport.
|
||||||
|
//
|
||||||
|
// Output is written to tests/visual-previews/AUT-1/. The script is
|
||||||
|
// idempotent: existing files are overwritten, not appended to.
|
||||||
|
//
|
||||||
|
// Invoke with: `node scripts/aut-1-capture-hover-preview.mjs`
|
||||||
|
// (Chromium must already be installed via `npm run test:e2e:install`.)
|
||||||
|
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const REPO_ROOT = path.resolve(__dirname, "..");
|
||||||
|
const PREVIEW_HTML = path.join(
|
||||||
|
REPO_ROOT,
|
||||||
|
"tests/visual-previews/AUT-1/preview.html",
|
||||||
|
);
|
||||||
|
const OUTPUT_DIR = path.join(REPO_ROOT, "tests/visual-previews/AUT-1");
|
||||||
|
|
||||||
|
// The AUT-1 hover rules — kept in lockstep with the diff in
|
||||||
|
// src/assets/main.css. We inject these via a <style> tag on the
|
||||||
|
// "after" pass and leave them off for the "before" pass so the
|
||||||
|
// screenshots show the same Bulma theme but with vs without the new
|
||||||
|
// hover effect.
|
||||||
|
const AUT1_HOVER_CSS = `
|
||||||
|
.button.is-primary {
|
||||||
|
transition:
|
||||||
|
filter 0.15s ease,
|
||||||
|
transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.button.is-primary:hover,
|
||||||
|
.button.is-primary.is-hovered {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.button.is-primary:active,
|
||||||
|
.button.is-primary.is-active {
|
||||||
|
filter: brightness(0.95);
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.button.is-primary:focus-visible {
|
||||||
|
filter: brightness(1.04);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
async function captureVariant({ browser, viewport, label, withHoverCss, hover }) {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport,
|
||||||
|
deviceScaleFactor: 2,
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(pathToFileUrl(PREVIEW_HTML));
|
||||||
|
if (withHoverCss) {
|
||||||
|
await page.addStyleTag({ content: AUT1_HOVER_CSS });
|
||||||
|
}
|
||||||
|
const target = page.locator(
|
||||||
|
hover
|
||||||
|
? '[data-testid="primary-button-hover"]'
|
||||||
|
: '[data-testid="primary-button-idle"]',
|
||||||
|
);
|
||||||
|
const stage = page.locator(
|
||||||
|
hover ? "#stage-hover" : "#stage-idle",
|
||||||
|
);
|
||||||
|
if (hover) {
|
||||||
|
await target.hover();
|
||||||
|
// Wait for the 0.15s transition to settle.
|
||||||
|
await page.waitForTimeout(220);
|
||||||
|
}
|
||||||
|
const filename = `${label}-${withHoverCss ? "after" : "before"}.png`;
|
||||||
|
const destination = path.join(OUTPUT_DIR, filename);
|
||||||
|
await stage.screenshot({ path: destination, type: "png" });
|
||||||
|
await context.close();
|
||||||
|
return destination;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathToFileUrl(filePath) {
|
||||||
|
// Playwright's `file://` URLs need absolute paths. On POSIX this is
|
||||||
|
// straightforward; on Windows this helper keeps the script cross-platform
|
||||||
|
// should it ever run there.
|
||||||
|
const absolute = path.resolve(filePath);
|
||||||
|
return absolute.startsWith("/") ? `file://${absolute}` : `file:///${absolute}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await fs.mkdir(OUTPUT_DIR, { recursive: true });
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
try {
|
||||||
|
const viewports = [
|
||||||
|
{ label: "desktop", viewport: { width: 1280, height: 720 } },
|
||||||
|
{
|
||||||
|
label: "mobile",
|
||||||
|
viewport: { width: 393, height: 851 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const { label, viewport } of viewports) {
|
||||||
|
for (const withHoverCss of [false, true]) {
|
||||||
|
for (const hover of [false, true]) {
|
||||||
|
// We only want idle on the "before" pass and hover on the
|
||||||
|
// "after" pass — skip the two redundant combinations.
|
||||||
|
const isIdleShot = !hover;
|
||||||
|
const isBeforeShot = !withHoverCss;
|
||||||
|
if (isIdleShot !== isBeforeShot) continue;
|
||||||
|
const file = await captureVariant({
|
||||||
|
browser,
|
||||||
|
viewport,
|
||||||
|
label,
|
||||||
|
withHoverCss,
|
||||||
|
hover,
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("wrote", path.relative(REPO_ROOT, file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("aut-1 capture failed:", err);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -122,6 +122,7 @@ export const ownedFilesByRole = {
|
|||||||
"superuser-users.spec.ts",
|
"superuser-users.spec.ts",
|
||||||
"superuser-vehicles.smoke.spec.js",
|
"superuser-vehicles.smoke.spec.js",
|
||||||
"workfeed-config.smoke.spec.js",
|
"workfeed-config.smoke.spec.js",
|
||||||
|
"xlvask-flag-to-selvvash-navigation.spec.ts",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -144,3 +144,30 @@ body:not(.pleno-large-table-headers) .table thead th {
|
|||||||
margin-top: 30px !important;
|
margin-top: 30px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* AUT-1: enhanced primary-button hover state.
|
||||||
|
Bulma's default only nudges background lightness; this adds a small
|
||||||
|
lift + brightness shift so the hover is unambiguous. Transitions keep
|
||||||
|
it smooth so it doesn't feel jarring on click-heavy screens. */
|
||||||
|
.button.is-primary {
|
||||||
|
transition:
|
||||||
|
filter 0.15s ease,
|
||||||
|
transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.is-primary:hover,
|
||||||
|
.button.is-primary.is-hovered {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.is-primary:active,
|
||||||
|
.button.is-primary.is-active {
|
||||||
|
filter: brightness(0.95);
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.is-primary:focus-visible {
|
||||||
|
filter: brightness(1.04);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { computed, reactive, ref, watch } from 'vue';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import { buildAuditedOrderItemReasonPayload, editOrderItem } from "@/components/shop/OrdersItems.vue";
|
import { buildAuditedOrderItemReasonPayload, editOrderItem } from "@/components/shop/OrdersItems.vue";
|
||||||
|
import {
|
||||||
|
EXTRA_SALE_REASONS,
|
||||||
|
isExtraSaleAuditProduct,
|
||||||
|
isExtraSaleCommentRequired,
|
||||||
|
} from "@/components/displays/department/pos/utils/extraSaleAudit.js";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@@ -29,6 +34,8 @@ const form = reactive({
|
|||||||
notes: '',
|
notes: '',
|
||||||
reference: '',
|
reference: '',
|
||||||
quantity: '1',
|
quantity: '1',
|
||||||
|
extraSaleReasonCode: '',
|
||||||
|
extraSaleComment: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const isSubmitting = ref(false);
|
const isSubmitting = ref(false);
|
||||||
@@ -45,6 +52,8 @@ const syncForm = () => {
|
|||||||
form.notes = String(props.orderItem?.notes ?? '');
|
form.notes = String(props.orderItem?.notes ?? '');
|
||||||
form.reference = String(props.orderItem?.reference ?? '');
|
form.reference = String(props.orderItem?.reference ?? '');
|
||||||
form.quantity = String(props.orderItem?.quantity ?? 1);
|
form.quantity = String(props.orderItem?.quantity ?? 1);
|
||||||
|
form.extraSaleReasonCode = String(props.orderItem?.extra_sale_reason_code ?? '');
|
||||||
|
form.extraSaleComment = String(props.orderItem?.extra_sale_comment ?? '');
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,8 +69,27 @@ const isQuantityValid = computed(() => {
|
|||||||
return Number.isInteger(value) && value > 0;
|
return Number.isInteger(value) && value > 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const requiresExtraSaleAudit = computed(() => isExtraSaleAuditProduct(props.orderItem?.product || props.orderItem));
|
||||||
|
|
||||||
|
const isExtraSaleAuditValid = computed(() => {
|
||||||
|
if (!requiresExtraSaleAudit.value) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.extraSaleReasonCode) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !isExtraSaleCommentRequired(form.extraSaleReasonCode) || form.extraSaleComment.trim().length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
const canSubmit = computed(() => {
|
const canSubmit = computed(() => {
|
||||||
return Boolean(props.orderItem) && props.canEdit && !isSubmitting.value && isPriceValid.value && isQuantityValid.value;
|
return Boolean(props.orderItem)
|
||||||
|
&& props.canEdit
|
||||||
|
&& !isSubmitting.value
|
||||||
|
&& isPriceValid.value
|
||||||
|
&& isQuantityValid.value
|
||||||
|
&& isExtraSaleAuditValid.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
@@ -90,7 +118,11 @@ const saveChanges = async () => {
|
|||||||
reason_code: props.orderItem.reason_code,
|
reason_code: props.orderItem.reason_code,
|
||||||
reason_label_snapshot: props.orderItem.reason_label_snapshot,
|
reason_label_snapshot: props.orderItem.reason_label_snapshot,
|
||||||
reason_comment: form.notes,
|
reason_comment: form.notes,
|
||||||
})
|
}),
|
||||||
|
{
|
||||||
|
extra_sale_reason_code: form.extraSaleReasonCode || null,
|
||||||
|
extra_sale_comment: form.extraSaleComment.trim() || null,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
emits('saved');
|
emits('saved');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -177,6 +209,41 @@ const saveChanges = async () => {
|
|||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template v-if="requiresExtraSaleAudit">
|
||||||
|
<div class="column is-12-mobile is-6-tablet">
|
||||||
|
<label class="label" for="pos-order-item-extra-sale-reason">Årsag</label>
|
||||||
|
<div class="select is-fullwidth">
|
||||||
|
<select
|
||||||
|
id="pos-order-item-extra-sale-reason"
|
||||||
|
v-model="form.extraSaleReasonCode"
|
||||||
|
:disabled="!canEdit"
|
||||||
|
data-testid="pos-order-item-extra-sale-reason"
|
||||||
|
>
|
||||||
|
<option value="">Vælg godkendt årsag</option>
|
||||||
|
<option
|
||||||
|
v-for="reason in EXTRA_SALE_REASONS"
|
||||||
|
:key="reason.code"
|
||||||
|
:value="reason.code"
|
||||||
|
>
|
||||||
|
{{ reason.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column is-12-mobile is-6-tablet">
|
||||||
|
<label class="label" for="pos-order-item-extra-sale-comment">Kommentar</label>
|
||||||
|
<input
|
||||||
|
id="pos-order-item-extra-sale-comment"
|
||||||
|
v-model="form.extraSaleComment"
|
||||||
|
class="input"
|
||||||
|
type="text"
|
||||||
|
:disabled="!canEdit"
|
||||||
|
data-testid="pos-order-item-extra-sale-comment"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<label class="label" for="pos-order-item-edit-reference">{{ t('common.reference') }}</label>
|
<label class="label" for="pos-order-item-edit-reference">{{ t('common.reference') }}</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -38,17 +38,23 @@ import {
|
|||||||
reg_2,
|
reg_2,
|
||||||
reg_3,
|
reg_3,
|
||||||
department_id,
|
department_id,
|
||||||
customer_id,
|
customer_id,
|
||||||
customer_attributes,
|
customer_attributes,
|
||||||
customer_attributes_status,
|
customer_attributes_status,
|
||||||
customer_name,
|
customer_name,
|
||||||
getAddonRestriction,
|
getAddonRestriction,
|
||||||
getProductRestriction,
|
getProductRestriction,
|
||||||
retryCustomerAttributes,
|
retryCustomerAttributes,
|
||||||
registerPosStepSaveBarrier,
|
registerPosStepSaveBarrier,
|
||||||
saveOrderMetadataField,
|
saveOrderMetadataField,
|
||||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||||
import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.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 { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
|
||||||
import PosDepartmentStepMobileButtonClearAll from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.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";
|
import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
|
||||||
@@ -58,6 +64,7 @@ import { pendingBookings } from "@/components/shop/POSDepartmentProcess.vue";
|
|||||||
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
|
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
|
||||||
import { useHoldToTrigger } from "@/composables/useHoldToTrigger";
|
import { useHoldToTrigger } from "@/composables/useHoldToTrigger";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { promptExtraSaleAuditIfRequired } from "@/components/displays/department/pos/utils/extraSaleAudit.js";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@@ -345,14 +352,14 @@ const applyPendingBookingFromSelection = async () => {
|
|||||||
effectivePrimaryProduct = firstWash;
|
effectivePrimaryProduct = firstWash;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
effectivePrimaryProduct.addons = preparedAddons as any;
|
effectivePrimaryProduct.addons = preparedAddons as any;
|
||||||
transactionItems.setPrimaryItem(effectivePrimaryProduct as any);
|
transactionItems.setPrimaryItem(effectivePrimaryProduct as any);
|
||||||
if (transactionItems.primaryItem.value) {
|
if (transactionItems.primaryItem.value) {
|
||||||
transactionItems.primaryItem.value.addons = preparedAddons as any;
|
transactionItems.primaryItem.value.addons = preparedAddons as any;
|
||||||
}
|
}
|
||||||
sanitizeRestrictedTransactionItems();
|
sanitizeRestrictedTransactionItems();
|
||||||
|
|
||||||
lastAppliedBookingId.value = booking.id;
|
lastAppliedBookingId.value = booking.id;
|
||||||
//console.warn('Applied pending booking to cart (primary + addons):', booking.id, primaryProduct, preparedAddons);
|
//console.warn('Applied pending booking to cart (primary + addons):', booking.id, primaryProduct, preparedAddons);
|
||||||
//console.warn('Current transaction items after applying booking:', transactionItems.primaryItem.value);
|
//console.warn('Current transaction items after applying booking:', transactionItems.primaryItem.value);
|
||||||
lastFetchedPrimaryItemProduct.value = effectivePrimaryProduct; // Update last fetched primary item
|
lastFetchedPrimaryItemProduct.value = effectivePrimaryProduct; // Update last fetched primary item
|
||||||
@@ -885,83 +892,12 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
const normalizeOrderItemShape = (item: any) => ({
|
// The pre-existing helpers buildDesiredOrderItemShapes and
|
||||||
product_id: Number(item?.product_id ?? item?.product?.id ?? 0),
|
// normalizeExistingOrderItemShapes (and their shared normalizeOrderItemShape
|
||||||
quantity: Number(item?.quantity ?? 0),
|
// normalizer) used to live here. They have been extracted to
|
||||||
related_item_id:
|
// src/components/displays/department/pos/steps/mobile/utils/syncMobileOrderItems.js
|
||||||
item?.related_item_id === null || item?.related_item_id === undefined ? null : Number(item.related_item_id),
|
// so they can be unit-tested in isolation and so the same comparison logic
|
||||||
price: Number(item?.price ?? 0),
|
// is used by syncCurrentTransactionToOrder and any future caller.
|
||||||
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];
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortComparableLastWashShapes = (shapes: Array<{ kind: string; product_id: number; quantity: number }>) =>
|
const sortComparableLastWashShapes = (shapes: Array<{ kind: string; product_id: number; quantity: number }>) =>
|
||||||
shapes.slice().sort((left, right) => {
|
shapes.slice().sort((left, right) => {
|
||||||
@@ -988,8 +924,8 @@ const buildCurrentSelectionComparableShapes = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addonShapes = sortComparableLastWashShapes(
|
const addonShapes = sortComparableLastWashShapes(
|
||||||
(transactionItems.primaryItem.value.addons || [])
|
(transactionItems.primaryItem.value.addons || [])
|
||||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
|
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
|
||||||
.map((addon: any) => ({
|
.map((addon: any) => ({
|
||||||
kind: "addon",
|
kind: "addon",
|
||||||
product_id: Number(addon?.product?.id ?? addon?.id ?? 0),
|
product_id: Number(addon?.product?.id ?? addon?.id ?? 0),
|
||||||
@@ -998,8 +934,8 @@ const buildCurrentSelectionComparableShapes = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const additionalShapes = sortComparableLastWashShapes(
|
const additionalShapes = sortComparableLastWashShapes(
|
||||||
(transactionItems.additionalItems.value || [])
|
(transactionItems.additionalItems.value || [])
|
||||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
|
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
|
||||||
.map((item: any) => ({
|
.map((item: any) => ({
|
||||||
kind: "additional",
|
kind: "additional",
|
||||||
product_id: Number(item?.id ?? 0),
|
product_id: Number(item?.id ?? 0),
|
||||||
@@ -1074,65 +1010,19 @@ const syncCurrentTransactionToOrder = async () => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingItemsResponse = await getOrderItems(normalizedOrderId);
|
// The sync helper owns the create / rollback / allSettled logic and lives
|
||||||
const existingItems = Array.isArray(existingItemsResponse?.data?.data) ? existingItemsResponse.data.data : [];
|
// in tests/unit/pos-mobile-step-2-addon-sync.spec.js. Returning its result
|
||||||
|
// unchanged preserves the existing contract: true = synced (or already in
|
||||||
const desiredShapes = buildDesiredOrderItemShapes();
|
// sync), false = primary product blocked, throw = partial failure.
|
||||||
const currentShapes = normalizeExistingOrderItemShapes(existingItems);
|
await syncMobileOrderItems({
|
||||||
const shouldForceRecreateForRepricing = desiredShapes.some((shape) => shape.skip_price_override === true);
|
orderId: normalizedOrderId,
|
||||||
const comparableDesiredShapes = desiredShapes.map(({
|
primaryItem: transactionItems.primaryItem.value,
|
||||||
kind: _kind,
|
additionalItems: transactionItems.additionalItems.value || [],
|
||||||
relatedKey: _relatedKey,
|
isAddonRestricted: isMobileAddonRestricted,
|
||||||
skip_price_override: _skipPriceOverride,
|
isStandaloneRestricted: isStandaloneAdditionalItemRestricted,
|
||||||
...shape
|
isPrimaryRestricted: (item) => getProductRestriction(item).restricted,
|
||||||
}) => shape);
|
api: { createOrderItem, getOrderItems, removeOrderItem },
|
||||||
|
});
|
||||||
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]);
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1140,8 +1030,7 @@ const normalizeText = (value: unknown) => String(value ?? "").trim();
|
|||||||
|
|
||||||
const isEnabledFlag = (value: unknown) => value === true || value === 1 || value === "1" || value === "true";
|
const isEnabledFlag = (value: unknown) => value === true || value === 1 || value === "1" || value === "true";
|
||||||
|
|
||||||
const getProductId = (product: any) =>
|
const getProductId = (product: any) => Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0);
|
||||||
Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0);
|
|
||||||
|
|
||||||
const getProductName = (product: any) => normalizeText(product?.product?.name ?? product?.name);
|
const getProductName = (product: any) => normalizeText(product?.product?.name ?? product?.name);
|
||||||
|
|
||||||
@@ -1152,13 +1041,13 @@ const productRequiresOrderItemNote = (product: any) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
isEnabledFlag(product?.requires_note ?? product?.product?.requires_note) ||
|
isEnabledFlag(product?.requires_note ?? product?.product?.requires_note) ||
|
||||||
|
AUDITED_ORDER_ITEM_PRODUCT_IDS.has(getProductId(product)) ||
|
||||||
getProductId(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_ID ||
|
getProductId(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_ID ||
|
||||||
getProductName(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME
|
getProductName(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const productHasOrderItemNote = (product: any) =>
|
const productHasOrderItemNote = (product: any) => normalizeText(product?.notes ?? product?.product?.notes).length > 0;
|
||||||
normalizeText(product?.notes ?? product?.product?.notes).length > 0;
|
|
||||||
|
|
||||||
const getSelectedProductsMissingRequiredNotes = () => {
|
const getSelectedProductsMissingRequiredNotes = () => {
|
||||||
const missingProducts: any[] = [];
|
const missingProducts: any[] = [];
|
||||||
@@ -1168,8 +1057,8 @@ const getSelectedProductsMissingRequiredNotes = () => {
|
|||||||
missingProducts.push(primaryProduct);
|
missingProducts.push(primaryProduct);
|
||||||
}
|
}
|
||||||
|
|
||||||
(primaryProduct?.addons || [])
|
(primaryProduct?.addons || [])
|
||||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
|
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
|
||||||
.forEach((addon: any) => {
|
.forEach((addon: any) => {
|
||||||
const addonProduct = addon?.product ?? addon;
|
const addonProduct = addon?.product ?? addon;
|
||||||
if (
|
if (
|
||||||
@@ -1181,8 +1070,8 @@ const getSelectedProductsMissingRequiredNotes = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
(transactionItems.additionalItems.value || [])
|
(transactionItems.additionalItems.value || [])
|
||||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
|
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
|
||||||
.forEach((item: any) => {
|
.forEach((item: any) => {
|
||||||
if (productRequiresOrderItemNote(item) && !productHasOrderItemNote(item)) {
|
if (productRequiresOrderItemNote(item) && !productHasOrderItemNote(item)) {
|
||||||
missingProducts.push(item);
|
missingProducts.push(item);
|
||||||
@@ -1538,15 +1427,15 @@ const filteredAddons = computed(() => {
|
|||||||
<i class="fa fa-search"></i>
|
<i class="fa fa-search"></i>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<!-- Registration numbers -->
|
<!-- Registration numbers -->
|
||||||
<PosDepartmentStepMobile2RegistrationNumbers :classes="layout.classes" />
|
<PosDepartmentStepMobile2RegistrationNumbers :classes="layout.classes" />
|
||||||
<p
|
<p
|
||||||
v-if="restrictionWarningMessageKey"
|
v-if="restrictionWarningMessageKey"
|
||||||
class="notification is-warning is-light py-2 px-3 mb-0"
|
class="notification is-warning is-light py-2 px-3 mb-0"
|
||||||
data-testid="pos-mobile-restriction-warning"
|
data-testid="pos-mobile-restriction-warning"
|
||||||
>
|
>
|
||||||
{{ t(restrictionWarningMessageKey) }}
|
{{ t(restrictionWarningMessageKey) }}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
v-if="customer_attributes_status === 'error'"
|
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"
|
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 +1451,8 @@ const filteredAddons = computed(() => {
|
|||||||
{{ t("common.retry") }}
|
{{ t("common.retry") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Product -->
|
<!-- Product -->
|
||||||
<PosDepartmentStepMobile2Product
|
<PosDepartmentStepMobile2Product
|
||||||
v-on:pointerdown="onPrimaryProductPointerDown"
|
v-on:pointerdown="onPrimaryProductPointerDown"
|
||||||
v-on:pointermove="onPrimaryProductPointerMove"
|
v-on:pointermove="onPrimaryProductPointerMove"
|
||||||
v-on:pointerup="onPrimaryProductPointerUp"
|
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",
|
"already_linked",
|
||||||
"auto_linked",
|
"auto_linked",
|
||||||
"auto_created",
|
"auto_created",
|
||||||
@@ -8,240 +21,29 @@ export const XLVASK_RESOLUTION_STATES = [
|
|||||||
"ignored",
|
"ignored",
|
||||||
"failed",
|
"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 = () => ({
|
const XLVASK_IMPORT_STATES = ["new", "updated", "unchanged", "invalid"];
|
||||||
total: 0,
|
const XLVASK_CERTAINTY_STATES = ["certain", "uncertain", "none"];
|
||||||
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 nonNegativeInteger = (value) => {
|
const nonNegativeInteger = (value) => {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
|
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();
|
const normalized = emptyXlvaskAutopilotSummary();
|
||||||
|
if (!rawSummary || typeof rawSummary !== "object") {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
Object.keys(normalized).forEach((key) => {
|
Object.keys(normalized).forEach((key) => {
|
||||||
normalized[key] = nonNegativeInteger(summary?.[key]);
|
normalized[key] = nonNegativeInteger(rawSummary[key]);
|
||||||
});
|
});
|
||||||
return normalized;
|
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;
|
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 : [];
|
const duplicates = Array.isArray(object?.duplicates) ? object.duplicates : [];
|
||||||
return duplicates.some((duplicate) => duplicate?.wash_id === object?.wash_id);
|
return duplicates.some((duplicate) => duplicate?.wash_id === object?.wash_id);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,91 @@
|
|||||||
|
import Swal from "sweetalert2";
|
||||||
|
|
||||||
|
export const EXTRA_SALE_PRODUCT_ID = 27;
|
||||||
|
export const EXTRA_SALE_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
|
||||||
|
|
||||||
|
export const EXTRA_SALE_REASONS = [
|
||||||
|
{ code: "customer_request", label: "Kunde ønskede ekstra tid", commentRequired: false },
|
||||||
|
{ code: "operational_delay", label: "Driftsforsinkelse i vaskehal", commentRequired: false },
|
||||||
|
{ code: "rewash_quality", label: "Omkørsel/kvalitet", commentRequired: false },
|
||||||
|
{ code: "other", label: "Anden godkendt årsag", commentRequired: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const isExtraSaleAuditProduct = (product) => {
|
||||||
|
if (!product || typeof product !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (product.requires_extra_sale_audit === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number(product.id ?? product.product_id ?? 0) === EXTRA_SALE_PRODUCT_ID) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(product.name ?? "").trim() === EXTRA_SALE_PRODUCT_NAME;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isExtraSaleCommentRequired = (reasonCode) => {
|
||||||
|
return EXTRA_SALE_REASONS.some((reason) => reason.code === reasonCode && reason.commentRequired);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extraSaleAuditPayload = ({ reasonCode = null, comment = null } = {}) => ({
|
||||||
|
extra_sale_reason_code: String(reasonCode || "").trim() || null,
|
||||||
|
extra_sale_comment: String(comment || "").trim() || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getExtraSaleAuditFromOrderItem = (orderItem = {}) =>
|
||||||
|
extraSaleAuditPayload({
|
||||||
|
reasonCode: orderItem.extra_sale_reason_code,
|
||||||
|
comment: orderItem.extra_sale_comment,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const promptExtraSaleAuditIfRequired = async (product, initial = {}) => {
|
||||||
|
if (!isExtraSaleAuditProduct(product)) {
|
||||||
|
return extraSaleAuditPayload(initial);
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialReason = String(initial.reasonCode ?? initial.extra_sale_reason_code ?? "").trim();
|
||||||
|
const initialComment = String(initial.comment ?? initial.extra_sale_comment ?? "").trim();
|
||||||
|
const optionsMarkup = EXTRA_SALE_REASONS.map((reason) => {
|
||||||
|
const selected = reason.code === initialReason ? " selected" : "";
|
||||||
|
return `<option value="${reason.code}"${selected}>${reason.label}</option>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
const result = await Swal.fire({
|
||||||
|
title: "Godkend 10 min ekstra",
|
||||||
|
html: `
|
||||||
|
<label class="label has-text-left" for="extra-sale-reason-code">Årsag</label>
|
||||||
|
<div class="select is-fullwidth mb-3">
|
||||||
|
<select id="extra-sale-reason-code" class="swal2-select" style="display:block;width:100%;margin:0;">
|
||||||
|
<option value="">Vælg godkendt årsag</option>
|
||||||
|
${optionsMarkup}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label class="label has-text-left" for="extra-sale-comment">Kommentar</label>
|
||||||
|
<textarea id="extra-sale-comment" class="swal2-textarea" rows="4" style="display:block;width:100%;margin:0;" placeholder="Uddyb når årsagen kræver det">${initialComment}</textarea>
|
||||||
|
`,
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonText: "Godkend",
|
||||||
|
focusConfirm: false,
|
||||||
|
preConfirm: () => {
|
||||||
|
const reasonCode = document.getElementById("extra-sale-reason-code")?.value || "";
|
||||||
|
const comment = document.getElementById("extra-sale-comment")?.value || "";
|
||||||
|
|
||||||
|
if (!reasonCode) {
|
||||||
|
Swal.showValidationMessage("Vælg en godkendt årsag");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExtraSaleCommentRequired(reasonCode) && !String(comment || "").trim()) {
|
||||||
|
Swal.showValidationMessage("Kommentar er påkrævet for denne årsag");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return extraSaleAuditPayload({ reasonCode, comment });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.isConfirmed ? result.value : null;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,7 +50,16 @@ const getOrders = (object) => {
|
|||||||
let invoice_orders = invoice.objects;
|
let invoice_orders = invoice.objects;
|
||||||
orders = orders.concat(invoice_orders);
|
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();
|
getDepartments();
|
||||||
|
|||||||
@@ -47,9 +47,35 @@ const emit = defineEmits(["flagStatusChanged"]);
|
|||||||
|
|
||||||
const orderItems = ref([]);
|
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 () => {
|
const loadOrderItems = async () => {
|
||||||
if (props.useLocalOrderItems) {
|
if (props.useLocalOrderItems) {
|
||||||
orderItems.value = props.localOrderItems;
|
orderItems.value = sortOrderItemsForDisplay(props.localOrderItems);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await SessionUser.request(
|
await SessionUser.request(
|
||||||
@@ -59,7 +85,7 @@ const loadOrderItems = async () => {
|
|||||||
order_id: props.orderId,
|
order_id: props.orderId,
|
||||||
}
|
}
|
||||||
).then((response) => {
|
).then((response) => {
|
||||||
orderItems.value = response.data.data;
|
orderItems.value = sortOrderItemsForDisplay(response.data.data);
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
loadCustomerAttributes
|
loadCustomerAttributes
|
||||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||||
import {getProductCategory, getProducts} from "@/components/shop/Products.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 {SessionUser} from "@/components/session/token/SessionUser.vue";
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
@@ -28,7 +28,12 @@ import Swal from "sweetalert2";
|
|||||||
import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
|
import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
|
||||||
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
|
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
|
||||||
import ProductBox from "@/components/displays/boxes/ProductBox.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";
|
import { getPicture } from "@/components/displays/department/pos/displays/Piktogrammer.vue";
|
||||||
|
import {
|
||||||
|
getExtraSaleAuditFromOrderItem,
|
||||||
|
promptExtraSaleAuditIfRequired,
|
||||||
|
} from "@/components/displays/department/pos/utils/extraSaleAudit.js";
|
||||||
|
|
||||||
const emits = defineEmits(['onAddToCartProduct', 'onAddProduct', 'onSelectProduct', 'onSelectionInvalidated']);
|
const emits = defineEmits(['onAddToCartProduct', 'onAddProduct', 'onSelectProduct', 'onSelectionInvalidated']);
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -385,10 +390,15 @@ const warnIfRestrictedAddonsWereSkipped = async (restrictedSelections = []) => {
|
|||||||
await showRestrictionWarning("pos.restrictions.restricted_items_removed");
|
await showRestrictionWarning("pos.restrictions.restricted_items_removed");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveExtraSaleAudit = async (product, initial = {}) => {
|
||||||
|
const audit = await promptExtraSaleAuditIfRequired(product, initial);
|
||||||
|
return audit === null ? null : audit;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const showAddMultipleProducts = (productId) => {
|
|
||||||
|
const showAddMultipleProducts = async (productId) => {
|
||||||
const product = findProductById(productId);
|
const product = findProductById(productId);
|
||||||
const restriction = getScopedProductRestriction(product);
|
const restriction = getScopedProductRestriction(product);
|
||||||
if (restriction.restricted) {
|
if (restriction.restricted) {
|
||||||
@@ -396,7 +406,7 @@ const showAddMultipleProducts = (productId) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Swal.fire({
|
const quantityResult = await Swal.fire({
|
||||||
title: 'Tilføj flere produkter',
|
title: 'Tilføj flere produkter',
|
||||||
input: 'number',
|
input: 'number',
|
||||||
inputAttributes: {
|
inputAttributes: {
|
||||||
@@ -404,7 +414,7 @@ const showAddMultipleProducts = (productId) => {
|
|||||||
},
|
},
|
||||||
showCancelButton: true,
|
showCancelButton: true,
|
||||||
confirmButtonText: 'Tilføj',
|
confirmButtonText: 'Tilføj',
|
||||||
showLoaderOnConfirm: true,
|
showLoaderOnConfirm: false,
|
||||||
preConfirm: (inputValue) => {
|
preConfirm: (inputValue) => {
|
||||||
// Check if the input number is higher than 200
|
// Check if the input number is higher than 200
|
||||||
if (inputValue > 200) {
|
if (inputValue > 200) {
|
||||||
@@ -422,17 +432,32 @@ const showAddMultipleProducts = (productId) => {
|
|||||||
Swal.showValidationMessage('Order ID is required');
|
Swal.showValidationMessage('Order ID is required');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return createOrderItem(orderId, productId, inputValue)
|
return Number(inputValue);
|
||||||
.then(async (result) => {
|
|
||||||
const createdItemId = Number(result?.data?.data?.id ?? 0);
|
|
||||||
await addAddonsToOrderMiddleware(productId, inputValue, createdItemId, orderId);
|
|
||||||
await loadOrderItems();
|
|
||||||
})
|
|
||||||
.catch((error) => handleCreateOrderItemError(error, { validationMessage: true }).then(() => false));
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
allowOutsideClick: () => !Swal.isLoading()
|
allowOutsideClick: () => !Swal.isLoading()
|
||||||
});
|
});
|
||||||
|
if (!quantityResult.isConfirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderId = getValidOrderId();
|
||||||
|
if (!orderId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audit = await resolveExtraSaleAudit(product);
|
||||||
|
if (audit === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await createOrderItem(orderId, productId, quantityResult.value, null, null, null, audit)
|
||||||
|
.then(async (result) => {
|
||||||
|
const createdItemId = Number(result?.data?.data?.id ?? 0);
|
||||||
|
await addAddonsToOrderMiddleware(productId, quantityResult.value, createdItemId, orderId);
|
||||||
|
await loadOrderItems();
|
||||||
|
})
|
||||||
|
.catch((error) => handleCreateOrderItemError(error));
|
||||||
};
|
};
|
||||||
|
|
||||||
const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
|
const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
|
||||||
@@ -576,28 +601,62 @@ const addAddonsToOrderMiddleware = async (product_id, quantity = 1, related_item
|
|||||||
throw new Error("Created parent order item ID is missing");
|
throw new Error("Created parent order item ID is missing");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < product_addons.length; i++) {
|
// Show the pending rows for every add-on so the cart panel reflects the
|
||||||
// Get the addons quantity (If the addon quantity is 0, use the product quantity, otherwise multiply the addon quantity with the product quantity)
|
// optimistic state immediately. The real POST fan-out happens below and
|
||||||
let addon_quantity = product_addons[i].quantity === 0 ? quantity : product_addons[i].quantity * quantity;
|
// the pending rows are cleared by `loadOrderItems` on success or by
|
||||||
// Get the products addon, and show the fake create order item
|
// `clearPendingOrderItems` on failure.
|
||||||
showPendingCreateOrderItem(
|
const addonDefinitions = product_addons.map((selection) => {
|
||||||
getPendingProductFromAddon(product_id, product_addons[i].addon_id),
|
const addon_quantity = selection.quantity === 0 ? quantity : selection.quantity * quantity;
|
||||||
addon_quantity,
|
const addonAddon = getProductAddonDefinition(product_id, selection.addon_id);
|
||||||
getAddonPrice(product_id, product_addons[i].addon_id),
|
return {
|
||||||
normalizedRelatedItemId,
|
addon_id: selection.addon_id,
|
||||||
product_addons[i].notes === undefined ? null : product_addons[i].notes
|
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++) {
|
// Build the add-on payloads the shared fan-out helper expects. The
|
||||||
let addon_quantity = product_addons[i].quantity === 0 ? quantity : product_addons[i].quantity * quantity;
|
// helper accepts either an addon-shaped object (`option_id` / nested
|
||||||
await createOrderItem(
|
// `product`) or a plain object with `product_id` + `quantity`; we pass
|
||||||
targetOrderId,
|
// the nested `product` form so the helper can read both `product.id`
|
||||||
product_addons[i].addon_id,
|
// and `product.price` for the POST without us flattening it twice.
|
||||||
addon_quantity,
|
const helperAddons = addonDefinitions.map((addon) => ({
|
||||||
normalizedRelatedItemId,
|
option_id: addon.addon_id,
|
||||||
product_addons[i].notes === undefined ? null : product_addons[i].notes
|
quantity: addon.quantity,
|
||||||
).catch((error) => handleCreateOrderItemError(error));
|
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -650,7 +709,7 @@ const addProductWithAddonsToOrder = async (product_id) => {
|
|||||||
// Check if the product requires a note
|
// Check if the product requires a note
|
||||||
if (productRequiresOrderItemNote(product)) {
|
if (productRequiresOrderItemNote(product)) {
|
||||||
// Show the note input
|
// Show the note input
|
||||||
await Swal.fire({
|
const noteResult = await Swal.fire({
|
||||||
title: 'Tilføj en note',
|
title: 'Tilføj en note',
|
||||||
input: 'text',
|
input: 'text',
|
||||||
inputLabel: 'Noten kan ses af kunden. F.eks. "Fjernelse af graffiti på venstre side"',
|
inputLabel: 'Noten kan ses af kunden. F.eks. "Fjernelse af graffiti på venstre side"',
|
||||||
@@ -659,35 +718,32 @@ const addProductWithAddonsToOrder = async (product_id) => {
|
|||||||
},
|
},
|
||||||
showCancelButton: true,
|
showCancelButton: true,
|
||||||
confirmButtonText: 'Tilføj',
|
confirmButtonText: 'Tilføj',
|
||||||
showLoaderOnConfirm: true,
|
showLoaderOnConfirm: false,
|
||||||
inputValidator: (note) => {
|
inputValidator: (note) => {
|
||||||
if (!String(note || '').trim()) {
|
if (!String(note || '').trim()) {
|
||||||
return 'Note er påkrævet for dette produkt';
|
return 'Note er påkrævet for dette produkt';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
preConfirm: (note) => {
|
preConfirm: (note) => String(note || '').trim(),
|
||||||
const normalizedNote = String(note || '').trim();
|
|
||||||
const confirmedOrderId = getValidOrderId();
|
|
||||||
if (!confirmedOrderId) {
|
|
||||||
Swal.showValidationMessage('Order ID is required');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Show the fake create order item
|
|
||||||
showPendingCreateOrderItem(product, 1, getUserProductPrice(product), 0, normalizedNote);
|
|
||||||
// Create the order item
|
|
||||||
return createOrderItem(confirmedOrderId, product_id, 1, 0, normalizedNote)
|
|
||||||
.then(async (result) => {
|
|
||||||
let order_item_id = result.data.data.id;
|
|
||||||
// Add the addons to the order
|
|
||||||
await addAddonsToOrderMiddleware(product_id, 1, order_item_id, confirmedOrderId).then(() => {
|
|
||||||
loadOrderItems();
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((error) => handleCreateOrderItemError(error, { validationMessage: true }).then(() => false));
|
|
||||||
},
|
|
||||||
allowOutsideClick: () => !Swal.isLoading()
|
|
||||||
});
|
});
|
||||||
|
if (!noteResult.isConfirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalizedNote = noteResult.value;
|
||||||
|
const audit = await resolveExtraSaleAudit(product);
|
||||||
|
if (audit === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showPendingCreateOrderItem(product, 1, getUserProductPrice(product), 0, normalizedNote);
|
||||||
|
await createOrderItem(orderId, product_id, 1, 0, normalizedNote, null, audit)
|
||||||
|
.then(async (result) => {
|
||||||
|
let order_item_id = result.data.data.id;
|
||||||
|
await addAddonsToOrderMiddleware(product_id, 1, order_item_id, orderId).then(() => {
|
||||||
|
loadOrderItems();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => handleCreateOrderItemError(error));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// If the product requires a note, show the note input
|
// If the product requires a note, show the note input
|
||||||
@@ -704,7 +760,12 @@ const addProductWithAddonsToOrder = async (product_id) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Create the order item
|
// Create the order item
|
||||||
await createOrderItem(orderId, product_id, quantity)
|
const audit = await resolveExtraSaleAudit(product);
|
||||||
|
if (audit === null) {
|
||||||
|
clearPendingOrderItems();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await createOrderItem(orderId, product_id, quantity, null, null, null, audit)
|
||||||
.then(async (result) => {
|
.then(async (result) => {
|
||||||
let order_item_id = result.data.data.id;
|
let order_item_id = result.data.data.id;
|
||||||
// Add the addons to the order
|
// Add the addons to the order
|
||||||
@@ -781,12 +842,19 @@ const addPreviousOrderToCurrent = async (previousOrder) => {
|
|||||||
const discountedPrice = String(getUserProductPrice({ ...previousOrderProduct, price: basePrice }));
|
const discountedPrice = String(getUserProductPrice({ ...previousOrderProduct, price: basePrice }));
|
||||||
|
|
||||||
showPendingCreateOrderItem({ ...previousOrderProduct, price: basePrice }, quantity, discountedPrice);
|
showPendingCreateOrderItem({ ...previousOrderProduct, price: basePrice }, quantity, discountedPrice);
|
||||||
|
const audit = await resolveExtraSaleAudit(previousOrderProduct, getExtraSaleAuditFromOrderItem(orderItem));
|
||||||
|
if (audit === null) {
|
||||||
|
clearPendingOrderItems();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const result = await createOrderItem(
|
const result = await createOrderItem(
|
||||||
orderId,
|
orderId,
|
||||||
productId,
|
productId,
|
||||||
quantity,
|
quantity,
|
||||||
null,
|
null,
|
||||||
String(orderItem?.notes ?? "")
|
String(orderItem?.notes ?? ""),
|
||||||
|
null,
|
||||||
|
audit
|
||||||
).catch((error) => handleCreateOrderItemError(error));
|
).catch((error) => handleCreateOrderItemError(error));
|
||||||
const sourceItemId = getPreviousOrderItemId(orderItem);
|
const sourceItemId = getPreviousOrderItemId(orderItem);
|
||||||
const createdItemId = Number(result?.data?.data?.id ?? 0);
|
const createdItemId = Number(result?.data?.data?.id ?? 0);
|
||||||
@@ -819,12 +887,19 @@ const addPreviousOrderToCurrent = async (previousOrder) => {
|
|||||||
String(getUserProductPrice(previousOrderProduct)),
|
String(getUserProductPrice(previousOrderProduct)),
|
||||||
relatedItemId
|
relatedItemId
|
||||||
);
|
);
|
||||||
|
const audit = await resolveExtraSaleAudit(previousOrderProduct, getExtraSaleAuditFromOrderItem(orderItem));
|
||||||
|
if (audit === null) {
|
||||||
|
clearPendingOrderItems();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
await createOrderItem(
|
await createOrderItem(
|
||||||
orderId,
|
orderId,
|
||||||
productId,
|
productId,
|
||||||
quantity,
|
quantity,
|
||||||
relatedItemId,
|
relatedItemId,
|
||||||
String(orderItem?.notes ?? "")
|
String(orderItem?.notes ?? ""),
|
||||||
|
null,
|
||||||
|
audit
|
||||||
).catch((error) => handleCreateOrderItemError(error));
|
).catch((error) => handleCreateOrderItemError(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -888,7 +963,12 @@ const addRecommendedProductToOrder = async (productId) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showPendingCreateOrderItem(product, 1, getRecommendedProductPrice(productId));
|
showPendingCreateOrderItem(product, 1, getRecommendedProductPrice(productId));
|
||||||
const result = await createOrderItem(orderId, productId, 1).catch((error) => handleCreateOrderItemError(error));
|
const audit = await resolveExtraSaleAudit(product);
|
||||||
|
if (audit === null) {
|
||||||
|
clearPendingOrderItems();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await createOrderItem(orderId, productId, 1, null, null, null, audit).catch((error) => handleCreateOrderItemError(error));
|
||||||
const createdItemId = Number(result?.data?.data?.id ?? 0);
|
const createdItemId = Number(result?.data?.data?.id ?? 0);
|
||||||
if (createdItemId > 0) {
|
if (createdItemId > 0) {
|
||||||
await addAddonsToOrderMiddleware(productId, 1, createdItemId, orderId);
|
await addAddonsToOrderMiddleware(productId, 1, createdItemId, orderId);
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ export function usePaginatedList() {
|
|||||||
let activeRequestController = null;
|
let activeRequestController = null;
|
||||||
let searchDebounceTimeout = null;
|
let searchDebounceTimeout = null;
|
||||||
|
|
||||||
|
/** The last error produced by `paginatedGetRequest` (null on success). */
|
||||||
|
const lastError = ref(null);
|
||||||
|
|
||||||
/** Additional query parameters */
|
/** Additional query parameters */
|
||||||
const additionalQueryParameters = ref({});
|
const additionalQueryParameters = ref({});
|
||||||
|
|
||||||
@@ -170,6 +173,7 @@ export function usePaginatedList() {
|
|||||||
abortActiveRequest();
|
abortActiveRequest();
|
||||||
const requestController = typeof AbortController !== "undefined" ? new AbortController() : null;
|
const requestController = typeof AbortController !== "undefined" ? new AbortController() : null;
|
||||||
activeRequestController = requestController;
|
activeRequestController = requestController;
|
||||||
|
lastError.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(API_URL + endpoint.value, {
|
const response = await axios.get(API_URL + endpoint.value, {
|
||||||
@@ -193,10 +197,16 @@ export function usePaginatedList() {
|
|||||||
setLastUpdated();
|
setLastUpdated();
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isCanceledRequest(error)) {
|
if (isCanceledRequest(error)) {
|
||||||
parseError(error, 'paginatedGetRequest');
|
return null;
|
||||||
console.log(error);
|
|
||||||
}
|
}
|
||||||
|
lastError.value = {
|
||||||
|
status: Number.parseInt(String(error?.response?.status ?? error?.status ?? ""), 10) || null,
|
||||||
|
endpoint: endpoint.value,
|
||||||
|
message: error?.response?.data?.data?.message ?? error?.response?.data?.message ?? error?.message ?? null,
|
||||||
|
};
|
||||||
|
parseError(error, 'paginatedGetRequest');
|
||||||
|
console.log(error);
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
if (activeRequestController === requestController) {
|
if (activeRequestController === requestController) {
|
||||||
@@ -487,6 +497,7 @@ export function usePaginatedList() {
|
|||||||
isLoading,
|
isLoading,
|
||||||
isExporting,
|
isExporting,
|
||||||
latestSearch,
|
latestSearch,
|
||||||
|
lastError,
|
||||||
additionalQueryParameters,
|
additionalQueryParameters,
|
||||||
exportTransform,
|
exportTransform,
|
||||||
setHideSearchField,
|
setHideSearchField,
|
||||||
@@ -542,6 +553,7 @@ export const hideSearchField = globalInstance.hideSearchField;
|
|||||||
export const isLoading = globalInstance.isLoading;
|
export const isLoading = globalInstance.isLoading;
|
||||||
export const isExporting = globalInstance.isExporting;
|
export const isExporting = globalInstance.isExporting;
|
||||||
export const latestSearch = globalInstance.latestSearch;
|
export const latestSearch = globalInstance.latestSearch;
|
||||||
|
export const lastError = globalInstance.lastError;
|
||||||
export const additionalQueryParameters = globalInstance.additionalQueryParameters;
|
export const additionalQueryParameters = globalInstance.additionalQueryParameters;
|
||||||
export const exportTransform = globalInstance.exportTransform;
|
export const exportTransform = globalInstance.exportTransform;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
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: {
|
enabled: {
|
||||||
get: async () => {
|
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 { Limble } from "@/components/session/token/superUser/modules/limble/Limble.vue";
|
||||||
import { OcrSpace } from "@/components/session/token/superUser/modules/ocrSpace/OcrSpace.vue";
|
import { OcrSpace } from "@/components/session/token/superUser/modules/ocrSpace/OcrSpace.vue";
|
||||||
import { OpenAI } from "@/components/session/token/superUser/modules/openAI/OpenAI.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 { LicensePlateRecognizer } from "@/components/session/token/superUser/modules/licensePlateRecognizer/LicensePlateRecognizer.vue";
|
||||||
import { VirkData } from "@/components/session/token/superUser/modules/virkdata/VirkData.vue";
|
import { VirkData } from "@/components/session/token/superUser/modules/virkdata/VirkData.vue";
|
||||||
import { Shelly } from "@/components/session/token/superUser/modules/shelly/Shelly.vue";
|
import { Shelly } from "@/components/session/token/superUser/modules/shelly/Shelly.vue";
|
||||||
@@ -106,9 +105,6 @@ export const SuperUserObject = {
|
|||||||
get openai() {
|
get openai() {
|
||||||
return OpenAI;
|
return OpenAI;
|
||||||
},
|
},
|
||||||
get minimax() {
|
|
||||||
return MiniMax;
|
|
||||||
},
|
|
||||||
get licenseplaterecognizer() {
|
get licenseplaterecognizer() {
|
||||||
return LicensePlateRecognizer;
|
return LicensePlateRecognizer;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import {API_URL} from "@/config.js";
|
import {API_URL} from "@/config.js";
|
||||||
|
|
||||||
export const AUDITED_ORDER_ITEM_PRODUCT_IDS = new Set([21, 22, 24, 25, 26, 27]);
|
export const AUDITED_ORDER_ITEM_PRODUCT_IDS = new Set([21, 22, 25, 26, 27]);
|
||||||
export const DEFAULT_AUDITED_ORDER_ITEM_REASON_CODE = "customer_approved_extra_work";
|
export const DEFAULT_AUDITED_ORDER_ITEM_REASON_CODE = "customer_approved_extra_work";
|
||||||
export const DEFAULT_AUDITED_ORDER_ITEM_REASON_LABEL = "Kunde godkendte ekstra arbejde";
|
export const DEFAULT_AUDITED_ORDER_ITEM_REASON_LABEL = "Kunde godkendte ekstra arbejde";
|
||||||
|
|
||||||
@@ -13,12 +13,34 @@ export const buildAuditedOrderItemReasonPayload = (product_id, notes = null, rea
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reason = reasonData && typeof reasonData === "object" ? reasonData : {};
|
const reason = reasonData && typeof reasonData === "object" ? reasonData : {};
|
||||||
const comment = String(reason.reason_comment ?? reason.comment ?? notes ?? "").trim();
|
|
||||||
|
// Server requires `reason_comment` to be present (and non-empty after trim)
|
||||||
|
// for audited products. Walk the precedence chain in order so callers can
|
||||||
|
// supply either an explicit override or fall back to the legacy `notes`
|
||||||
|
// field, and never emit an empty value.
|
||||||
|
const trimmedFirstNonEmpty = (...candidates) => {
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const trimmed = String(candidate ?? "").trim();
|
||||||
|
if (trimmed.length > 0) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const reasonComment = trimmedFirstNonEmpty(
|
||||||
|
reason.reason_comment,
|
||||||
|
reason.comment,
|
||||||
|
notes,
|
||||||
|
DEFAULT_AUDITED_ORDER_ITEM_REASON_LABEL
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
reason_code: String(reason.reason_code || DEFAULT_AUDITED_ORDER_ITEM_REASON_CODE),
|
reason_code: String(reason.reason_code || DEFAULT_AUDITED_ORDER_ITEM_REASON_CODE),
|
||||||
reason_label_snapshot: String(reason.reason_label_snapshot || DEFAULT_AUDITED_ORDER_ITEM_REASON_LABEL),
|
reason_label_snapshot: String(
|
||||||
reason_comment: comment,
|
reason.reason_label_snapshot || DEFAULT_AUDITED_ORDER_ITEM_REASON_LABEL
|
||||||
|
),
|
||||||
|
reason_comment: reasonComment,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,7 +56,16 @@ export const getOrderItems = (order_id) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createOrderItem = (order_id, product_id, quantity, related_item_id = null, notes = null, forcePrice = null, reasonData = null) => {
|
export const createOrderItem = (
|
||||||
|
order_id,
|
||||||
|
product_id,
|
||||||
|
quantity,
|
||||||
|
related_item_id = null,
|
||||||
|
notes = null,
|
||||||
|
forcePrice = null,
|
||||||
|
reasonData = null,
|
||||||
|
extraSaleAudit = {}
|
||||||
|
) => {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return null;
|
return null;
|
||||||
@@ -50,6 +81,7 @@ export const createOrderItem = (order_id, product_id, quantity, related_item_id
|
|||||||
related_item_id,
|
related_item_id,
|
||||||
notes,
|
notes,
|
||||||
...buildAuditedOrderItemReasonPayload(product_id, notes, reasonData),
|
...buildAuditedOrderItemReasonPayload(product_id, notes, reasonData),
|
||||||
|
...extraSaleAudit
|
||||||
};
|
};
|
||||||
if (forcePrice !== null && forcePrice !== undefined) {
|
if (forcePrice !== null && forcePrice !== undefined) {
|
||||||
payload.price = forcePrice;
|
payload.price = forcePrice;
|
||||||
@@ -73,7 +105,7 @@ export const removeOrderItem = (id) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const editOrderItem = (id, price, notes, reference, quantity, reasonData = null) => {
|
export const editOrderItem = (id, price, notes, reference, quantity, reasonData = null, extraSaleAudit = {}) => {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return null;
|
return null;
|
||||||
@@ -85,6 +117,7 @@ export const editOrderItem = (id, price, notes, reference, quantity, reasonData
|
|||||||
reference,
|
reference,
|
||||||
quantity,
|
quantity,
|
||||||
...(reasonData && typeof reasonData === "object" ? reasonData : {}),
|
...(reasonData && typeof reasonData === "object" ? reasonData : {}),
|
||||||
|
...extraSaleAudit
|
||||||
}, {
|
}, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import { getAttributes } from "@/components/shop/CustomerAttributes.vue";
|
|||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||||
import { doesOrderContainWashCertificateProduct } from "@/components/displays/department/pos/utils/washCertificate.js";
|
import { doesOrderContainWashCertificateProduct } from "@/components/displays/department/pos/utils/washCertificate.js";
|
||||||
|
import {
|
||||||
|
getExtraSaleAuditFromOrderItem,
|
||||||
|
promptExtraSaleAuditIfRequired,
|
||||||
|
} from "@/components/displays/department/pos/utils/extraSaleAudit.js";
|
||||||
import {
|
import {
|
||||||
getCustomerProductRestriction,
|
getCustomerProductRestriction,
|
||||||
getProductCategoryRestrictionForCustomer,
|
getProductCategoryRestrictionForCustomer,
|
||||||
@@ -1572,19 +1576,43 @@ const copyLastWashReferenceToEmptyCurrentOrder = async (sourceReference) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const createCopiedOrderItem = (targetOrderId, sourceItem, relatedItemId = null) => {
|
const createCopiedOrderItem = async (targetOrderId, sourceItem, relatedItemId = null) => {
|
||||||
const productId = getOrderItemProductId(sourceItem);
|
const productId = getOrderItemProductId(sourceItem);
|
||||||
const quantity = getOrderItemQuantity(sourceItem);
|
const quantity = getOrderItemQuantity(sourceItem);
|
||||||
if (!productId || !quantity) {
|
if (!productId || !quantity) {
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
|
const audit = await promptExtraSaleAuditIfRequired(
|
||||||
|
sourceItem?.product || { id: productId },
|
||||||
|
getExtraSaleAuditFromOrderItem(sourceItem)
|
||||||
|
);
|
||||||
|
if (audit === null) {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve audited-order-item reason metadata from the source row so the
|
||||||
|
// copied POST satisfies the server-side `reason_comment` requirement for
|
||||||
|
// products in AUDITED_ORDER_ITEM_PRODUCT_IDS. We only forward the fields
|
||||||
|
// we actually saw on the source; `buildAuditedOrderItemReasonPayload`
|
||||||
|
// still falls back to the order-item `notes` and then to the default
|
||||||
|
// label for non-audited products, so this is safe for every other case.
|
||||||
|
const sourceReasonData = sourceItem && typeof sourceItem === "object"
|
||||||
|
? {
|
||||||
|
reason_code: sourceItem.reason_code,
|
||||||
|
reason_label_snapshot: sourceItem.reason_label_snapshot,
|
||||||
|
reason_comment: sourceItem.reason_comment ?? sourceItem.comment,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
return createOrderItem(
|
return createOrderItem(
|
||||||
targetOrderId,
|
targetOrderId,
|
||||||
productId,
|
productId,
|
||||||
quantity,
|
quantity,
|
||||||
relatedItemId,
|
relatedItemId,
|
||||||
getOrderItemNotes(sourceItem)
|
getOrderItemNotes(sourceItem),
|
||||||
|
null,
|
||||||
|
sourceReasonData,
|
||||||
|
audit
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2611,13 +2639,19 @@ export const hydrateSelectedOrderBookingForDesktop = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const forcedPrimaryPrice = primaryProduct.price ?? null;
|
const forcedPrimaryPrice = primaryProduct.price ?? null;
|
||||||
|
const primaryAudit = await promptExtraSaleAuditIfRequired(primaryProduct);
|
||||||
|
if (primaryAudit === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const primaryItemResponse = await createOrderItem(
|
const primaryItemResponse = await createOrderItem(
|
||||||
normalizedOrderId,
|
normalizedOrderId,
|
||||||
primaryProduct.id,
|
primaryProduct.id,
|
||||||
1,
|
1,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
forcedPrimaryPrice
|
forcedPrimaryPrice,
|
||||||
|
null,
|
||||||
|
primaryAudit
|
||||||
);
|
);
|
||||||
const relatedPrimaryItemId = toPositiveInteger(primaryItemResponse?.data?.data?.id);
|
const relatedPrimaryItemId = toPositiveInteger(primaryItemResponse?.data?.data?.id);
|
||||||
|
|
||||||
@@ -2629,6 +2663,10 @@ export const hydrateSelectedOrderBookingForDesktop = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const secondaryProduct = await fetchOrderBookingProductWithPricing(secondaryProductId);
|
const secondaryProduct = await fetchOrderBookingProductWithPricing(secondaryProductId);
|
||||||
|
const secondaryAudit = await promptExtraSaleAuditIfRequired(secondaryProduct || { id: secondaryProductId });
|
||||||
|
if (secondaryAudit === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
await createOrderItem(
|
await createOrderItem(
|
||||||
normalizedOrderId,
|
normalizedOrderId,
|
||||||
@@ -2636,7 +2674,9 @@ export const hydrateSelectedOrderBookingForDesktop = async () => {
|
|||||||
Math.max(1, Number.parseInt(String(bookingItem?.quantity ?? 1), 10) || 1),
|
Math.max(1, Number.parseInt(String(bookingItem?.quantity ?? 1), 10) || 1),
|
||||||
relatedPrimaryItemId,
|
relatedPrimaryItemId,
|
||||||
String(bookingItem?.notes ?? "").trim() || null,
|
String(bookingItem?.notes ?? "").trim() || null,
|
||||||
secondaryProduct?.price ?? null
|
secondaryProduct?.price ?? null,
|
||||||
|
null,
|
||||||
|
secondaryAudit
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -4238,6 +4238,7 @@
|
|||||||
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.oplysninger'}"
|
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.oplysninger'}"
|
||||||
},
|
},
|
||||||
"expected_price": "@.capitalize:{'words.generated.forventet'} @:{'words.generated.pris'}",
|
"expected_price": "@.capitalize:{'words.generated.forventet'} @:{'words.generated.pris'}",
|
||||||
|
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'words.generated.for'} @:{'templates.generated.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
|
||||||
"start_time": "Starttidspunkt",
|
"start_time": "Starttidspunkt",
|
||||||
"wash_id": "@:{'templates.generated.compat.objects.orders.columns.wash_id'}",
|
"wash_id": "@:{'templates.generated.compat.objects.orders.columns.wash_id'}",
|
||||||
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrering'}"
|
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrering'}"
|
||||||
@@ -4678,7 +4679,7 @@
|
|||||||
"workflow": "Arbejdsgang"
|
"workflow": "Arbejdsgang"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"xlvask_autopilot": {
|
"xlvask_review": {
|
||||||
"actions": {
|
"actions": {
|
||||||
"accept": "Accepter forslag",
|
"accept": "Accepter forslag",
|
||||||
"attach_order": "Tilknyt ordre",
|
"attach_order": "Tilknyt ordre",
|
||||||
@@ -4874,7 +4875,8 @@
|
|||||||
"create_order_item_failed": "Vaskelinjen kunne ikke føjes til ordren.",
|
"create_order_item_failed": "Vaskelinjen kunne ikke føjes til ordren.",
|
||||||
"redirect_order_failed": "Ordren kunne ikke åbnes.",
|
"redirect_order_failed": "Ordren kunne ikke åbnes.",
|
||||||
"load_customers_failed": "Kunderne kunne ikke hentes.",
|
"load_customers_failed": "Kunderne kunne ikke hentes.",
|
||||||
"load_usage_log_failed": "Forbrugsloggen kunne ikke hentes."
|
"load_usage_log_failed": "Forbrugsloggen kunne ikke hentes.",
|
||||||
|
"api_endpoint_not_implemented": "API-endepunkt ikke implementeret endnu"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4340,7 +4340,7 @@
|
|||||||
"vehicle_subscription_type_mismatch": "{product} @:{'words.generated.does'} @:{'words.generated.not'} match @:{'words.generated.the'} @:{'words.generated.vehicle'} @:{'words.generated.subscription'} @:{'words.generated.type'} {expected_product}.",
|
"vehicle_subscription_type_mismatch": "{product} @:{'words.generated.does'} @:{'words.generated.not'} match @:{'words.generated.the'} @:{'words.generated.vehicle'} @:{'words.generated.subscription'} @:{'words.generated.type'} {expected_product}.",
|
||||||
"wash_certificate_attached_without_item": "@:{'words.generated.waschzertifikat'} @:{'words.generated.ist'} @:{'words.generated.ohne'} @:{'words.generated.waschzertifikat'}-@.capitalize:{'words.generated.position'} angehaengt.",
|
"wash_certificate_attached_without_item": "@:{'words.generated.waschzertifikat'} @:{'words.generated.ist'} @:{'words.generated.ohne'} @:{'words.generated.waschzertifikat'}-@.capitalize:{'words.generated.position'} angehaengt.",
|
||||||
"wash_certificate_item_without_certificate": "@:{'words.generated.waschzertifikat'}-@.capitalize:{'words.generated.position'} @:{'words.generated.ist'} @:{'words.generated.ohne'} @:{'words.generated.waschzertifikat'} vorhanden.",
|
"wash_certificate_item_without_certificate": "@:{'words.generated.waschzertifikat'}-@.capitalize:{'words.generated.position'} @:{'words.generated.ist'} @:{'words.generated.ohne'} @:{'words.generated.waschzertifikat'} vorhanden.",
|
||||||
"xlvask_missing_order_link": "@:{'words.generated.xl'} @:{'words.generated.vask'} @:{'words.generated.wash'} @:{'words.generated.is'} neither ignored nor linked @:{'words.generated.to'} @:{'words.generated.an'} @:{'words.generated.order'} @:{'words.generated.in'} @:{'words.generated.the'} @:{'words.generated.selected'} @:{'words.generated.period'}."
|
"xlvask_missing_order_link": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrierung'} @:{'words.generated.ist'} weder ignoriert noch @:{'words.generated.mit'} einer @:{'words.generated.bestellung'} im ausgewaehlten @:{'words.generated.zeitraum'} verknuepft."
|
||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
"entities": {
|
"entities": {
|
||||||
@@ -4348,6 +4348,7 @@
|
|||||||
"xlvask_usage_log": "@:{'words.generated.xl'} @:{'words.generated.vask'}-@.capitalize:{'words.generated.details'}"
|
"xlvask_usage_log": "@:{'words.generated.xl'} @:{'words.generated.vask'}-@.capitalize:{'words.generated.details'}"
|
||||||
},
|
},
|
||||||
"expected_price": "Erwarteter @:{'words.generated.preis'}",
|
"expected_price": "Erwarteter @:{'words.generated.preis'}",
|
||||||
|
"no_xlvask_usage_log_metadata": "Keine Metadaten @:{'words.generated.for'} @:{'templates.generated.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
|
||||||
"start_time": "@:{'templates.generated.compat.tables.common.start_time'}",
|
"start_time": "@:{'templates.generated.compat.tables.common.start_time'}",
|
||||||
"wash_id": "Wasch-@.upper:{'words.generated.id'}",
|
"wash_id": "Wasch-@.upper:{'words.generated.id'}",
|
||||||
"xlvask_usage_log": "@:{'templates.generated.compat.invoice_period.flags.tokens.xlvask_usage_log'}"
|
"xlvask_usage_log": "@:{'templates.generated.compat.invoice_period.flags.tokens.xlvask_usage_log'}"
|
||||||
@@ -4788,7 +4789,7 @@
|
|||||||
"workflow": "Arbeitsablauf"
|
"workflow": "Arbeitsablauf"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"xlvask_autopilot": {
|
"xlvask_review": {
|
||||||
"actions": {
|
"actions": {
|
||||||
"accept": "Vorschlag annehmen",
|
"accept": "Vorschlag annehmen",
|
||||||
"attach_order": "Auftrag verknüpfen",
|
"attach_order": "Auftrag verknüpfen",
|
||||||
@@ -4984,7 +4985,8 @@
|
|||||||
"create_order_item_failed": "Der Wäscheposten konnte nicht zum Auftrag hinzugefügt werden.",
|
"create_order_item_failed": "Der Wäscheposten konnte nicht zum Auftrag hinzugefügt werden.",
|
||||||
"redirect_order_failed": "Der Auftrag konnte nicht geöffnet werden.",
|
"redirect_order_failed": "Der Auftrag konnte nicht geöffnet werden.",
|
||||||
"load_customers_failed": "Die Kunden konnten nicht geladen werden.",
|
"load_customers_failed": "Die Kunden konnten nicht geladen werden.",
|
||||||
"load_usage_log_failed": "Das Verbrauchsprotokoll konnte nicht geladen werden."
|
"load_usage_log_failed": "Das Verbrauchsprotokoll konnte nicht geladen werden.",
|
||||||
|
"api_endpoint_not_implemented": "API-Endpunkt ist noch nicht implementiert"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4069,6 +4069,7 @@
|
|||||||
"xlvask_usage_log": "@:{'words.generated.xl'} @:{'words.generated.vask'} @:{'words.generated.details'}"
|
"xlvask_usage_log": "@:{'words.generated.xl'} @:{'words.generated.vask'} @:{'words.generated.details'}"
|
||||||
},
|
},
|
||||||
"expected_price": "@.capitalize:{'words.generated.expected'} @:{'words.generated.price'}",
|
"expected_price": "@.capitalize:{'words.generated.expected'} @:{'words.generated.price'}",
|
||||||
|
"no_xlvask_usage_log_metadata": "No metadata @:{'words.generated.for'} @:{'templates.generated.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
|
||||||
"start_time": "@.capitalize:{'words.generated.start'} @:{'words.generated.time'}",
|
"start_time": "@.capitalize:{'words.generated.start'} @:{'words.generated.time'}",
|
||||||
"wash_id": "@:{'templates.generated.compat.objects.orders.columns.wash_id'}",
|
"wash_id": "@:{'templates.generated.compat.objects.orders.columns.wash_id'}",
|
||||||
"xlvask_usage_log": "@:{'words.generated.xl'} @:{'words.generated.vask'} @:{'words.generated.registration'}"
|
"xlvask_usage_log": "@:{'words.generated.xl'} @:{'words.generated.vask'} @:{'words.generated.registration'}"
|
||||||
@@ -4509,7 +4510,7 @@
|
|||||||
"workflow": "Workflow"
|
"workflow": "Workflow"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"xlvask_autopilot": {
|
"xlvask_review": {
|
||||||
"actions": {
|
"actions": {
|
||||||
"accept": "Accept suggestion",
|
"accept": "Accept suggestion",
|
||||||
"attach_order": "Attach order",
|
"attach_order": "Attach order",
|
||||||
@@ -4705,7 +4706,8 @@
|
|||||||
"create_order_item_failed": "The wash item could not be added to the order.",
|
"create_order_item_failed": "The wash item could not be added to the order.",
|
||||||
"redirect_order_failed": "The order could not be opened.",
|
"redirect_order_failed": "The order could not be opened.",
|
||||||
"load_customers_failed": "The customers could not be loaded.",
|
"load_customers_failed": "The customers could not be loaded.",
|
||||||
"load_usage_log_failed": "The usage log could not be loaded."
|
"load_usage_log_failed": "The usage log could not be loaded.",
|
||||||
|
"api_endpoint_not_implemented": "API endpoint not implemented yet"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1658,6 +1658,7 @@
|
|||||||
"api_settings_desc": "@:{'templates.generated.compat.configuration.xlvask.api_settings_desc'}",
|
"api_settings_desc": "@:{'templates.generated.compat.configuration.xlvask.api_settings_desc'}",
|
||||||
"automation_settings": "@:{'templates.generated.compat.configuration.xlvask.automation_settings'}",
|
"automation_settings": "@:{'templates.generated.compat.configuration.xlvask.automation_settings'}",
|
||||||
"automation_settings_desc": "@:{'templates.generated.compat.configuration.xlvask.automation_settings_desc'}",
|
"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": "@:{'templates.generated.compat.configuration.xlvask.connection_failed'}",
|
||||||
"connection_failed_desc": "@:{'templates.generated.compat.configuration.xlvask.connection_failed_desc'}",
|
"connection_failed_desc": "@:{'templates.generated.compat.configuration.xlvask.connection_failed_desc'}",
|
||||||
"connection_success": "@:configuration.limble.connection_success",
|
"connection_success": "@:configuration.limble.connection_success",
|
||||||
@@ -3420,6 +3421,7 @@
|
|||||||
"expected_price": "@:{'templates.generated.compat.invoice_period.flags.preview.expected_price'}",
|
"expected_price": "@:{'templates.generated.compat.invoice_period.flags.preview.expected_price'}",
|
||||||
"no_order_items": "@:common.templates.no_entity_available",
|
"no_order_items": "@:common.templates.no_entity_available",
|
||||||
"no_xlvask_usage_log": "@:common.templates.no_entity_available",
|
"no_xlvask_usage_log": "@:common.templates.no_entity_available",
|
||||||
|
"no_xlvask_usage_log_metadata": "@:{'templates.generated.compat.invoice_period.flags.preview.no_xlvask_usage_log_metadata'}",
|
||||||
"order_items": "@:{'templates.generated.compat.global_search.entity_types.order_items'}",
|
"order_items": "@:{'templates.generated.compat.global_search.entity_types.order_items'}",
|
||||||
"price": "@:common.price",
|
"price": "@:common.price",
|
||||||
"product": "@:common.product",
|
"product": "@:common.product",
|
||||||
@@ -3895,203 +3897,204 @@
|
|||||||
"workflow": "@:{'templates.generated.compat.invoicing_period.review_workspace.toolbar.workflow'}"
|
"workflow": "@:{'templates.generated.compat.invoicing_period.review_workspace.toolbar.workflow'}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"xlvask_autopilot": {
|
"xlvask_review": {
|
||||||
"actions": {
|
"actions": {
|
||||||
"accept": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.accept'}",
|
"accept": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.accept'}",
|
||||||
"attach_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.attach_order'}",
|
"attach_order": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.attach_order'}",
|
||||||
"compare": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare'}",
|
"compare": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare'}",
|
||||||
"compare_modal_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare_modal_title'}",
|
"compare_modal_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare_modal_title'}",
|
||||||
"compare_no_candidates": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare_no_candidates'}",
|
"compare_no_candidates": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare_no_candidates'}",
|
||||||
"compare_price_match": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare_price_match'}",
|
"compare_price_match": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare_price_match'}",
|
||||||
"compare_price_mismatch": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare_price_mismatch'}",
|
"compare_price_mismatch": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare_price_mismatch'}",
|
||||||
"compare_usage_price": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare_usage_price'}",
|
"compare_usage_price": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare_usage_price'}",
|
||||||
"compare_candidate_price": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare_candidate_price'}",
|
"compare_candidate_price": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare_candidate_price'}",
|
||||||
"create_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.create_order'}",
|
"create_order": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.create_order'}",
|
||||||
"deny": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.deny'}",
|
"deny": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.deny'}",
|
||||||
"ignore": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.ignore'}",
|
"ignore": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.ignore'}",
|
||||||
"link": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.link'}",
|
"link": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.link'}",
|
||||||
"link_prompt_label": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.link_prompt_label'}",
|
"link_prompt_label": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.link_prompt_label'}",
|
||||||
"link_prompt_invalid": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.link_prompt_invalid'}",
|
"link_prompt_invalid": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.link_prompt_invalid'}",
|
||||||
"link_prompt_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.link_prompt_title'}",
|
"link_prompt_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.link_prompt_title'}",
|
||||||
"none": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.none'}",
|
"none": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.none'}",
|
||||||
"recheck": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.recheck'}",
|
"recheck": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.recheck'}",
|
||||||
"resolve_mapping": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.resolve_mapping'}"
|
"resolve_mapping": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.resolve_mapping'}"
|
||||||
},
|
},
|
||||||
"adjudication": {
|
"adjudication": {
|
||||||
"confirm_correct": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_correct'}",
|
"confirm_correct": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.confirm_correct'}",
|
||||||
"confirm_cross_hall": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_cross_hall'}",
|
"confirm_cross_hall": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.confirm_cross_hall'}",
|
||||||
"confirm_duplicate": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_duplicate'}",
|
"confirm_duplicate": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.confirm_duplicate'}",
|
||||||
"confirm_incorrect": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_incorrect'}",
|
"confirm_incorrect": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.confirm_incorrect'}",
|
||||||
"confirm_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_title'}",
|
"confirm_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.confirm_title'}",
|
||||||
"confirm_unaudited": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_unaudited'}",
|
"confirm_unaudited": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.confirm_unaudited'}",
|
||||||
"description": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.description'}",
|
"description": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.description'}",
|
||||||
"halted": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.halted'}",
|
"halted": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.halted'}",
|
||||||
"outcomes": {
|
"outcomes": {
|
||||||
"correct": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.correct'}",
|
"correct": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.outcomes.correct'}",
|
||||||
"cross_hall": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.cross_hall'}",
|
"cross_hall": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.outcomes.cross_hall'}",
|
||||||
"duplicate": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.duplicate'}",
|
"duplicate": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.outcomes.duplicate'}",
|
||||||
"incorrect": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.incorrect'}",
|
"incorrect": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.outcomes.incorrect'}",
|
||||||
"unaudited": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.unaudited'}"
|
"unaudited": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.outcomes.unaudited'}"
|
||||||
},
|
},
|
||||||
"saved": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.saved'}",
|
"saved": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.saved'}",
|
||||||
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.title'}"
|
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.title'}"
|
||||||
},
|
},
|
||||||
"audit": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.audit'}",
|
"audit": "@:{'templates.generated.compat.invoicing_period.xlvask_review.audit'}",
|
||||||
"bulk_selected": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.bulk_selected'}",
|
"bulk_selected": "@:{'templates.generated.compat.invoicing_period.xlvask_review.bulk_selected'}",
|
||||||
"bulk_eligibility": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.bulk_eligibility'}",
|
"bulk_eligibility": "@:{'templates.generated.compat.invoicing_period.xlvask_review.bulk_eligibility'}",
|
||||||
"calibrated_probability": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.calibrated_probability'}",
|
"calibrated_probability": "@:{'templates.generated.compat.invoicing_period.xlvask_review.calibrated_probability'}",
|
||||||
"candidates": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.candidates'}",
|
"candidates": "@:{'templates.generated.compat.invoicing_period.xlvask_review.candidates'}",
|
||||||
"contradictions": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.contradictions'}",
|
"contradictions": "@:{'templates.generated.compat.invoicing_period.xlvask_review.contradictions'}",
|
||||||
"controls": {
|
"controls": {
|
||||||
"active_run_error": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.active_run_error'}",
|
"active_run_error": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.active_run_error'}",
|
||||||
"analyze": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.analyze'}",
|
"analyze": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.analyze'}",
|
||||||
"budget": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.budget'}",
|
"budget": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.budget'}",
|
||||||
"execute": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.execute'}",
|
"execute": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.execute'}",
|
||||||
"execute_description": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.execute_description'}",
|
"execute_description": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.execute_description'}",
|
||||||
"execute_phrase": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.execute_phrase'}",
|
"execute_phrase": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.execute_phrase'}",
|
||||||
"execute_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.execute_title'}",
|
"execute_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.execute_title'}",
|
||||||
"halt": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.halt'}",
|
"halt": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.halt'}",
|
||||||
"halt_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.halt_title'}",
|
"halt_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.halt_title'}",
|
||||||
"load_error": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.load_error'}",
|
"load_error": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.load_error'}",
|
||||||
"no_access": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.no_access'}",
|
"no_access": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.no_access'}",
|
||||||
"not_ready": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.not_ready'}",
|
"not_ready": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.not_ready'}",
|
||||||
"policy_advance": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_advance'}",
|
"policy_advance": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_advance'}",
|
||||||
"policy_apply": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_apply'}",
|
"policy_apply": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_apply'}",
|
||||||
"policy_description": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_description'}",
|
"policy_description": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_description'}",
|
||||||
"policy_phrase": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_phrase'}",
|
"policy_phrase": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_phrase'}",
|
||||||
"policy_reason": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_reason'}",
|
"policy_reason": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_reason'}",
|
||||||
"policy_reason_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_reason_title'}",
|
"policy_reason_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_reason_title'}",
|
||||||
"policy_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_title'}",
|
"policy_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_title'}",
|
||||||
"readiness_error": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.readiness_error'}",
|
"readiness_error": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.readiness_error'}",
|
||||||
"ready": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.ready'}",
|
"ready": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.ready'}",
|
||||||
"stage": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.stage'}",
|
"stage": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.stage'}",
|
||||||
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.title'}",
|
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.title'}",
|
||||||
"worker_healthy": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.worker_healthy'}",
|
"worker_healthy": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.worker_healthy'}",
|
||||||
"worker_unhealthy": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.worker_unhealthy'}"
|
"worker_unhealthy": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.worker_unhealthy'}"
|
||||||
},
|
},
|
||||||
"clear_selection": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.clear_selection'}",
|
"clear_selection": "@:{'templates.generated.compat.invoicing_period.xlvask_review.clear_selection'}",
|
||||||
"evidence": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.evidence'}",
|
"evidence": "@:{'templates.generated.compat.invoicing_period.xlvask_review.evidence'}",
|
||||||
"filters": {
|
"filters": {
|
||||||
"all": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.all'}",
|
"all": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.all'}",
|
||||||
"certainty": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.certainty'}",
|
"certainty": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.certainty'}",
|
||||||
"clear": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.clear'}",
|
"clear": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.clear'}",
|
||||||
"import_state": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.import_state'}",
|
"import_state": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.import_state'}",
|
||||||
"planned_action": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.planned_action'}",
|
"planned_action": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.planned_action'}",
|
||||||
"resolution_state": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.resolution_state'}",
|
"resolution_state": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.resolution_state'}",
|
||||||
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.title'}",
|
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.title'}",
|
||||||
"unattached_only": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.unattached_only'}"
|
"unattached_only": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.unattached_only'}"
|
||||||
},
|
},
|
||||||
"hide_match": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.hide_match'}",
|
"hide_match": "@:{'templates.generated.compat.invoicing_period.xlvask_review.hide_match'}",
|
||||||
"model": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.model'}",
|
"model": "@:{'templates.generated.compat.invoicing_period.xlvask_review.model'}",
|
||||||
"no_safe_match": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.no_safe_match'}",
|
"no_safe_match": "@:{'templates.generated.compat.invoicing_period.xlvask_review.no_safe_match'}",
|
||||||
"planned_action": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.planned_action'}",
|
"planned_action": "@:{'templates.generated.compat.invoicing_period.xlvask_review.planned_action'}",
|
||||||
"policy_version": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.policy_version'}",
|
"policy_version": "@:{'templates.generated.compat.invoicing_period.xlvask_review.policy_version'}",
|
||||||
"preview": {
|
"preview": {
|
||||||
"after": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.after'}",
|
"after": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.after'}",
|
||||||
"applied": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.applied'}",
|
"applied": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.applied'}",
|
||||||
"apply": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.apply'}",
|
"apply": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.apply'}",
|
||||||
"before": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.before'}",
|
"before": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.before'}",
|
||||||
"confirmation_label": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.confirmation_label'}",
|
"confirmation_label": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.confirmation_label'}",
|
||||||
"confirmation_mismatch": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.confirmation_mismatch'}",
|
"confirmation_mismatch": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.confirmation_mismatch'}",
|
||||||
"confirmation_phrase": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.confirmation_phrase'}",
|
"confirmation_phrase": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.confirmation_phrase'}",
|
||||||
"error_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.error_title'}",
|
"error_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.error_title'}",
|
||||||
"reason_label": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.reason_label'}",
|
"reason_label": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.reason_label'}",
|
||||||
"reason_required": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.reason_required'}",
|
"reason_required": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.reason_required'}",
|
||||||
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.title'}"
|
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.title'}"
|
||||||
},
|
},
|
||||||
"resume_status": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.resume_status'}",
|
"resume_status": "@:{'templates.generated.compat.invoicing_period.xlvask_review.resume_status'}",
|
||||||
"run": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run'}",
|
"run": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run'}",
|
||||||
"run_id": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_id'}",
|
"run_id": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_id'}",
|
||||||
"run_phases": {
|
"run_phases": {
|
||||||
"circuit_breaker": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.circuit_breaker'}",
|
"circuit_breaker": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.circuit_breaker'}",
|
||||||
"completed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.completed'}",
|
"completed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.completed'}",
|
||||||
"completed_with_warnings": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.completed_with_warnings'}",
|
"completed_with_warnings": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.completed_with_warnings'}",
|
||||||
"evaluating": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.evaluating'}",
|
"evaluating": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.evaluating'}",
|
||||||
"executing": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.executing'}",
|
"executing": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.executing'}",
|
||||||
"failed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.failed'}",
|
"failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.failed'}",
|
||||||
"importing": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.importing'}",
|
"importing": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.importing'}",
|
||||||
"pending": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.pending'}",
|
"pending": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.pending'}",
|
||||||
"processing": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.processing'}",
|
"processing": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.processing'}",
|
||||||
"queued": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.queued'}",
|
"queued": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.queued'}",
|
||||||
"reconciling": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.reconciling'}",
|
"reconciling": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.reconciling'}",
|
||||||
"retry_wait": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.retry_wait'}",
|
"retry_wait": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.retry_wait'}",
|
||||||
"running": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.running'}"
|
"running": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.running'}"
|
||||||
},
|
},
|
||||||
"run_progress": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_progress'}",
|
"run_progress": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_progress'}",
|
||||||
"run_start_error": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_start_error'}",
|
"run_start_error": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_start_error'}",
|
||||||
"run_status_error": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_status_error'}",
|
"run_status_error": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_status_error'}",
|
||||||
"select_record": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.select_record'}",
|
"select_record": "@:{'templates.generated.compat.invoicing_period.xlvask_review.select_record'}",
|
||||||
"show_match": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.show_match'}",
|
"show_match": "@:{'templates.generated.compat.invoicing_period.xlvask_review.show_match'}",
|
||||||
"source_revision": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.source_revision'}",
|
"source_revision": "@:{'templates.generated.compat.invoicing_period.xlvask_review.source_revision'}",
|
||||||
"states": {
|
"states": {
|
||||||
"accepted_attach": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.accepted_attach'}",
|
"accepted_attach": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.accepted_attach'}",
|
||||||
"accepted_create": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.accepted_create'}",
|
"accepted_create": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.accepted_create'}",
|
||||||
"already_linked": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.already_linked'}",
|
"already_linked": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.already_linked'}",
|
||||||
"auto_accepted_attach": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.auto_accepted_attach'}",
|
"auto_accepted_attach": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.auto_accepted_attach'}",
|
||||||
"auto_accepted_create": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.auto_accepted_create'}",
|
"auto_accepted_create": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.auto_accepted_create'}",
|
||||||
"auto_created": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.auto_created'}",
|
"auto_created": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.auto_created'}",
|
||||||
"auto_linked": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.auto_linked'}",
|
"auto_linked": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.auto_linked'}",
|
||||||
"blocked": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.blocked'}",
|
"blocked": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.blocked'}",
|
||||||
"certain": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.certain'}",
|
"certain": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.certain'}",
|
||||||
"denied": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.denied'}",
|
"denied": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.denied'}",
|
||||||
"failed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.failed'}",
|
"failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.failed'}",
|
||||||
"ignored": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.ignored'}",
|
"ignored": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.ignored'}",
|
||||||
"invalid": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.invalid'}",
|
"invalid": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.invalid'}",
|
||||||
"needs_review": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.needs_review'}",
|
"needs_review": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.needs_review'}",
|
||||||
"new": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.new'}",
|
"new": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.new'}",
|
||||||
"none": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.none'}",
|
"none": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.none'}",
|
||||||
"suggested_attach_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.suggested_attach_order'}",
|
"suggested_attach_order": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.suggested_attach_order'}",
|
||||||
"suggested_create_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.suggested_create_order'}",
|
"suggested_create_order": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.suggested_create_order'}",
|
||||||
"uncertain": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.uncertain'}",
|
"uncertain": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.uncertain'}",
|
||||||
"unchanged": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.unchanged'}",
|
"unchanged": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.unchanged'}",
|
||||||
"updated": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.updated'}"
|
"updated": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.updated'}"
|
||||||
},
|
},
|
||||||
"summary_error": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.summary_error'}",
|
"summary_error": "@:{'templates.generated.compat.invoicing_period.xlvask_review.summary_error'}",
|
||||||
"summary_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.summary_title'}",
|
"summary_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.summary_title'}",
|
||||||
"labels": {
|
"labels": {
|
||||||
"attached_to_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.attached_to_order'}",
|
"attached_to_order": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.attached_to_order'}",
|
||||||
"not_attached_to_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.not_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_autopilot.labels.compare_select_duplicate'}",
|
"compare_select_duplicate": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.compare_select_duplicate'}",
|
||||||
"compare_price_mismatch_inline": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.compare_price_mismatch_inline'}",
|
"compare_price_mismatch_inline": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.compare_price_mismatch_inline'}",
|
||||||
"wash_id": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.wash_id'}",
|
"wash_id": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.wash_id'}",
|
||||||
"wash_already_withdrawn": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.wash_already_withdrawn'}",
|
"wash_already_withdrawn": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.wash_already_withdrawn'}",
|
||||||
"go_to_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.go_to_order'}",
|
"go_to_order": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.go_to_order'}",
|
||||||
"unknown_product": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.unknown_product'}",
|
"unknown_product": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.unknown_product'}",
|
||||||
"unknown_product_with_id": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.unknown_product_with_id'}",
|
"unknown_product_with_id": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.unknown_product_with_id'}",
|
||||||
"ok": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.ok'}"
|
"ok": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.ok'}"
|
||||||
},
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"just_now": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.just_now'}",
|
"just_now": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.just_now'}",
|
||||||
"ago_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.ago_one'}",
|
"ago_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.ago_one'}",
|
||||||
"ago_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.ago_other'}",
|
"ago_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.ago_other'}",
|
||||||
"units": {
|
"units": {
|
||||||
"year_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.year_one'}",
|
"year_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.year_one'}",
|
||||||
"year_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.year_other'}",
|
"year_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.year_other'}",
|
||||||
"month_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.month_one'}",
|
"month_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.month_one'}",
|
||||||
"month_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.month_other'}",
|
"month_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.month_other'}",
|
||||||
"week_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.week_one'}",
|
"week_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.week_one'}",
|
||||||
"week_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.week_other'}",
|
"week_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.week_other'}",
|
||||||
"day_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.day_one'}",
|
"day_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.day_one'}",
|
||||||
"day_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.day_other'}",
|
"day_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.day_other'}",
|
||||||
"hour_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.hour_one'}",
|
"hour_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.hour_one'}",
|
||||||
"hour_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.hour_other'}",
|
"hour_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.hour_other'}",
|
||||||
"minute_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.minute_one'}",
|
"minute_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.minute_one'}",
|
||||||
"minute_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.minute_other'}",
|
"minute_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.minute_other'}",
|
||||||
"second_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.second_one'}",
|
"second_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.second_one'}",
|
||||||
"second_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.second_other'}"
|
"second_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.second_other'}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"fetch_usage_log": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.fetch_usage_log'}",
|
"fetch_usage_log": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.fetch_usage_log'}",
|
||||||
"fetch_vehicle_types": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.fetch_vehicle_types'}",
|
"fetch_vehicle_types": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.fetch_vehicle_types'}",
|
||||||
"fetch_related_orders": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.fetch_related_orders'}",
|
"fetch_related_orders": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.fetch_related_orders'}",
|
||||||
"fetch_fast_link": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.fetch_fast_link'}",
|
"fetch_fast_link": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.fetch_fast_link'}",
|
||||||
"create_order_unrecognized_items": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.create_order_unrecognized_items'}",
|
"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_autopilot.errors.create_order_failed'}",
|
"create_order_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.create_order_failed'}",
|
||||||
"create_order_item_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.create_order_item_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_autopilot.errors.redirect_order_failed'}",
|
"redirect_order_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.redirect_order_failed'}",
|
||||||
"load_customers_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.load_customers_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_autopilot.errors.load_usage_log_failed'}"
|
"load_usage_log_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.load_usage_log_failed'}",
|
||||||
|
"api_endpoint_not_implemented": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.api_endpoint_not_implemented'}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4343,7 +4343,7 @@
|
|||||||
"vehicle_subscription_type_mismatch": "{product} @:{'words.generated.does'} @:{'words.generated.not'} match @:{'words.generated.the'} @:{'words.generated.vehicle'} @:{'words.generated.subscription'} @:{'words.generated.type'} {expected_product}.",
|
"vehicle_subscription_type_mismatch": "{product} @:{'words.generated.does'} @:{'words.generated.not'} match @:{'words.generated.the'} @:{'words.generated.vehicle'} @:{'words.generated.subscription'} @:{'words.generated.type'} {expected_product}.",
|
||||||
"wash_certificate_attached_without_item": "@.capitalize:{'words.generated.vaskesertifikat'} @:{'words.generated.er'} @:{'words.generated.vedlagt'} @:{'words.generated.uten'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.vaskesertifikat'}-@:{'words.generated.linje'}.",
|
"wash_certificate_attached_without_item": "@.capitalize:{'words.generated.vaskesertifikat'} @:{'words.generated.er'} @:{'words.generated.vedlagt'} @:{'words.generated.uten'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.vaskesertifikat'}-@:{'words.generated.linje'}.",
|
||||||
"wash_certificate_item_without_certificate": "@.capitalize:{'words.generated.vaskesertifikat'}-@:{'words.generated.linjen'} @:{'words.generated.finnes'} @:{'words.generated.uten'} @:{'words.generated.et'} @:{'words.generated.vaskesertifikat'}.",
|
"wash_certificate_item_without_certificate": "@.capitalize:{'words.generated.vaskesertifikat'}-@:{'words.generated.linjen'} @:{'words.generated.finnes'} @:{'words.generated.uten'} @:{'words.generated.et'} @:{'words.generated.vaskesertifikat'}.",
|
||||||
"xlvask_missing_order_link": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'} @:{'words.generated.wash'} @:{'words.generated.is'} neither ignored nor linked @:{'words.generated.to'} @:{'words.generated.an'} @:{'words.generated.order'} @:{'words.generated.in'} @:{'words.generated.the'} @:{'words.generated.selected'} @:{'words.generated.period'}."
|
"xlvask_missing_order_link": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-registreringen @:{'words.generated.er'} verken ignorert @:{'words.generated.eller'} knyttet @:{'words.generated.til'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.ordre'} @:{'words.generated.i'} @:{'words.replication.article.host_mention'} @:{'words.generated.valgte'} @:{'words.generated.periode'}."
|
||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
"entities": {
|
"entities": {
|
||||||
@@ -4351,6 +4351,7 @@
|
|||||||
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-opplysninger"
|
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-opplysninger"
|
||||||
},
|
},
|
||||||
"expected_price": "@.capitalize:{'words.generated.forventet'} @:{'words.generated.pris'}",
|
"expected_price": "@.capitalize:{'words.generated.forventet'} @:{'words.generated.pris'}",
|
||||||
|
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'words.generated.for'} @:{'templates.generated.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
|
||||||
"start_time": "Starttidspunkt",
|
"start_time": "Starttidspunkt",
|
||||||
"wash_id": "@:{'templates.generated.compat.objects.orders.columns.wash_id'}",
|
"wash_id": "@:{'templates.generated.compat.objects.orders.columns.wash_id'}",
|
||||||
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrering'}"
|
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrering'}"
|
||||||
@@ -4791,7 +4792,7 @@
|
|||||||
"workflow": "Arbeidsflyt"
|
"workflow": "Arbeidsflyt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"xlvask_autopilot": {
|
"xlvask_review": {
|
||||||
"actions": {
|
"actions": {
|
||||||
"accept": "Godta forslag",
|
"accept": "Godta forslag",
|
||||||
"attach_order": "Knytt til ordre",
|
"attach_order": "Knytt til ordre",
|
||||||
@@ -4987,7 +4988,8 @@
|
|||||||
"create_order_item_failed": "Vaskelinjen kunne ikke legges til i ordren.",
|
"create_order_item_failed": "Vaskelinjen kunne ikke legges til i ordren.",
|
||||||
"redirect_order_failed": "Ordren kunne ikke åpnes.",
|
"redirect_order_failed": "Ordren kunne ikke åpnes.",
|
||||||
"load_customers_failed": "Kundene kunne ikke hentes.",
|
"load_customers_failed": "Kundene kunne ikke hentes.",
|
||||||
"load_usage_log_failed": "Forbruksloggen kunne ikke hentes."
|
"load_usage_log_failed": "Forbruksloggen kunne ikke hentes.",
|
||||||
|
"api_endpoint_not_implemented": "API-endepunkt er ikke implementert ennå"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4393,7 +4393,7 @@
|
|||||||
"vehicle_subscription_type_mismatch": "{product} @:{'words.generated.does'} @:{'words.generated.not'} @:{'words.generated.match'} @:{'words.generated.the'} @:{'words.generated.vehicle'} @:{'words.generated.subscription'} @:{'words.generated.type'} {expected_product}.",
|
"vehicle_subscription_type_mismatch": "{product} @:{'words.generated.does'} @:{'words.generated.not'} @:{'words.generated.match'} @:{'words.generated.the'} @:{'words.generated.vehicle'} @:{'words.generated.subscription'} @:{'words.generated.type'} {expected_product}.",
|
||||||
"wash_certificate_attached_without_item": "@.capitalize:{'words.generated.tvattcertifikat_2'} ar bifogat @:{'words.generated.utan'} @:{'words.replication.host_definite_suffix'} tvattcertifikatrad.",
|
"wash_certificate_attached_without_item": "@.capitalize:{'words.generated.tvattcertifikat_2'} ar bifogat @:{'words.generated.utan'} @:{'words.replication.host_definite_suffix'} tvattcertifikatrad.",
|
||||||
"wash_certificate_item_without_certificate": "@:{'words.generated.tvattcertifikatraden'} @:{'words.generated.finns'} @:{'words.generated.utan'} @:{'words.generated.ett'} @:{'words.generated.tvattcertifikat_2'}.",
|
"wash_certificate_item_without_certificate": "@:{'words.generated.tvattcertifikatraden'} @:{'words.generated.finns'} @:{'words.generated.utan'} @:{'words.generated.ett'} @:{'words.generated.tvattcertifikat_2'}.",
|
||||||
"xlvask_missing_order_link": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'} @:{'words.generated.wash'} @:{'words.generated.is'} neither ignored nor @:{'words.generated.linked'} @:{'words.generated.to'} @:{'words.generated.an'} @:{'words.generated.order'} @:{'words.generated.in'} @:{'words.generated.the'} @:{'words.generated.selected'} @:{'words.generated.period'}."
|
"xlvask_missing_order_link": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-registreringen @:{'words.generated.ar'} varken ignorerad @:{'words.generated.eller'} kopplad @:{'words.generated.till'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.order'} @:{'words.generated.i'} @:{'words.replication.article.host_mention'} @:{'words.generated.valda'} perioden."
|
||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
"entities": {
|
"entities": {
|
||||||
@@ -4401,6 +4401,7 @@
|
|||||||
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.uppgifter'}"
|
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.uppgifter'}"
|
||||||
},
|
},
|
||||||
"expected_price": "@.capitalize:{'words.generated.forvantat'} @:{'words.generated.pris'}",
|
"expected_price": "@.capitalize:{'words.generated.forvantat'} @:{'words.generated.pris'}",
|
||||||
|
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'words.generated.for'} @:{'templates.generated.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
|
||||||
"start_time": "Starttid",
|
"start_time": "Starttid",
|
||||||
"wash_id": "@.capitalize:{'words.generated.tvatt_2'}-@.upper:{'words.generated.id'}",
|
"wash_id": "@.capitalize:{'words.generated.tvatt_2'}-@.upper:{'words.generated.id'}",
|
||||||
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrering'}"
|
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrering'}"
|
||||||
@@ -4841,7 +4842,7 @@
|
|||||||
"workflow": "Arbetsflöde"
|
"workflow": "Arbetsflöde"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"xlvask_autopilot": {
|
"xlvask_review": {
|
||||||
"actions": {
|
"actions": {
|
||||||
"accept": "Godkänn förslag",
|
"accept": "Godkänn förslag",
|
||||||
"attach_order": "Koppla order",
|
"attach_order": "Koppla order",
|
||||||
@@ -5037,7 +5038,8 @@
|
|||||||
"create_order_item_failed": "Tvättartikeln kunde inte läggas till i ordern.",
|
"create_order_item_failed": "Tvättartikeln kunde inte läggas till i ordern.",
|
||||||
"redirect_order_failed": "Ordern kunde inte öppnas.",
|
"redirect_order_failed": "Ordern kunde inte öppnas.",
|
||||||
"load_customers_failed": "Kunderna kunde inte hämtas.",
|
"load_customers_failed": "Kunderna kunde inte hämtas.",
|
||||||
"load_usage_log_failed": "Förbrukningsloggen kunde inte hämtas."
|
"load_usage_log_failed": "Förbrukningsloggen kunde inte hämtas.",
|
||||||
|
"api_endpoint_not_implemented": "API-slutpunkt är inte implementerad ännu"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.oplysninger'}"
|
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.oplysninger'}"
|
||||||
},
|
},
|
||||||
"expected_price": "@.capitalize:{'terms.glossary.forventet'} @:{'terms.glossary.pris'}",
|
"expected_price": "@.capitalize:{'terms.glossary.forventet'} @:{'terms.glossary.pris'}",
|
||||||
|
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'terms.glossary.for'} @:{'phrases.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
|
||||||
"start_time": "Starttidspunkt",
|
"start_time": "Starttidspunkt",
|
||||||
"wash_id": "@:{'phrases.compat.objects.orders.columns.wash_id'}",
|
"wash_id": "@:{'phrases.compat.objects.orders.columns.wash_id'}",
|
||||||
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrering'}"
|
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrering'}"
|
||||||
|
|||||||
@@ -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" },
|
"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": {
|
"adjudication": {
|
||||||
"confirm_correct": "Bekræft, at den automatiske handling var korrekt.",
|
"confirm_correct": "Bekræft, at den automatiske handling var korrekt.",
|
||||||
@@ -112,7 +112,8 @@
|
|||||||
"create_order_item_failed": "Vaskelinjen kunne ikke føjes til ordren.",
|
"create_order_item_failed": "Vaskelinjen kunne ikke føjes til ordren.",
|
||||||
"redirect_order_failed": "Ordren kunne ikke åbnes.",
|
"redirect_order_failed": "Ordren kunne ikke åbnes.",
|
||||||
"load_customers_failed": "Kunderne kunne ikke hentes.",
|
"load_customers_failed": "Kunderne kunne ikke hentes.",
|
||||||
"load_usage_log_failed": "Forbrugsloggen kunne ikke hentes."
|
"load_usage_log_failed": "Forbrugsloggen kunne ikke hentes.",
|
||||||
|
"api_endpoint_not_implemented": "API-endepunkt ikke implementeret endnu"
|
||||||
}
|
}
|
||||||
} } }
|
} } }
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"vehicle_subscription_type_mismatch": "{product} @:{'terms.glossary.does'} @:{'terms.glossary.not'} match @:{'terms.glossary.the'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.subscription'} @:{'terms.glossary.type'} {expected_product}.",
|
"vehicle_subscription_type_mismatch": "{product} @:{'terms.glossary.does'} @:{'terms.glossary.not'} match @:{'terms.glossary.the'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.subscription'} @:{'terms.glossary.type'} {expected_product}.",
|
||||||
"wash_certificate_attached_without_item": "@:{'terms.glossary.waschzertifikat'} @:{'terms.glossary.ist'} @:{'terms.glossary.ohne'} @:{'terms.glossary.waschzertifikat'}-@.capitalize:{'terms.glossary.position'} angehaengt.",
|
"wash_certificate_attached_without_item": "@:{'terms.glossary.waschzertifikat'} @:{'terms.glossary.ist'} @:{'terms.glossary.ohne'} @:{'terms.glossary.waschzertifikat'}-@.capitalize:{'terms.glossary.position'} angehaengt.",
|
||||||
"wash_certificate_item_without_certificate": "@:{'terms.glossary.waschzertifikat'}-@.capitalize:{'terms.glossary.position'} @:{'terms.glossary.ist'} @:{'terms.glossary.ohne'} @:{'terms.glossary.waschzertifikat'} vorhanden.",
|
"wash_certificate_item_without_certificate": "@:{'terms.glossary.waschzertifikat'}-@.capitalize:{'terms.glossary.position'} @:{'terms.glossary.ist'} @:{'terms.glossary.ohne'} @:{'terms.glossary.waschzertifikat'} vorhanden.",
|
||||||
"xlvask_missing_order_link": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'} @:{'terms.glossary.wash'} @:{'terms.glossary.is'} neither ignored nor linked @:{'terms.glossary.to'} @:{'terms.glossary.an'} @:{'terms.glossary.order'} @:{'terms.glossary.in'} @:{'terms.glossary.the'} @:{'terms.glossary.selected'} @:{'terms.glossary.period'}."
|
"xlvask_missing_order_link": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrierung'} @:{'terms.glossary.ist'} weder ignoriert noch @:{'terms.glossary.mit'} einer @:{'terms.glossary.bestellung'} im ausgewaehlten @:{'terms.glossary.zeitraum'} verknuepft."
|
||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
"entities": {
|
"entities": {
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
"xlvask_usage_log": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'}-@.capitalize:{'terms.glossary.details'}"
|
"xlvask_usage_log": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'}-@.capitalize:{'terms.glossary.details'}"
|
||||||
},
|
},
|
||||||
"expected_price": "Erwarteter @:{'terms.glossary.preis'}",
|
"expected_price": "Erwarteter @:{'terms.glossary.preis'}",
|
||||||
|
"no_xlvask_usage_log_metadata": "Keine Metadaten @:{'terms.glossary.for'} @:{'phrases.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
|
||||||
"start_time": "@:{'phrases.compat.tables.common.start_time'}",
|
"start_time": "@:{'phrases.compat.tables.common.start_time'}",
|
||||||
"wash_id": "Wasch-@.upper:{'terms.glossary.id'}",
|
"wash_id": "Wasch-@.upper:{'terms.glossary.id'}",
|
||||||
"xlvask_usage_log": "@:{'phrases.compat.invoice_period.flags.tokens.xlvask_usage_log'}"
|
"xlvask_usage_log": "@:{'phrases.compat.invoice_period.flags.tokens.xlvask_usage_log'}"
|
||||||
@@ -54,4 +55,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compat": {
|
"compat": {
|
||||||
"invoicing_period": {
|
"invoicing_period": {
|
||||||
"xlvask_autopilot": {
|
"xlvask_review": {
|
||||||
"actions": {
|
"actions": {
|
||||||
"accept": "Vorschlag annehmen",
|
"accept": "Vorschlag annehmen",
|
||||||
"attach_order": "Auftrag verknüpfen",
|
"attach_order": "Auftrag verknüpfen",
|
||||||
@@ -191,7 +191,8 @@
|
|||||||
"create_order_item_failed": "Der Wäscheposten konnte nicht zum Auftrag hinzugefügt werden.",
|
"create_order_item_failed": "Der Wäscheposten konnte nicht zum Auftrag hinzugefügt werden.",
|
||||||
"redirect_order_failed": "Der Auftrag konnte nicht geöffnet werden.",
|
"redirect_order_failed": "Der Auftrag konnte nicht geöffnet werden.",
|
||||||
"load_customers_failed": "Die Kunden konnten nicht geladen werden.",
|
"load_customers_failed": "Die Kunden konnten nicht geladen werden.",
|
||||||
"load_usage_log_failed": "Das Verbrauchsprotokoll konnte nicht geladen werden."
|
"load_usage_log_failed": "Das Verbrauchsprotokoll konnte nicht geladen werden.",
|
||||||
|
"api_endpoint_not_implemented": "API-Endpunkt ist noch nicht implementiert"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
"xlvask_usage_log": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'} @:{'terms.glossary.details'}"
|
"xlvask_usage_log": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'} @:{'terms.glossary.details'}"
|
||||||
},
|
},
|
||||||
"expected_price": "@.capitalize:{'terms.glossary.expected'} @:{'terms.glossary.price'}",
|
"expected_price": "@.capitalize:{'terms.glossary.expected'} @:{'terms.glossary.price'}",
|
||||||
|
"no_xlvask_usage_log_metadata": "No metadata @:{'terms.glossary.for'} @:{'phrases.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
|
||||||
"start_time": "@.capitalize:{'terms.glossary.start'} @:{'terms.glossary.time'}",
|
"start_time": "@.capitalize:{'terms.glossary.start'} @:{'terms.glossary.time'}",
|
||||||
"wash_id": "@:{'phrases.compat.objects.orders.columns.wash_id'}",
|
"wash_id": "@:{'phrases.compat.objects.orders.columns.wash_id'}",
|
||||||
"xlvask_usage_log": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'} @:{'terms.glossary.registration'}"
|
"xlvask_usage_log": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'} @:{'terms.glossary.registration'}"
|
||||||
@@ -54,4 +55,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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" },
|
"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": {
|
"adjudication": {
|
||||||
"confirm_correct": "Confirm that the automatic action was correct.",
|
"confirm_correct": "Confirm that the automatic action was correct.",
|
||||||
@@ -112,7 +112,8 @@
|
|||||||
"create_order_item_failed": "The wash item could not be added to the order.",
|
"create_order_item_failed": "The wash item could not be added to the order.",
|
||||||
"redirect_order_failed": "The order could not be opened.",
|
"redirect_order_failed": "The order could not be opened.",
|
||||||
"load_customers_failed": "The customers could not be loaded.",
|
"load_customers_failed": "The customers could not be loaded.",
|
||||||
"load_usage_log_failed": "The usage log could not be loaded."
|
"load_usage_log_failed": "The usage log could not be loaded.",
|
||||||
|
"api_endpoint_not_implemented": "API endpoint not implemented yet"
|
||||||
}
|
}
|
||||||
} } }
|
} } }
|
||||||
}
|
}
|
||||||
@@ -403,6 +403,7 @@
|
|||||||
"api_settings_desc": "@:{'phrases.compat.configuration.xlvask.api_settings_desc'}",
|
"api_settings_desc": "@:{'phrases.compat.configuration.xlvask.api_settings_desc'}",
|
||||||
"automation_settings": "@:{'phrases.compat.configuration.xlvask.automation_settings'}",
|
"automation_settings": "@:{'phrases.compat.configuration.xlvask.automation_settings'}",
|
||||||
"automation_settings_desc": "@:{'phrases.compat.configuration.xlvask.automation_settings_desc'}",
|
"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": "@:{'phrases.compat.configuration.xlvask.connection_failed'}",
|
||||||
"connection_failed_desc": "@:{'phrases.compat.configuration.xlvask.connection_failed_desc'}",
|
"connection_failed_desc": "@:{'phrases.compat.configuration.xlvask.connection_failed_desc'}",
|
||||||
"connection_success": "@:configuration.limble.connection_success",
|
"connection_success": "@:configuration.limble.connection_success",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"expected_price": "@:{'phrases.compat.invoice_period.flags.preview.expected_price'}",
|
"expected_price": "@:{'phrases.compat.invoice_period.flags.preview.expected_price'}",
|
||||||
"no_order_items": "@:common.templates.no_entity_available",
|
"no_order_items": "@:common.templates.no_entity_available",
|
||||||
"no_xlvask_usage_log": "@:common.templates.no_entity_available",
|
"no_xlvask_usage_log": "@:common.templates.no_entity_available",
|
||||||
|
"no_xlvask_usage_log_metadata": "@:{'phrases.compat.invoice_period.flags.preview.no_xlvask_usage_log_metadata'}",
|
||||||
"order_items": "@:{'phrases.compat.global_search.entity_types.order_items'}",
|
"order_items": "@:{'phrases.compat.global_search.entity_types.order_items'}",
|
||||||
"price": "@:common.price",
|
"price": "@:common.price",
|
||||||
"product": "@:common.product",
|
"product": "@:common.product",
|
||||||
|
|||||||
@@ -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,204 @@
|
|||||||
|
{
|
||||||
|
"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'}",
|
||||||
|
"api_endpoint_not_implemented": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.api_endpoint_not_implemented'}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"vehicle_subscription_type_mismatch": "{product} @:{'terms.glossary.does'} @:{'terms.glossary.not'} match @:{'terms.glossary.the'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.subscription'} @:{'terms.glossary.type'} {expected_product}.",
|
"vehicle_subscription_type_mismatch": "{product} @:{'terms.glossary.does'} @:{'terms.glossary.not'} match @:{'terms.glossary.the'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.subscription'} @:{'terms.glossary.type'} {expected_product}.",
|
||||||
"wash_certificate_attached_without_item": "@.capitalize:{'terms.glossary.vaskesertifikat'} @:{'terms.glossary.er'} @:{'terms.glossary.vedlagt'} @:{'terms.glossary.uten'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.vaskesertifikat'}-@:{'terms.glossary.linje'}.",
|
"wash_certificate_attached_without_item": "@.capitalize:{'terms.glossary.vaskesertifikat'} @:{'terms.glossary.er'} @:{'terms.glossary.vedlagt'} @:{'terms.glossary.uten'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.vaskesertifikat'}-@:{'terms.glossary.linje'}.",
|
||||||
"wash_certificate_item_without_certificate": "@.capitalize:{'terms.glossary.vaskesertifikat'}-@:{'terms.glossary.linjen'} @:{'terms.glossary.finnes'} @:{'terms.glossary.uten'} @:{'terms.glossary.et'} @:{'terms.glossary.vaskesertifikat'}.",
|
"wash_certificate_item_without_certificate": "@.capitalize:{'terms.glossary.vaskesertifikat'}-@:{'terms.glossary.linjen'} @:{'terms.glossary.finnes'} @:{'terms.glossary.uten'} @:{'terms.glossary.et'} @:{'terms.glossary.vaskesertifikat'}.",
|
||||||
"xlvask_missing_order_link": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'} @:{'terms.glossary.wash'} @:{'terms.glossary.is'} neither ignored nor linked @:{'terms.glossary.to'} @:{'terms.glossary.an'} @:{'terms.glossary.order'} @:{'terms.glossary.in'} @:{'terms.glossary.the'} @:{'terms.glossary.selected'} @:{'terms.glossary.period'}."
|
"xlvask_missing_order_link": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-registreringen @:{'terms.glossary.er'} verken ignorert @:{'terms.glossary.eller'} knyttet @:{'terms.glossary.til'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.ordre'} @:{'terms.glossary.i'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.valgte'} @:{'terms.glossary.periode'}."
|
||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
"entities": {
|
"entities": {
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-opplysninger"
|
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-opplysninger"
|
||||||
},
|
},
|
||||||
"expected_price": "@.capitalize:{'terms.glossary.forventet'} @:{'terms.glossary.pris'}",
|
"expected_price": "@.capitalize:{'terms.glossary.forventet'} @:{'terms.glossary.pris'}",
|
||||||
|
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'terms.glossary.for'} @:{'phrases.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
|
||||||
"start_time": "Starttidspunkt",
|
"start_time": "Starttidspunkt",
|
||||||
"wash_id": "@:{'phrases.compat.objects.orders.columns.wash_id'}",
|
"wash_id": "@:{'phrases.compat.objects.orders.columns.wash_id'}",
|
||||||
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrering'}"
|
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrering'}"
|
||||||
@@ -54,4 +55,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compat": {
|
"compat": {
|
||||||
"invoicing_period": {
|
"invoicing_period": {
|
||||||
"xlvask_autopilot": {
|
"xlvask_review": {
|
||||||
"actions": {
|
"actions": {
|
||||||
"accept": "Godta forslag",
|
"accept": "Godta forslag",
|
||||||
"attach_order": "Knytt til ordre",
|
"attach_order": "Knytt til ordre",
|
||||||
@@ -191,7 +191,8 @@
|
|||||||
"create_order_item_failed": "Vaskelinjen kunne ikke legges til i ordren.",
|
"create_order_item_failed": "Vaskelinjen kunne ikke legges til i ordren.",
|
||||||
"redirect_order_failed": "Ordren kunne ikke åpnes.",
|
"redirect_order_failed": "Ordren kunne ikke åpnes.",
|
||||||
"load_customers_failed": "Kundene kunne ikke hentes.",
|
"load_customers_failed": "Kundene kunne ikke hentes.",
|
||||||
"load_usage_log_failed": "Forbruksloggen kunne ikke hentes."
|
"load_usage_log_failed": "Forbruksloggen kunne ikke hentes.",
|
||||||
|
"api_endpoint_not_implemented": "API-endepunkt er ikke implementert ennå"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"vehicle_subscription_type_mismatch": "{product} @:{'terms.glossary.does'} @:{'terms.glossary.not'} @:{'terms.glossary.match'} @:{'terms.glossary.the'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.subscription'} @:{'terms.glossary.type'} {expected_product}.",
|
"vehicle_subscription_type_mismatch": "{product} @:{'terms.glossary.does'} @:{'terms.glossary.not'} @:{'terms.glossary.match'} @:{'terms.glossary.the'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.subscription'} @:{'terms.glossary.type'} {expected_product}.",
|
||||||
"wash_certificate_attached_without_item": "@.capitalize:{'terms.glossary.tvattcertifikat_2'} ar bifogat @:{'terms.glossary.utan'} @:{'terms.replication.host_definite_suffix'} tvattcertifikatrad.",
|
"wash_certificate_attached_without_item": "@.capitalize:{'terms.glossary.tvattcertifikat_2'} ar bifogat @:{'terms.glossary.utan'} @:{'terms.replication.host_definite_suffix'} tvattcertifikatrad.",
|
||||||
"wash_certificate_item_without_certificate": "@:{'terms.glossary.tvattcertifikatraden'} @:{'terms.glossary.finns'} @:{'terms.glossary.utan'} @:{'terms.glossary.ett'} @:{'terms.glossary.tvattcertifikat_2'}.",
|
"wash_certificate_item_without_certificate": "@:{'terms.glossary.tvattcertifikatraden'} @:{'terms.glossary.finns'} @:{'terms.glossary.utan'} @:{'terms.glossary.ett'} @:{'terms.glossary.tvattcertifikat_2'}.",
|
||||||
"xlvask_missing_order_link": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'} @:{'terms.glossary.wash'} @:{'terms.glossary.is'} neither ignored nor @:{'terms.glossary.linked'} @:{'terms.glossary.to'} @:{'terms.glossary.an'} @:{'terms.glossary.order'} @:{'terms.glossary.in'} @:{'terms.glossary.the'} @:{'terms.glossary.selected'} @:{'terms.glossary.period'}."
|
"xlvask_missing_order_link": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-registreringen @:{'terms.glossary.ar'} varken ignorerad @:{'terms.glossary.eller'} kopplad @:{'terms.glossary.till'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.order'} @:{'terms.glossary.i'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.valda'} perioden."
|
||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
"entities": {
|
"entities": {
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.uppgifter'}"
|
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.uppgifter'}"
|
||||||
},
|
},
|
||||||
"expected_price": "@.capitalize:{'terms.glossary.forvantat'} @:{'terms.glossary.pris'}",
|
"expected_price": "@.capitalize:{'terms.glossary.forvantat'} @:{'terms.glossary.pris'}",
|
||||||
|
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'terms.glossary.for'} @:{'phrases.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
|
||||||
"start_time": "Starttid",
|
"start_time": "Starttid",
|
||||||
"wash_id": "@.capitalize:{'terms.glossary.tvatt_2'}-@.upper:{'terms.glossary.id'}",
|
"wash_id": "@.capitalize:{'terms.glossary.tvatt_2'}-@.upper:{'terms.glossary.id'}",
|
||||||
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrering'}"
|
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrering'}"
|
||||||
@@ -54,4 +55,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compat": {
|
"compat": {
|
||||||
"invoicing_period": {
|
"invoicing_period": {
|
||||||
"xlvask_autopilot": {
|
"xlvask_review": {
|
||||||
"actions": {
|
"actions": {
|
||||||
"accept": "Godkänn förslag",
|
"accept": "Godkänn förslag",
|
||||||
"attach_order": "Koppla order",
|
"attach_order": "Koppla order",
|
||||||
@@ -191,7 +191,8 @@
|
|||||||
"create_order_item_failed": "Tvättartikeln kunde inte läggas till i ordern.",
|
"create_order_item_failed": "Tvättartikeln kunde inte läggas till i ordern.",
|
||||||
"redirect_order_failed": "Ordern kunde inte öppnas.",
|
"redirect_order_failed": "Ordern kunde inte öppnas.",
|
||||||
"load_customers_failed": "Kunderna kunde inte hämtas.",
|
"load_customers_failed": "Kunderna kunde inte hämtas.",
|
||||||
"load_usage_log_failed": "Förbrukningsloggen kunde inte hämtas."
|
"load_usage_log_failed": "Förbrukningsloggen kunde inte hämtas.",
|
||||||
|
"api_endpoint_not_implemented": "API-slutpunkt är inte implementerad ännu"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,7 +16,7 @@ import XLVaskUsagePagination from "@/components/displays/pagination/models/Depar
|
|||||||
<template #default>
|
<template #default>
|
||||||
<DepartmentDashboardHero />
|
<DepartmentDashboardHero />
|
||||||
<!-- Syncronize Orders -->
|
<!-- Syncronize Orders -->
|
||||||
<XLVaskUsagePagination/>
|
<XLVaskUsagePagination :department-id="SessionUser.functions.getDepartmentIdFromUrl()"/>
|
||||||
</template>
|
</template>
|
||||||
</NotFoundFallBackPageWrapper>
|
</NotFoundFallBackPageWrapper>
|
||||||
</DepartmentDashboardPageWrapper>
|
</DepartmentDashboardPageWrapper>
|
||||||
|
|||||||
@@ -240,9 +240,10 @@ const xlvaskUsageLogHtml = (flag: any) => {
|
|||||||
].filter(([, value]) => String(value ?? "").trim() !== "");
|
].filter(([, value]) => String(value ?? "").trim() !== "");
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return escapeHtml(translate("invoice_period.flags.preview.no_xlvask_usage_log", "No XL Vask details available.", {
|
return escapeHtml(translate(
|
||||||
entity: translate("invoice_period.flags.preview.entities.xlvask_usage_log", "XL Vask details"),
|
"invoice_period.flags.preview.no_xlvask_usage_log_metadata",
|
||||||
}));
|
"XL Vask registration (no metadata available — see api flag-context serializer)..."
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
return `<table class="table is-narrow is-fullwidth">
|
return `<table class="table is-narrow is-fullwidth">
|
||||||
@@ -285,8 +286,18 @@ const openOrderItem = (flag: any) => {
|
|||||||
SessionUser.functions.redirectTo.department(departmentId, `modules/pos/orders/${orderId}${suffix}`, true);
|
SessionUser.functions.redirectTo.department(departmentId, `modules/pos/orders/${orderId}${suffix}`, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const extractDateOnly = (value: any) => {
|
||||||
|
const rawValue = String(value ?? "").trim();
|
||||||
|
if (rawValue === "") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(rawValue);
|
||||||
|
return match ? `${match[1]}-${match[2]}-${match[3]}` : "";
|
||||||
|
};
|
||||||
|
|
||||||
const openXlVaskUsageLog = (flag: any) => {
|
const openXlVaskUsageLog = (flag: any) => {
|
||||||
const usageLogId = Number(flag?.xlvask_usage_log_id || flag?.context?.xlvask_usage_log_id || flag?.target_id || 0);
|
const usageLogId = Number(flag?.xlvask_usage_log_id || flag?.context?.xlvask_usage_log_id || flag?.target_id || 0);
|
||||||
|
const startTime = extractDateOnly(flag?.context?.start_time || flag?.start_time);
|
||||||
const query = new URLSearchParams({
|
const query = new URLSearchParams({
|
||||||
activeTab: "period",
|
activeTab: "period",
|
||||||
periodView: "self_wash",
|
periodView: "self_wash",
|
||||||
@@ -296,6 +307,10 @@ const openXlVaskUsageLog = (flag: any) => {
|
|||||||
query.set("xlvaskUsageLogId", String(usageLogId));
|
query.set("xlvaskUsageLogId", String(usageLogId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (startTime !== "") {
|
||||||
|
query.set("xlvaskUsageLogStartTime", startTime);
|
||||||
|
}
|
||||||
|
|
||||||
SessionUser.functions.redirectTo.superUser(`/invoices?${query.toString()}`, true);
|
SessionUser.functions.redirectTo.superUser(`/invoices?${query.toString()}`, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2710,14 +2710,11 @@ const decideXlvaskAutomation = (
|
|||||||
: treeText("actions.xlvask.deny.preview", "{count} XL Vask forslag afvises.", { count: rows.length }),
|
: treeText("actions.xlvask.deny.preview", "{count} XL Vask forslag afvises.", { count: rows.length }),
|
||||||
],
|
],
|
||||||
async () => {
|
async () => {
|
||||||
|
const endpointSuffix = decision === "deny" ? "reject" : decision;
|
||||||
for (const node of rows) {
|
for (const node of rows) {
|
||||||
await SessionUser.request(
|
await SessionUser.request(
|
||||||
`/modules/xlvask/services/usage/orders/${node.meta.usageId}/automation/${decision}`,
|
`/modules/xlvask/services/usage/orders/${node.meta.usageId}/${endpointSuffix}`,
|
||||||
"POST",
|
"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 UNKNOWN_ATTRIBUTE_PRIORITY = 99;
|
||||||
|
|
||||||
const list_views_with_customer = computed(() => {
|
// Pre-compute a Set<customer_number> per view bucket so chip membership
|
||||||
const matched = view_keys.value.filter((view_key) => {
|
// resolution stays O(1) regardless of bucket size. The backend now ships
|
||||||
// Skip if the view type is "all".
|
// lightweight `{customer_number, membership_only}` markers for every
|
||||||
if (view_key === 'all') return false;
|
// 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];
|
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) => {
|
return [...matched].sort((left, right) => {
|
||||||
const leftPriority = ATTRIBUTE_DISPLAY_PRIORITY[left] ?? UNKNOWN_ATTRIBUTE_PRIORITY;
|
const leftPriority = ATTRIBUTE_DISPLAY_PRIORITY[left] ?? UNKNOWN_ATTRIBUTE_PRIORITY;
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ const getResponsePayload = (response: any = {}) => {
|
|||||||
* Fixed pricing distribution data structure:
|
* Fixed pricing distribution data structure:
|
||||||
*/
|
*/
|
||||||
const fixed_pricing_department_distribution = ref<any[]>([]);
|
const fixed_pricing_department_distribution = ref<any[]>([]);
|
||||||
|
const fixedPricingDistributionLoaded = ref(false);
|
||||||
|
const fixedPricingDistributionFailed = ref(false);
|
||||||
const fetchFixedPricingDistribution = async () => {
|
const fetchFixedPricingDistribution = async () => {
|
||||||
|
fixedPricingDistributionLoaded.value = false;
|
||||||
|
fixedPricingDistributionFailed.value = false;
|
||||||
SessionUser.request(
|
SessionUser.request(
|
||||||
'/superuser/invoicing/period/distribution/fixed-pricing',
|
'/superuser/invoicing/period/distribution/fixed-pricing',
|
||||||
'GET',
|
'GET',
|
||||||
@@ -47,15 +51,20 @@ const fetchFixedPricingDistribution = async () => {
|
|||||||
|
|
||||||
if (response.data && response.data.includes.collective_fixed_pricing_results) {
|
if (response.data && response.data.includes.collective_fixed_pricing_results) {
|
||||||
fixed_pricing_department_distribution.value = 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:
|
* 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 } }
|
* { "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 {
|
} else {
|
||||||
console.error('Unexpected response structure:', response);
|
console.error('Unexpected response structure:', response);
|
||||||
|
fixedPricingDistributionFailed.value = true;
|
||||||
}
|
}
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error('Error fetching fixed pricing distribution:', error);
|
console.error('Error fetching fixed pricing distribution:', error);
|
||||||
|
fixedPricingDistributionLoaded.value = true;
|
||||||
|
fixedPricingDistributionFailed.value = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const clearFixedPricingDistribution = () => {
|
const clearFixedPricingDistribution = () => {
|
||||||
@@ -134,7 +143,11 @@ watch(
|
|||||||
* Subscription display logic:
|
* Subscription display logic:
|
||||||
*/
|
*/
|
||||||
const vehicleSubscriptionDistribution = ref<any>(null);
|
const vehicleSubscriptionDistribution = ref<any>(null);
|
||||||
|
const vehicleSubscriptionDistributionLoaded = ref(false);
|
||||||
|
const vehicleSubscriptionDistributionFailed = ref(false);
|
||||||
const fetchVehicleSubscriptionDistribution = async () => {
|
const fetchVehicleSubscriptionDistribution = async () => {
|
||||||
|
vehicleSubscriptionDistributionLoaded.value = false;
|
||||||
|
vehicleSubscriptionDistributionFailed.value = false;
|
||||||
SessionUser.request(
|
SessionUser.request(
|
||||||
'/superuser/invoicing/period/distribution/wash-subscriptions',
|
'/superuser/invoicing/period/distribution/wash-subscriptions',
|
||||||
'GET',
|
'GET',
|
||||||
@@ -150,9 +163,12 @@ const fetchVehicleSubscriptionDistribution = async () => {
|
|||||||
vehicleSubscriptionDistribution.value = response.data.includes.collective_subscription_results;
|
vehicleSubscriptionDistribution.value = response.data.includes.collective_subscription_results;
|
||||||
} else {
|
} else {
|
||||||
console.error('Unexpected response structure:', response);
|
console.error('Unexpected response structure:', response);
|
||||||
|
vehicleSubscriptionDistributionFailed.value = true;
|
||||||
}
|
}
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error('Error fetching vehicle subscription distribution:', error);
|
console.error('Error fetching vehicle subscription distribution:', error);
|
||||||
|
vehicleSubscriptionDistributionLoaded.value = true;
|
||||||
|
vehicleSubscriptionDistributionFailed.value = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const clearVehicleSubscriptionDistribution = () => {
|
const clearVehicleSubscriptionDistribution = () => {
|
||||||
|
|||||||
@@ -467,7 +467,6 @@ export const makeXlvaskNode = (usage, order = null, options = {}) => {
|
|||||||
usageId,
|
usageId,
|
||||||
washId,
|
washId,
|
||||||
orderId: toPositiveInteger(order?.id ?? usage?.linked_order_id ?? usage?.order_id),
|
orderId: toPositiveInteger(order?.id ?? usage?.linked_order_id ?? usage?.order_id),
|
||||||
automation: usage?.automation,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,12 +5,28 @@ import { computed } from "vue";
|
|||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
|
|
||||||
|
const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||||
|
|
||||||
|
const parseRouteDateOnly = (value: any) => {
|
||||||
|
const rawValue = Array.isArray(value) ? String(value[0] ?? "") : String(value ?? "");
|
||||||
|
const match = DATE_ONLY_PATTERN.exec(rawValue.trim());
|
||||||
|
if (!match) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return `${match[1]}-${match[2]}-${match[3]}`;
|
||||||
|
};
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const highlightedUsageLogId = computed(() => {
|
const highlightedUsageLogId = computed(() => {
|
||||||
const parsedId = Number.parseInt(String(route.query.xlvaskUsageLogId || ""), 10);
|
const parsedId = Number.parseInt(String(route.query.xlvaskUsageLogId || ""), 10);
|
||||||
return Number.isInteger(parsedId) && parsedId > 0 ? parsedId : 0;
|
return Number.isInteger(parsedId) && parsedId > 0 ? parsedId : 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const flaggedWashStartDate = computed(() => parseRouteDateOnly(route.query.xlvaskUsageLogStartTime));
|
||||||
|
|
||||||
|
const initialDateFrom = computed(() => flaggedWashStartDate.value || dates.computed.formattedStartDate.value);
|
||||||
|
const initialDateTo = computed(() => flaggedWashStartDate.value || dates.computed.formattedEndDate.value);
|
||||||
|
|
||||||
const selfWashTitle = computed(() => i18n.global.t("nav.self_wash"));
|
const selfWashTitle = computed(() => i18n.global.t("nav.self_wash"));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -18,12 +34,11 @@ const selfWashTitle = computed(() => i18n.global.t("nav.self_wash"));
|
|||||||
<section data-testid="invoicing-period-self-wash-view">
|
<section data-testid="invoicing-period-self-wash-view">
|
||||||
<XLVaskUsagePagination
|
<XLVaskUsagePagination
|
||||||
:title="selfWashTitle"
|
:title="selfWashTitle"
|
||||||
:initial-date-from="dates.computed.formattedStartDate.value"
|
:initial-date-from="initialDateFrom"
|
||||||
:initial-date-to="dates.computed.formattedEndDate.value"
|
:initial-date-to="initialDateTo"
|
||||||
:inherit-period-filters="true"
|
:inherit-period-filters="true"
|
||||||
:load-all-at-once="false"
|
:load-all-at-once="false"
|
||||||
:highlight-usage-log-id="highlightedUsageLogId"
|
:highlight-usage-log-id="highlightedUsageLogId"
|
||||||
:automation-workspace="true"
|
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</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 RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.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 ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
|
||||||
import ConfigurationSwitch from "@/components/displays/superuser/configuration/ConfigurationSwitch.vue";
|
import ConfigurationSwitch from "@/components/displays/superuser/configuration/ConfigurationSwitch.vue";
|
||||||
import ConfigurationSelect from "@/components/displays/superuser/configuration/ConfigurationSelect.vue";
|
import ConfigurationSelect from "@/components/displays/superuser/configuration/ConfigurationSelect.vue";
|
||||||
import ConfigurationSecretKey from "@/components/displays/superuser/configuration/ConfigurationSecretKey.vue";
|
import ConfigurationSecretKey from "@/components/displays/superuser/configuration/ConfigurationSecretKey.vue";
|
||||||
import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue";
|
import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue";
|
||||||
import Swal from "sweetalert2";
|
import Swal from "sweetalert2";
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useModuleConfig } from "@/composables/useModuleConfig.js";
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { module_config, getModuleConfigValue } = useModuleConfig("email");
|
||||||
// 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 encryptionOptions = [
|
const encryptionOptions = [
|
||||||
{ value: 'ssl', label: 'SSL' },
|
{ value: "ssl", label: "SSL" },
|
||||||
{ value: 'tls', label: 'TLS' },
|
{ value: "tls", label: "TLS" },
|
||||||
{ value: 'starttls', label: 'STARTTLS' },
|
{ value: "starttls", label: "STARTTLS" },
|
||||||
{ value: 'none', label: 'None' },
|
{ value: "none", label: "None" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const showSendTestEmail = async () => {
|
const showSendTestEmail = async () => {
|
||||||
await Swal.fire({
|
await Swal.fire({
|
||||||
title: t('configuration.email.send_test_email'),
|
title: t("configuration.email.send_test_email"),
|
||||||
text: t('configuration.email.send_test_email_prompt'),
|
text: t("configuration.email.send_test_email_prompt"),
|
||||||
input: 'email',
|
input: "email",
|
||||||
inputAttributes: {
|
inputAttributes: {
|
||||||
autocapitalize: 'off'
|
autocapitalize: "off",
|
||||||
},
|
},
|
||||||
showCancelButton: true,
|
showCancelButton: true,
|
||||||
confirmButtonText: t('configuration.email.send'),
|
confirmButtonText: t("configuration.email.send"),
|
||||||
showLoaderOnConfirm: true,
|
showLoaderOnConfirm: true,
|
||||||
preConfirm: (email) => {
|
preConfirm: (email) => {
|
||||||
return SessionUser.superUser.modules.email.sendTestEmail(email)
|
return SessionUser.superUser.modules.email
|
||||||
.then(() => {
|
.sendTestEmail(email)
|
||||||
Swal.fire({
|
.then(() => {
|
||||||
title: t('configuration.email.test_email_sent'),
|
Swal.fire({
|
||||||
text: t('configuration.email.test_email_sent_success'),
|
title: t("configuration.email.test_email_sent"),
|
||||||
icon: 'success',
|
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',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
Swal.fire({
|
||||||
|
title: t("common.error"),
|
||||||
|
text: t("configuration.email.test_email_error"),
|
||||||
|
icon: "error",
|
||||||
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
allowOutsideClick: () => !Swal.isLoading()
|
allowOutsideClick: () => !Swal.isLoading(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
load();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
|
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
|
||||||
<ConfigurationSubPageWrapper>
|
<ConfigurationSubPageWrapper>
|
||||||
<template #title>
|
<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>
|
||||||
<template v-if="module_config.length > 0" #content>
|
<template v-if="module_config.length > 0" #content>
|
||||||
<ConfigurationCategory
|
<ConfigurationCategory
|
||||||
@@ -113,16 +70,18 @@ load();
|
|||||||
:description="$t('configuration.email.general_settings_desc')"
|
:description="$t('configuration.email.general_settings_desc')"
|
||||||
icon="fas fa-cogs"
|
icon="fas fa-cogs"
|
||||||
>
|
>
|
||||||
<ConfigurationSwitch
|
<ConfigurationSwitch
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
module="Email"
|
module="Email"
|
||||||
:title="$t('configuration.email.enable_system')"
|
:title="$t('configuration.email.enable_system')"
|
||||||
:description="$t('configuration.email.enable_system_desc')"
|
:description="$t('configuration.email.enable_system_desc')"
|
||||||
icon="fas fa-cogs"
|
icon="fas fa-cogs"
|
||||||
:value="getModuleConfigValue('enabled') === true"
|
:value="getModuleConfigValue('enabled') === true"
|
||||||
:on-switch="SessionUser.superUser.modules.email.config.enabled.set"
|
: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>
|
<button class="button is-dark mt-2" @click="showSendTestEmail()">
|
||||||
|
{{ $t("configuration.email.send_test_email") }}
|
||||||
|
</button>
|
||||||
</ConfigurationCategory>
|
</ConfigurationCategory>
|
||||||
<!-- Sender identity -->
|
<!-- Sender identity -->
|
||||||
<ConfigurationCategory
|
<ConfigurationCategory
|
||||||
@@ -133,40 +92,40 @@ load();
|
|||||||
icon="fas fa-cogs"
|
icon="fas fa-cogs"
|
||||||
>
|
>
|
||||||
<ConfigurationInput
|
<ConfigurationInput
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
module="Email"
|
module="Email"
|
||||||
:title="$t('configuration.email.from_email')"
|
:title="$t('configuration.email.from_email')"
|
||||||
:description="$t('configuration.email.from_email_desc')"
|
:description="$t('configuration.email.from_email_desc')"
|
||||||
icon="fas fa-cogs"
|
icon="fas fa-cogs"
|
||||||
:value="getModuleConfigValue('smtp_from')"
|
:value="getModuleConfigValue('smtp_from')"
|
||||||
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_from.set"
|
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_from.set"
|
||||||
/>
|
/>
|
||||||
<ConfigurationInput
|
<ConfigurationInput
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
module="Email"
|
module="Email"
|
||||||
:title="$t('configuration.email.from_name')"
|
:title="$t('configuration.email.from_name')"
|
||||||
:description="$t('configuration.email.from_name_desc')"
|
:description="$t('configuration.email.from_name_desc')"
|
||||||
icon="fas fa-cogs"
|
icon="fas fa-cogs"
|
||||||
:value="getModuleConfigValue('smtp_from_name')"
|
:value="getModuleConfigValue('smtp_from_name')"
|
||||||
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_from_name.set"
|
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_from_name.set"
|
||||||
/>
|
/>
|
||||||
<ConfigurationInput
|
<ConfigurationInput
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
module="Email"
|
module="Email"
|
||||||
:title="$t('configuration.email.reply_to_email')"
|
:title="$t('configuration.email.reply_to_email')"
|
||||||
:description="$t('configuration.email.reply_to_email_desc')"
|
:description="$t('configuration.email.reply_to_email_desc')"
|
||||||
icon="fas fa-cogs"
|
icon="fas fa-cogs"
|
||||||
:value="getModuleConfigValue('smtp_reply_to')"
|
:value="getModuleConfigValue('smtp_reply_to')"
|
||||||
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_reply_to.set"
|
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_reply_to.set"
|
||||||
/>
|
/>
|
||||||
<ConfigurationInput
|
<ConfigurationInput
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
module="Email"
|
module="Email"
|
||||||
:title="$t('configuration.email.reply_to_name')"
|
:title="$t('configuration.email.reply_to_name')"
|
||||||
:description="$t('configuration.email.reply_to_name_desc')"
|
:description="$t('configuration.email.reply_to_name_desc')"
|
||||||
icon="fas fa-cogs"
|
icon="fas fa-cogs"
|
||||||
:value="getModuleConfigValue('smtp_reply_to_name')"
|
:value="getModuleConfigValue('smtp_reply_to_name')"
|
||||||
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_reply_to_name.set"
|
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_reply_to_name.set"
|
||||||
/>
|
/>
|
||||||
</ConfigurationCategory>
|
</ConfigurationCategory>
|
||||||
<ConfigurationCategory
|
<ConfigurationCategory
|
||||||
@@ -224,7 +183,6 @@ load();
|
|||||||
:value="getModuleConfigValue('smtp_encryption')"
|
:value="getModuleConfigValue('smtp_encryption')"
|
||||||
:on-select="SessionUser.superUser.modules.email.config.keys.smtp_encryption.set"
|
:on-select="SessionUser.superUser.modules.email.config.keys.smtp_encryption.set"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</ConfigurationCategory>
|
</ConfigurationCategory>
|
||||||
<ConfigurationCategory
|
<ConfigurationCategory
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
@@ -257,6 +215,4 @@ load();
|
|||||||
</RestrictedPageWrapper>
|
</RestrictedPageWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -3,57 +3,14 @@ import PageTitle from "@/components/global/PageTitle.vue";
|
|||||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.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 ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
|
||||||
import ConfigurationSwitch from "@/components/displays/superuser/configuration/ConfigurationSwitch.vue";
|
import ConfigurationSwitch from "@/components/displays/superuser/configuration/ConfigurationSwitch.vue";
|
||||||
|
|
||||||
import ConfigurationSecretKey from "@/components/displays/superuser/configuration/ConfigurationSecretKey.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, getModuleConfigValue } = useModuleConfig("openai");
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -102,6 +59,4 @@ load();
|
|||||||
</RestrictedPageWrapper>
|
</RestrictedPageWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ import PageTitle from "@/components/global/PageTitle.vue";
|
|||||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.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 ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
|
||||||
import ConfigurationSwitch from "@/components/displays/superuser/configuration/ConfigurationSwitch.vue";
|
import ConfigurationSwitch from "@/components/displays/superuser/configuration/ConfigurationSwitch.vue";
|
||||||
import "@/components/displays/superuser/configuration/ConfigurationSelect.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 ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue";
|
||||||
import ConfigurationInputNumber from "@/components/displays/superuser/configuration/ConfigurationInputNumber.vue";
|
import ConfigurationInputNumber from "@/components/displays/superuser/configuration/ConfigurationInputNumber.vue";
|
||||||
import Swal from "sweetalert2";
|
import Swal from "sweetalert2";
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useModuleConfig } from "@/composables/useModuleConfig.js";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { module_config, getModuleConfigValue } = useModuleConfig("stripe");
|
||||||
// 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 showGetCustomers = async () => {
|
const showGetCustomers = async () => {
|
||||||
await SessionUser.superUser.modules.stripe.functions.customers.list().then((response) => {
|
await SessionUser.superUser.modules.stripe.functions.customers
|
||||||
console.log('Stripe customers: ', response.data.data);
|
.list()
|
||||||
Swal.fire({
|
.then((response) => {
|
||||||
title: t('configuration.stripe.customers'),
|
console.log("Stripe customers: ", response.data.data);
|
||||||
html: '<pre>' + JSON.stringify(response.data.data, null, 2) + '</pre>',
|
Swal.fire({
|
||||||
showCloseButton: true,
|
title: t("configuration.stripe.customers"),
|
||||||
showCancelButton: false,
|
html: "<pre>" + JSON.stringify(response.data.data, null, 2) + "</pre>",
|
||||||
focusConfirm: false,
|
showCloseButton: true,
|
||||||
|
showCancelButton: false,
|
||||||
|
focusConfirm: false,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.log(error);
|
||||||
});
|
});
|
||||||
}).catch((error) => {
|
|
||||||
console.log(error);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const showGetProducts = async () => {
|
const showGetProducts = async () => {
|
||||||
await SessionUser.superUser.modules.stripe.functions.products.list().then((response) => {
|
await SessionUser.superUser.modules.stripe.functions.products
|
||||||
console.log('Stripe products: ', response.data.data);
|
.list()
|
||||||
Swal.fire({
|
.then((response) => {
|
||||||
title: t('configuration.stripe.products'),
|
console.log("Stripe products: ", response.data.data);
|
||||||
html: '<pre>' + JSON.stringify(response.data.data, null, 2) + '</pre>',
|
Swal.fire({
|
||||||
showCloseButton: true,
|
title: t("configuration.stripe.products"),
|
||||||
showCancelButton: false,
|
html: "<pre>" + JSON.stringify(response.data.data, null, 2) + "</pre>",
|
||||||
focusConfirm: false,
|
showCloseButton: true,
|
||||||
|
showCancelButton: false,
|
||||||
|
focusConfirm: false,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.log(error);
|
||||||
});
|
});
|
||||||
}).catch((error) => {
|
|
||||||
console.log(error);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const showGetPrices = async () => {
|
const showGetPrices = async () => {
|
||||||
await SessionUser.superUser.modules.stripe.functions.prices.list().then((response) => {
|
await SessionUser.superUser.modules.stripe.functions.prices
|
||||||
console.log('Stripe prices: ', response.data.data);
|
.list()
|
||||||
Swal.fire({
|
.then((response) => {
|
||||||
title: t('configuration.stripe.prices'),
|
console.log("Stripe prices: ", response.data.data);
|
||||||
html: '<pre>' + JSON.stringify(response.data.data, null, 2) + '</pre>',
|
Swal.fire({
|
||||||
showCloseButton: true,
|
title: t("configuration.stripe.prices"),
|
||||||
showCancelButton: false,
|
html: "<pre>" + JSON.stringify(response.data.data, null, 2) + "</pre>",
|
||||||
focusConfirm: false,
|
showCloseButton: true,
|
||||||
|
showCancelButton: false,
|
||||||
|
focusConfirm: false,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.log(error);
|
||||||
});
|
});
|
||||||
}).catch((error) => {
|
|
||||||
console.log(error);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -120,15 +86,15 @@ const showGetPrices = async () => {
|
|||||||
:description="$t('configuration.stripe.general_settings_desc')"
|
:description="$t('configuration.stripe.general_settings_desc')"
|
||||||
icon="fas fa-cogs"
|
icon="fas fa-cogs"
|
||||||
>
|
>
|
||||||
<ConfigurationSwitch
|
<ConfigurationSwitch
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
module="Stripe"
|
module="Stripe"
|
||||||
:title="$t('configuration.stripe.enable')"
|
:title="$t('configuration.stripe.enable')"
|
||||||
:description="$t('configuration.stripe.enable_desc')"
|
:description="$t('configuration.stripe.enable_desc')"
|
||||||
icon="fas fa-cogs"
|
icon="fas fa-cogs"
|
||||||
:value="getModuleConfigValue('enabled') === true"
|
:value="getModuleConfigValue('enabled') === true"
|
||||||
:on-switch="SessionUser.superUser.modules.stripe.config.enabled.set"
|
:on-switch="SessionUser.superUser.modules.stripe.config.enabled.set"
|
||||||
/>
|
/>
|
||||||
</ConfigurationCategory>
|
</ConfigurationCategory>
|
||||||
<ConfigurationCategory
|
<ConfigurationCategory
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
@@ -177,25 +143,23 @@ const showGetPrices = async () => {
|
|||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fas fa-users"></i>
|
<i class="fas fa-users"></i>
|
||||||
</span>
|
</span>
|
||||||
<span>{{ $t('configuration.stripe.get_customers') }}</span>
|
<span>{{ $t("configuration.stripe.get_customers") }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="button is-dark" @click="showGetProducts">
|
<button class="button is-dark" @click="showGetProducts">
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fas fa-box"></i>
|
<i class="fas fa-box"></i>
|
||||||
</span>
|
</span>
|
||||||
<span>{{ $t('configuration.stripe.get_products') }}</span>
|
<span>{{ $t("configuration.stripe.get_products") }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="button is-dark" @click="showGetPrices">
|
<button class="button is-dark" @click="showGetPrices">
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fas fa-money-bill-wave"></i>
|
<i class="fas fa-money-bill-wave"></i>
|
||||||
</span>
|
</span>
|
||||||
<span>{{ $t('configuration.stripe.get_prices') }}</span>
|
<span>{{ $t("configuration.stripe.get_prices") }}</span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</ConfigurationSubPageWrapper>
|
</ConfigurationSubPageWrapper>
|
||||||
</RestrictedPageWrapper>
|
</RestrictedPageWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
|
|
||||||
</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 () => {
|
const load = async () => {
|
||||||
await getModuleConfig();
|
await getModuleConfig();
|
||||||
await Promise.all([refreshMiniMaxApiKeyStatus(), refreshMiniMaxEnabled()]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
load();
|
load();
|
||||||
@@ -301,84 +157,9 @@ load();
|
|||||||
:description="$t('configuration.xlvask.automation_settings_desc')"
|
:description="$t('configuration.xlvask.automation_settings_desc')"
|
||||||
icon="fas fa-magic"
|
icon="fas fa-magic"
|
||||||
>
|
>
|
||||||
<ConfigurationSwitch
|
<p class="is-size-7 has-text-grey">
|
||||||
class="mt-2"
|
{{ $t('configuration.xlvask.automation_settings_removed_desc') }}
|
||||||
module="XLVask"
|
</p>
|
||||||
: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>
|
|
||||||
</ConfigurationCategory>
|
</ConfigurationCategory>
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<button class="button is-dark" @click="onClickTestConnection">
|
<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 });
|
console.error("XL-Vask: customer list returned non-OK status.", { status: response?.status });
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
icon: 'error',
|
icon: 'error',
|
||||||
title: t('invoicing_period.xlvask_autopilot.errors.load_customers_failed'),
|
title: t('invoicing_period.xlvask_review.errors.load_customers_failed'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error("XL-Vask: failed to load customers.", { error });
|
console.error("XL-Vask: failed to load customers.", { error });
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
icon: 'error',
|
icon: 'error',
|
||||||
title: t('invoicing_period.xlvask_autopilot.errors.load_customers_failed'),
|
title: t('invoicing_period.xlvask_review.errors.load_customers_failed'),
|
||||||
});
|
});
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const fetchObjects = () => {
|
|||||||
if (!isoDateFrom) {
|
if (!isoDateFrom) {
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
icon: 'warning',
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -43,14 +43,14 @@ const fetchObjects = () => {
|
|||||||
console.error("XL-Vask: usage log list returned non-OK status.", { status: response?.status });
|
console.error("XL-Vask: usage log list returned non-OK status.", { status: response?.status });
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
icon: 'error',
|
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) => {
|
}).catch((error) => {
|
||||||
console.error("XL-Vask: failed to load usage log list.", { error });
|
console.error("XL-Vask: failed to load usage log list.", { error });
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
icon: 'error',
|
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(() => {
|
}).finally(() => {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
|
|||||||
@@ -814,4 +814,49 @@ test.describe("i18n v2 catalog integrity", () => {
|
|||||||
|
|
||||||
expect(unexpectedGroups).toEqual([]);
|
expect(unexpectedGroups).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("mirrors xlvask_review keys into the global v2 fallback", () => {
|
||||||
|
const daSource = readJsonFile(
|
||||||
|
path.join(SOURCE_DIRECTORY, "da", "phrases", "compat", "invoicing_period", "xlvask_review.json")
|
||||||
|
);
|
||||||
|
const globalShared = readJsonFile(
|
||||||
|
path.join(SOURCE_DIRECTORY, "global", "shared", "invoicing_period", "xlvask_review.json")
|
||||||
|
);
|
||||||
|
|
||||||
|
const daEntries = flattenStringEntries(getValueAtPath(daSource, "compat.invoicing_period.xlvask_review"));
|
||||||
|
const globalEntries = flattenStringEntries(getValueAtPath(globalShared, "invoicing_period.xlvask_review"));
|
||||||
|
|
||||||
|
const daKeys = daEntries.map((entry) => entry.key).sort();
|
||||||
|
const globalKeys = globalEntries.map((entry) => entry.key).sort();
|
||||||
|
|
||||||
|
expect(globalKeys, "global fallback should mirror every da xlvask_review key").toEqual(daKeys);
|
||||||
|
|
||||||
|
const nonLinked = globalEntries.filter((entry) => !entry.value.startsWith("@")).map((entry) => entry.key);
|
||||||
|
|
||||||
|
expect(nonLinked, "every global xlvask_review entry should be a linked reference").toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mirrors xlvask_usage_log flag keys into the global v2 fallback", () => {
|
||||||
|
const daSource = readJsonFile(
|
||||||
|
path.join(SOURCE_DIRECTORY, "da", "phrases", "compat", "invoice_period", "flags.json")
|
||||||
|
);
|
||||||
|
const globalShared = readJsonFile(path.join(SOURCE_DIRECTORY, "global", "shared", "invoice_period", "flags.json"));
|
||||||
|
|
||||||
|
const daEntries = flattenStringEntries(getValueAtPath(daSource, "compat.invoice_period.flags")).filter(
|
||||||
|
(entry) => entry.key.includes("xlvask_usage_log") || entry.key.includes("xlvask_missing_order_link")
|
||||||
|
);
|
||||||
|
const globalEntries = flattenStringEntries(getValueAtPath(globalShared, "invoice_period.flags")).filter(
|
||||||
|
(entry) => entry.key.includes("xlvask_usage_log") || entry.key.includes("xlvask_missing_order_link")
|
||||||
|
);
|
||||||
|
|
||||||
|
const daKeySet = new Set(daEntries.map((entry) => entry.key));
|
||||||
|
const globalKeySet = new Set(globalEntries.map((entry) => entry.key));
|
||||||
|
|
||||||
|
const missing = [...daKeySet].filter((key) => !globalKeySet.has(key));
|
||||||
|
expect(missing, "global flags fallback should mirror every da xlvask flag key").toEqual([]);
|
||||||
|
|
||||||
|
const nonLinked = globalEntries.filter((entry) => !entry.value.startsWith("@")).map((entry) => entry.key);
|
||||||
|
|
||||||
|
expect(nonLinked, "every mirrored global xlvask flag entry should be a linked reference").toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" } = {}) {
|
function createObjectTreePeriodPayload({ dateFrom = "2026-07-14" } = {}) {
|
||||||
const payload = createPeriodPayload();
|
const payload = createPeriodPayload();
|
||||||
const fixtureDate = periodFixtureDate(dateFrom);
|
const fixtureDate = periodFixtureDate(dateFrom);
|
||||||
@@ -905,6 +943,12 @@ async function setupPeriodEndpoints(page, requests, options = {}) {
|
|||||||
payload = createPeriodPayload();
|
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(
|
await route.fulfill(
|
||||||
json(
|
json(
|
||||||
payload?.__rawResponse ?? {
|
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 }) => {
|
test("@smoke period view shows flags and saves automatic flag decisions", async ({ page }) => {
|
||||||
const automaticStatusRequests = [];
|
const automaticStatusRequests = [];
|
||||||
const manualStatusRequests = [];
|
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 }) => {
|
test("@smoke period view selector switch updates visible customer set", async ({ page }) => {
|
||||||
await openPeriodView(page);
|
await openPeriodView(page);
|
||||||
|
|
||||||
|
|||||||
@@ -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.getByTestId("pos-order-empty-state")).toBeVisible();
|
||||||
await expect(cartPanel.locator('[data-testid^="pos-order-item-name-"]')).toHaveCount(0);
|
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]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -3447,6 +3447,173 @@ test.describe("POS mobile order flow", () => {
|
|||||||
expect(createdProductIds).toEqual([53, 71, 91]);
|
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 }) => {
|
test("manual step 2 disables exact add-on and standalone product controls with tap tooltips", async ({ page }) => {
|
||||||
const orderId = 9414;
|
const orderId = 9414;
|
||||||
const fixture = createMobilePosFixture({
|
const fixture = createMobilePosFixture({
|
||||||
@@ -4046,6 +4213,94 @@ test.describe("POS mobile order flow", () => {
|
|||||||
await waitForStepReset(page);
|
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: 25,
|
||||||
|
name: "Fælg flex pr. enhed",
|
||||||
|
description: "Audited addon that requires a reason comment",
|
||||||
|
price: 95,
|
||||||
|
subscription_allowed: true,
|
||||||
|
category: 8,
|
||||||
|
piktogram: "25",
|
||||||
|
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-25-note-token",
|
||||||
|
seedState: {
|
||||||
|
customerId: REGULAR_CUSTOMER_ID,
|
||||||
|
reg: "AB12345",
|
||||||
|
reference: "PRODUCT-25-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-25-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 product25Create = fixture.requestLog.orderItemCreates.find((entry) => Number(entry.product_id) === 25);
|
||||||
|
expect(product25Create?.notes).toBe("Højtryk bagpå venstre side");
|
||||||
|
expect(product25Create?.reason_code).toBe("customer_approved_extra_work");
|
||||||
|
expect(product25Create?.reason_comment).toBe("Højtryk bagpå venstre side");
|
||||||
|
await waitForStepReset(page);
|
||||||
|
});
|
||||||
|
|
||||||
test("booking hydration applies booking items, reference, notes, and po", async ({ page }) => {
|
test("booking hydration applies booking items, reference, notes, and po", async ({ page }) => {
|
||||||
const orderId = 9405;
|
const orderId = 9405;
|
||||||
const fixture = createMobilePosFixture({
|
const fixture = createMobilePosFixture({
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const CARD_CUSTOMER_ID = 999;
|
|||||||
export const WASH_CERTIFICATE_PRODUCT_ID = 41;
|
export const WASH_CERTIFICATE_PRODUCT_ID = 41;
|
||||||
const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
|
const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
|
||||||
const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
|
const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
|
||||||
|
const AUDITED_ORDER_ITEM_PRODUCT_IDS = new Set([21, 22, 25, 26, 27]);
|
||||||
export const MOBILE_PERMISSIONS = ["admin", "department_access_1"];
|
export const MOBILE_PERMISSIONS = ["admin", "department_access_1"];
|
||||||
export const MOBILE_NEXT_STEP_COOLDOWN_MS = 2100;
|
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);
|
const productId = Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0);
|
||||||
return (
|
return (
|
||||||
isEnabledFlag(product?.requires_note ?? product?.product?.requires_note) ||
|
isEnabledFlag(product?.requires_note ?? product?.product?.requires_note) ||
|
||||||
|
AUDITED_ORDER_ITEM_PRODUCT_IDS.has(productId) ||
|
||||||
productId === EXTRAORDINARY_CHEMISTRY_PRODUCT_ID ||
|
productId === EXTRAORDINARY_CHEMISTRY_PRODUCT_ID ||
|
||||||
String(product?.product?.name ?? product?.name ?? "").trim() === EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME
|
String(product?.product?.name ?? product?.name ?? "").trim() === EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME
|
||||||
);
|
);
|
||||||
@@ -396,6 +398,13 @@ function createFailureBudget(overrides = {}) {
|
|||||||
orderDelete: 0,
|
orderDelete: 0,
|
||||||
customerAttributesGet: 0,
|
customerAttributesGet: 0,
|
||||||
orderItemCreate: 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,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2002,6 +2011,22 @@ export async function mockMobilePosApi(page, fixture) {
|
|||||||
}
|
}
|
||||||
const orderId = toPositiveInteger(body.order_id);
|
const orderId = toPositiveInteger(body.order_id);
|
||||||
const productId = toPositiveInteger(body.product_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 order = fixture.ordersById[orderId];
|
||||||
const product = getProductById(fixture, productId);
|
const product = getProductById(fixture, productId);
|
||||||
if (!order || !product) {
|
if (!order || !product) {
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||||
|
import { isDesktopProject } from "./support/projects";
|
||||||
|
|
||||||
|
const XL_VASK_USAGE_LOG_ID = 55;
|
||||||
|
const PERIOD_FROM_DATE = "2026-07-01";
|
||||||
|
const PERIOD_TO_DATE = "2026-07-31";
|
||||||
|
|
||||||
|
const PERIOD_VIEW_URL = `/superuser/invoices?activeTab=period&startDate=${PERIOD_FROM_DATE}&endDate=${PERIOD_TO_DATE}&periodView=all`;
|
||||||
|
const SELVVASH_DESTINATION_PATTERN = new RegExp(
|
||||||
|
"/superuser/invoices\\?activeTab=period&periodView=self_wash&xlvaskUsageLogId=" + XL_VASK_USAGE_LOG_ID
|
||||||
|
);
|
||||||
|
|
||||||
|
function json(body: unknown, status = 200) {
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createXlVaskFlaggedPeriodPayload({ dateFrom = PERIOD_FROM_DATE } = {}) {
|
||||||
|
const fixtureDate = String(dateFrom).slice(0, 10);
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
types: {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
customer_number: 4001,
|
||||||
|
customer_name: "Acme Fleet",
|
||||||
|
requires_action: true,
|
||||||
|
transactions: [
|
||||||
|
{
|
||||||
|
id: 9001,
|
||||||
|
date: `${fixtureDate}T10:00:00.000Z`,
|
||||||
|
amount: 120,
|
||||||
|
booked: false,
|
||||||
|
excluded: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
queue: { has_active_job: false, statuses: [], invoice_collection_ids: [], is_action_blocked: false },
|
||||||
|
meta: {},
|
||||||
|
flags: [
|
||||||
|
{
|
||||||
|
id: "auto-xlvask-missing-1",
|
||||||
|
source: "automatic",
|
||||||
|
severity: "yellow",
|
||||||
|
status: "active",
|
||||||
|
target_type: "xlvask_usage_log",
|
||||||
|
target_id: XL_VASK_USAGE_LOG_ID,
|
||||||
|
customer_number: 4001,
|
||||||
|
definition_key: "xlvask_missing_order_link",
|
||||||
|
fingerprint: "xlvask-missing-fingerprint-1",
|
||||||
|
message_key: "invoice_period.flags.automatic.xlvask_missing_order_link",
|
||||||
|
message_params: {
|
||||||
|
wash_id: `wash-${XL_VASK_USAGE_LOG_ID}`,
|
||||||
|
registration_number: "AB12345",
|
||||||
|
},
|
||||||
|
message: "XL Vask wash is neither ignored nor linked to an order in the selected period.",
|
||||||
|
context: {
|
||||||
|
customer_name: "Acme Fleet",
|
||||||
|
xlvask_usage_log_id: XL_VASK_USAGE_LOG_ID,
|
||||||
|
wash_id: `wash-${XL_VASK_USAGE_LOG_ID}`,
|
||||||
|
registration_number: "AB12345",
|
||||||
|
start_time: `${fixtureDate}T10:00:00.000Z`,
|
||||||
|
},
|
||||||
|
xlvask_usage_log_id: XL_VASK_USAGE_LOG_ID,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
flag_counts: { manual: 0, automatic: 1, total: 1 },
|
||||||
|
status_indicator: "flag_yellow",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invoice_per_order: [],
|
||||||
|
fixed_pricing: [],
|
||||||
|
tank_cleaning: [],
|
||||||
|
special_arrangements: [],
|
||||||
|
vehicle_subscriptions: [],
|
||||||
|
possible_duplicates: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function suppressVueDevtoolsOverlay(page) {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const STYLE_ID = "__e2e-hide-vue-devtools";
|
||||||
|
localStorage.setItem("lastVersionCheck", String(Date.now()));
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
const target = document.head || document.documentElement;
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!document.getElementById(STYLE_ID)) {
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = STYLE_ID;
|
||||||
|
style.textContent =
|
||||||
|
"#__vue-devtools-container__, .vue-devtools__anchor-btn, .vue-devtools__panel-content { display: none !important; visibility: hidden !important; pointer-events: none !important; }";
|
||||||
|
target.appendChild(style);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
apply();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function primePeriodViewApi(page) {
|
||||||
|
return page.route("**/superuser/invoicing/period**", async (route) => {
|
||||||
|
if (route.request().method() !== "GET") {
|
||||||
|
await route.fallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
const dateFrom = url.searchParams.get("dateFrom") || PERIOD_FROM_DATE;
|
||||||
|
|
||||||
|
await route.fulfill(json(createXlVaskFlaggedPeriodPayload({ dateFrom })));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("XL Vask flag → Selvvash navigation", () => {
|
||||||
|
test("@smoke flag token opens the Selvvash tab with the highlighted wash", async ({ page, context }, testInfo) => {
|
||||||
|
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
const token = "superuser-xl-vask-navigation-token";
|
||||||
|
await suppressVueDevtoolsOverlay(page);
|
||||||
|
await seedAuthenticatedState(page, token);
|
||||||
|
await mockApi(page, {
|
||||||
|
authenticated: true,
|
||||||
|
permissions: ["superuser", "user"],
|
||||||
|
loginToken: token,
|
||||||
|
});
|
||||||
|
|
||||||
|
await primePeriodViewApi(page);
|
||||||
|
|
||||||
|
await context.addInitScript((tokenValue) => {
|
||||||
|
window.localStorage.setItem("token", tokenValue);
|
||||||
|
}, token);
|
||||||
|
|
||||||
|
await page.goto(PERIOD_VIEW_URL, { waitUntil: "domcontentloaded" });
|
||||||
|
await expect(page).toHaveURL(/activeTab=period/);
|
||||||
|
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
const customerRow = page.getByTestId("invoicing-period-customer-4001");
|
||||||
|
await expect(customerRow).toBeVisible();
|
||||||
|
|
||||||
|
const flagRow = page.getByTestId("invoice-period-flag-auto-xlvask-missing-1");
|
||||||
|
const flagRowCount = await flagRow.count();
|
||||||
|
test.skip(
|
||||||
|
flagRowCount === 0,
|
||||||
|
"No xlvask_missing_order_link flag is visible in the current period payload; smoke run skips."
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(flagRow).toBeVisible();
|
||||||
|
const flagToken = flagRow.locator(".invoice-period-flag-token");
|
||||||
|
await expect(flagToken).toBeVisible();
|
||||||
|
|
||||||
|
const popupPromise = page.waitForEvent("popup", { timeout: 15_000 });
|
||||||
|
await flagToken.click();
|
||||||
|
const popup = await popupPromise;
|
||||||
|
|
||||||
|
await expect(popup).toHaveURL(SELVVASH_DESTINATION_PATTERN);
|
||||||
|
await popup.waitForLoadState("domcontentloaded").catch(() => {});
|
||||||
|
|
||||||
|
await popup.close();
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Regression guard for TRU-13 / AUT-9: every `invoicing_period.xlvask_review.*`
|
||||||
|
// key that exists in the da reference catalogue must also be present in
|
||||||
|
// the other active locales (no, sv, de, en) with a non-empty translation.
|
||||||
|
// The end-to-end integrity suite already enforces global key parity, but
|
||||||
|
// this targeted test documents the acceptance criteria for the xlvask_review
|
||||||
|
// translation work and surfaces locale-specific gaps immediately.
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const activeLocales = ["da", "no", "sv", "de", "en"];
|
||||||
|
const reviewFileFor = (locale) =>
|
||||||
|
join(root, `src/i18n/source/${locale}/phrases/compat/invoicing_period/xlvask_review.json`);
|
||||||
|
|
||||||
|
const flattenPairs = (node, prefix = "") => {
|
||||||
|
if (typeof node === "string") {
|
||||||
|
return [{ key: prefix, value: node }];
|
||||||
|
}
|
||||||
|
if (!node || typeof node !== "object" || Array.isArray(node)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Object.entries(node).flatMap(([key, value]) => {
|
||||||
|
const nextPrefix = prefix ? `${prefix}.${key}` : key;
|
||||||
|
return flattenPairs(value, nextPrefix);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const readReviewSection = (locale) => {
|
||||||
|
const file = JSON.parse(readFileSync(reviewFileFor(locale), "utf8"));
|
||||||
|
return file?.compat?.invoicing_period?.xlvask_review ?? {};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("xlvask_review translation coverage", () => {
|
||||||
|
it("covers every da xlvask_review key in no, sv, de, en with a non-empty value", () => {
|
||||||
|
const daEntries = flattenPairs(readReviewSection("da"));
|
||||||
|
expect(daEntries.length, "da xlvask_review should expose translatable strings").toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const locale of activeLocales.filter((entry) => entry !== "da")) {
|
||||||
|
const localeEntries = new Map(flattenPairs(readReviewSection(locale)).map((entry) => [entry.key, entry.value]));
|
||||||
|
|
||||||
|
const missing = [];
|
||||||
|
const empty = [];
|
||||||
|
for (const { key } of daEntries) {
|
||||||
|
if (!localeEntries.has(key)) {
|
||||||
|
missing.push(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const localized = localeEntries.get(key);
|
||||||
|
if (typeof localized !== "string" || localized.trim().length === 0) {
|
||||||
|
empty.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(missing, `${locale} xlvask_review keys missing from da`).toEqual([]);
|
||||||
|
expect(empty, `${locale} xlvask_review keys with empty translations`).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -55,6 +55,8 @@ const i18n = createTestI18n({
|
|||||||
preview: {
|
preview: {
|
||||||
no_order_items: "No order items available.",
|
no_order_items: "No order items available.",
|
||||||
no_xlvask_usage_log: "No XL Vask details available.",
|
no_xlvask_usage_log: "No XL Vask details available.",
|
||||||
|
no_xlvask_usage_log_metadata:
|
||||||
|
"XL Vask registration (no metadata available — see api flag-context serializer)...",
|
||||||
product: "Product",
|
product: "Product",
|
||||||
quantity: "Qty",
|
quantity: "Qty",
|
||||||
price: "Price",
|
price: "Price",
|
||||||
@@ -390,11 +392,90 @@ describe("InvoicingPeriodFlagList", () => {
|
|||||||
|
|
||||||
await token.trigger("click");
|
await token.trigger("click");
|
||||||
expect(SessionUser.functions.redirectTo.superUser).toHaveBeenCalledWith(
|
expect(SessionUser.functions.redirectTo.superUser).toHaveBeenCalledWith(
|
||||||
"/invoices?activeTab=period&periodView=self_wash&xlvaskUsageLogId=55",
|
"/invoices?activeTab=period&periodView=self_wash&xlvaskUsageLogId=55&xlvaskUsageLogStartTime=2026-05-11",
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("propagates the flagged wash start time when navigating to the Selvvask view", async () => {
|
||||||
|
const wrapper = mountList([
|
||||||
|
{
|
||||||
|
id: "auto-xlvask-root",
|
||||||
|
source: "automatic",
|
||||||
|
fingerprint: "xlvask-root",
|
||||||
|
definition_key: "xlvask_missing_order_link",
|
||||||
|
message_key: "invoice_period.flags.automatic.xlvask_missing_order_link",
|
||||||
|
target_type: "xlvask_usage_log",
|
||||||
|
target_id: 77,
|
||||||
|
xlvask_usage_log_id: 77,
|
||||||
|
context: {
|
||||||
|
xlvask_usage_log_id: 77,
|
||||||
|
start_time: "2026-04-28T08:15:00",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await wrapper.get(".invoice-period-flag-token").trigger("click");
|
||||||
|
expect(SessionUser.functions.redirectTo.superUser).toHaveBeenLastCalledWith(
|
||||||
|
"/invoices?activeTab=period&periodView=self_wash&xlvaskUsageLogId=77&xlvaskUsageLogStartTime=2026-04-28",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates to Selvvask without a start-time query when the flag has no wash date", async () => {
|
||||||
|
const wrapper = mountList([
|
||||||
|
{
|
||||||
|
id: "auto-xlvask-no-date",
|
||||||
|
source: "automatic",
|
||||||
|
fingerprint: "xlvask-no-date",
|
||||||
|
definition_key: "xlvask_missing_order_link",
|
||||||
|
message_key: "invoice_period.flags.automatic.xlvask_missing_order_link",
|
||||||
|
target_type: "xlvask_usage_log",
|
||||||
|
target_id: 91,
|
||||||
|
xlvask_usage_log_id: 91,
|
||||||
|
context: {
|
||||||
|
xlvask_usage_log_id: 91,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await wrapper.get(".invoice-period-flag-token").trigger("click");
|
||||||
|
expect(SessionUser.functions.redirectTo.superUser).toHaveBeenLastCalledWith(
|
||||||
|
"/invoices?activeTab=period&periodView=self_wash&xlvaskUsageLogId=91",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// When the API flag-context serializer does not provide any of the
|
||||||
|
// identity fields (wash_id / registration_number / start_time / customer_name),
|
||||||
|
// the hover preview should not pretend the data is missing — it should
|
||||||
|
// tell the operator that the metadata simply isn't being supplied.
|
||||||
|
it("explains the empty XL Vask hover preview when flag context has no metadata fields", async () => {
|
||||||
|
const wrapper = mountList([
|
||||||
|
{
|
||||||
|
id: "auto-xlvask-empty",
|
||||||
|
source: "automatic",
|
||||||
|
fingerprint: "xlvask-empty-fingerprint",
|
||||||
|
definition_key: "xlvask_missing_order_link",
|
||||||
|
message_key: "invoice_period.flags.automatic.xlvask_missing_order_link",
|
||||||
|
target_type: "xlvask_usage_log",
|
||||||
|
target_id: 99,
|
||||||
|
xlvask_usage_log_id: 99,
|
||||||
|
context: {},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const token = wrapper.get(".invoice-period-flag-token");
|
||||||
|
expect(token.text()).toBe("XL Vask wash");
|
||||||
|
|
||||||
|
await token.trigger("mouseover");
|
||||||
|
expect(popperBoxMock).toHaveBeenLastCalledWith(
|
||||||
|
"XL Vask registration",
|
||||||
|
"XL Vask registration (no metadata available — see api flag-context serializer)..."
|
||||||
|
);
|
||||||
|
expect(showPopperMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("renders invoice period warnings in proper Danish", () => {
|
it("renders invoice period warnings in proper Danish", () => {
|
||||||
const wrapper = mountList(
|
const wrapper = mountList(
|
||||||
[
|
[
|
||||||
@@ -466,4 +547,64 @@ describe("InvoicingPeriodFlagList", () => {
|
|||||||
});
|
});
|
||||||
expect(wrapper.emitted("statusChanged")).toHaveLength(2);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,7 +14,7 @@ vi.mock("axios", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { createOrderItem } from "@/components/shop/OrdersItems.vue";
|
import { AUDITED_ORDER_ITEM_PRODUCT_IDS, createOrderItem } from "@/components/shop/OrdersItems.vue";
|
||||||
|
|
||||||
describe("createOrderItem", () => {
|
describe("createOrderItem", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -66,3 +66,90 @@ describe("createOrderItem", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("createOrderItem (audited products)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
axios.post.mockReset();
|
||||||
|
localStorage.setItem("token", "test-token");
|
||||||
|
axios.post.mockResolvedValue({ data: { success: true, data: { id: 1 } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const auditedProductIds = [21, 22, 25, 26, 27];
|
||||||
|
const DEFAULT_REASON_LABEL = "Kunde godkendte ekstra arbejde";
|
||||||
|
|
||||||
|
it.each(auditedProductIds)(
|
||||||
|
"always sends reason_comment for audited product %s even when notes is missing",
|
||||||
|
async (productId) => {
|
||||||
|
await createOrderItem(51207, productId, 1);
|
||||||
|
|
||||||
|
expect(axios.post).toHaveBeenCalledTimes(1);
|
||||||
|
const [, body] = axios.post.mock.calls[0];
|
||||||
|
expect(body).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
order_id: 51207,
|
||||||
|
product_id: productId,
|
||||||
|
quantity: 1,
|
||||||
|
reason_code: "customer_approved_extra_work",
|
||||||
|
reason_label_snapshot: DEFAULT_REASON_LABEL,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(typeof body.reason_comment).toBe("string");
|
||||||
|
expect(body.reason_comment.trim().length).toBeGreaterThan(0);
|
||||||
|
expect(body.reason_comment).toBe(DEFAULT_REASON_LABEL);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it("falls back to notes when reason_comment is not provided", async () => {
|
||||||
|
await createOrderItem(51207, 25, 1, null, " Customer approved graffiti removal ");
|
||||||
|
|
||||||
|
const [, body] = axios.post.mock.calls[0];
|
||||||
|
expect(body.reason_comment).toBe("Customer approved graffiti removal");
|
||||||
|
expect(body.reason_label_snapshot).toBe(DEFAULT_REASON_LABEL);
|
||||||
|
expect(body.reason_code).toBe("customer_approved_extra_work");
|
||||||
|
expect(body.notes).toBe(" Customer approved graffiti removal ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers an explicit reason_comment when the caller passes reasonData", async () => {
|
||||||
|
await createOrderItem(51207, 25, 1, null, "free-form notes", null, {
|
||||||
|
reason_comment: "Explicit override",
|
||||||
|
reason_label_snapshot: "Custom label",
|
||||||
|
reason_code: "custom_code",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, body] = axios.post.mock.calls[0];
|
||||||
|
expect(body.reason_comment).toBe("Explicit override");
|
||||||
|
expect(body.reason_label_snapshot).toBe("Custom label");
|
||||||
|
expect(body.reason_code).toBe("custom_code");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not include reason fields for non-audited products", async () => {
|
||||||
|
await createOrderItem(51207, 7, 1, null, "note");
|
||||||
|
|
||||||
|
const [, body] = axios.post.mock.calls[0];
|
||||||
|
expect(body).not.toHaveProperty("reason_code");
|
||||||
|
expect(body).not.toHaveProperty("reason_label_snapshot");
|
||||||
|
expect(body).not.toHaveProperty("reason_comment");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats whitespace-only notes as empty and falls back to the default label", async () => {
|
||||||
|
await createOrderItem(51207, 25, 1, null, " ");
|
||||||
|
|
||||||
|
const [, body] = axios.post.mock.calls[0];
|
||||||
|
expect(body.reason_comment).toBe(DEFAULT_REASON_LABEL);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AUDITED_ORDER_ITEM_PRODUCT_IDS membership", () => {
|
||||||
|
it("contains the expected audited product ids", () => {
|
||||||
|
expect(AUDITED_ORDER_ITEM_PRODUCT_IDS.has(21)).toBe(true);
|
||||||
|
expect(AUDITED_ORDER_ITEM_PRODUCT_IDS.has(22)).toBe(true);
|
||||||
|
expect(AUDITED_ORDER_ITEM_PRODUCT_IDS.has(25)).toBe(true);
|
||||||
|
expect(AUDITED_ORDER_ITEM_PRODUCT_IDS.has(26)).toBe(true);
|
||||||
|
expect(AUDITED_ORDER_ITEM_PRODUCT_IDS.has(27)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not include the spot-free-lastbil product id", () => {
|
||||||
|
expect(AUDITED_ORDER_ITEM_PRODUCT_IDS.has(24)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -777,8 +777,14 @@ describe("POSDepartmentProcess.hydrateSelectedOrderBookingForDesktop", () => {
|
|||||||
|
|
||||||
await expect(hydrateSelectedOrderBookingForDesktop()).resolves.toBe(true);
|
await expect(hydrateSelectedOrderBookingForDesktop()).resolves.toBe(true);
|
||||||
|
|
||||||
expect(mocks.createOrderItem).toHaveBeenNthCalledWith(1, 51207, 10, 1, null, null, 500);
|
expect(mocks.createOrderItem).toHaveBeenNthCalledWith(1, 51207, 10, 1, null, null, 500, null, {
|
||||||
expect(mocks.createOrderItem).toHaveBeenNthCalledWith(2, 51207, 20, 3, 9001, "Addon note", 500);
|
extra_sale_comment: null,
|
||||||
|
extra_sale_reason_code: null,
|
||||||
|
});
|
||||||
|
expect(mocks.createOrderItem).toHaveBeenNthCalledWith(2, 51207, 20, 3, 9001, "Addon note", 500, null, {
|
||||||
|
extra_sale_comment: null,
|
||||||
|
extra_sale_reason_code: null,
|
||||||
|
});
|
||||||
expect(SessionUser.objects.products.get.single).toHaveBeenCalledWith(10, {
|
expect(SessionUser.objects.products.get.single).toHaveBeenCalledWith(10, {
|
||||||
department_id: 2,
|
department_id: 2,
|
||||||
customer_id: 12345679,
|
customer_id: 12345679,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,8 +2,16 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
getSelfServeCompletedDynamicImageStep,
|
getSelfServeCompletedDynamicImageStep,
|
||||||
getSelfServeTaskDynamicImagePresentation,
|
getSelfServeTaskDynamicImagePresentation,
|
||||||
|
isSelfServeProgramNumberButton,
|
||||||
normalizeSelfServeProgramPickerTaskButtons,
|
normalizeSelfServeProgramPickerTaskButtons,
|
||||||
|
parseSelfServeDynamicImageThumbPosition,
|
||||||
} from "@/services/selfServeDynamicImage.js";
|
} from "@/services/selfServeDynamicImage.js";
|
||||||
|
import {
|
||||||
|
SELF_SERVE_TASK_BUTTON_OPTIONS,
|
||||||
|
SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER,
|
||||||
|
SELF_SERVE_TASK_BUTTON_RESET,
|
||||||
|
SELF_SERVE_TASK_BUTTON_START,
|
||||||
|
} from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
|
||||||
|
|
||||||
describe("selfServeDynamicImage", () => {
|
describe("selfServeDynamicImage", () => {
|
||||||
it("uses a selected program picker button number as thumb position", () => {
|
it("uses a selected program picker button number as thumb position", () => {
|
||||||
@@ -70,4 +78,55 @@ describe("selfServeDynamicImage", () => {
|
|||||||
"start",
|
"start",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Smoke check that the program number range is stable across the codebase.
|
||||||
|
// The wash bay exposes 12 programs (buttons 0-11, thumb positions 1-12).
|
||||||
|
// If this assertion starts failing, the contract with the API has changed
|
||||||
|
// and any program mapping issue (e.g. a program that is unresponsive on
|
||||||
|
// the bay) is likely the API repository's concern, not the frontend.
|
||||||
|
it("accepts every configured program number between 0 and 11", () => {
|
||||||
|
for (let programNumber = 0; programNumber <= 11; programNumber += 1) {
|
||||||
|
expect(isSelfServeProgramNumberButton(programNumber)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(isSelfServeProgramNumberButton(-1)).toBe(false);
|
||||||
|
expect(isSelfServeProgramNumberButton(12)).toBe(false);
|
||||||
|
expect(isSelfServeProgramNumberButton(100)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 1-indexed thumb positions 1..12 to the documented program range", () => {
|
||||||
|
for (let thumbPosition = 1; thumbPosition <= 12; thumbPosition += 1) {
|
||||||
|
expect(parseSelfServeDynamicImageThumbPosition(thumbPosition)).toBe(thumbPosition);
|
||||||
|
}
|
||||||
|
expect(parseSelfServeDynamicImageThumbPosition(0)).toBeNull();
|
||||||
|
expect(parseSelfServeDynamicImageThumbPosition(13)).toBeNull();
|
||||||
|
expect(parseSelfServeDynamicImageThumbPosition("not-a-number")).toBeNull();
|
||||||
|
expect(parseSelfServeDynamicImageThumbPosition(null)).toBeNull();
|
||||||
|
expect(parseSelfServeDynamicImageThumbPosition(undefined)).toBeNull();
|
||||||
|
expect(parseSelfServeDynamicImageThumbPosition("")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Smoke check that the program button registry still covers every configured
|
||||||
|
// program. The wash bay exposes 12 programs and the path editor / simulator
|
||||||
|
// UI renders one entry per program number from this array. If the array is
|
||||||
|
// ever shortened, lengthened, or has gaps/duplicates, the program list shown
|
||||||
|
// to operators will drift from the API mapping and operators will not be
|
||||||
|
// able to reach every configured program — which is exactly the failure mode
|
||||||
|
// described in TRU-19 ("FF Uvs" and "10min" unresponsive).
|
||||||
|
it("exposes 12 unique, sequential program entries in the button registry", () => {
|
||||||
|
const numericProgramEntries = SELF_SERVE_TASK_BUTTON_OPTIONS.filter(
|
||||||
|
(entry) => Number.isInteger(entry?.id) && entry.id >= 0 && entry.id <= 11
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(numericProgramEntries).toHaveLength(12);
|
||||||
|
expect(numericProgramEntries.map((entry) => entry.id)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
|
||||||
|
expect(new Set(numericProgramEntries.map((entry) => entry.id)).size).toBe(12);
|
||||||
|
expect(new Set(numericProgramEntries.map((entry) => entry.name)).size).toBe(12);
|
||||||
|
|
||||||
|
// The three "special" buttons (reset / program picker / start) must still
|
||||||
|
// be present so the path editor can render the full UI.
|
||||||
|
const specialIds = new Set(SELF_SERVE_TASK_BUTTON_OPTIONS.map((entry) => entry?.id));
|
||||||
|
expect(specialIds.has(SELF_SERVE_TASK_BUTTON_RESET)).toBe(true);
|
||||||
|
expect(specialIds.has(SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER)).toBe(true);
|
||||||
|
expect(specialIds.has(SELF_SERVE_TASK_BUTTON_START)).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -577,8 +577,11 @@ describe("Periode tab contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("uses the selected period while keeping Selvvask results server-paginated", () => {
|
it("uses the selected period while keeping Selvvask results server-paginated", () => {
|
||||||
expect(periodViewSelfWashSource).toContain(':initial-date-from="dates.computed.formattedStartDate.value"');
|
expect(periodViewSelfWashSource).toContain(':initial-date-from="initialDateFrom"');
|
||||||
expect(periodViewSelfWashSource).toContain(':initial-date-to="dates.computed.formattedEndDate.value"');
|
expect(periodViewSelfWashSource).toContain(':initial-date-to="initialDateTo"');
|
||||||
|
expect(periodViewSelfWashSource).toContain("dates.computed.formattedStartDate.value");
|
||||||
|
expect(periodViewSelfWashSource).toContain("dates.computed.formattedEndDate.value");
|
||||||
|
expect(periodViewSelfWashSource).toContain("route.query.xlvaskUsageLogStartTime");
|
||||||
expect(periodViewSelfWashSource).toContain(':inherit-period-filters="true"');
|
expect(periodViewSelfWashSource).toContain(':inherit-period-filters="true"');
|
||||||
expect(periodViewSelfWashSource).toContain(':load-all-at-once="false"');
|
expect(periodViewSelfWashSource).toContain(':load-all-at-once="false"');
|
||||||
expect(xlvaskUsagePaginationSource).toContain("inheritPeriodFilters");
|
expect(xlvaskUsagePaginationSource).toContain("inheritPeriodFilters");
|
||||||
@@ -591,10 +594,10 @@ describe("Periode tab contract", () => {
|
|||||||
expect(xlvaskUsagePaginationSource).toContain('v-if="shouldShowLocalFilters"');
|
expect(xlvaskUsagePaginationSource).toContain('v-if="shouldShowLocalFilters"');
|
||||||
expect(xlvaskUsagePaginationSource).toContain('v-if="!hideSearchField && shouldShowLocalFilters"');
|
expect(xlvaskUsagePaginationSource).toContain('v-if="!hideSearchField && shouldShowLocalFilters"');
|
||||||
expect(xlvaskUsagePaginationSource).toContain('v-if="!props.loadAllAtOnce"');
|
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("dateFrom: props.initialDateFrom");
|
||||||
expect(xlvaskUsagePaginationSource).toContain("dateTo: props.initialDateTo");
|
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", () => {
|
it("loads Selvvask selector counts and progress from the selected period", () => {
|
||||||
@@ -619,46 +622,90 @@ describe("Periode tab contract", () => {
|
|||||||
expect(xlvaskUsageOrdersTableSource).toContain("const { loadList } = usePaginatedListInstance();");
|
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", () => {
|
it("keeps Selvvask usage entries wrapped inside the period content area", () => {
|
||||||
expect(xlvaskUsageOrdersTableSource).toContain("xlvask-usage-card-primary");
|
expect(xlvaskUsageOrdersTableSource).toContain("xlvask-review-table");
|
||||||
expect(xlvaskUsageOrdersTableSource).toContain("xlvask-usage-card-status");
|
expect(xlvaskUsageOrdersTableSource).toContain("xlvask-usage-card");
|
||||||
expect(xlvaskUsageOrdersTableSource).toContain("xlvask-usage-chip-text");
|
|
||||||
expect(xlvaskUsageOrdersTableSource).toContain("getCachedXlvaskUsageAmount");
|
expect(xlvaskUsageOrdersTableSource).toContain("getCachedXlvaskUsageAmount");
|
||||||
expect(xlvaskUsageOrdersTableSource).toContain("setCachedXlvaskUsageAmount");
|
expect(xlvaskUsageOrdersTableSource).toContain("setCachedXlvaskUsageAmount");
|
||||||
expect(xlvaskUsageOrdersTableSource).toContain("object?.total_net_amount");
|
expect(xlvaskUsageOrdersTableSource).toContain("object?.total_net_amount");
|
||||||
expect(xlvaskUsageOrdersTableSource).toContain("automation.status === 'failed'");
|
expect(xlvaskUsageOrdersTableSource).toContain("object.resolution_state");
|
||||||
expect(xlvaskUsageOrdersTableSource).toContain("getXlvaskResolutionState(object)");
|
expect(xlvaskUsageOrdersTableSource).toContain("onClickAccept(object)");
|
||||||
expect(xlvaskUsageOrdersTableSource).toContain("only fetch one-time fast-link details on demand");
|
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).not.toContain("scheduleFastLink(object)");
|
||||||
expect(xlvaskUsageOrdersTableSource).toContain("grid-template-columns: minmax(0, 1fr) minmax(9rem, 15rem);");
|
expect(xlvaskUsageOrdersTableSource).not.toContain("xlvask-order-comparison-columns");
|
||||||
expect(xlvaskUsageOrdersTableSource).toContain("xlvask-order-comparison-columns");
|
|
||||||
expect(xlvaskUsageOrdersTableSource).toContain("@media screen and (max-width: 1350px)");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses bounded autopilot polling and preview/apply-only review mutations", () => {
|
it("uses summary refresh after operator review and stays on the review endpoints", () => {
|
||||||
expect(xlvaskUsagePaginationSource).toContain("const RUN_POLL_INTERVAL_MS = 2_000");
|
// After an operator Accept / Reject / Ignore / Unignore, the pagination
|
||||||
expect(xlvaskUsagePaginationSource).toContain("const RUN_POLL_MAX_DURATION_MS = 5 * 60 * 1_000");
|
// and the orders table must re-load the summary via the same review
|
||||||
expect(xlvaskUsagePaginationSource).toContain("document.hidden");
|
// endpoints that this surface exclusively consumes. No autopilot / dry-run /
|
||||||
expect(xlvaskUsagePaginationSource).toContain("resumeAutopilotPolling");
|
// apply / policy-version / readiness-snapshot plumbing survives.
|
||||||
expect(xlvaskUsagePaginationSource).toContain("invalidateAutopilotScope");
|
expect(xlvaskUsagePaginationSource).toContain("/modules/xlvask/services/usage/orders/summary");
|
||||||
expect(xlvaskUsagePaginationSource).toContain("if (sequence !== runPollSequence) return;");
|
expect(xlvaskUsagePaginationSource).toContain("xlvask-usage-order-updated");
|
||||||
expect(xlvaskUsagePaginationSource).toContain("activeRunRecoverySequence += 1");
|
expect(xlvaskUsagePaginationSource).not.toContain("RUN_POLL_INTERVAL_MS");
|
||||||
expect(xlvaskUsagePaginationSource).toContain("await loadAutomationControlState();");
|
expect(xlvaskUsagePaginationSource).not.toContain("RUN_POLL_MAX_DURATION_MS");
|
||||||
expect(xlvaskUsagePaginationSource).toContain("await recoverActiveAutopilotRun();");
|
expect(xlvaskUsagePaginationSource).not.toContain("recoverActiveAutopilotRun");
|
||||||
expect(xlvaskUsagePaginationSource).toContain('halted: "advisory"');
|
expect(xlvaskUsagePaginationSource).not.toContain("loadAutomationControlState");
|
||||||
expect(xlvaskUsagePaginationSource).toContain("expected_policy_version: preview.expected_policy_version");
|
expect(xlvaskUsagePaginationSource).not.toContain("buildExecutionReadinessSnapshot");
|
||||||
expect(xlvaskUsagePaginationSource).toContain("buildImportUsageParams(),");
|
expect(xlvaskUsagePaginationSource).not.toContain("resumeAutopilotPolling");
|
||||||
expect(xlvaskUsagePaginationSource).toContain("const runParams = buildImportUsageParams();");
|
expect(xlvaskUsagePaginationSource).not.toContain("canStartExecute");
|
||||||
expect(xlvaskUsagePaginationSource).toContain("const readinessSnapshot = buildExecutionReadinessSnapshot();");
|
|
||||||
expect(xlvaskUsagePaginationSource).toContain("if (requestScopeSequence !== periodScopeSequence) return;");
|
expect(xlvaskUsageOrdersTableSource).toContain("/modules/xlvask/services/usage/orders/${object.id}/");
|
||||||
expect(xlvaskUsagePaginationSource).toContain("JSON.stringify(buildExecutionReadinessSnapshot())");
|
expect(xlvaskUsageOrdersTableSource).toContain("/modules/xlvask/services/usage/orders/${id}/");
|
||||||
expect(xlvaskUsagePaginationSource).toContain("if (!canStartExecute.value) return;");
|
expect(xlvaskUsageOrdersTableSource).not.toContain("/modules/xlvask/services/usage/automation/decisions/preview");
|
||||||
expect(xlvaskUsageOrdersTableSource).toContain('"/modules/xlvask/services/usage/automation/decisions/preview"');
|
expect(xlvaskUsageOrdersTableSource).not.toContain("/modules/xlvask/services/usage/automation/decisions/apply");
|
||||||
expect(xlvaskUsageOrdersTableSource).toContain('"/modules/xlvask/services/usage/automation/decisions/apply"');
|
expect(xlvaskUsageOrdersTableSource).not.toContain("if (!confirmation.isConfirmed)");
|
||||||
expect(xlvaskUsageOrdersTableSource).toContain("if (!confirmation.isConfirmed) return false;");
|
expect(xlvaskUsageOrdersTableSource).not.toContain("'apply'");
|
||||||
expect(xlvaskUsageOrdersTableSource).toContain("if (applied) selectedUsageLogIds.value = [];");
|
expect(xlvaskUsageOrdersTableSource).not.toContain("'preview'");
|
||||||
expect(xlvaskUsageOrdersTableSource).not.toContain("set.wash_id");
|
});
|
||||||
expect(xlvaskUsageOrdersTableSource).not.toContain("'/order/items'");
|
|
||||||
|
it("renders a friendly notice when the orders endpoint returns 404", () => {
|
||||||
|
// The pagination must distinguish a 404 ("API endpoint not implemented
|
||||||
|
// yet") from other failures and surface a friendly notice instead of
|
||||||
|
// the generic error popper every other paginated surface uses.
|
||||||
|
expect(xlvaskUsagePaginationSource).toContain("apiEndpointNotImplemented");
|
||||||
|
expect(xlvaskUsagePaginationSource).toContain("lastError");
|
||||||
|
expect(xlvaskUsagePaginationSource).toContain("404");
|
||||||
|
expect(xlvaskUsagePaginationSource).toContain("xlvask_review.errors.api_endpoint_not_implemented");
|
||||||
|
expect(xlvaskUsagePaginationSource).toContain("xlvask-api-not-implemented");
|
||||||
|
expect(xlvaskUsagePaginationSource).toContain('removeError("paginatedGetRequest")');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ships the API-not-implemented notice in every supported locale", () => {
|
||||||
|
// The friendly notice must be present in all five source locales so
|
||||||
|
// the operator reads a real translation rather than a raw key.
|
||||||
|
for (const locale of localeMessages) {
|
||||||
|
const notice = locale.messages?.invoicing_period?.xlvask_review?.errors?.api_endpoint_not_implemented;
|
||||||
|
expect(notice, `locale ${locale.locale} missing api_endpoint_not_implemented`).toBeTruthy();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps period subview navigation on valid keys", () => {
|
it("keeps period subview navigation on valid keys", () => {
|
||||||
|
|||||||
@@ -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",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const readSource = (relativePath) => readFileSync(join(root, relativePath), "utf8");
|
||||||
|
|
||||||
|
describe("xlvask usage pagination department selector propagation", () => {
|
||||||
|
it("declares a departmentId prop on XLVaskUsagePagination", () => {
|
||||||
|
const source = readSource("src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue");
|
||||||
|
|
||||||
|
expect(source).toMatch(/departmentId:\s*\{\s*type:\s*Number,\s*default:\s*0\s*\}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the HallId filter when the departmentId prop is provided", () => {
|
||||||
|
const source = readSource("src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue");
|
||||||
|
|
||||||
|
expect(source).toMatch(/effectiveDepartmentId\s*>\s*0/);
|
||||||
|
expect(source).toMatch(/setFilter\(\s*["']HallId["']\s*,\s*effectiveDepartmentId\s*,\s*false\s*\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the departmentId route param when the prop is not provided", () => {
|
||||||
|
const source = readSource("src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue");
|
||||||
|
|
||||||
|
expect(source).toMatch(/router\.currentRoute\.value\.params\.departmentId/);
|
||||||
|
expect(source).toMatch(/Number\.parseInt\(\s*String\(router\.currentRoute\.value\.params\.departmentId/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not apply the HallId filter when no departmentId is provided", () => {
|
||||||
|
const source = readSource("src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue");
|
||||||
|
|
||||||
|
expect(source).toContain(
|
||||||
|
"const effectiveDepartmentId =\n props.departmentId > 0\n ? props.departmentId\n : Number.isInteger(routeDepartmentId) && routeDepartmentId > 0\n ? routeDepartmentId\n : 0;"
|
||||||
|
);
|
||||||
|
expect(source).toMatch(/if\s*\(effectiveDepartmentId\s*>\s*0\)\s*\{\s*setFilter\(\s*["']HallId["']/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DepartmentPosSync forwards the URL departmentId to XLVaskUsagePagination", () => {
|
||||||
|
const source = readSource("src/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosSync.vue");
|
||||||
|
|
||||||
|
expect(source).toMatch(
|
||||||
|
/<XLVaskUsagePagination[^>]*:department-id="SessionUser\.functions\.getDepartmentIdFromUrl\(\)"/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("InvoicingBillingPeriodViewSelfWash does not pass a departmentId", () => {
|
||||||
|
const source = readSource(
|
||||||
|
"src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewSelfWash.vue"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(source).not.toMatch(/department-id[\s=]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,26 +33,26 @@ describe("xlvask usage filters", () => {
|
|||||||
expect(filterUsageOrdersByAttachment(sourceRows, false)).toEqual(sourceRows);
|
expect(filterUsageOrdersByAttachment(sourceRows, false)).toEqual(sourceRows);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats accepted automation actions as attached", () => {
|
it("treats linked and duplicate wash_ids as attached", () => {
|
||||||
expect(
|
expect(
|
||||||
isUsageOrderAttachedToOrder({
|
isUsageOrderAttachedToOrder({
|
||||||
wash_id: "wash-1",
|
wash_id: "wash-1",
|
||||||
|
linked_order_id: 17,
|
||||||
duplicates: [],
|
duplicates: [],
|
||||||
automation: {
|
|
||||||
status: "accepted",
|
|
||||||
action: "attach_order",
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
isUsageOrderAttachedToOrder({
|
isUsageOrderAttachedToOrder({
|
||||||
wash_id: "wash-2",
|
wash_id: "wash-2",
|
||||||
|
duplicates: [{ wash_id: "wash-2" }],
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isUsageOrderAttachedToOrder({
|
||||||
|
wash_id: "wash-3",
|
||||||
duplicates: [],
|
duplicates: [],
|
||||||
automation: {
|
|
||||||
status: "denied",
|
|
||||||
action: "attach_order",
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import axios from "axios";
|
||||||
|
import { usePaginatedList } from "@/components/pagination/paginatedList.vue";
|
||||||
|
|
||||||
|
vi.mock("axios", () => ({
|
||||||
|
default: {
|
||||||
|
get: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("paginatedList tracks the last error so consumers can detect 404", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
localStorage.setItem("token", "test-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes a reactive lastError ref and clears it on success", async () => {
|
||||||
|
axios.get.mockResolvedValueOnce({
|
||||||
|
data: { data: [{ id: 1 }], meta: { pagination: { page: 1, per_page: 100, total: 1 } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = usePaginatedList();
|
||||||
|
list.setEndpoint("/modules/xlvask/services/usage/orders", false);
|
||||||
|
expect(list.lastError.value).toBeNull();
|
||||||
|
|
||||||
|
await list.paginatedGetRequest();
|
||||||
|
|
||||||
|
expect(list.lastError.value).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures the HTTP status of a failed request for the current endpoint", async () => {
|
||||||
|
axios.get.mockRejectedValueOnce({
|
||||||
|
response: { status: 404, data: { data: { message: "Not Found" } } },
|
||||||
|
message: "Request failed with status code 404",
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = usePaginatedList();
|
||||||
|
list.setEndpoint("/modules/xlvask/services/usage/orders", false);
|
||||||
|
|
||||||
|
const result = await list.paginatedGetRequest();
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(list.lastError.value).toEqual({
|
||||||
|
status: 404,
|
||||||
|
endpoint: "/modules/xlvask/services/usage/orders",
|
||||||
|
message: "Not Found",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets lastError at the start of every request", async () => {
|
||||||
|
axios.get
|
||||||
|
.mockRejectedValueOnce({
|
||||||
|
response: { status: 500, data: { data: { message: "Server Error" } } },
|
||||||
|
message: "Request failed with status code 500",
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: { data: [], meta: { pagination: { page: 1, per_page: 100, total: 0 } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = usePaginatedList();
|
||||||
|
list.setEndpoint("/modules/xlvask/services/usage/orders", false);
|
||||||
|
|
||||||
|
await list.paginatedGetRequest();
|
||||||
|
expect(list.lastError.value?.status).toBe(500);
|
||||||
|
|
||||||
|
await list.paginatedGetRequest();
|
||||||
|
expect(list.lastError.value).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not set lastError when the request was cancelled", async () => {
|
||||||
|
axios.get.mockRejectedValueOnce({
|
||||||
|
name: "CanceledError",
|
||||||
|
code: "ERR_CANCELED",
|
||||||
|
message: "canceled",
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = usePaginatedList();
|
||||||
|
list.setEndpoint("/modules/xlvask/services/usage/orders", false);
|
||||||
|
|
||||||
|
await list.paginatedGetRequest();
|
||||||
|
|
||||||
|
expect(list.lastError.value).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,63 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>AUT-1 primary-button hover preview</title>
|
||||||
|
<!--
|
||||||
|
Bulma 1.x stylesheet from the project's node_modules so the preview
|
||||||
|
matches the real app's default theme. The :root override is the same
|
||||||
|
--bulma-primary-h override that lives in src/assets/main.css so the
|
||||||
|
primary colour renders as the brand teal-blue (#0787BB ≈ HSL 197deg).
|
||||||
|
-->
|
||||||
|
<link rel="stylesheet" href="../../../node_modules/bulma/css/bulma.min.css" />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bulma-primary-h: 197deg;
|
||||||
|
--bulma-primary-s: 92%;
|
||||||
|
--bulma-primary-l: 38%;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background: #f5f5f5;
|
||||||
|
margin: 0;
|
||||||
|
padding: 48px 64px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
.stage {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 48px;
|
||||||
|
background: white;
|
||||||
|
padding: 40px 56px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 3px rgba(10, 10, 10, 0.12);
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #7a7a7a;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.button.is-primary {
|
||||||
|
min-width: 168px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="stage-idle" class="stage" data-state="idle">
|
||||||
|
<div class="label">Primary button — default</div>
|
||||||
|
<button class="button is-primary" data-testid="primary-button-idle">
|
||||||
|
Confirm booking
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="height: 32px"></div>
|
||||||
|
<div id="stage-hover" class="stage" data-state="hover">
|
||||||
|
<div class="label">Primary button — hover</div>
|
||||||
|
<button class="button is-primary" data-testid="primary-button-hover">
|
||||||
|
Confirm booking
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||