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:
@@ -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],
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user