feat(period): render customer indicator chips on every subpage
The backend now projects lightweight
"{customer_number, membership_only: true}" entries onto every
non-active view bucket of the period response. The
InvoicingBillingPeriodCustomerAttributes component already iterates
sharedVariables.types to compute its chip stack, but it used a
nested Array.prototype.some() call against every bucket on every
reactivity tick.
* Pre-compute a Set<customer_number> per bucket so membership lookups
are O(1) regardless of bucket size.
* Skip entries that don't carry a positive integer customer_number
so non-numeric or null payloads from legacy clients stay inert.
* Honour the deterministic ATTRIBUTE_DISPLAY_PRIORITY ordering across
the chips so the surface stays stable.
e2e: the existing smoke harness now mirrors the live backend contract
through a new projectPeriodMockPagedPayload() helper that maps the
in-memory fixture to the { full cards on active bucket, lightweight
memberships elsewhere } shape. A new @smoke spec verifies that the
indicator chips for invoice_per_order / fixed_pricing / tank_cleaning
all render on the Alle tab and that single-category customers render
exactly one chip.
unit: invoicing-billing-period-customer-attributes-membership.spec.js
adds five focused jsdom tests covering the active bucket path, the
lightweight-membership path, the explicit 'all' exclusion, the
defensive numeric guard, and the deterministic ordering.
Plan: docs/invoicing-period-tag-membership-plan.md captures the full
investigation, contract change, and verification steps.
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
@@ -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.
|
||||
+33
-5
@@ -51,12 +51,40 @@ const ATTRIBUTE_DISPLAY_PRIORITY: Record<string, number> = {
|
||||
};
|
||||
const UNKNOWN_ATTRIBUTE_PRIORITY = 99;
|
||||
|
||||
const list_views_with_customer = computed(() => {
|
||||
const matched = view_keys.value.filter((view_key) => {
|
||||
// Skip if the view type is "all".
|
||||
if (view_key === 'all') return false;
|
||||
// Pre-compute a Set<customer_number> per view bucket so chip membership
|
||||
// resolution stays O(1) regardless of bucket size. The backend now ships
|
||||
// lightweight `{customer_number, membership_only}` markers for every
|
||||
// non-active bucket, so this lookup also drives the category chips shown
|
||||
// on the Alle tab.
|
||||
const membershipIndexes = computed<Record<string, Set<number>>>(() => {
|
||||
const result: Record<string, Set<number>> = {};
|
||||
for (const view_key of view_keys.value) {
|
||||
if (view_key === 'all') {
|
||||
result[view_key] = new Set<number>();
|
||||
continue;
|
||||
}
|
||||
const view_type = sharedTypes.value[view_key];
|
||||
return view_type && view_type.some((v: any) => v.customer_number === props.customer.customer_number);
|
||||
const set = new Set<number>();
|
||||
if (Array.isArray(view_type)) {
|
||||
for (const entry of view_type) {
|
||||
const raw = (entry as { customer_number?: number | string } | null)
|
||||
?.customer_number;
|
||||
const number = Number(raw);
|
||||
if (Number.isInteger(number) && number > 0) {
|
||||
set.add(number);
|
||||
}
|
||||
}
|
||||
}
|
||||
result[view_key] = set;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const list_views_with_customer = computed(() => {
|
||||
const customerNumber = Number(props.customer.customer_number);
|
||||
const matched = view_keys.value.filter((view_key) => {
|
||||
if (view_key === 'all') return false;
|
||||
return membershipIndexes.value[view_key]?.has(customerNumber) === true;
|
||||
});
|
||||
return [...matched].sort((left, right) => {
|
||||
const leftPriority = ATTRIBUTE_DISPLAY_PRIORITY[left] ?? UNKNOWN_ATTRIBUTE_PRIORITY;
|
||||
|
||||
@@ -179,6 +179,44 @@ function createPeriodPayload() {
|
||||
};
|
||||
}
|
||||
|
||||
// Mirrors the real backend's applyPeriodPagination: only the requested
|
||||
// `periodView` bucket keeps full customer cards; every other bucket is
|
||||
// replaced with lightweight `{customer_number, membership_only}`
|
||||
// memberships deduplicated by customer_number. This keeps the e2e mock
|
||||
// in lock-step with the live API contract so the chip-stacking
|
||||
// scenarios actually exercise the membership path.
|
||||
function projectPeriodMockPagedPayload(payload, periodView) {
|
||||
if (!payload || !payload.types || payload.__rawResponse) {
|
||||
return payload;
|
||||
}
|
||||
const activeView = periodView && payload.types[periodView] !== undefined ? periodView : "all";
|
||||
const types = payload.types;
|
||||
const projected = {};
|
||||
for (const [typeName, customers] of Object.entries(types)) {
|
||||
if (typeName === activeView) {
|
||||
projected[typeName] = Array.isArray(customers) ? customers.slice() : [];
|
||||
continue;
|
||||
}
|
||||
const seen = new Set();
|
||||
const memberships = [];
|
||||
if (Array.isArray(customers)) {
|
||||
for (const customer of customers) {
|
||||
if (!customer || typeof customer !== "object") continue;
|
||||
const customerNumber = Number(customer.customer_number);
|
||||
if (!Number.isInteger(customerNumber) || customerNumber < 1) continue;
|
||||
if (seen.has(customerNumber)) continue;
|
||||
seen.add(customerNumber);
|
||||
memberships.push({
|
||||
customer_number: customerNumber,
|
||||
membership_only: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
projected[typeName] = memberships;
|
||||
}
|
||||
return { ...payload, types: projected };
|
||||
}
|
||||
|
||||
function createObjectTreePeriodPayload({ dateFrom = "2026-07-14" } = {}) {
|
||||
const payload = createPeriodPayload();
|
||||
const fixtureDate = periodFixtureDate(dateFrom);
|
||||
@@ -905,6 +943,12 @@ async function setupPeriodEndpoints(page, requests, options = {}) {
|
||||
payload = createPeriodPayload();
|
||||
}
|
||||
|
||||
// Mirror the real backend contract: only the requested periodView
|
||||
// bucket carries full customer cards; every other bucket carries a
|
||||
// lightweight `{customer_number, membership_only}` membership so the
|
||||
// front-end can render category indicator chips on every subpage.
|
||||
payload = projectPeriodMockPagedPayload(payload, url.searchParams.get("periodView"));
|
||||
|
||||
await route.fulfill(
|
||||
json(
|
||||
payload?.__rawResponse ?? {
|
||||
@@ -2460,6 +2504,51 @@ test.describe("Invoicing period tab", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("@smoke period customer attribute chips render on every subpage including Alle", async ({ page }) => {
|
||||
// Pin down the regression: customer indicator chips (e.g. "Faktura pr.
|
||||
// ordre") were previously invisible on the Alle tab because the backend
|
||||
// only returned full customer data for the active view bucket. The
|
||||
// backend now projects lightweight `{customer_number, membership_only}`
|
||||
// entries on every non-active bucket so the front-end can resolve
|
||||
// category membership regardless of the active view.
|
||||
await openPeriodView(page, { payloadFactory: createAttributeStackPeriodPayload });
|
||||
|
||||
await page.getByTestId("invoicing-period-view-selector-all").click();
|
||||
await expect(page).toHaveURL(/periodView=all/);
|
||||
|
||||
const multiAttributeCard = page.getByTestId("invoicing-period-customer-4101");
|
||||
await expect(multiAttributeCard).toBeVisible();
|
||||
|
||||
const multiAttributeStack = multiAttributeCard.locator(
|
||||
'[data-testid="invoicing-period-customer-attributes-4101"]'
|
||||
);
|
||||
await expect(multiAttributeStack).toBeVisible();
|
||||
|
||||
for (const viewKey of ["invoice_per_order", "fixed_pricing", "tank_cleaning"]) {
|
||||
const chip = page.getByTestId(`invoicing-period-customer-attribute-4101-${viewKey}`);
|
||||
await expect(chip, `expected chip "${viewKey}" on the Alle tab`).toBeVisible();
|
||||
const chipText = (await chip.textContent())?.trim() ?? "";
|
||||
expect(chipText, `chip ${viewKey} carries a non-empty label`).not.toBe("");
|
||||
}
|
||||
|
||||
// The single-attribute customer should still show exactly one chip
|
||||
// (matches the "fixed_pricing" bucket in the fixture).
|
||||
const singleAttributeCard = page.getByTestId("invoicing-period-customer-4102");
|
||||
await expect(singleAttributeCard).toBeVisible();
|
||||
const singleStack = singleAttributeCard.locator(
|
||||
'[data-testid="invoicing-period-customer-attributes-4102"]'
|
||||
);
|
||||
await expect(singleStack).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("invoicing-period-customer-attribute-4102-fixed_pricing"),
|
||||
"single-category customer renders its fixed_pricing chip on the Alle tab"
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("invoicing-period-customer-attribute-4102-invoice_per_order"),
|
||||
"single-category customer does not render an invoice_per_order chip"
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("@smoke period view selector switch updates visible customer set", async ({ page }) => {
|
||||
await openPeriodView(page);
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
// @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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user