diff --git a/docs/invoicing-period-tag-membership-plan.md b/docs/invoicing-period-tag-membership-plan.md new file mode 100644 index 00000000..b96b3b78 --- /dev/null +++ b/docs/invoicing-period-tag-membership-plan.md @@ -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> $customers + * @return array + */ +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` lookups via a small `computed`: + +```ts +const membershipIndexes = computed(() => { + const result: Record> = {}; + 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(); + 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. diff --git a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue index 25b55510..d3351ae8 100644 --- a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue +++ b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue @@ -51,12 +51,40 @@ const ATTRIBUTE_DISPLAY_PRIORITY: Record = { }; 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 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>>(() => { + const result: Record> = {}; + for (const view_key of view_keys.value) { + if (view_key === 'all') { + result[view_key] = new Set(); + 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(); + 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; diff --git a/tests/e2e/invoicing-period.smoke.spec.js b/tests/e2e/invoicing-period.smoke.spec.js index c148bfea..764df6f8 100644 --- a/tests/e2e/invoicing-period.smoke.spec.js +++ b/tests/e2e/invoicing-period.smoke.spec.js @@ -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,47 @@ test.describe("Invoicing period tab", () => { } }); + test("@smoke period customer attribute chips render on every subpage including Alle", async ({ page }) => { + // Pin down the regression: customer indicator chips (e.g. "Faktura pr. + // ordre") were previously invisible on the Alle tab because the backend + // only returned full customer data for the active view bucket. The + // backend now projects lightweight `{customer_number, membership_only}` + // entries on every non-active bucket so the front-end can resolve + // category membership regardless of the active view. + await openPeriodView(page, { payloadFactory: createAttributeStackPeriodPayload }); + + await page.getByTestId("invoicing-period-view-selector-all").click(); + await expect(page).toHaveURL(/periodView=all/); + + const multiAttributeCard = page.getByTestId("invoicing-period-customer-4101"); + await expect(multiAttributeCard).toBeVisible(); + + const multiAttributeStack = multiAttributeCard.locator('[data-testid="invoicing-period-customer-attributes-4101"]'); + await expect(multiAttributeStack).toBeVisible(); + + for (const viewKey of ["invoice_per_order", "fixed_pricing", "tank_cleaning"]) { + const chip = page.getByTestId(`invoicing-period-customer-attribute-4101-${viewKey}`); + await expect(chip, `expected chip "${viewKey}" on the Alle tab`).toBeVisible(); + const chipText = (await chip.textContent())?.trim() ?? ""; + expect(chipText, `chip ${viewKey} carries a non-empty label`).not.toBe(""); + } + + // The single-attribute customer should still show exactly one chip + // (matches the "fixed_pricing" bucket in the fixture). + const singleAttributeCard = page.getByTestId("invoicing-period-customer-4102"); + await expect(singleAttributeCard).toBeVisible(); + const singleStack = singleAttributeCard.locator('[data-testid="invoicing-period-customer-attributes-4102"]'); + await expect(singleStack).toBeVisible(); + await expect( + page.getByTestId("invoicing-period-customer-attribute-4102-fixed_pricing"), + "single-category customer renders its fixed_pricing chip on the Alle tab" + ).toBeVisible(); + await expect( + page.getByTestId("invoicing-period-customer-attribute-4102-invoice_per_order"), + "single-category customer does not render an invoice_per_order chip" + ).toHaveCount(0); + }); + test("@smoke period view selector switch updates visible customer set", async ({ page }) => { await openPeriodView(page); diff --git a/tests/unit/invoicing-billing-period-customer-attributes-membership.spec.js b/tests/unit/invoicing-billing-period-customer-attributes-membership.spec.js new file mode 100644 index 00000000..6727b5b5 --- /dev/null +++ b/tests/unit/invoicing-billing-period-customer-attributes-membership.spec.js @@ -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", + ]); + }); +});