## Summary Pairs with [copenhagentruckwash/api#371](https://github.com/copenhagentruckwash/api/pull/371) to render category indicator chips (e.g. *Faktura pr. ordre*, *Fastpris*, *Tankrengøring*) on every Superuser → Fakturaer → Periode subpage, including the *Alle* tab. ## What changed * `InvoicingBillingPeriodCustomerAttributes.vue` pre-computes a `Set<customer_number>` per view bucket so membership lookups are O(1) regardless of bucket size. The component already iterated `sharedVariables.types`; this PR just hoists the membership check out of the per-chip `Array.some()` into a precomputed Set index. * Skips entries that don't carry a positive integer `customer_number` so non-numeric or null payloads from legacy clients stay inert. * Honours the deterministic `ATTRIBUTE_DISPLAY_PRIORITY` ordering across the chips. ## Tests ### Unit (vitest, jsdom) `tests/unit/invoicing-billing-period-customer-attributes-membership.spec.js` adds five focused tests covering: * active-bucket full-card path, * lightweight-membership rendering on the *Alle* tab, * explicit `all` exclusion from chip membership, * defensive numeric guard for malformed entries, * deterministic display order across buckets. ### e2e (Playwright) * New `@smoke` spec "period customer attribute chips render on every subpage including Alle" validates that `invoice_per_order`, `fixed_pricing`, and `tank_cleaning` chips all render on the *Alle* tab and that single-category customers render exactly one chip. * 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 so the new test actually exercises the membership path. ## Plan `docs/invoicing-period-tag-membership-plan.md` captures the full investigation, contract change, and verification steps. 🤖 Generated by [OpenHands](https://docs.openhands.dev/) on behalf of copenhagentruckwash. --------- Co-authored-by: openhands <openhands@all-hands.dev>
18 KiB
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/periodwith the correspondingperiodViewquery param (all,invoice_per_order, …). -
InvoicingBillingPeriodViewAll.vueis rendered for every active view (includingall). It reads the active bucket viaview.variables.sharedVariables.value.types[componentName]. -
For each customer card it mounts
InvoicingBillingPeriodCustomerAttributes.vue, which computeslist_views_with_customer: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 samecustomer_number.
Back-end paging path
-
InvoicingPeriodRoute::getInvoicingPeriodbuilds atypesobject 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$periodViewcarries the full card data; every other bucket becomes[].$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. ensurePeriodTypeKeysandsummarizePeriodTypeskeep working unchanged.type_counts(already computed before pagination) keeps the totals per view, so tab counters remain correct.- The cache (
InvoicingBillingPeriodImportPaging → setCachedPeriodPage) stores the fullperiodResultverbatim, 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:
// 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:
/**
* 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_numberso a customer appearing twice in a bucket (rare but possible — multiple PO transactions for the same customer ininvoice_per_order) still only emits one membership. - We keep the existing
ensurePeriodTypeKeys(array_fill_keys) guarantees so consumers that iterateObject.keys(types)still see every view even when the filtered list ends up empty. - The active bucket's structure is unchanged — the front-end
customersInCurrentViewandlist_views_with_customerpaths continue to work as before. type_countsandtype_totalsare computed before pagination (seesummarizePeriodTypes) 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:
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:
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:
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 fromview.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:
expect($result['period']['types']['fixed_pricing'])->toBe([]);
…becomes:
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 entrytypes.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:
{
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
Alletab 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@smokeso 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)
bash scripts/setup.sh(or the appropriate docker compose command) to bring up the API stack.cd /workspace/pleno-vue && npm run dev.- Sign in as a superuser that owns customers spanning multiple categories (fixed_pricing + invoice_per_order, for instance).
- Navigate to Superuser → Fakturaer → Periode, pick a date range.
- On the Alle tab confirm every customer card shows every chip it qualifies for.
- Click into the Faktura pr. ordre tab and confirm the same chips render (sans the active tab's own chip).
- Repeat for Fastpris, Tankrengøring, Wash Subscriptions.
- Apply the search box; chips should update with the filter.
- Toggle the Kræver handling flag tab; chips should narrow to the flagged subset.
- Switch page sizes (10/25/50/100/200/500/all) and confirm chips remain consistent across pages.
- 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 insidephp1container perscripts/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
- Branch: cut
fix/invoicing-period-tag-membershipfrommasterinapiand frompr-296(current dev branch) inpleno-vue. - Backend change (3.1) + new helper + updated/new unit tests (4.1).
- OpenAPI updates (3.2) in both repos.
- Frontend attribute component (3.3) — add the
Setindex, keep the array.some()fallback for back-compat. - E2E mock update (3.4) + extended chip-stacking test (4.3).
- Run the full verification suite (5.2) locally before pushing.
- Open the PR; CI should turn green; Qodana should not flag the new memberships (they are deliberate additive fields).
- 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— addsummarizePeriodCustomerMemberships, populate non-active buckets.services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php— relax line 292, add membership / search / flag-tab tests.openapi.yaml— addInvoicingPeriodCustomerMembership, relaxInvoicingPeriodCustomerrequireds, union-typedtypesitems.
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.