From 58d5e177a94332a6e3e11037e1921e4e19689dd7 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Tue, 11 Aug 2026 07:05:59 +0200 Subject: [PATCH] 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. --- services/nginx/app/objects/orders_o.php | 3 +- .../Orders/OrderItemsListingOrderingTest.php | 56 +++++++++++++++++-- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/services/nginx/app/objects/orders_o.php b/services/nginx/app/objects/orders_o.php index ef54330d..d80c5766 100644 --- a/services/nginx/app/objects/orders_o.php +++ b/services/nginx/app/objects/orders_o.php @@ -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 = []; diff --git a/services/nginx/app/tests/Unit/Orders/OrderItemsListingOrderingTest.php b/services/nginx/app/tests/Unit/Orders/OrderItemsListingOrderingTest.php index ec7d4f22..25878c9e 100644 --- a/services/nginx/app/tests/Unit/Orders/OrderItemsListingOrderingTest.php +++ b/services/nginx/app/tests/Unit/Orders/OrderItemsListingOrderingTest.php @@ -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"); -}); +}); \ No newline at end of file