From c01596aeb5c913d7fc02898852e14260e3faad5f Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Thu, 13 Aug 2026 12:17:21 +0200 Subject: [PATCH] fix(pleno-vue): prompt for audited add-on note on mobile POS step 2 (#296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes the mobile POS step-2 400 on `POST /order/items` for audited add-on products (21, 22, 24, 25, 26, 27) such as product 24. Reproduces on https://truckwash.io/admin/12/modules/pos?id=76596&customer_id=12345679&step=2: ``` Request body: {"order_id":76596,"product_id":24,"quantity":1, "related_item_id":193235,"notes":"", "reason_code":"customer_approved_extra_work", "reason_label_snapshot":"Kunde godkendte ekstra arbejde", "reason_comment":""} Response: 400 {"success":false,"data":{"message":"Reason comment is required for this product"}} Component trace: OrderItemsPartialSyncError: Product 24: Reason comment is required for this product ``` `syncMobileOrderItems` did roll back already-created sibling add-ons correctly; the user-side prompt was missing. ## Root cause `PosDepartmentStepMobile2.vue`'s `productRequiresOrderItemNote` (line ~1036) only checked `requires_note`, the chemistry product 27 by ID, and the chemistry product name. It did **not** include the audited product ID set that the desktop flow (`SelectProductsFormPOS.vue:447`) and the server policy (`order_item_reason_policy.php` `AFFECTED_PRODUCT_IDS`) both rely on. So the mobile flow never prompted the operator for a reason note before POST when the audited add-on was product 21/22/24/25/26/27. The POST then went out with empty `reason_comment`, and the server policy rejected it with 400. ## Fix Three minimal changes, mirroring the desktop flow: 1. **`src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue`** - Import the existing `AUDITED_ORDER_ITEM_PRODUCT_IDS` export from `OrdersItems.vue`. - Extend `productRequiresOrderItemNote` to include `AUDITED_ORDER_ITEM_PRODUCT_IDS.has(getProductId(product))`. - Existing `promptForRequiredProductNote` → `addOrderItemAddons` → `createOrderItem` pipeline already populates both `notes` and (via `buildAuditedOrderItemReasonPayload`'s `notes` fallback) `reason_comment`, so no other plumbing changes are needed. 2. **`tests/e2e/support/mobilePos.js`** — extend the test fixture's `productRequiresOrderItemNote` with the same audited constant. The mock server rejection (line 2046) now matches production for audited products. 3. **`tests/e2e/pos-mobile-order-flow.spec.js`** — new e2e test "prompts for a required reason note for audited add-on products that are not the chemistry product" covering the exact failing product 24 case. Mirrors the existing product 27 test, asserts that the resulting `/order/items` POST carries `notes`, `reason_code`, and `reason_comment` populated. ## Verification - `vitest run` of directly related suites: order-items-addon-fanout, pos-mobile-step-2-addon-sync, pos-order-item-product-reconciliation → 37/37 pass - `eslint` and `prettier --check` clean on all three modified files - Pre-commit hook auto-formatted the diff during commit No new dependencies. Reuses existing exports. ``` PosDepartmentStepMobile2.vue | 8 +++++++- pos-mobile-order-flow.spec.js | 88 ++++++++++++++++++++++++++++++++++++++++++++ mobilePos.js | 2 ++ ``` Co-authored-by: openhands --- .../steps/mobile/PosDepartmentStepMobile2.vue | 8 +- tests/e2e/pos-mobile-order-flow.spec.js | 88 +++++++++++++++++++ tests/e2e/support/mobilePos.js | 2 + 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue b/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue index 85810ca6..2fef6520 100644 --- a/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue +++ b/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue @@ -48,7 +48,12 @@ import { registerPosStepSaveBarrier, saveOrderMetadataField, } from "@/components/shop/POSDepartmentProcess.vue"; -import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue"; +import { + AUDITED_ORDER_ITEM_PRODUCT_IDS, + createOrderItem, + getOrderItems, + removeOrderItem, +} from "@/components/shop/OrdersItems.vue"; import { syncMobileOrderItems } from "@/components/displays/department/pos/steps/mobile/utils/syncMobileOrderItems.js"; import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue"; import PosDepartmentStepMobileButtonClearAll from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue"; @@ -1035,6 +1040,7 @@ const productRequiresOrderItemNote = (product: any) => { return ( isEnabledFlag(product?.requires_note ?? product?.product?.requires_note) || + AUDITED_ORDER_ITEM_PRODUCT_IDS.has(getProductId(product)) || getProductId(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_ID || getProductName(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME ); diff --git a/tests/e2e/pos-mobile-order-flow.spec.js b/tests/e2e/pos-mobile-order-flow.spec.js index 3229f3b8..e987db06 100644 --- a/tests/e2e/pos-mobile-order-flow.spec.js +++ b/tests/e2e/pos-mobile-order-flow.spec.js @@ -4213,6 +4213,94 @@ test.describe("POS mobile order flow", () => { await waitForStepReset(page); }); + test("prompts for a required reason note for audited add-on products that are not the chemistry product", async ({ + page, + }) => { + const orderId = 9416; + const baseFixture = createMobilePosFixture(); + const auditedProduct = { + id: 24, + name: "Højtryk - ekstra tid", + description: "Audited addon that requires a reason comment", + price: 95, + subscription_allowed: true, + category: 8, + piktogram: "24", + apply_category_discount: false, + requires_note: false, + is_wash: false, + display_in_booking_form: true, + order_priority: 6, + addons: [], + }; + const primaryProduct = { + ...fixtureProduct(53), + addons: [ + ...fixtureProduct(53).addons, + { + id: auditedProduct.id, + name: auditedProduct.name, + price: auditedProduct.price, + product: { ...auditedProduct }, + quantity: 1, + min: 0, + max: -1, + }, + ], + }; + const fixture = createMobilePosFixture({ + products: baseFixture.products + .map((product) => { + if (Number(product.id) !== 53) { + return product; + } + return primaryProduct; + }) + .concat(auditedProduct), + ordersById: { + [orderId]: buildRegularOrder(orderId), + }, + orderItemsByOrderId: { + [orderId]: [], + }, + }); + + await setupMobilePosPage(page, fixture, { + token: "mobile-product-24-note-token", + seedState: { + customerId: REGULAR_CUSTOMER_ID, + reg: "AB12345", + reference: "PRODUCT-24-NOTE", + primaryItem: primaryProduct, + vehicleType: 53, + lastOrderId: null, + }, + route: { + step: 2, + orderId, + customerId: REGULAR_CUSTOMER_ID, + }, + }); + + await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId("pos-mobile-addon-24-value")).toHaveText("1", { timeout: 10_000 }); + + await page.getByTestId("pos-mobile-next-step").click(); + await expect(page.locator('[data-testid="pos-mobile-popup"][data-popup-id="add_product_note"]')).toBeVisible({ + timeout: 10_000, + }); + await page.getByTestId("pos-mobile-product-note-input").fill("Højtryk bagpå venstre side"); + await page.getByTestId("pos-mobile-product-note-confirm").click(); + + await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(2); + await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1); + const product24Create = fixture.requestLog.orderItemCreates.find((entry) => Number(entry.product_id) === 24); + expect(product24Create?.notes).toBe("Højtryk bagpå venstre side"); + expect(product24Create?.reason_code).toBe("customer_approved_extra_work"); + expect(product24Create?.reason_comment).toBe("Højtryk bagpå venstre side"); + await waitForStepReset(page); + }); + test("booking hydration applies booking items, reference, notes, and po", async ({ page }) => { const orderId = 9405; const fixture = createMobilePosFixture({ diff --git a/tests/e2e/support/mobilePos.js b/tests/e2e/support/mobilePos.js index bad5b82e..5d092279 100644 --- a/tests/e2e/support/mobilePos.js +++ b/tests/e2e/support/mobilePos.js @@ -11,6 +11,7 @@ export const CARD_CUSTOMER_ID = 999; export const WASH_CERTIFICATE_PRODUCT_ID = 41; const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27; const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi"; +const AUDITED_ORDER_ITEM_PRODUCT_IDS = new Set([21, 22, 24, 25, 26, 27]); export const MOBILE_PERMISSIONS = ["admin", "department_access_1"]; export const MOBILE_NEXT_STEP_COOLDOWN_MS = 2100; @@ -342,6 +343,7 @@ function productRequiresOrderItemNote(product) { const productId = Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0); return ( isEnabledFlag(product?.requires_note ?? product?.product?.requires_note) || + AUDITED_ORDER_ITEM_PRODUCT_IDS.has(productId) || productId === EXTRAORDINARY_CHEMISTRY_PRODUCT_ID || String(product?.product?.name ?? product?.name ?? "").trim() === EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME );