Files
api/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php
T
Jeppe B d81634033e fix(api): do not raise historical_primary_product_mismatch for single-tractor orders (#363)
Partition primary rows by reg_2 presence in getPrimaryProductHistory() so a single-tractor order (reg_2 empty) is compared only against other single-tractor orders, not against historical tractor-trailer orders. 2 new tests + 1 updated signature contract test in InvoicePeriodFlagServiceTest.
2026-08-11 07:39:00 +02:00

1317 lines
47 KiB
PHP

<?php
app_require('classes/invoice_period_flag_service.php');
use classes\invoice_period_flag_service;
function invoice_period_flag_service_instance(): invoice_period_flag_service
{
$reflection = new ReflectionClass(invoice_period_flag_service::class);
/** @var invoice_period_flag_service $service */
$service = $reflection->newInstanceWithoutConstructor();
return $service;
}
function invoice_period_flag_service_invoke(string $method, array $args = []): mixed
{
$service = invoice_period_flag_service_instance();
$reflection = new ReflectionClass(invoice_period_flag_service::class);
$target = $reflection->getMethod($method);
return $target->invokeArgs($service, $args);
}
it('normalizes invoice period date ranges to full-day timestamps before cache-backed reads', function (): void {
expect(invoice_period_flag_service_invoke('normalizePeriodDateRange', ['2025-03-01', '2025-03-31']))
->toBe(['2025-03-01 00:00:00', '2025-03-31 23:59:59']);
expect(invoice_period_flag_service_invoke('normalizePeriodDateRange', [
'2025-03-01 00:00:00',
'2025-03-31 23:59:59',
]))->toBe(['2025-03-01 00:00:00', '2025-03-31 23:59:59']);
});
it('canonicalizes invoice period redis keys for bare dates and normalized API timestamps', function (): void {
$reflection = new ReflectionClass(\classes\redis::class);
$redis = $reflection->newInstanceWithoutConstructor();
$key = $reflection->getMethod('invoicePeriodCacheKey');
expect($key->invoke($redis, 'invoice_period_automatic_flags', '2025-03-01', '2025-03-31'))
->toBe($key->invoke(
$redis,
'invoice_period_automatic_flags',
'2025-03-01 00:00:00',
'2025-03-31 23:59:59'
));
});
it('builds deterministic automatic flag fingerprints and interactive price message parts', function (): void {
$row = [
'customer_number' => 424242,
'customer_name' => 'Flagged Customer',
'order_id' => 9001,
'order_item_id' => 7001,
'invoice_collection_id' => 3001,
];
$params = [
'product' => 'Spot Free',
'expected_price' => 81,
'actual_price' => 99,
];
$context = [
'department_id' => 1,
'order_id' => 9001,
'order_item_id' => 7001,
];
$flag = invoice_period_flag_service_invoke('automaticFlag', [
'price_mismatch',
'order_item_field',
7001,
'price',
$row,
$params,
$context,
]);
$sameFlag = invoice_period_flag_service_invoke('automaticFlag', [
'price_mismatch',
'order_item_field',
7001,
'price',
$row,
$params,
$context,
]);
$changedPriceFlag = invoice_period_flag_service_invoke('automaticFlag', [
'price_mismatch',
'order_item_field',
7001,
'price',
$row,
[
...$params,
'actual_price' => 100,
],
$context,
]);
expect($flag['fingerprint'])->toBe($sameFlag['fingerprint']);
expect($flag['fingerprint'])->not->toBe($changedPriceFlag['fingerprint']);
expect($flag['id'])->toBe('auto:' . $flag['fingerprint']);
expect($flag['message_key'])->toBe('invoice_period.flags.automatic.price_mismatch');
expect($flag['message'])->toBe('Spot Free product price differs from expected.');
expect($flag['message_parts'])->toBe([
['type' => 'order_item', 'text' => 'Spot Free'],
['type' => 'text', 'text' => ' product price differs from '],
['type' => 'expected_price', 'text' => 'expected'],
['type' => 'text', 'text' => '.'],
]);
});
it('builds interactive message parts for order and wash certificate warnings', function (): void {
$row = [
'customer_number' => 424242,
'customer_name' => 'Flagged Customer',
'order_id' => 9001,
'order_item_id' => 7001,
'invoice_collection_id' => 3001,
];
$orderFlag = invoice_period_flag_service_invoke('automaticFlag', [
'multiple_identical_primary_vehicle_items',
'order',
9001,
null,
$row,
[],
['department_id' => 1, 'order_id' => 9001],
]);
$washCertificateFlag = invoice_period_flag_service_invoke('automaticFlag', [
'wash_certificate_item_without_certificate',
'order_item',
7001,
null,
$row,
[],
['department_id' => 1, 'order_id' => 9001, 'order_item_id' => 7001],
]);
$xlVaskFlag = invoice_period_flag_service_invoke('automaticFlag', [
'xlvask_missing_order_link',
'xlvask_usage_log',
55,
null,
[
'customer_number' => 424242,
'customer_name' => 'Flagged Customer',
'xlvask_usage_log_id' => 55,
],
[
'wash_id' => 'wash-55',
'registration_number' => 'AB12345',
],
[
'customer_number' => 424242,
'customer_name' => 'Flagged Customer',
'xlvask_usage_log_id' => 55,
'wash_id' => 'wash-55',
'registration_number' => 'AB12345',
'start_time' => '2026-05-11 10:00:00',
],
]);
expect($orderFlag['message_parts'])->toBe([
['type' => 'order', 'text' => 'Order'],
['type' => 'text', 'text' => ' contains multiple identical primary vehicle items.'],
]);
expect($washCertificateFlag['message_parts'])->toBe([
['type' => 'order_item', 'text' => 'Wash certificate item'],
['type' => 'text', 'text' => ' is present without a wash certificate.'],
]);
expect($xlVaskFlag['message_parts'])->toBe([
['type' => 'xlvask_usage_log', 'text' => 'XL Vask wash'],
['type' => 'text', 'text' => ' is neither ignored nor linked to an order in the selected period.'],
]);
});
it('includes order item preview context for required order field warnings', function (): void {
global $db;
$hadDb = array_key_exists('db', $GLOBALS);
$previousDb = $GLOBALS['db'] ?? null;
$db = new class {
public array $queries = [];
public function query(string $sql): object|false
{
$this->queries[] = $sql;
if (str_contains($sql, 'FROM order_items')) {
return $this->result([
[
'id' => 91,
'order_id' => 61415,
'product_id' => 3,
'reference' => '',
'notes' => '',
'price' => 649,
'quantity' => 1,
'related_item_id' => 0,
'product_name' => 'Forvogn',
'product_base_price' => 649,
],
[
'id' => 92,
'order_id' => 61415,
'product_id' => 4,
'reference' => '',
'notes' => '',
'price' => 599,
'quantity' => 1,
'related_item_id' => 0,
'product_name' => 'Trailer',
'product_base_price' => 599,
],
]);
}
return false;
}
public function fetch_all(object $result): array
{
return $result->rows;
}
private function result(array $rows): object
{
return new class($rows) {
public int $num_rows;
public array $rows;
public function __construct(array $rows)
{
$this->rows = $rows;
$this->num_rows = count($rows);
}
public function fetch_assoc(): ?array
{
return array_shift($this->rows);
}
};
}
};
try {
$row = [
'customer_number' => 424242,
'customer_name' => 'Flagged Customer',
'order_id' => 61415,
'order_item_id' => 91,
'invoice_collection_id' => 3001,
'department_id' => 5,
'order_reference' => '',
'order_po' => '',
'reg_1' => 'EC21233',
];
$flags = invoice_period_flag_service_invoke('detectCustomerRuleViolations', [
[$row],
[424242 => ['requiresReferenceNumber' => true, 'usePONumbers' => true]],
]);
$byDefinition = [];
foreach ($flags as $flag) {
$byDefinition[$flag['definition_key']] = $flag;
}
expect($byDefinition['customer_rule_requires_reference'] ?? null)->not->toBeNull();
expect($byDefinition['customer_rule_requires_po_number'] ?? null)->not->toBeNull();
expect($byDefinition['customer_rule_requires_reference']['context']['order_items'])->toHaveCount(2);
expect($byDefinition['customer_rule_requires_reference']['context']['order_items'][0]['product_name'])->toBe('Forvogn');
expect($byDefinition['customer_rule_requires_po_number']['context']['order_items'][1]['product_name'])->toBe('Trailer');
} finally {
if ($hadDb) {
$db = $previousDb;
} else {
unset($GLOBALS['db']);
}
}
});
it('allows tank cleaning products for only tank cleaning customers', function (): void {
$baseRow = [
'customer_number' => 424242,
'customer_name' => 'Tank Customer',
'order_id' => 61426,
'invoice_collection_id' => 3090,
'department_id' => 5,
'related_item_id' => 0,
'item_price' => 100,
'order_reference' => 'REF',
'order_po' => 'PO',
'reg_1' => 'NI465',
];
$tankCleaningRow = $baseRow + [
'order_item_id' => 801,
'product_id' => 30,
'product_name' => 'Tank cleaning 4 spulehoveder',
'product_category' => 5,
'category_name' => 'Tank cleaning',
];
$tankCleaningAddonRow = $baseRow + [
'order_item_id' => 802,
'product_id' => 33,
'product_name' => 'Saebe/kemi, 1-4 spulehoveder',
'product_category' => 5,
'category_name' => 'Tank cleaning',
];
$washRow = $baseRow + [
'order_item_id' => 803,
'product_id' => 3,
'product_name' => 'Forvogn',
'product_category' => 1,
'category_name' => 'Vask',
];
$onlyTankCleaningFlags = invoice_period_flag_service_invoke('detectCustomerRuleViolations', [
[$tankCleaningRow, $tankCleaningAddonRow, $washRow],
[424242 => [
'onlyTankCleaning' => true,
'__disabled_products' => [3 => ['onlyTankCleaning' => true]],
]],
]);
expect(array_column($onlyTankCleaningFlags, 'definition_key'))->toBe(['customer_rule_only_tank_cleaning']);
expect($onlyTankCleaningFlags[0]['target_id'])->toBe(803);
$restrictedTankCleaningFlags = invoice_period_flag_service_invoke('detectCustomerRuleViolations', [
[$tankCleaningRow, $washRow],
[424242 => [
'restrictTankCleaning' => true,
'__disabled_products' => [30 => ['restrictTankCleaning' => true]],
]],
]);
expect(array_column($restrictedTankCleaningFlags, 'definition_key'))->toBe(['customer_rule_restrict_tank_cleaning']);
expect($restrictedTankCleaningFlags[0]['target_id'])->toBe(801);
});
it('flags every exactly configured product regardless of standalone or addon context', function (): void {
$baseRow = [
'customer_number' => 424242,
'customer_name' => 'Additional Services Customer',
'order_id' => 61427,
'invoice_collection_id' => 3091,
'department_id' => 5,
'item_price' => 100,
'order_reference' => 'REF',
'order_po' => 'PO',
'reg_1' => 'AB12345',
];
$standaloneCategoryEight = $baseRow + [
'order_item_id' => 811,
'product_id' => 91,
'product_name' => 'Extra detergent',
'product_category' => 8,
'category_name' => 'Tillægsydelser',
'related_item_id' => null,
];
$relatedCategoryEight = $baseRow + [
'order_item_id' => 812,
'product_id' => 71,
'product_name' => 'Interior rinse',
'product_category' => 8,
'category_name' => 'Tillægsydelser',
'related_item_id' => 810,
];
$namedCategoryFourAddon = $baseRow + [
'order_item_id' => 813,
'product_id' => 63,
'product_name' => 'Trailer add-on',
'product_category' => 4,
'category_name' => 'Addons',
'related_item_id' => null,
];
$legacyCategoryName = $baseRow + [
'order_item_id' => 814,
'product_id' => 94,
'product_name' => 'Legacy additional service',
'product_category' => 18,
'category_name' => 'TILLÆGSYDELSER',
'related_item_id' => null,
];
$flags = invoice_period_flag_service_invoke('detectCustomerRuleViolations', [
[$standaloneCategoryEight, $relatedCategoryEight, $namedCategoryFourAddon, $legacyCategoryName],
[424242 => [
'restrictAdditionalServices' => true,
'__disabled_products' => [
91 => ['restrictAdditionalServices' => true],
71 => ['restrictAdditionalServices' => true],
94 => ['restrictAdditionalServices' => true],
],
]],
]);
expect(array_column($flags, 'definition_key'))->toBe([
'customer_rule_restrict_addon_services',
'customer_rule_restrict_addon_services',
'customer_rule_restrict_addon_services',
]);
expect(array_column($flags, 'target_id'))->toBe([811, 812, 814]);
});
it('does not flag interior wash variants as historical primary product mismatches', function (): void {
global $db;
$hadDb = array_key_exists('db', $GLOBALS);
$previousDb = $GLOBALS['db'] ?? null;
$db = new class {
public function escape_string(string $value): string
{
return addslashes($value);
}
public function query(string $sql): object|false
{
if (str_contains($sql, 'SHOW COLUMNS FROM `customer_vehicles`')) {
return $this->result([]);
}
if (str_contains($sql, 'FROM customer_vehicles')) {
return $this->result([]);
}
if (str_contains($sql, 'FROM orders o') && str_contains($sql, 'GROUP BY UPPER(TRIM(o.reg_1))')) {
return $this->result([
[
'reg' => 'CN96636',
'product_id' => 3,
'product_name' => 'Forvogn',
'usage_count' => 5,
],
]);
}
return false;
}
private function result(array $rows): object
{
return new class($rows) {
public int $num_rows;
public array $rows;
public function __construct(array $rows)
{
$this->rows = $rows;
$this->num_rows = count($rows);
}
public function fetch_assoc(): ?array
{
return array_shift($this->rows);
}
};
}
};
try {
$baseRow = [
'customer_number' => 424242,
'customer_name' => 'History Customer',
'order_id' => 61311,
'order_item_id' => 901,
'invoice_collection_id' => 16912,
'department_id' => 7,
'reg_1' => 'CN96636',
'is_wash' => 1,
'related_item_id' => 0,
'order_created_at' => '2026-05-11 08:05:21',
];
$interiorVariantFlags = invoice_period_flag_service_invoke('detectVehicleTypeMismatches', [
[
$baseRow + [
'product_id' => 99,
'product_name' => 'Indvendig vask Forvogn',
],
],
'2026-05-11 00:00:00',
]);
expect($interiorVariantFlags)->toBe([]);
$mismatchFlags = invoice_period_flag_service_invoke('detectVehicleTypeMismatches', [
[
$baseRow + [
'product_id' => 17,
'product_name' => 'Bus',
],
],
'2026-05-11 00:00:00',
]);
expect(array_column($mismatchFlags, 'definition_key'))->toBe(['historical_primary_product_mismatch']);
} finally {
if ($hadDb) {
$db = $previousDb;
} else {
unset($GLOBALS['db']);
}
}
});
it('does not report duplicate primary vehicle products from duplicated detector rows for the same order item', function (): void {
$row = [
'customer_number' => 424242,
'customer_name' => 'Flagged Customer',
'order_id' => 9001,
'order_item_id' => 7001,
'product_id' => 3,
'product_name' => 'Forvogn',
'category_name' => 'Vask',
'is_wash' => 1,
'related_item_id' => 0,
'item_quantity' => 1,
'max_quantity_per_order' => null,
'safety_seal' => '',
'order_created_at' => '2026-05-11 10:00:00',
'reg_1' => 'AB12345',
];
$flags = invoice_period_flag_service_invoke('detectAbnormalQuantities', [
[$row, $row],
'2026-05-11 00:00:00',
'2026-05-11 23:59:59',
]);
expect(array_column($flags, 'definition_key'))->not->toContain('multiple_identical_primary_vehicle_items');
});
it('uses attached wash certificate documents instead of safety seal text for certificate presence', function (): void {
$row = [
'customer_number' => 424242,
'customer_name' => 'Flagged Customer',
'order_id' => 9001,
'order_item_id' => 7001,
'product_id' => 41,
'product_name' => 'Vaskecertifikat - Safety Seal',
'category_name' => 'Tillæg',
'is_wash' => 0,
'related_item_id' => 0,
'item_quantity' => 1,
'max_quantity_per_order' => null,
'safety_seal' => '',
'has_wash_certificate_attachment' => 1,
'order_created_at' => '2026-05-11 10:00:00',
'reg_1' => 'AB12345',
];
$flags = invoice_period_flag_service_invoke('detectAbnormalQuantities', [
[$row],
'2026-05-11 00:00:00',
'2026-05-11 23:59:59',
]);
expect(array_column($flags, 'definition_key'))->not->toContain('wash_certificate_item_without_certificate');
});
it('loads wash certificate attachment presence from order attachment content', function (): void {
global $db;
$hadDb = array_key_exists('db', $GLOBALS);
$previousDb = $GLOBALS['db'] ?? null;
$db = new class {
public array $queries = [];
public function escape_string(string $value): string
{
return addslashes($value);
}
public function query(string $sql)
{
$this->queries[] = $sql;
if (str_contains($sql, 'SHOW TABLES LIKE')) {
return $this->result([['table' => 'object_attachments']]);
}
if (str_contains($sql, 'FROM object_attachments')) {
return $this->result([
['object_id' => 9001, 'content' => json_encode(['other' => 'WASH_CERTIFICATE'])],
['object_id' => 9002, 'content' => json_encode(['other' => 'invoice'])],
]);
}
return false;
}
private function result(array $rows): object
{
return new class($rows) {
public int $num_rows;
private array $rows;
public function __construct(array $rows)
{
$this->rows = $rows;
$this->num_rows = count($rows);
}
public function fetch_assoc(): ?array
{
return array_shift($this->rows);
}
};
}
};
try {
$attached = invoice_period_flag_service_invoke('getWashCertificateAttachmentOrderIds', [[9001, 9002, 9001]]);
expect($attached)->toBe([9001 => true]);
expect($db->queries[1])->toContain("object_type IN ('orders','`orders`')");
expect($db->queries[1])->toContain('deleted_at IS NULL');
} finally {
if ($hadDb) {
$db = $previousDb;
} else {
unset($GLOBALS['db']);
}
}
});
it('uses the highest customer-specific discount in expected price breakdowns', function (): void {
$row = [
'customer_number' => 0,
'product_base_price' => 150,
'department_price' => 100,
'product_discount_percentage' => 5,
'category_discount_percentage' => 12,
'apply_category_discount' => 1,
];
$expected = invoice_period_flag_service_invoke('calculateExpectedPrice', [$row]);
$breakdown = invoice_period_flag_service_invoke('priceBreakdown', [$row, $expected]);
expect($expected)->toBe(88);
expect($breakdown)->toMatchArray([
'product_price' => 150,
'department_price' => 100,
'effective_base_price' => 100,
'product_discount_percentage' => 5,
'category_discount_percentage' => 12,
'economic_customer_discount_percentage' => 0,
'applied_discount_percentage' => 12,
'expected_price' => 88,
]);
});
it('uses a product fixed price before customer discounts in expected price breakdowns', function (): void {
$row = [
'customer_number' => 0,
'product_base_price' => 1000,
'department_price' => null,
'product_fixed_price' => 350,
'product_discount_percentage' => 10,
'category_discount_percentage' => 80,
'apply_category_discount' => 1,
];
$expected = invoice_period_flag_service_invoke('calculateExpectedPrice', [$row]);
$breakdown = invoice_period_flag_service_invoke('priceBreakdown', [$row, $expected]);
expect($expected)->toBe(350);
expect($breakdown)->toMatchArray([
'product_price' => 1000,
'effective_base_price' => 1000,
'product_fixed_price' => 350,
'product_discount_percentage' => 10,
'category_discount_percentage' => 80,
'economic_customer_discount_percentage' => 0,
'applied_discount_percentage' => 0,
'expected_price' => 350,
]);
});
it('uses a preloaded e-conomic global discount in expected price breakdowns', function (): void {
$service = invoice_period_flag_service_instance();
$reflection = new ReflectionClass(invoice_period_flag_service::class);
$cache = $reflection->getProperty('economicCustomerDiscountCache');
$cache->setValue($service, [
35131752 => 18,
]);
$calculate = $reflection->getMethod('calculateExpectedPrice');
$breakdownMethod = $reflection->getMethod('priceBreakdown');
$row = [
'customer_number' => 35131752,
'user_id' => 411,
'product_base_price' => 100,
'department_price' => null,
'product_discount_percentage' => 5,
'category_discount_percentage' => 12,
'apply_category_discount' => 1,
];
$expected = $calculate->invoke($service, $row);
$breakdown = $breakdownMethod->invoke($service, $row, $expected);
expect($expected)->toBe(82);
expect($breakdown)->toMatchArray([
'effective_base_price' => 100,
'product_discount_percentage' => 5,
'category_discount_percentage' => 12,
'economic_customer_discount_percentage' => 18,
'applied_discount_percentage' => 18,
'expected_price' => 82,
]);
});
it('uses the custom-only sentinel without discounts when department price is missing', function (): void {
$row = [
'customer_number' => 35131752,
'user_id' => 411,
'product_base_price' => 100,
'department_price' => null,
'department_custom_pricing_only' => 1,
'product_discount_percentage' => 50,
'category_discount_percentage' => 25,
'apply_category_discount' => 0,
];
$expected = invoice_period_flag_service_invoke('calculateExpectedPrice', [$row]);
$breakdown = invoice_period_flag_service_invoke('priceBreakdown', [$row, $expected]);
expect($expected)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE);
expect($breakdown)->toMatchArray([
'product_price' => \objects\products_o::CUSTOM_PRICING_MISSING_PRICE,
'department_price' => null,
'effective_base_price' => \objects\products_o::CUSTOM_PRICING_MISSING_PRICE,
'product_discount_percentage' => 50,
'category_discount_percentage' => 0,
'applied_discount_percentage' => 0,
'expected_price' => \objects\products_o::CUSTOM_PRICING_MISSING_PRICE,
]);
});
it('does not report a price mismatch when a product-specific discount makes the expected price zero', function (): void {
$row = [
'customer_number' => 35131752,
'customer_name' => 'BHS Logistics A/S',
'order_id' => 61359,
'order_item_id' => 7701,
'invoice_collection_id' => 16891,
'department_id' => 1,
'product_id' => 24,
'product_name' => 'Spot Free- Lastbil',
'product_base_price' => 39,
'department_price' => null,
'product_discount_percentage' => 100,
'category_discount_percentage' => 0,
'apply_category_discount' => 0,
'item_price' => 0,
'item_quantity' => 1,
'item_include_in_invoice' => 1,
];
$expected = invoice_period_flag_service_invoke('calculateExpectedPrice', [$row]);
$breakdown = invoice_period_flag_service_invoke('priceBreakdown', [$row, $expected]);
$flags = invoice_period_flag_service_invoke('detectPriceMismatches', [[$row]]);
expect($expected)->toBe(0);
expect($breakdown)->toMatchArray([
'product_price' => 39,
'effective_base_price' => 39,
'product_discount_percentage' => 100,
'applied_discount_percentage' => 100,
'expected_price' => 0,
]);
expect($flags)->toBe([]);
});
it('does not treat subset primary vehicle product names as equivalent', function (): void {
expect(invoice_period_flag_service_invoke('primaryVehicleProductsMatch', [5, 'Forvogn', 6, 'Forvogn med hænger']))
->toBeFalse()
->and(invoice_period_flag_service_invoke('primaryVehicleProductsMatch', [10, 'Indvendig vask Kassevogn', 11, 'Kassevogn']))
->toBeTrue();
});
it('rebuilds automatic invoice period data on cache misses instead of hiding warnings', function (): void {
$content = file_get_contents(app_path('classes/invoice_period_flag_service.php'));
expect($content)->not->toBeFalse();
$content = (string)$content;
expect($content)->toContain('$flags = $this->calculateAutomaticFlagsForPeriod($dateFrom, $dateTo);');
expect($content)->toContain('$rows = $this->fetchOrderItemRowsFromDb($dateFrom, $dateTo);');
expect($content)->toContain('cache_invoice_period_order_item_rows($dateFrom, $dateTo, $rows)');
expect($content)->not->toContain('enqueue_invoice_period_warming($dateFrom, $dateTo)');
});
it('preloads and caches missing e-conomic discounts before price mismatch detection', function (): void {
$content = file_get_contents(app_path('classes/invoice_period_flag_service.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('$this->preloadEconomicCustomerDiscounts($rows);');
expect($content)->toContain('private function preloadEconomicCustomerDiscounts(array $rows): void');
expect($content)->toContain("constant('redis')->get_economic_customer_discount_percentage(\$userId)");
expect($content)->toContain('getCustomerDiscountPercentage($customerNumber)');
expect($content)->toContain("constant('redis')->cache_economic_customer_discount_percentage(\$userId, \$discount)");
});
it('seeds order item preview cache from period rows', function (): void {
$service = invoice_period_flag_service_instance();
$reflection = new ReflectionClass(invoice_period_flag_service::class);
$seed = $reflection->getMethod('seedOrderItemsPreviewCacheFromRows');
$preview = $reflection->getMethod('getOrderItemsForPreview');
$seed->invoke($service, [
[
'order_id' => 9001,
'order_item_id' => 13,
'product_id' => 102,
'product_name' => 'Addon',
'item_quantity' => 2,
'item_price' => 25,
'related_item_id' => 12,
],
[
'order_id' => 9001,
'order_item_id' => 12,
'product_id' => 101,
'product_name' => 'Wash',
'item_quantity' => 1,
'item_price' => 100,
'related_item_id' => null,
],
[
'order_id' => 9002,
'order_item_id' => null,
],
]);
expect($preview->invoke($service, 9001))->toBe([
[
'id' => 12,
'product_id' => 101,
'product_name' => 'Wash',
'quantity' => 1,
'price' => 100,
],
[
'id' => 13,
'product_id' => 102,
'product_name' => 'Addon',
'quantity' => 2,
'price' => 25,
],
])->and($preview->invoke($service, 9002))->toBe([]);
});
it('sorts manual flags before automatic warnings and preserves legacy circle indicators without flags', function (): void {
$manual = [
'id' => 12,
'source' => 'manual',
'status' => 'active',
'created_at' => '2026-05-11 10:00:00',
];
$automatic = [
'id' => 'auto:abc',
'source' => 'automatic',
'status' => 'active',
'fingerprint' => 'abc',
];
$resolvedManual = [
'id' => 13,
'source' => 'manual',
'status' => 'resolved',
'created_at' => '2026-05-11 11:00:00',
];
$falsePositiveAutomatic = [
'id' => 'auto:def',
'source' => 'automatic',
'status' => 'false_positive',
'fingerprint' => 'def',
];
$flags = [$automatic, $manual];
usort($flags, static fn(array $a, array $b): int => invoice_period_flag_service_invoke('sortFlags', [$a, $b]));
expect($flags[0]['source'])->toBe('manual');
expect(invoice_period_flag_service_invoke('countFlags', [$flags]))->toBe([
'manual' => 1,
'automatic' => 1,
'total' => 2,
]);
expect(invoice_period_flag_service_invoke('countFlags', [[$resolvedManual, $falsePositiveAutomatic]]))->toBe([
'manual' => 0,
'automatic' => 0,
'total' => 0,
]);
expect(invoice_period_flag_service_invoke('statusIndicatorForCustomer', [['requires_action' => true], [$manual]]))
->toBe('flag_red');
expect(invoice_period_flag_service_invoke('statusIndicatorForCustomer', [['requires_action' => true], [$automatic]]))
->toBe('flag_yellow');
expect(invoice_period_flag_service_invoke('statusIndicatorForCustomer', [
['requires_action' => true],
[$resolvedManual, $falsePositiveAutomatic],
]))->toBe('circle_red');
expect(invoice_period_flag_service_invoke('statusIndicatorForCustomer', [['requires_action' => true], []]))
->toBe('circle_red');
expect(invoice_period_flag_service_invoke('statusIndicatorForCustomer', [['requires_action' => false], []]))
->toBe('circle_green');
expect(invoice_period_flag_service_invoke('statusIndicatorForCustomer', [
['requires_action' => false, 'draft' => ['is_action_blocked' => true]],
[],
]))->toBe('circle_yellow');
});
it('scopes invoice period flags to the customer card that can render them', function (): void {
$customer = [
'customer_number' => 424242,
'transactions' => [
['id' => 61311, 'invoice_collection_id' => 16912],
],
];
expect(invoice_period_flag_service_invoke('flagBelongsToCustomerCard', [
$customer,
['status' => 'active', 'target_type' => 'order_item_field', 'order_id' => 61311],
[61311 => true],
[16912 => true],
'all',
]))->toBeTrue();
expect(invoice_period_flag_service_invoke('flagBelongsToCustomerCard', [
$customer,
['status' => 'active', 'target_type' => 'order', 'order_id' => 99999],
[61311 => true],
[16912 => true],
'all',
]))->toBeFalse();
expect(invoice_period_flag_service_invoke('flagBelongsToCustomerCard', [
$customer,
['status' => 'active', 'target_type' => 'collected_order_invoice', 'target_id' => 16912],
[61311 => true],
[16912 => true],
'all',
]))->toBeTrue();
expect(invoice_period_flag_service_invoke('flagBelongsToCustomerCard', [
$customer,
['status' => 'active', 'target_type' => 'xlvask_usage_log', 'customer_number' => 424242],
[61311 => true],
[16912 => true],
'vehicle_subscriptions',
]))->toBeFalse();
expect(invoice_period_flag_service_invoke('flagBelongsToCustomerCard', [
$customer,
['status' => 'active', 'target_type' => 'xlvask_usage_log', 'customer_number' => 424242],
[61311 => true],
[16912 => true],
'all',
]))->toBeTrue();
});
it('keeps order item preview context compact for the period response', function (): void {
global $db;
$hadDb = array_key_exists('db', $GLOBALS);
$previousDb = $GLOBALS['db'] ?? null;
$db = new class {
public string $lastQuery = '';
public function query(string $sql): object
{
$this->lastQuery = $sql;
return (object)['ok' => true];
}
public function fetch_all(object $result): array
{
return [[
'id' => '7001',
'order_id' => '61311',
'product_id' => '3',
'reference' => 'REF',
'notes' => str_repeat('x', 1024),
'price' => '649',
'quantity' => '1',
'related_item_id' => '0',
'product_name' => 'Forvogn',
'product_base_price' => '649',
]];
}
};
try {
$rows = invoice_period_flag_service_invoke('getOrderItemsForPreview', [61311]);
expect($rows)->toBe([[
'id' => 7001,
'product_id' => 3,
'product_name' => 'Forvogn',
'quantity' => 1,
'price' => 649,
]]);
expect($db->lastQuery)->not->toContain('oi.reference');
expect($db->lastQuery)->not->toContain('oi.notes');
expect($db->lastQuery)->not->toContain('p.price AS product_base_price');
} finally {
if ($hadDb) {
$db = $previousDb;
} else {
unset($GLOBALS['db']);
}
}
});
it('formats stored manual flags with the creating superuser display name', function (): void {
global $db;
$hadDb = array_key_exists('db', $GLOBALS);
$previousDb = $GLOBALS['db'] ?? null;
$db = new class {
public function query(string $sql): object|false
{
if (str_contains($sql, 'SELECT display_name FROM users WHERE id = 42')) {
return new class {
public int $num_rows = 1;
public function fetch_assoc(): array
{
return ['display_name' => 'Jeppe'];
}
};
}
return false;
}
};
try {
$flag = invoice_period_flag_service_invoke('formatStoredFlag', [[
'id' => 12,
'source' => 'manual',
'severity' => 'red',
'status' => 'active',
'target_type' => 'customer',
'target_id' => 424242,
'field' => null,
'customer_number' => 424242,
'order_id' => null,
'order_item_id' => null,
'invoice_collection_id' => null,
'xlvask_usage_log_id' => null,
'definition_key' => null,
'fingerprint' => null,
'reason' => 'Manual review',
'status_reason' => null,
'context_json' => null,
'created_by' => 42,
'status_changed_by' => null,
'status_changed_at' => null,
'created_at' => '2026-05-11 10:00:00',
'updated_at' => '2026-05-11 10:00:00',
]]);
expect($flag['created_by'])->toBe(42);
expect($flag['created_by_name'])->toBe('Jeppe');
} finally {
if ($hadDb) {
$db = $previousDb;
} else {
unset($GLOBALS['db']);
}
}
});
it('validates supported manual flag fields by target type', function (): void {
expect(invoice_period_flag_service_invoke('normalizeField', ['order_field', 'reference']))->toBe('reference');
expect(invoice_period_flag_service_invoke('normalizeField', ['order_item_field', 'price']))->toBe('price');
expect(invoice_period_flag_service_invoke('normalizeField', ['customer', '']))->toBeNull();
invoice_period_flag_service_invoke('normalizeField', ['order_field', 'price']);
})->throws(InvalidArgumentException::class, 'Invalid order flag field.');
it('refreshes the shared manual flag cache immediately after create and status mutations', function (): void {
$content = (string)file_get_contents(app_path('classes/invoice_period_flag_service.php'));
expect(substr_count($content, '$this->refreshManualFlagsCacheAfterMutation();'))->toBe(2)
->and($content)->toContain('$this->manualFlagsInstanceCache = null;')
->and($content)->toContain('$this->warmManualFlagsCache();');
});
it('derives aggregate manual flag counts without returning restricted flag details', function (): void {
$types = [
'all' => [[
'customer_number' => 7701,
'transactions' => [['id' => 8801, 'invoice_collection_id' => 9901]],
]],
];
$manualFlags = [[
'id' => 41,
'source' => 'manual',
'status' => 'active',
'target_type' => 'customer',
'target_id' => 7701,
'customer_number' => 7701,
]];
$result = invoice_period_flag_service_invoke('applyManualFlagCounts', [$types, $manualFlags]);
expect($result['all'][0]['flag_counts'])->toBe([
'manual' => 1,
'automatic' => 0,
'total' => 1,
])->and($result['all'][0])->not->toHaveKey('flags');
});
it('wires invoice period flag routes with explicit list create and update permissions', function (): void {
$content = file_get_contents(app_path('routes/InvoicingPeriodRoute.php'));
expect($content)->not->toBeFalse();
$content = (string)$content;
expect($content)->toContain("\$this->get('/superuser/invoicing/period'");
expect($content)->toContain("'list_invoice_period_flags' => 'List invoice period flags in the period response'");
expect($content)->toContain("\$this->post('/superuser/invoicing/period/flags'");
expect($content)->toContain("\$this->requirePermission('add_invoice_period_flag')");
expect($content)->toContain("\$this->patch('/superuser/invoicing/period/flags/{id}/status'");
expect($content)->toContain("\$this->post('/superuser/invoicing/period/flags/automatic/status'");
expect($content)->toContain("\$this->requirePermission('update_invoice_period_flag_status')");
});
it('uses the users display_name column in detector queries', function (): void {
$content = file_get_contents(app_path('classes/invoice_period_flag_service.php'));
expect($content)->not->toBeFalse();
$content = (string)$content;
expect($content)->toContain('u.display_name AS customer_name');
expect($content)->toContain('COALESCE(u.display_name, x.Customer) AS customer_name');
expect($content)->not->toContain('u.name');
});
it('aggregates customer price overrides by customer number for price mismatch detection', function (): void {
$content = file_get_contents(app_path('classes/invoice_period_flag_service.php'));
expect($content)->not->toBeFalse();
$content = (string)$content;
expect($content)->toContain('MAX(po.percentage) AS percentage');
expect($content)->toContain('GROUP BY discount_user.customer_number, po.product_or_category_id');
expect($content)->toContain('product_discount.customer_number = o.customer_id');
expect($content)->toContain('category_discount.customer_number = o.customer_id');
expect($content)->not->toContain('po_product.user_id = u.id');
});
it('guards optional customer vehicle deleted_at filtering behind a column check', function (): void {
$content = file_get_contents(app_path('classes/invoice_period_flag_service.php'));
expect($content)->not->toBeFalse();
$content = (string)$content;
expect($content)->toContain("\$this->columnExists('customer_vehicles', 'deleted_at')");
expect($content)->toContain('$deletedFilter');
expect($content)->toContain('{$deletedFilter}');
});
it('limits historical primary product lookup to current period registrations', function (): void {
$content = file_get_contents(app_path('classes/invoice_period_flag_service.php'));
expect($content)->not->toBeFalse();
$content = (string)$content;
expect($content)->toContain('private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers, ?bool $requireReg2Empty = null): array');
expect($content)->toContain('AND o.reg_1 IN ({$registrationFilter})');
expect($content)->not->toContain('$byReg = []');
});
it('does not flag single-tractor orders against historical tractor-trailer products', function (): void {
global $db;
$hadDb = array_key_exists('db', $GLOBALS);
$previousDb = $GLOBALS['db'] ?? null;
$db = new class {
public function escape_string(string $value): string
{
return addslashes($value);
}
public function query(string $sql): object|false
{
if (str_contains($sql, 'SHOW COLUMNS FROM `customer_vehicles`')) {
return $this->result([]);
}
if (str_contains($sql, 'FROM customer_vehicles')) {
return $this->result([]);
}
// History lookup that matches the reg_2-empty filter (single-tractor history)
if (str_contains($sql, 'FROM orders o')
&& str_contains($sql, "COALESCE(o.reg_2, '') = ''")) {
return $this->result([
[
'reg' => 'EC21233',
'product_id' => 3,
'product_name' => 'Forvogn',
'usage_count' => 8,
],
]);
}
// History lookup that matches the reg_2-non-empty filter (tractor-trailer history)
if (str_contains($sql, 'FROM orders o')
&& str_contains($sql, "COALESCE(o.reg_2, '') <> ''")) {
return $this->result([
[
'reg' => 'EC21233',
'product_id' => 7,
'product_name' => 'Forvogn med hænger',
'usage_count' => 12,
],
]);
}
return false;
}
private function result(array $rows): object
{
return new class($rows) {
public int $num_rows;
public array $rows;
public function __construct(array $rows)
{
$this->rows = $rows;
$this->num_rows = count($rows);
}
public function fetch_assoc(): ?array
{
return array_shift($this->rows);
}
};
}
};
try {
$baseRow = [
'customer_number' => 35131752,
'customer_name' => 'Single Tractor Customer',
'order_id' => 7001,
'order_item_id' => 8001,
'invoice_collection_id' => 901,
'department_id' => 7,
'reg_1' => 'EC21233',
'reg_2' => '',
'is_wash' => 1,
'related_item_id' => 0,
'order_created_at' => '2026-08-01 08:05:21',
];
// Single-tractor order (reg_2 = '') whose primary product is just "Forvogn" should NOT be
// flagged, even though historical tractor-trailer orders (reg_2 non-empty) have used the
// "Forvogn med hænger" product for the same registration.
$singleTractorFlags = invoice_period_flag_service_invoke('detectVehicleTypeMismatches', [
[
$baseRow + [
'product_id' => 3,
'product_name' => 'Forvogn',
],
],
'2026-08-01 00:00:00',
]);
expect(array_column($singleTractorFlags, 'definition_key'))->not->toContain('historical_primary_product_mismatch');
// Tractor-trailer order (reg_2 non-empty) using a non-matching product SHOULD still be flagged.
$trailerFlags = invoice_period_flag_service_invoke('detectVehicleTypeMismatches', [
[
[
'product_id' => 5,
'product_name' => 'Kassevogn',
'reg_2' => 'AB12345',
] + $baseRow,
],
'2026-08-01 00:00:00',
]);
$mismatchFlags = array_values(array_filter(
$trailerFlags,
static fn(array $flag): bool => ($flag['definition_key'] ?? null) === 'historical_primary_product_mismatch'
));
expect(count($mismatchFlags))->toBe(1);
expect($mismatchFlags[0]['message_params']['expected_product'] ?? null)->toBe('Forvogn med hænger');
} finally {
if ($hadDb) {
$db = $previousDb;
} else {
unset($GLOBALS['db']);
}
}
});
it('partitions historical primary product lookup by current rows reg_2 presence', function (): void {
$content = file_get_contents(app_path('classes/invoice_period_flag_service.php'));
expect($content)->not->toBeFalse();
$content = (string)$content;
// The detect() function must call getPrimaryProductHistory twice: once with requireReg2Empty=true
// for rows whose reg_2 is empty, and once with requireReg2Empty=false for rows that do have a
// trailer. This prevents the historical_primary_product_mismatch flag from naming the
// tractor-trailer (Forvogn med hænger) product as the expected product when the current order
// has no trailer.
expect($content)->toContain("getPrimaryProductHistory(\n \$dateFrom,\n array_column(\$emptyReg2, 'reg_1'),\n true\n )");
expect($content)->toContain("getPrimaryProductHistory(\n \$dateFrom,\n array_column(\$hasReg2, 'reg_1'),\n false\n )");
expect($content)->toContain("\$reg2Filter = \"AND (COALESCE(o.reg_2, '') = '')\"");
expect($content)->toContain("\$reg2Filter = \"AND (COALESCE(o.reg_2, '') <> '')\"");
});