diff --git a/services/nginx/app/objects/orders_o.php b/services/nginx/app/objects/orders_o.php index f37b49c8..a039d3dd 100644 --- a/services/nginx/app/objects/orders_o.php +++ b/services/nginx/app/objects/orders_o.php @@ -615,7 +615,12 @@ class orders_o extends db public function getOrderItems(int $order_id): array { global $db; - $sql = "SELECT * FROM order_items WHERE order_id = $order_id"; + // Order primary items first (related_item_id IS NULL), then addons grouped by + // 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. + $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 = []; if ($result->num_rows > 0 && $result) { diff --git a/services/nginx/app/tests/Unit/Orders/OrderItemsListingOrderingTest.php b/services/nginx/app/tests/Unit/Orders/OrderItemsListingOrderingTest.php new file mode 100644 index 00000000..ec7d4f22 --- /dev/null +++ b/services/nginx/app/tests/Unit/Orders/OrderItemsListingOrderingTest.php @@ -0,0 +1,60 @@ +not->toBeFalse(); + + // Locate the getOrderItems method body. + $start = strpos($content, 'public function getOrderItems(int $order_id): array'); + expect($start)->not->toBeFalse(); + + // Bound the search so we don't accidentally match unrelated SQL further down + // the file (the helper around line 645 in orders_o.php also uses ORDER BY id DESC). + $end = strpos($content, "\n }\n", $start); + expect($end)->not->toBeFalse(); + + $methodBody = substr($content, (int)$start, (int)$end - (int)$start); + + // The SELECT against order_items must include an explicit ORDER BY so MySQL + // does not return rows in undefined order (which has been observed to put + // primary order items after their addons, breaking the FE tree-builder and + // the invoice line listing). + expect($methodBody) + ->toContain("FROM order_items WHERE order_id = \$order_id") + ->and($methodBody) + ->toContain('ORDER BY'); + + // The ORDER BY must place primary items first (related_item_id IS NULL DESC), + // group addons by their parent (related_item_id ASC), and fall back to + // insertion order (id ASC). + expect($methodBody) + ->toContain('(related_item_id IS NULL) DESC') + ->and($methodBody) + ->toContain('related_item_id ASC') + ->and($methodBody) + ->toContain('id ASC'); +}); + +it('does not leave the legacy unordered SELECT in getOrderItems', function (): void { + $ordersObjectFile = app_path('objects/orders_o.php'); + $content = file_get_contents($ordersObjectFile); + + expect($content)->not->toBeFalse(); + + $start = strpos($content, 'public function getOrderItems(int $order_id): array'); + $end = strpos($content, "\n }\n", $start); + + expect($start)->not->toBeFalse() + ->and($end)->not->toBeFalse(); + + $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. + expect($methodBody) + ->not->toContain("FROM order_items WHERE order_id = \$order_id\";\n \$result"); +});