## 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>
908 lines
34 KiB
PHP
908 lines
34 KiB
PHP
<?php
|
|
|
|
app_require('classes/response.php');
|
|
app_require('routes/InvoicingPeriodRoute.php');
|
|
|
|
use classes\response;
|
|
use routes\InvoicingPeriodRoute;
|
|
|
|
function invoicing_period_pagination_invoke(string $method, array $args = []): mixed
|
|
{
|
|
$reflection = new ReflectionClass(InvoicingPeriodRoute::class);
|
|
$target = $reflection->getMethod($method);
|
|
return $target->invokeArgs(null, $args);
|
|
}
|
|
|
|
function invoicing_period_customer_card(
|
|
int $customerNumber,
|
|
string $customerName,
|
|
array $transactions,
|
|
bool $requiresAction = false,
|
|
array $extra = []
|
|
): array {
|
|
return array_merge([
|
|
'id' => $customerNumber,
|
|
'customer_number' => $customerNumber,
|
|
'customer_name' => $customerName,
|
|
'requires_action' => $requiresAction,
|
|
'transactions' => $transactions,
|
|
'meta' => [],
|
|
'queue' => [
|
|
'has_active_job' => false,
|
|
'statuses' => [],
|
|
'invoice_collection_ids' => [],
|
|
'is_action_blocked' => false,
|
|
],
|
|
'draft' => [
|
|
'has_valid_draft' => false,
|
|
'invoice_collection_ids' => [],
|
|
'is_action_blocked' => false,
|
|
],
|
|
], $extra);
|
|
}
|
|
|
|
function invoicing_period_transaction(array $overrides = []): array
|
|
{
|
|
return array_merge([
|
|
'id' => 9001,
|
|
'date' => '2026-04-10 12:00:00',
|
|
'amount' => 125.5,
|
|
'booked' => false,
|
|
'department_id' => 1,
|
|
'customer_number' => 1001,
|
|
'reference' => 'REF-9001',
|
|
'po' => 'PO-9001',
|
|
'notes' => 'Gate note',
|
|
'reg_1' => 'AB12345',
|
|
'reg_2' => '',
|
|
'reg_3' => '',
|
|
'excluded' => false,
|
|
'invoice_collection_id' => 3001,
|
|
'queue_status' => null,
|
|
'queue_job_id' => null,
|
|
], $overrides);
|
|
}
|
|
|
|
it('detects paginated period mode only when pagination parameters are present', function (): void {
|
|
global $response;
|
|
|
|
$previousResponse = $GLOBALS['response'] ?? null;
|
|
$previousGet = $_GET;
|
|
$previousMethod = $_SERVER['REQUEST_METHOD'] ?? null;
|
|
$previousQueryString = $_SERVER['QUERY_STRING'] ?? null;
|
|
$response = new response();
|
|
|
|
try {
|
|
$_SERVER['REQUEST_METHOD'] = 'GET';
|
|
$_GET = [
|
|
'dateFrom' => '2026-04-01',
|
|
'dateTo' => '2026-04-30',
|
|
];
|
|
|
|
expect(invoicing_period_pagination_invoke('getPeriodPaginationOptionsFromRequest'))->toBeNull();
|
|
|
|
$_GET['flagTab'] = 'yellow';
|
|
expect(invoicing_period_pagination_invoke('getPeriodPaginationOptionsFromRequest'))->toMatchArray([
|
|
'flagTab' => 'yellow',
|
|
]);
|
|
|
|
unset($_GET['flagTab']);
|
|
$_SERVER['QUERY_STRING'] = 'reviewState=blocked&reviewState=ready&departmentId=4%2C8';
|
|
$_GET['reviewState'] = 'ready';
|
|
$_GET['departmentId'] = '4,8';
|
|
expect(invoicing_period_pagination_invoke('getPeriodPaginationOptionsFromRequest'))->toMatchArray([
|
|
'reviewStates' => ['blocked', 'ready'],
|
|
'departmentIds' => [4, 8],
|
|
]);
|
|
|
|
$_SERVER['QUERY_STRING'] = '';
|
|
unset($_GET['reviewState'], $_GET['departmentId']);
|
|
$_GET['page'] = '2';
|
|
expect(invoicing_period_pagination_invoke('getPeriodPaginationOptionsFromRequest'))->toMatchArray([
|
|
'periodView' => 'all',
|
|
'page' => 2,
|
|
'limit' => 100,
|
|
'search' => '',
|
|
'includeRequiresAction' => true,
|
|
'includeBooked' => true,
|
|
]);
|
|
} finally {
|
|
$_GET = $previousGet;
|
|
if ($previousMethod === null) {
|
|
unset($_SERVER['REQUEST_METHOD']);
|
|
} else {
|
|
$_SERVER['REQUEST_METHOD'] = $previousMethod;
|
|
}
|
|
if ($previousQueryString === null) {
|
|
unset($_SERVER['QUERY_STRING']);
|
|
} else {
|
|
$_SERVER['QUERY_STRING'] = $previousQueryString;
|
|
}
|
|
if ($previousResponse === null) {
|
|
unset($GLOBALS['response']);
|
|
} else {
|
|
$response = $previousResponse;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('normalizes workflow filters and deterministic review sorting', function (): void {
|
|
$options = invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[
|
|
'reviewState' => ['blocked,ready', 'unknown'],
|
|
'severity' => 'red,green,invalid',
|
|
'invoiceState' => 'open,economic_booked',
|
|
'departmentId' => ['4,8', '0', '-1'],
|
|
'sort' => 'priority',
|
|
'direction' => 'DESC',
|
|
]]);
|
|
|
|
expect($options)->toMatchArray([
|
|
'reviewStates' => ['blocked', 'ready'],
|
|
'severities' => ['red', 'green'],
|
|
'invoiceStates' => ['open', 'economic_booked'],
|
|
'departmentIds' => [4, 8],
|
|
'sort' => 'priority',
|
|
'direction' => 'desc',
|
|
]);
|
|
});
|
|
|
|
it('normalizes period pagination options and clamps invalid page and limit values', function (): void {
|
|
$options = invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[
|
|
'periodView' => 'not-a-view',
|
|
'page' => '-4',
|
|
'limit' => '500',
|
|
'search' => ' Nordic ',
|
|
'includeRequiresAction' => '0',
|
|
'includeBooked' => 'false',
|
|
'flagTab' => 'invalid-tab',
|
|
]]);
|
|
|
|
expect($options)->toBe([
|
|
'periodView' => 'all',
|
|
'page' => 1,
|
|
'limit' => 500,
|
|
'search' => 'Nordic',
|
|
'flagTab' => 'all',
|
|
'reviewStates' => [],
|
|
'severities' => [],
|
|
'invoiceStates' => [],
|
|
'departmentIds' => [],
|
|
'sort' => 'customer_name',
|
|
'direction' => 'asc',
|
|
'includeRequiresAction' => false,
|
|
'includeBooked' => false,
|
|
]);
|
|
|
|
expect(invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[
|
|
'limit' => '900',
|
|
]]))->toMatchArray([
|
|
'limit' => 500,
|
|
]);
|
|
|
|
expect(invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[
|
|
'limit' => 'all',
|
|
]]))->toMatchArray([
|
|
'limit' => 'all',
|
|
]);
|
|
|
|
expect(invoicing_period_pagination_invoke('normalizePeriodPaginationOptions', [[
|
|
'periodView' => 'invoice_per_order',
|
|
'page' => '3',
|
|
'limit' => '0',
|
|
'flagTab' => 'yellow',
|
|
]]))->toMatchArray([
|
|
'periodView' => 'invoice_per_order',
|
|
'page' => 3,
|
|
'limit' => 100,
|
|
'flagTab' => 'yellow',
|
|
]);
|
|
});
|
|
|
|
it('slices only the active period view and keeps exact full-result type counts', function (): void {
|
|
$period = [
|
|
'dateFrom' => '2026-04-01 00:00:00',
|
|
'dateTo' => '2026-04-30 23:59:59',
|
|
'types' => [
|
|
'all' => [
|
|
invoicing_period_customer_card(1001, 'Alpha Transport', [
|
|
invoicing_period_transaction(['id' => 11, 'customer_number' => 1001, 'amount' => 50]),
|
|
], false),
|
|
invoicing_period_customer_card(1002, 'Beta Transport', [
|
|
invoicing_period_transaction([
|
|
'id' => 12,
|
|
'customer_number' => 1002,
|
|
'amount' => 75,
|
|
'booked' => true,
|
|
'excluded' => true,
|
|
'reference' => 'REF-BETA',
|
|
'po' => 'PO-BETA',
|
|
'reg_1' => 'BB22222',
|
|
]),
|
|
], true),
|
|
invoicing_period_customer_card(1003, 'Gamma Transport', [
|
|
invoicing_period_transaction([
|
|
'id' => 13,
|
|
'customer_number' => 1003,
|
|
'amount' => 125,
|
|
'booked' => true,
|
|
]),
|
|
], false, [
|
|
'draft' => [
|
|
'has_valid_draft' => true,
|
|
'invoice_collection_ids' => [3013],
|
|
'is_action_blocked' => true,
|
|
],
|
|
]),
|
|
],
|
|
'fixed_pricing' => [
|
|
invoicing_period_customer_card(1002, 'Beta Transport', [], true, [
|
|
'meta' => [
|
|
'fixed_pricing' => [
|
|
'price' => 999,
|
|
],
|
|
],
|
|
]),
|
|
],
|
|
'invoice_per_order' => [
|
|
invoicing_period_customer_card(1001, 'Alpha Transport', [], true, [
|
|
'flags' => [
|
|
['source' => 'manual', 'status' => 'active'],
|
|
],
|
|
]),
|
|
],
|
|
],
|
|
];
|
|
|
|
$result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
|
|
'periodView' => 'all',
|
|
'page' => 2,
|
|
'limit' => 1,
|
|
'search' => '',
|
|
'includeRequiresAction' => true,
|
|
'includeBooked' => true,
|
|
]]);
|
|
|
|
expect($result['pagination'])->toMatchArray([
|
|
'page' => 2,
|
|
'per_page' => 1,
|
|
'total' => 3,
|
|
'search' => '',
|
|
]);
|
|
|
|
expect($result['period']['types']['all'])->toHaveCount(1);
|
|
expect($result['period']['types']['all'][0])->toMatchArray([
|
|
'customer_number' => 1002,
|
|
'customer_name' => 'Beta Transport',
|
|
'requires_action' => true,
|
|
'meta' => [
|
|
'fixed_pricing' => [
|
|
'price' => 999,
|
|
],
|
|
],
|
|
]);
|
|
expect($result['period']['types']['all'][0]['transactions'][0])->toMatchArray([
|
|
'id' => 12,
|
|
'amount' => 75,
|
|
'booked' => true,
|
|
'excluded' => true,
|
|
'reference' => 'REF-BETA',
|
|
'po' => 'PO-BETA',
|
|
'reg_1' => 'BB22222',
|
|
]);
|
|
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,
|
|
'manual_flags' => 0,
|
|
'automatic_flags' => 0,
|
|
'completed' => 1,
|
|
'total' => 3,
|
|
]);
|
|
expect($result['period']['type_totals']['all'])->toBe([
|
|
'total' => 1174.0,
|
|
'booked' => 125.0,
|
|
'not_booked' => 1049.0,
|
|
]);
|
|
expect($result['period']['type_totals']['fixed_pricing'])->toBe([
|
|
'total' => 999.0,
|
|
'booked' => 0.0,
|
|
'not_booked' => 999.0,
|
|
]);
|
|
expect($result['period']['type_counts']['invoice_per_order']['manual_flags'])->toBe(1);
|
|
});
|
|
|
|
it('returns the entire active period view when the limit is all', function (): void {
|
|
$period = [
|
|
'dateFrom' => '2026-04-01 00:00:00',
|
|
'dateTo' => '2026-04-30 23:59:59',
|
|
'types' => [
|
|
'all' => [
|
|
invoicing_period_customer_card(1101, 'Alpha', [
|
|
invoicing_period_transaction(['id' => 31, 'customer_number' => 1101]),
|
|
]),
|
|
invoicing_period_customer_card(1102, 'Beta', [
|
|
invoicing_period_transaction(['id' => 32, 'customer_number' => 1102]),
|
|
]),
|
|
invoicing_period_customer_card(1103, 'Gamma', [
|
|
invoicing_period_transaction(['id' => 33, 'customer_number' => 1103]),
|
|
]),
|
|
],
|
|
],
|
|
];
|
|
|
|
$result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
|
|
'periodView' => 'all',
|
|
'page' => 3,
|
|
'limit' => 'all',
|
|
'search' => '',
|
|
'includeRequiresAction' => true,
|
|
'includeBooked' => true,
|
|
]]);
|
|
|
|
expect($result['pagination'])->toMatchArray([
|
|
'page' => 1,
|
|
'per_page' => 'all',
|
|
'total' => 3,
|
|
]);
|
|
expect(array_column($result['period']['types']['all'], 'customer_number'))->toBe([1101, 1102, 1103]);
|
|
});
|
|
|
|
it('searches customer fields and order fields at the customer-card level', function (): void {
|
|
$period = [
|
|
'dateFrom' => '2026-04-01 00:00:00',
|
|
'dateTo' => '2026-04-30 23:59:59',
|
|
'types' => [
|
|
'all' => [
|
|
invoicing_period_customer_card(2001, 'Solaris Fleet', [
|
|
invoicing_period_transaction([
|
|
'id' => 21,
|
|
'customer_number' => 2001,
|
|
'reference' => 'REF-KEEP',
|
|
'po' => 'PO-KEEP',
|
|
]),
|
|
]),
|
|
invoicing_period_customer_card(2002, 'Nordic Logistics', [
|
|
invoicing_period_transaction([
|
|
'id' => 22,
|
|
'customer_number' => 2002,
|
|
'reference' => 'MISS',
|
|
'po' => 'PO-777',
|
|
'notes' => 'Driver waits at gate',
|
|
'reg_1' => 'CD33333',
|
|
]),
|
|
invoicing_period_transaction([
|
|
'id' => 23,
|
|
'customer_number' => 2002,
|
|
'reference' => 'SECOND-LINE',
|
|
]),
|
|
]),
|
|
],
|
|
],
|
|
];
|
|
|
|
$result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
|
|
'periodView' => 'all',
|
|
'page' => 1,
|
|
'limit' => 25,
|
|
'search' => 'po-777',
|
|
'includeRequiresAction' => true,
|
|
'includeBooked' => true,
|
|
]]);
|
|
|
|
expect($result['pagination']['total'])->toBe(1);
|
|
expect($result['period']['types']['all'])->toHaveCount(1);
|
|
expect($result['period']['types']['all'][0]['customer_number'])->toBe(2002);
|
|
expect($result['period']['types']['all'][0]['transactions'])->toHaveCount(2);
|
|
|
|
$registrationMatch = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
|
|
'periodView' => 'all',
|
|
'page' => 1,
|
|
'limit' => 25,
|
|
'search' => 'cd33333',
|
|
'includeRequiresAction' => true,
|
|
'includeBooked' => true,
|
|
]]);
|
|
expect($registrationMatch['period']['types']['all'][0]['customer_name'])->toBe('Nordic Logistics');
|
|
});
|
|
|
|
it('applies requires-action and booked visibility filters before counting and slicing', function (): void {
|
|
$period = [
|
|
'dateFrom' => '2026-04-01 00:00:00',
|
|
'dateTo' => '2026-04-30 23:59:59',
|
|
'types' => [
|
|
'all' => [
|
|
invoicing_period_customer_card(3001, 'Needs Action', [
|
|
invoicing_period_transaction(['customer_number' => 3001, 'booked' => false]),
|
|
], true),
|
|
invoicing_period_customer_card(3002, 'Already Booked', [
|
|
invoicing_period_transaction(['customer_number' => 3002, 'booked' => true]),
|
|
], false),
|
|
invoicing_period_customer_card(3003, 'Still Open', [
|
|
invoicing_period_transaction(['customer_number' => 3003, 'booked' => false]),
|
|
], false),
|
|
],
|
|
],
|
|
];
|
|
|
|
$result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
|
|
'periodView' => 'all',
|
|
'page' => 1,
|
|
'limit' => 25,
|
|
'search' => '',
|
|
'includeRequiresAction' => false,
|
|
'includeBooked' => false,
|
|
]]);
|
|
|
|
expect($result['pagination']['total'])->toBe(1);
|
|
expect($result['period']['type_counts']['all']['total'])->toBe(1);
|
|
expect($result['period']['types']['all'][0]['customer_number'])->toBe(3003);
|
|
});
|
|
|
|
it('filters period flag tabs using active customer-scoped flag counts', function (): void {
|
|
$period = [
|
|
'dateFrom' => '2026-04-01 00:00:00',
|
|
'dateTo' => '2026-04-30 23:59:59',
|
|
'types' => [
|
|
'all' => [
|
|
invoicing_period_customer_card(4001, 'Yellow Order Flag', [
|
|
invoicing_period_transaction(['customer_number' => 4001, 'id' => 41]),
|
|
], false, [
|
|
'flags' => [
|
|
[
|
|
'source' => 'automatic',
|
|
'status' => 'active',
|
|
'target_type' => 'order',
|
|
'order_id' => 41,
|
|
],
|
|
],
|
|
]),
|
|
invoicing_period_customer_card(4002, 'Ignored Manual Flag', [
|
|
invoicing_period_transaction(['customer_number' => 4002, 'id' => 42]),
|
|
], false, [
|
|
'flags' => [
|
|
[
|
|
'source' => 'manual',
|
|
'status' => 'ignored',
|
|
'target_type' => 'customer',
|
|
],
|
|
],
|
|
]),
|
|
invoicing_period_customer_card(4003, 'Red Customer Flag', [
|
|
invoicing_period_transaction(['customer_number' => 4003, 'id' => 43]),
|
|
], false, [
|
|
'flags' => [
|
|
[
|
|
'source' => 'manual',
|
|
'status' => 'active',
|
|
'target_type' => 'customer',
|
|
],
|
|
],
|
|
]),
|
|
],
|
|
],
|
|
];
|
|
|
|
$yellowResult = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
|
|
'periodView' => 'all',
|
|
'page' => 1,
|
|
'limit' => 25,
|
|
'search' => '',
|
|
'flagTab' => 'yellow',
|
|
'includeRequiresAction' => true,
|
|
'includeBooked' => true,
|
|
]]);
|
|
|
|
expect($yellowResult['pagination']['total'])->toBe(1);
|
|
expect(array_column($yellowResult['period']['types']['all'], 'customer_number'))->toBe([4001]);
|
|
|
|
$noneResult = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
|
|
'periodView' => 'all',
|
|
'page' => 1,
|
|
'limit' => 25,
|
|
'search' => '',
|
|
'flagTab' => 'none',
|
|
'includeRequiresAction' => true,
|
|
'includeBooked' => true,
|
|
]]);
|
|
|
|
expect($noneResult['pagination']['total'])->toBe(1);
|
|
expect(array_column($noneResult['period']['types']['all'], 'customer_number'))->toBe([4002]);
|
|
expect($noneResult['period']['type_counts']['all'])->toMatchArray([
|
|
'manual_flags' => 1,
|
|
'automatic_flags' => 1,
|
|
'total' => 3,
|
|
]);
|
|
});
|
|
|
|
it('derives review workflow metadata, facets and priority order without persisted review state', function (): void {
|
|
$period = [
|
|
'types' => [
|
|
'all' => [
|
|
invoicing_period_customer_card(5105, 'Completed', [
|
|
invoicing_period_transaction([
|
|
'customer_number' => 5105,
|
|
'booked' => true,
|
|
'invoice_state' => 'economic_booked',
|
|
'department_id' => 5,
|
|
]),
|
|
]),
|
|
invoicing_period_customer_card(5104, 'Ready', [
|
|
invoicing_period_transaction([
|
|
'customer_number' => 5104,
|
|
'invoice_state' => 'open',
|
|
'department_id' => 4,
|
|
]),
|
|
]),
|
|
invoicing_period_customer_card(5103, 'Queued', [
|
|
invoicing_period_transaction(['customer_number' => 5103, 'invoice_state' => 'closed']),
|
|
], false, [
|
|
'queue' => [
|
|
'has_active_job' => true,
|
|
'job_ids' => [71],
|
|
'statuses' => ['pending'],
|
|
'is_action_blocked' => true,
|
|
],
|
|
]),
|
|
invoicing_period_customer_card(5102, 'Attention', [
|
|
invoicing_period_transaction(['customer_number' => 5102, 'invoice_state' => 'economic_draft']),
|
|
], false, [
|
|
'flags' => [['source' => 'automatic', 'status' => 'active']],
|
|
]),
|
|
invoicing_period_customer_card(5101, 'Blocked', [
|
|
invoicing_period_transaction(['customer_number' => 5101, 'invoice_state' => 'open']),
|
|
], false, [
|
|
'flags' => [['source' => 'manual', 'status' => 'active']],
|
|
'invoice_collections' => [[
|
|
'id' => 9101,
|
|
'state' => 'open',
|
|
'external_id' => 'DRAFT-ERROR-9101',
|
|
'error_message' => 'E-conomic rejected VAT code',
|
|
]],
|
|
]),
|
|
],
|
|
],
|
|
];
|
|
|
|
$result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
|
|
'periodView' => 'all',
|
|
'page' => 1,
|
|
'limit' => 25,
|
|
'sort' => 'priority',
|
|
'direction' => 'asc',
|
|
'includeRequiresAction' => true,
|
|
'includeBooked' => true,
|
|
]]);
|
|
|
|
expect(array_column($result['period']['types']['all'], 'customer_number'))
|
|
->toBe([5101, 5102, 5103, 5104, 5105]);
|
|
expect($result['period']['types']['all'][0]['review'])->toMatchArray([
|
|
'state' => 'blocked',
|
|
'severity' => 'red',
|
|
'next_action' => 'resolve_manual_flags',
|
|
'is_actionable' => true,
|
|
'counts' => [
|
|
'active_manual_flags' => 1,
|
|
'active_automatic_flags' => 0,
|
|
'collection_errors' => 1,
|
|
'active_queue_jobs' => 0,
|
|
'booked_transactions' => 0,
|
|
'unbooked_transactions' => 1,
|
|
],
|
|
]);
|
|
expect(array_column($result['pagination']['facets']['review_states'], 'value'))
|
|
->toBe(['attention', 'blocked', 'completed', 'queued', 'ready']);
|
|
expect($result['pagination']['order'])->toBe(['field' => 'priority', 'direction' => 'asc']);
|
|
});
|
|
|
|
it('filters workflow facets and searches collection identifiers and error details', function (): void {
|
|
$period = [
|
|
'types' => [
|
|
'all' => [
|
|
invoicing_period_customer_card(5201, 'Collection Failure', [
|
|
invoicing_period_transaction([
|
|
'customer_number' => 5201,
|
|
'invoice_state' => 'open',
|
|
'department_id' => 12,
|
|
]),
|
|
], false, [
|
|
'invoice_collections' => [[
|
|
'id' => 44001,
|
|
'invoice_collection_id' => 44001,
|
|
'external_id' => 'EXT-REVIEW-7788',
|
|
'booked_invoice_id' => null,
|
|
'error_message' => 'VAT account missing',
|
|
'state' => 'open',
|
|
]],
|
|
]),
|
|
invoicing_period_customer_card(5202, 'Other Customer', [
|
|
invoicing_period_transaction([
|
|
'customer_number' => 5202,
|
|
'booked' => true,
|
|
'invoice_state' => 'economic_booked',
|
|
'department_id' => 13,
|
|
]),
|
|
]),
|
|
],
|
|
],
|
|
];
|
|
|
|
$searched = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
|
|
'periodView' => 'all',
|
|
'page' => 1,
|
|
'limit' => 25,
|
|
'search' => 'vat account missing',
|
|
'includeRequiresAction' => true,
|
|
'includeBooked' => true,
|
|
]]);
|
|
expect(array_column($searched['period']['types']['all'], 'customer_number'))->toBe([5201]);
|
|
|
|
$filtered = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
|
|
'periodView' => 'all',
|
|
'page' => 1,
|
|
'limit' => 25,
|
|
'reviewStates' => ['completed'],
|
|
'severities' => ['green'],
|
|
'invoiceStates' => ['economic_booked'],
|
|
'departmentIds' => [13],
|
|
'sort' => 'customer_number',
|
|
'direction' => 'asc',
|
|
'includeRequiresAction' => true,
|
|
'includeBooked' => true,
|
|
]]);
|
|
expect($filtered['pagination']['total'])->toBe(1)
|
|
->and($filtered['period']['types']['all'][0]['customer_number'])->toBe(5202)
|
|
->and($filtered['pagination']['filters'])->toMatchArray([
|
|
'reviewState' => ['completed'],
|
|
'severity' => ['green'],
|
|
'invoiceState' => ['economic_booked'],
|
|
'departmentId' => [13],
|
|
]);
|
|
});
|
|
|
|
it('blocks review from aggregate manual counts when restricted flag details are absent', function (): void {
|
|
$period = [
|
|
'types' => [
|
|
'all' => [
|
|
invoicing_period_customer_card(5301, 'Restricted Flag Details', [
|
|
invoicing_period_transaction(['customer_number' => 5301]),
|
|
], false, [
|
|
'flag_counts' => ['manual' => 2, 'automatic' => 0, 'total' => 2],
|
|
]),
|
|
],
|
|
],
|
|
];
|
|
|
|
$result = invoicing_period_pagination_invoke('applyPeriodPagination', [$period, [
|
|
'periodView' => 'all',
|
|
'page' => 1,
|
|
'limit' => 25,
|
|
'includeRequiresAction' => true,
|
|
'includeBooked' => true,
|
|
]]);
|
|
|
|
expect($result['period']['types']['all'][0])->not->toHaveKey('flags')
|
|
->and($result['period']['types']['all'][0]['review'])->toMatchArray([
|
|
'state' => 'blocked',
|
|
'severity' => 'red',
|
|
'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],
|
|
]);
|
|
});
|