fix(api): order order_items so primary precedes addons in getOrderItems (#364)

Defensive ORDER BY in orders_o.php::getOrderItems so primary items render before their addons (related_item_id IS NULL DESC, related_item_id ASC, id ASC). Pinned with OrderItemsListingOrderingTest which locates orders_o.php via worktree-aware resolver.
This commit is contained in:
Jeppe B
2026-08-11 07:05:59 +02:00
committed by GitHub
parent 82a3684f05
commit 58d5e177a9
2 changed files with 52 additions and 7 deletions
+2 -1
View File
@@ -619,7 +619,8 @@ class orders_o extends db
// their parent (related_item_id ASC), and finally fall back to insertion order
// (id ASC). Without an explicit ORDER BY, MySQL is free to return rows in any
// order, which causes the FE tree-builder to render addons before their
// primary on the invoice and POS displays.
// primary on the invoice and POS displays (Trækker + addons like Trailer/Dolly
// visually appearing as if only Trailer/Dolly were attached to the order).
$sql = "SELECT * FROM order_items WHERE order_id = $order_id ORDER BY (related_item_id IS NULL) DESC, related_item_id ASC, id ASC";
$result = $db->query($sql);
$order_items = [];
@@ -2,8 +2,51 @@
declare(strict_types=1);
it('orders the SELECT in getOrderItems so primary items precede their addons', function (): void {
$ordersObjectFile = app_path('objects/orders_o.php');
// Locate the worktree's `orders_o.php`. The default `app_path()` helper resolves
// symlinks and points at the primary checkout's source, which we are forbidden to
// mutate. We prefer (in order):
// 1. The TRUCKWASH_WORKTREE_ROOT env var when set and valid
// 2. Walking up from this test file's directory to a project root that owns
// the `services/nginx/app/objects/orders_o.php` file
// 3. Falling back to the primary checkout's path (only when neither of the
// above resolves; this is the production CI path)
$ordersObjectFileResolver = static function (): string {
$candidate = getenv('TRUCKWASH_WORKTREE_ROOT');
if (is_string($candidate) && $candidate !== '' && is_dir($candidate)) {
$path = $candidate . '/services/nginx/app/objects/orders_o.php';
if (is_file($path)) {
return $path;
}
}
// Walk up from this test file looking for the orders_o.php in the same
// services/nginx/app tree. The test lives in
// services/nginx/app/tests/Unit/Orders/, so the target lives 4 levels up
// from this file's directory. We still walk defensively so the test works
// even if the test is moved into a deeper or shallower location.
$directory = __DIR__;
for ($i = 0; $i < 10; $i++) {
$candidatePath = $directory . '/objects/orders_o.php';
if (is_file($candidatePath)) {
return $candidatePath;
}
$parent = dirname($directory);
if ($parent === $directory) {
break;
}
$directory = $parent;
}
$existing = app_path('objects/orders_o.php');
if (is_file($existing)) {
return $existing;
}
throw new RuntimeException('Unable to locate orders_o.php for the addon ordering test.');
};
it('orders the SELECT in getOrderItems so primary items precede their addons', function () use ($ordersObjectFileResolver): void {
$ordersObjectFile = $ordersObjectFileResolver();
$content = file_get_contents($ordersObjectFile);
expect($content)->not->toBeFalse();
@@ -39,8 +82,8 @@ it('orders the SELECT in getOrderItems so primary items precede their addons', f
->toContain('id ASC');
});
it('does not leave the legacy unordered SELECT in getOrderItems', function (): void {
$ordersObjectFile = app_path('objects/orders_o.php');
it('does not leave the legacy unordered SELECT in getOrderItems', function () use ($ordersObjectFileResolver): void {
$ordersObjectFile = $ordersObjectFileResolver();
$content = file_get_contents($ordersObjectFile);
expect($content)->not->toBeFalse();
@@ -54,7 +97,8 @@ it('does not leave the legacy unordered SELECT in getOrderItems', function (): v
$methodBody = substr($content, (int)$start, (int)$end - (int)$start);
// The buggy SQL must be gone: previously this returned rows in whatever
// order MySQL felt like, leading to addons being listed before their primary.
// order MySQL felt like, leading to addons being listed before their primary
// and the operator seeing "only Trailer and Dolly" attached to a Trækker order.
expect($methodBody)
->not->toContain("FROM order_items WHERE order_id = \$order_id\";\n \$result");
});
});