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 = []'); });