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.
This commit is contained in:
Jeppe B
2026-08-11 07:39:00 +02:00
committed by GitHub
parent 58d5e177a9
commit d81634033e
2 changed files with 185 additions and 4 deletions
@@ -1297,7 +1297,34 @@ class invoice_period_flag_service
}
}
$history = $this->getPrimaryProductHistory($dateFrom, array_column($primaryRows, 'reg_1'));
// Partition primary rows by whether reg_2 is empty. A single-tractor order (reg_2 = '')
// should only be compared against historical orders that ALSO had reg_2 empty, so we don't
// raise a "historical_primary_product_mismatch" flag that names the tractor-trailer
// (Forvogn med hænger) product as the expected one when the current order has no trailer.
$hasReg2 = [];
$emptyReg2 = [];
foreach ($primaryRows as $row) {
if (trim((string)($row['reg_2'] ?? '')) === '') {
$emptyReg2[] = $row;
} else {
$hasReg2[] = $row;
}
}
$history = [];
if (!empty($emptyReg2)) {
$history = $history + $this->getPrimaryProductHistory(
$dateFrom,
array_column($emptyReg2, 'reg_1'),
true
);
}
if (!empty($hasReg2)) {
$history = $history + $this->getPrimaryProductHistory(
$dateFrom,
array_column($hasReg2, 'reg_1'),
false
);
}
foreach ($primaryRows as $row) {
$reg = strtoupper(trim((string)($row['reg_1'] ?? '')));
if ($reg === '' || !isset($history[$reg])) {
@@ -1774,7 +1801,7 @@ class invoice_period_flag_service
return $map;
}
private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers): array
private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers, ?bool $requireReg2Empty = null): array
{
global $db;
@@ -1795,6 +1822,16 @@ class invoice_period_flag_service
$registrationFilter = implode(',', array_map(static function (string $registrationNumber) use ($db): string {
return "'" . $db->escape_string($registrationNumber) . "'";
}, array_keys($registrations)));
// Restrict historical orders to those whose reg_2 status matches the current rows:
// - null → no filter (default behaviour, backwards compatible)
// - true → reg_2 empty (single-tractor orders only)
// - false → reg_2 non-empty (tractor-trailer combo orders only)
$reg2Filter = '';
if ($requireReg2Empty === true) {
$reg2Filter = "AND (COALESCE(o.reg_2, '') = '')";
} elseif ($requireReg2Empty === false) {
$reg2Filter = "AND (COALESCE(o.reg_2, '') <> '')";
}
$result = $db->query(
"SELECT UPPER(TRIM(o.reg_1)) AS reg, oi.product_id, p.name AS product_name, COUNT(*) AS usage_count
FROM orders o
@@ -1808,6 +1845,7 @@ class invoice_period_flag_service
AND COALESCE(oi.related_item_id, 0) = 0
AND COALESCE(o.reg_1, '') <> ''
AND o.reg_1 IN ({$registrationFilter})
{$reg2Filter}
GROUP BY UPPER(TRIM(o.reg_1)), oi.product_id, p.name
ORDER BY reg, usage_count DESC, oi.product_id ASC"
);
@@ -1166,8 +1166,151 @@ it('limits historical primary product lookup to current period registrations', f
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('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, '') <> '')\"");
});