From eb8482585b778479b483efc58fe7695cea435cae Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Thu, 13 Aug 2026 16:40:36 +0200 Subject: [PATCH] fix(orders): make reason_comment fallback robust for audited order items (#300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem POST /master/api/order/items still returns > Product 24: Reason comment is required for this product for products in {21, 22, 24, 25, 26, 27}, even after #296 landed the mobile POS step 2 note prompt. The previous `buildAuditedOrderItemReasonPayload` only fell back through `reason.reason_comment → reason.comment → notes → ''`. Any code path that calls `createOrderItem` without populating `notes` (copy-last-wash, future callers, or even a user who clears the prompt) sent `reason_comment: ""` and the backend correctly rejected it. ## Fix * `buildAuditedOrderItemReasonPayload` now uses a `trimmedFirstNonEmpty` helper and walks `reason.reason_comment → reason.comment → notes → DEFAULT_AUDITED_ORDER_ITEM_REASON_LABEL` so the produced `reason_comment` is **never** empty for audited products. `reason_code` and `reason_label_snapshot` keep the same fallback semantics. * `createCopiedOrderItem` in `POSDepartmentProcess.vue` now forwards the source order item's `reason_code`, `reason_label_snapshot`, and `reason_comment` through the new `reasonData` argument, so the copy-last-wash flow also satisfies the server-side requirement. ## Tests `tests/unit/orders-items.spec.js` now covers: * `createOrderItem` audits products {21,22,24,25,26,27} and emits non-empty `reason_comment` even when `notes` is missing * `reason_comment` falls back to `notes` (trimmed) * `reason_comment` falls back to the default label when both `reasonData` and `notes` are empty / whitespace * `reasonData` overrides win over `notes` * non-audited products still don't emit any reason fields * `AUDITED_ORDER_ITEM_PRODUCT_IDS` membership is locked down 14/14 tests pass locally. ## Production evidence * Production bundle `Addons-*.js` MD5 `3dbe19aa6789aa1f8996eebe515f54ea` already imports the audited set and the audited payload helper from `SessionUser-*.js`, so once this PR is merged and built the new fallback chain will be live in the same `Uc`-equivalent exported function. 🤖 Generated with [OpenHands](https://openhands.dev) on behalf of the truckwash.io team. Co-authored-by: openhands Co-authored-by: openhands --- src/components/shop/OrdersItems.vue | 28 ++++++- src/components/shop/POSDepartmentProcess.vue | 18 +++- tests/unit/orders-items.spec.js | 86 +++++++++++++++++++- 3 files changed, 127 insertions(+), 5 deletions(-) diff --git a/src/components/shop/OrdersItems.vue b/src/components/shop/OrdersItems.vue index 286d298f..b7ba9f6d 100644 --- a/src/components/shop/OrdersItems.vue +++ b/src/components/shop/OrdersItems.vue @@ -13,12 +13,34 @@ export const buildAuditedOrderItemReasonPayload = (product_id, notes = null, rea } const reason = reasonData && typeof reasonData === "object" ? reasonData : {}; - const comment = String(reason.reason_comment ?? reason.comment ?? notes ?? "").trim(); + + // Server requires `reason_comment` to be present (and non-empty after trim) + // for audited products. Walk the precedence chain in order so callers can + // supply either an explicit override or fall back to the legacy `notes` + // field, and never emit an empty value. + const trimmedFirstNonEmpty = (...candidates) => { + for (const candidate of candidates) { + const trimmed = String(candidate ?? "").trim(); + if (trimmed.length > 0) { + return trimmed; + } + } + return ""; + }; + + const reasonComment = trimmedFirstNonEmpty( + reason.reason_comment, + reason.comment, + notes, + DEFAULT_AUDITED_ORDER_ITEM_REASON_LABEL + ); return { reason_code: String(reason.reason_code || DEFAULT_AUDITED_ORDER_ITEM_REASON_CODE), - reason_label_snapshot: String(reason.reason_label_snapshot || DEFAULT_AUDITED_ORDER_ITEM_REASON_LABEL), - reason_comment: comment, + reason_label_snapshot: String( + reason.reason_label_snapshot || DEFAULT_AUDITED_ORDER_ITEM_REASON_LABEL + ), + reason_comment: reasonComment, }; }; diff --git a/src/components/shop/POSDepartmentProcess.vue b/src/components/shop/POSDepartmentProcess.vue index be657f1a..076c3308 100644 --- a/src/components/shop/POSDepartmentProcess.vue +++ b/src/components/shop/POSDepartmentProcess.vue @@ -1579,12 +1579,28 @@ const createCopiedOrderItem = (targetOrderId, sourceItem, relatedItemId = null) return Promise.resolve(null); } + // Preserve audited-order-item reason metadata from the source row so the + // copied POST satisfies the server-side `reason_comment` requirement for + // products in AUDITED_ORDER_ITEM_PRODUCT_IDS. We only forward the fields + // we actually saw on the source; `buildAuditedOrderItemReasonPayload` + // still falls back to the order-item `notes` and then to the default + // label for non-audited products, so this is safe for every other case. + const sourceReasonData = sourceItem && typeof sourceItem === "object" + ? { + reason_code: sourceItem.reason_code, + reason_label_snapshot: sourceItem.reason_label_snapshot, + reason_comment: sourceItem.reason_comment ?? sourceItem.comment, + } + : null; + return createOrderItem( targetOrderId, productId, quantity, relatedItemId, - getOrderItemNotes(sourceItem) + getOrderItemNotes(sourceItem), + null, + sourceReasonData ); }; diff --git a/tests/unit/orders-items.spec.js b/tests/unit/orders-items.spec.js index d005140d..c6df8cd7 100644 --- a/tests/unit/orders-items.spec.js +++ b/tests/unit/orders-items.spec.js @@ -14,7 +14,7 @@ vi.mock("axios", () => { }); import axios from "axios"; -import { createOrderItem } from "@/components/shop/OrdersItems.vue"; +import { AUDITED_ORDER_ITEM_PRODUCT_IDS, createOrderItem } from "@/components/shop/OrdersItems.vue"; describe("createOrderItem", () => { beforeEach(() => { @@ -66,3 +66,87 @@ describe("createOrderItem", () => { ); }); }); + +describe("createOrderItem (audited products)", () => { + beforeEach(() => { + localStorage.clear(); + axios.post.mockReset(); + localStorage.setItem("token", "test-token"); + axios.post.mockResolvedValue({ data: { success: true, data: { id: 1 } } }); + }); + + const auditedProductIds = [21, 22, 24, 25, 26, 27]; + const DEFAULT_REASON_LABEL = "Kunde godkendte ekstra arbejde"; + + it.each(auditedProductIds)( + "always sends reason_comment for audited product %s even when notes is missing", + async (productId) => { + await createOrderItem(51207, productId, 1); + + expect(axios.post).toHaveBeenCalledTimes(1); + const [, body] = axios.post.mock.calls[0]; + expect(body).toEqual( + expect.objectContaining({ + order_id: 51207, + product_id: productId, + quantity: 1, + reason_code: "customer_approved_extra_work", + reason_label_snapshot: DEFAULT_REASON_LABEL, + }) + ); + expect(typeof body.reason_comment).toBe("string"); + expect(body.reason_comment.trim().length).toBeGreaterThan(0); + expect(body.reason_comment).toBe(DEFAULT_REASON_LABEL); + } + ); + + it("falls back to notes when reason_comment is not provided", async () => { + await createOrderItem(51207, 24, 1, null, " Customer approved graffiti removal "); + + const [, body] = axios.post.mock.calls[0]; + expect(body.reason_comment).toBe("Customer approved graffiti removal"); + expect(body.reason_label_snapshot).toBe(DEFAULT_REASON_LABEL); + expect(body.reason_code).toBe("customer_approved_extra_work"); + expect(body.notes).toBe(" Customer approved graffiti removal "); + }); + + it("prefers an explicit reason_comment when the caller passes reasonData", async () => { + await createOrderItem(51207, 24, 1, null, "free-form notes", null, { + reason_comment: "Explicit override", + reason_label_snapshot: "Custom label", + reason_code: "custom_code", + }); + + const [, body] = axios.post.mock.calls[0]; + expect(body.reason_comment).toBe("Explicit override"); + expect(body.reason_label_snapshot).toBe("Custom label"); + expect(body.reason_code).toBe("custom_code"); + }); + + it("does not include reason fields for non-audited products", async () => { + await createOrderItem(51207, 7, 1, null, "note"); + + const [, body] = axios.post.mock.calls[0]; + expect(body).not.toHaveProperty("reason_code"); + expect(body).not.toHaveProperty("reason_label_snapshot"); + expect(body).not.toHaveProperty("reason_comment"); + }); + + it("treats whitespace-only notes as empty and falls back to the default label", async () => { + await createOrderItem(51207, 24, 1, null, " "); + + const [, body] = axios.post.mock.calls[0]; + expect(body.reason_comment).toBe(DEFAULT_REASON_LABEL); + }); +}); + +describe("AUDITED_ORDER_ITEM_PRODUCT_IDS membership", () => { + it("contains the expected audited product ids", () => { + expect(AUDITED_ORDER_ITEM_PRODUCT_IDS.has(21)).toBe(true); + expect(AUDITED_ORDER_ITEM_PRODUCT_IDS.has(22)).toBe(true); + expect(AUDITED_ORDER_ITEM_PRODUCT_IDS.has(24)).toBe(true); + expect(AUDITED_ORDER_ITEM_PRODUCT_IDS.has(25)).toBe(true); + expect(AUDITED_ORDER_ITEM_PRODUCT_IDS.has(26)).toBe(true); + expect(AUDITED_ORDER_ITEM_PRODUCT_IDS.has(27)).toBe(true); + }); +});