From 21bc57797bdd77ad47ebb1619e654022aa4203d5 Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 29 Aug 2026 14:22:21 +0200 Subject: [PATCH] feat(orders): auto-detect the source quote at order intake createOrder previously hard-coded quote_matching_attempted: false and never scheduled a match, so ~85% of orders that correspond to an existing quote arrived unmatched and stayed that way until an operator opened the order and clicked a button. Fire suggestQuoteForOrder(order.id) via next/server's after() once the response has been sent, so intake latency is unaffected, and guard both the matcher call and the after() call itself: after() throws synchronously outside a request scope (confirmed by reading node_modules/next/dist/server/after/after.js), so a caller without one (e.g. a background script invoking createOrder directly) falls back to firing the match inline instead of crashing order creation. Co-Authored-By: Claude Opus 5 --- src/lib/server/orders-service.test.ts | 49 +++++++++++++++++++++++++++ src/lib/server/orders-service.ts | 39 ++++++++++++++++++++- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/lib/server/orders-service.test.ts b/src/lib/server/orders-service.test.ts index 9775cfe..ae8ffe6 100644 --- a/src/lib/server/orders-service.test.ts +++ b/src/lib/server/orders-service.test.ts @@ -250,6 +250,18 @@ vi.mock("./order-quote-link-service", () => ({ suggestQuoteForOrder: vi.fn().mockResolvedValue({ best: null }), })); +// `after()` normally defers its callback until the response has been +// sent — outside a real Next.js request, nothing ever flushes that +// queue. Stub it to run the callback inline so `createOrder` tests can +// assert on the scheduled quote-match synchronously. +vi.mock("next/server", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + after: vi.fn((cb: () => unknown) => { void cb(); }), + }; +}); + import { createOrder, createOrderFromQuote, @@ -2871,3 +2883,40 @@ describe("findDuplicateOrders — email_message_id kind", () => { expect(groups).toHaveLength(0); }); }); + +describe("createOrder quote auto-detection", () => { + it("schedules a quote suggestion after the response", async () => { + const { suggestQuoteForOrder } = await import("./order-quote-link-service"); + const { after } = await import("next/server"); + vi.mocked(suggestQuoteForOrder).mockClear(); + vi.mocked(after).mockClear(); + await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" }); + await pbMock.collection("companies").create({ id: "comp-x", name: "Acme (Pty) Ltd" }); + + const created = await createOrder({ + customer_id: "cust-x", + company_id: "comp-x", + items: [{ description: "M16 Bolt", quantity: 50 }], + }); + + expect(after).toHaveBeenCalled(); + expect(suggestQuoteForOrder).toHaveBeenCalledWith(created.order.id); + }); + + it("never fails order creation when the matcher throws", async () => { + const { suggestQuoteForOrder } = await import("./order-quote-link-service"); + vi.mocked(suggestQuoteForOrder).mockClear(); + vi.mocked(suggestQuoteForOrder).mockRejectedValueOnce(new Error("PB down")); + await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" }); + await pbMock.collection("companies").create({ id: "comp-x", name: "Acme (Pty) Ltd" }); + + const result = await createOrder({ + customer_id: "cust-x", + company_id: "comp-x", + items: [{ description: "M16 Bolt", quantity: 50 }], + }); + + expect(result.order.id).toBeDefined(); + expect(result.items_created.length).toBe(1); + }); +}); diff --git a/src/lib/server/orders-service.ts b/src/lib/server/orders-service.ts index ea90d3f..955319d 100644 --- a/src/lib/server/orders-service.ts +++ b/src/lib/server/orders-service.ts @@ -1,5 +1,6 @@ +import { after } from "next/server"; import { getAdminPocketBase } from "./pocketbase-admin"; -import { pairOrderLines } from "./order-quote-link-service"; +import { pairOrderLines, suggestQuoteForOrder } from "./order-quote-link-service"; import { TAB_GROUP_ITEM_STATUSES, TAB_GROUP_ORDER, @@ -2211,6 +2212,42 @@ export async function createOrder(input: CreateOrderInput): Promise { + try { + await suggestQuoteForOrder(order.id); + } catch (err) { + console.error( + `[createOrder] quote auto-detection failed for order ${order.id}:`, + err instanceof Error ? err.message : err, + ); + } + }; + try { + after(runQuoteMatch); + } catch { + // `after()` throws synchronously when called outside a request + // scope — `workAsyncStorage.getStore()` comes back empty, which + // happens for any caller that isn't inside a Route Handler, Server + // Action, or Server Component (e.g. a smoke script or another + // background job invoking `createOrder` directly). Since that + // throw is synchronous, letting it escape would turn "matcher + // unavailable" into "order creation failed" — exactly the failure + // mode this whole block exists to avoid. Fall back to firing the + // match without blocking the return. + void runQuoteMatch(); + } + return { order, order_items_collection: orderItemsCollection,