From a0e7e152f72d827dbd091611ada74517a1e21ff0 Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 29 Aug 2026 14:06:10 +0200 Subject: [PATCH] feat(orders): pair lines when a quote is linked to an order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setOrderQuote is the single choke point for creating orders.quote — used by the UI, REST API, and Hermes/n8n. Call pairOrderLines(orderId, quoteId) right after the hard link lands so confident order-line to quote-line pairs persist automatically instead of needing a separate manual step. Pairing also runs on the idempotent no-op path (order already points at the requested quote) — it's safe to repeat and lets an operator refresh pairing by re-running the same call after editing order lines. A pairing failure never fails the link itself (console.error only, by design) — the hard link is the operator's important intent and must land even if pairing can't complete. --- src/lib/server/orders-service.test.ts | 90 +++++++++++++++++++++++++++ src/lib/server/orders-service.ts | 42 ++++++++++++- 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/src/lib/server/orders-service.test.ts b/src/lib/server/orders-service.test.ts index d8eeb3b..4610136 100644 --- a/src/lib/server/orders-service.test.ts +++ b/src/lib/server/orders-service.test.ts @@ -243,6 +243,13 @@ vi.mock("./pocketbase-admin", () => ({ getAdminPocketBase: vi.fn().mockResolvedValue(pbMock), })); +vi.mock("./order-quote-link-service", () => ({ + pairOrderLines: vi.fn().mockResolvedValue({ + confident: [], possible: [], unmatchedOrderItemIds: [], + }), + suggestQuoteForOrder: vi.fn().mockResolvedValue({ best: null }), +})); + import { createOrder, createOrderFromQuote, @@ -2660,6 +2667,89 @@ describe("setOrderQuote", () => { }); }); +// ────────────────────────────────────────────────────────────────────── +// setOrderQuote line pairing (Task 5) +// ────────────────────────────────────────────────────────────────────── + +describe("setOrderQuote line pairing", () => { + it("pairs order lines to the quote's lines after writing the hard link", async () => { + const { pairOrderLines } = await import("./order-quote-link-service"); + await pbMock.collection("quotes").create({ id: "q-1" }); + await pbMock.collection("orders").create({ + id: "o-1", order_number: "ORD-1", quote: "", created: "2026-08-21T00:00:00Z", + }); + await pbMock.collection("order_items").create({ + id: "oi-1", order: "o-1", description: "hex bolt m20", item_status: "pending", + }); + + const result = await setOrderQuote({ orderId: "o-1", quoteId: "q-1" }); + + expect(pairOrderLines).toHaveBeenCalledWith("o-1", "q-1"); + expect(result.order.quote).toBe("q-1"); + }); + + it("does not fail the link when pairing throws", async () => { + const { pairOrderLines } = await import("./order-quote-link-service"); + vi.mocked(pairOrderLines).mockRejectedValueOnce(new Error("PB down")); + + await pbMock.collection("quotes").create({ id: "q-1" }); + await pbMock.collection("orders").create({ + id: "o-1", order_number: "ORD-1", quote: "", created: "2026-08-21T00:00:00Z", + }); + await pbMock.collection("order_items").create({ + id: "oi-1", order: "o-1", description: "hex bolt m20", item_status: "pending", + }); + + const result = await setOrderQuote({ orderId: "o-1", quoteId: "q-1" }); + + // The link still landed despite the pairing failure. + expect(result.order.quote).toBe("q-1"); + }); + + it("does not pair lines when the link is refused (order_locked)", async () => { + const { pairOrderLines } = await import("./order-quote-link-service"); + vi.mocked(pairOrderLines).mockClear(); + + await pbMock.collection("quotes").create({ id: "q-1" }); + await pbMock.collection("orders").create({ + id: "o-1", order_number: "ORD-1", quote: "", + }); + await pbMock.collection("order_items").create({ + id: "oi-1", order: "o-1", description: "x", item_status: "po_generated", + }); + + try { + await setOrderQuote({ orderId: "o-1", quoteId: "q-1" }); + expect.fail("should have thrown"); + } catch (err) { + expect((err as Error & { status?: number }).status).toBe(409); + } + + expect(pairOrderLines).not.toHaveBeenCalled(); + }); + + it("also pairs lines on the idempotent no-op path (order already points at the quote)", async () => { + const { pairOrderLines } = await import("./order-quote-link-service"); + vi.mocked(pairOrderLines).mockClear(); + + await pbMock.collection("quotes").create({ id: "q-1" }); + await pbMock.collection("orders").create({ + id: "o-1", order_number: "ORD-1", quote: "q-1", + }); + await pbMock.collection("order_items").create({ + id: "oi-1", order: "o-1", description: "hex bolt m20", item_status: "pending", + }); + + await setOrderQuote({ orderId: "o-1", quoteId: "q-1" }); + + // Re-running pairing on an already-linked order is safe (it never + // overwrites an existing `quote_item`) and picks up lines added + // or edited since the original link — so pairing runs here too, + // even though the hard link itself is a no-op. + expect(pairOrderLines).toHaveBeenCalledWith("o-1", "q-1"); + }); +}); + // ────────────────────────────────────────────────────────────────────── // findOrderByOrderNumber (Hermes unblock) // ────────────────────────────────────────────────────────────────────── diff --git a/src/lib/server/orders-service.ts b/src/lib/server/orders-service.ts index 3d10040..9a859e7 100644 --- a/src/lib/server/orders-service.ts +++ b/src/lib/server/orders-service.ts @@ -1,4 +1,5 @@ import { getAdminPocketBase } from "./pocketbase-admin"; +import { pairOrderLines } from "./order-quote-link-service"; import { TAB_GROUP_ITEM_STATUSES, TAB_GROUP_ORDER, @@ -3720,9 +3721,15 @@ export async function setOrderQuote( const previous: { quote: string } = { quote: existing.quote || "" }; // Idempotent no-op when the order already points at this quote. - // Don't write, don't stamp — just return the order as-is. + // Don't write, don't stamp — just return the order as-is. Pairing + // still runs on this path: it's safe to repeat (confident pairs + // are only ever added, never overwritten) and it's the only way + // to pick up lines that were added or edited after the original + // link was made — e.g. a re-run of `orders.set_quote` used purely + // to refresh pairing. if (previous.quote === quoteId) { const fresh = await pb.collection(ORDER_COLLECTION).getOne(orderId); + await pairOrderLinesSafely(orderId, quoteId); return { order: fresh, previous }; } @@ -3740,9 +3747,42 @@ ${stamp}` : stamp; await pb.collection(ORDER_COLLECTION).update(orderId, update); const updated = await pb.collection(ORDER_COLLECTION).getOne(orderId); + // Pair the order's lines to the quote's lines now that the link is + // real. Confident pairs persist onto `order_items.quote_item`; + // doubtful ones are surfaced in the UI for confirmation instead. + await pairOrderLinesSafely(orderId, quoteId); + return { order: updated, previous }; } +/** + * Run `pairOrderLines`, never letting it fail the caller. + * + * A pairing failure must not undo or fail a link the operator (or + * agent) just made — the hard link is the important write, pairing + * is a best-effort enhancement on top of it. The pairing can always + * be redone later by re-running `setOrderQuote` with the same + * quoteId (see the idempotent-path comment above). + * + * `console.error` is the only trace of a pairing failure right now — + * there's no user-facing surface for "the link worked but pairing + * didn't." That's an acceptable gap for this task (see the plan's + * notes on watch-for #4) but worth revisiting if silent pairing + * failures turn out to be common in practice — e.g. surfacing them + * via the `potential_quote` suggestion machinery or an operator-visible + * warning instead of only a server log line. + */ +async function pairOrderLinesSafely(orderId: string, quoteId: string): Promise { + try { + await pairOrderLines(orderId, quoteId); + } catch (err) { + console.error( + `[setOrderQuote] line pairing failed for order ${orderId} / quote ${quoteId}:`, + err instanceof Error ? err.message : err, + ); + } +} + // ────────────────────────────────────────────────────────────────────── // createOrderItemForOrder (Phase H) — strict-pending-gated item create // ──────────────────────────────────────────────────────────────────────