diff --git a/openapi.yaml b/openapi.yaml index 1be1b5a5..aa598aac 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -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] diff --git a/services/nginx/app/routes/InvoicingPeriodRoute.php b/services/nginx/app/routes/InvoicingPeriodRoute.php index c094b83f..0119eb6c 100644 --- a/services/nginx/app/routes/InvoicingPeriodRoute.php +++ b/services/nginx/app/routes/InvoicingPeriodRoute.php @@ -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> $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; + } + private static function summarizePeriodTypeTotals(array $types): array { $totals = []; diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php index 39f190e7..0e4eca27 100644 --- a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php @@ -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], + ]); +});