feat(invoicing-period): surface lightweight customer membership on non-active views (#371)

## Summary

Customer indicator chips (e.g. *Faktura pr. ordre*, *Fastpris*,
*Tankrengøring*) currently only render on the matching view tab because
the period paged response strips customer data from every non-active
view bucket. The front-end therefore cannot determine which other
categories a customer belongs to from the *Alle* tab.

This change projects a deduplicated lightweight customer marker
`{customer_number, membership_only: true}` onto every non-active view
bucket in `applyPeriodPagination`. The active bucket still carries full
customer cards so paginated full-data output, type_counts and
type_totals are unchanged. Filters, search, sort, flag tab and workflow
filters upstream of the membership projection make the non-active
membership set match the active-bucket semantics for the same request.

## Contract change (openapi.yaml)

* New schema `InvoicingPeriodCustomerMembership` with `{
customer_number, membership_only: true }` and `additionalProperties:
false`.
* `InvoicingPeriodData.types[view].items` is now a `oneOf` of
`InvoicingPeriodCustomer` and `InvoicingPeriodCustomerMembership`.
* `InvoicingPeriodCustomer.required` relaxed to `customer_number` only
(other fields are now reported per-view).

## Implementation

* New helper `summarizeNonActiveCustomerMemberships()` projects +
deduplicates by `customer_number`.
* Pagination emits full cards for the active bucket and lightweight
memberships everywhere else.

## Tests

* Updated existing `fixed_pricing` assertion to include the membership
marker.
* Added four new tests:
* default projection across all view buckets (with explicit dedup
assertion),
  * search filter propagation,
  * flag-tab filter propagation,
  * single-customer active-bucket edge case.

All 15 `InvoicingPeriodPaginationTest` tests pass locally (158
assertions).

🤖 Generated by [OpenHands](https://docs.openhands.dev/) on behalf of
copenhagentruckwash.

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Jeppe B
2026-08-13 15:16:25 +02:00
committed by GitHub
co-authored by openhands
parent 0dea2f0b76
commit 51e5c2ed01
3 changed files with 295 additions and 3 deletions
+25 -2
View File
@@ -18548,11 +18548,13 @@ components:
additionalProperties:
type: array
items:
$ref: '#/components/schemas/InvoicingPeriodCustomer'
oneOf:
- $ref: '#/components/schemas/InvoicingPeriodCustomer'
- $ref: '#/components/schemas/InvoicingPeriodCustomerMembership'
InvoicingPeriodCustomer:
type: object
required: [customer_number, customer_name, transactions, invoice_collections]
required: [customer_number]
additionalProperties: true
properties:
customer_number:
@@ -18568,6 +18570,27 @@ components:
items:
$ref: '#/components/schemas/InvoicingPeriodInvoiceCollection'
InvoicingPeriodCustomerMembership:
type: object
description: >-
Lightweight customer marker returned for every non-active view
bucket of the period response. Used by the front-end to render
category indicator chips (e.g. "Faktura pr. ordre") regardless of
which tab the user is currently looking at. Full customer-card
data (transactions, invoice collections, queue, draft, meta)
is intentionally omitted for non-active buckets; 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: boolean
enum: [true]
InvoicingPeriodTransaction:
type: object
required: [id, booked, invoice_state]
@@ -739,6 +739,22 @@ class InvoicingPeriodRoute
$pagedTypes[$periodView] = array_slice($types[$periodView], $offset, $perPage);
}
// Surface lightweight customer memberships for every non-active
// view bucket so the front-end can render category indicator
// chips (e.g. "Faktura pr. ordre") regardless of which tab the
// user is currently looking at. Filters, search, sort, flag tab
// and workflow filters have already been applied to `$types`
// above, so the membership set matches the active bucket's
// semantics for this request.
foreach ($types as $typeName => $customers) {
if ($typeName === $periodView) {
continue;
}
$pagedTypes[$typeName] = self::summarizePeriodCustomerMemberships(
is_array($customers) ? $customers : []
);
}
$period['types'] = $pagedTypes;
$period['type_counts'] = $typeCounts;
$period['type_totals'] = self::summarizePeriodTypeTotals($types);
@@ -1230,6 +1246,41 @@ class InvoicingPeriodRoute
return $counts;
}
/**
* Build a deduplicated list of lightweight `{customer_number}` markers
* for a single non-active view bucket. These entries let the front-end
* know which customers belong to a category without shipping the full
* card (transactions, invoice_collections, queue, draft, meta, …).
*
* Filters, search, sort, flag tab and workflow filters are expected to
* have been applied to `$customers` upstream — we only de-duplicate and
* project the `customer_number` field here.
*
* @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;
}
private static function summarizePeriodTypeTotals(array $types): array
{
$totals = [];
@@ -289,7 +289,9 @@ it('slices only the active period view and keeps exact full-result type counts',
'po' => 'PO-BETA',
'reg_1' => 'BB22222',
]);
expect($result['period']['types']['fixed_pricing'])->toBe([]);
expect($result['period']['types']['fixed_pricing'])->toBe([
['customer_number' => 1002, 'membership_only' => true],
]);
expect($result['period']['type_counts']['all'])->toBe([
'requires_action' => 1,
'draft' => 1,
@@ -687,3 +689,219 @@ it('blocks review from aggregate manual counts when restricted flag details are
'next_action' => 'resolve_manual_flags',
]);
});
it('surfaces lightweight customer memberships on every non-active period view bucket', function (): void {
$period = [
'dateFrom' => '2026-04-01 00:00:00',
'dateTo' => '2026-04-30 23:59:59',
'types' => [
'all' => [
invoicing_period_customer_card(2001, 'Alpha Logistics', [
invoicing_period_transaction(['id' => 21, 'customer_number' => 2001, 'amount' => 100]),
]),
invoicing_period_customer_card(2002, 'Beta Logistics', [
invoicing_period_transaction(['id' => 22, 'customer_number' => 2002, 'amount' => 200]),
]),
invoicing_period_customer_card(2003, 'Gamma Logistics', [
invoicing_period_transaction(['id' => 23, 'customer_number' => 2003, 'amount' => 300]),
]),
],
'fixed_pricing' => [
invoicing_period_customer_card(2001, 'Alpha Logistics', [], false, [
'meta' => ['fixed_pricing' => ['price' => 600]],
]),
],
'invoice_per_order' => [
invoicing_period_customer_card(2002, 'Beta Logistics', [], true),
invoicing_period_customer_card(2002, 'Beta Logistics', [], true),
],
'tank_cleaning' => [
invoicing_period_customer_card(2003, 'Gamma Logistics', [], false),
],
'vehicle_subscriptions' => [],
'special_arrangements' => [],
'possible_duplicates' => [],
'self_wash' => [],
],
];
$result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
'periodView' => 'all',
'page' => 1,
'limit' => 25,
'includeRequiresAction' => true,
'includeBooked' => true,
]]);
// Active bucket still carries full customer cards.
expect($result['period']['types']['all'])->toHaveCount(3);
expect($result['period']['types']['all'][0])->toHaveKey('transactions');
expect($result['period']['types']['all'][0])->toHaveKey('customer_name');
// Non-active buckets expose only {customer_number, membership_only: true} entries.
expect($result['period']['types']['fixed_pricing'])->toBe([
['customer_number' => 2001, 'membership_only' => true],
]);
// Membership entries must de-duplicate by customer_number even when the
// source bucket contains the customer twice.
expect($result['period']['types']['invoice_per_order'])->toBe([
['customer_number' => 2002, 'membership_only' => true],
]);
expect($result['period']['types']['tank_cleaning'])->toBe([
['customer_number' => 2003, 'membership_only' => true],
]);
// Buckets with no matching customers stay as empty arrays.
expect($result['period']['types']['vehicle_subscriptions'])->toBe([]);
expect($result['period']['types']['special_arrangements'])->toBe([]);
expect($result['period']['types']['possible_duplicates'])->toBe([]);
expect($result['period']['types']['self_wash'])->toBe([]);
// Counts and totals remain authoritative and unaffected by pagination.
expect($result['period']['type_counts']['all']['total'])->toBe(3);
// summarizePeriodType counts raw array entries; the duplicate 2002 entry
// in invoice_per_order is therefore reflected in type_counts but our
// membership projector de-duplicates it (asserted above).
expect($result['period']['type_counts']['invoice_per_order']['total'])->toBe(2);
expect($result['period']['type_totals']['fixed_pricing']['total'])->toBe(600.0);
});
it('respects the search filter when emitting customer memberships on non-active buckets', function (): void {
$period = [
'dateFrom' => '2026-04-01 00:00:00',
'dateTo' => '2026-04-30 23:59:59',
'types' => [
'all' => [
invoicing_period_customer_card(3001, 'Alpha', [
invoicing_period_transaction(['id' => 31, 'customer_number' => 3001, 'amount' => 10]),
]),
invoicing_period_customer_card(3002, 'Beta', [
invoicing_period_transaction(['id' => 32, 'customer_number' => 3002, 'amount' => 20]),
]),
invoicing_period_customer_card(3003, 'Gamma', [
invoicing_period_transaction(['id' => 33, 'customer_number' => 3003, 'amount' => 30]),
]),
],
'invoice_per_order' => [
invoicing_period_customer_card(3001, 'Alpha', [], true),
invoicing_period_customer_card(3003, 'Gamma', [], true),
],
'fixed_pricing' => [
invoicing_period_customer_card(3002, 'Beta', [], false),
invoicing_period_customer_card(3003, 'Gamma', [], false),
],
],
];
$result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
'periodView' => 'all',
'page' => 1,
'limit' => 25,
'search' => 'Beta',
'includeRequiresAction' => true,
'includeBooked' => true,
]]);
expect(array_column($result['period']['types']['all'], 'customer_number'))->toBe([3002]);
expect($result['period']['types']['invoice_per_order'])->toBe([]);
expect($result['period']['types']['fixed_pricing'])->toBe([
['customer_number' => 3002, 'membership_only' => true],
]);
});
it('respects the flag-tab filter when emitting customer memberships on non-active buckets', function (): void {
$period = [
'dateFrom' => '2026-04-01 00:00:00',
'dateTo' => '2026-04-30 23:59:59',
'types' => [
'all' => [
invoicing_period_customer_card(4001, 'Red Flag Customer', [
invoicing_period_transaction(['id' => 41, 'customer_number' => 4001, 'amount' => 50]),
], false, [
'flags' => [
[
'source' => 'manual',
'status' => 'active',
'target_type' => 'customer',
],
],
]),
invoicing_period_customer_card(4002, 'Clean Customer', [
invoicing_period_transaction(['id' => 42, 'customer_number' => 4002, 'amount' => 60]),
], false),
],
'invoice_per_order' => [
invoicing_period_customer_card(4001, 'Red Flag Customer', [], true, [
'flags' => [
[
'source' => 'manual',
'status' => 'active',
'target_type' => 'customer',
],
],
]),
invoicing_period_customer_card(4002, 'Clean Customer', [], true),
],
'fixed_pricing' => [
invoicing_period_customer_card(4001, 'Red Flag Customer', [], true, [
'flags' => [
[
'source' => 'manual',
'status' => 'active',
'target_type' => 'customer',
],
],
]),
invoicing_period_customer_card(4002, 'Clean Customer', [], false),
],
],
];
$result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
'periodView' => 'all',
'page' => 1,
'limit' => 25,
'flagTab' => 'red',
'includeRequiresAction' => true,
'includeBooked' => true,
]]);
expect(array_column($result['period']['types']['all'], 'customer_number'))->toBe([4001]);
expect($result['period']['types']['invoice_per_order'])->toBe([
['customer_number' => 4001, 'membership_only' => true],
]);
expect($result['period']['types']['fixed_pricing'])->toBe([
['customer_number' => 4001, 'membership_only' => true],
]);
});
it('emits lightweight memberships on non-active buckets when the active bucket is a single customer', function (): void {
$period = [
'dateFrom' => '2026-04-01 00:00:00',
'dateTo' => '2026-04-30 23:59:59',
'types' => [
'all' => [
invoicing_period_customer_card(5001, 'Solo Customer', [
invoicing_period_transaction(['id' => 51, 'customer_number' => 5001, 'amount' => 80]),
]),
],
'invoice_per_order' => [
invoicing_period_customer_card(5001, 'Solo Customer', [], true),
],
],
];
$result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
'periodView' => 'invoice_per_order',
'page' => 1,
'limit' => 25,
'includeRequiresAction' => true,
'includeBooked' => true,
]]);
expect($result['period']['types']['invoice_per_order'])->toHaveCount(1);
expect($result['period']['types']['invoice_per_order'][0])->toHaveKey('transactions');
expect($result['period']['types']['all'])->toBe([
['customer_number' => 5001, 'membership_only' => true],
]);
});