1148 lines
40 KiB
PHP
1148 lines
40 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);
|
|
$target->setAccessible(true);
|
|
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');
|
|
$key->setAccessible(true);
|
|
|
|
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->setAccessible(true);
|
|
$cache->setValue($service, [
|
|
35131752 => 18,
|
|
]);
|
|
|
|
$calculate = $reflection->getMethod('calculateExpectedPrice');
|
|
$calculate->setAccessible(true);
|
|
$breakdownMethod = $reflection->getMethod('priceBreakdown');
|
|
$breakdownMethod->setAccessible(true);
|
|
|
|
$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');
|
|
$seed->setAccessible(true);
|
|
$preview = $reflection->getMethod('getOrderItemsForPreview');
|
|
$preview->setAccessible(true);
|
|
|
|
$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('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('getPrimaryProductHistory($dateFrom, array_column($primaryRows, \'reg_1\'))');
|
|
expect($content)->toContain('private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers): array');
|
|
expect($content)->toContain('AND o.reg_1 IN ({$registrationFilter})');
|
|
expect($content)->not->toContain('$byReg = []');
|
|
});
|