Files
pleno-vue/docs/invoicing-period-tag-membership-plan.md
T
Jeppe Bandopenhands c207fea61e feat(period): render customer indicator chips on every subpage including Alle (#299)
## 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>
2026-08-13 14:36:23 +02:00

18 KiB
Raw Blame History

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:

    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 [].

    $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:

// 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_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:

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 from view.computed.getViewFriendlyName(...).

3.4 Front-end — e2e mock

tests/e2e/invoicing-period.smoke.spec.jssetupPeriodEndpoints (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 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:

{
  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.vueSet-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.