From fa28aca8bc7be64d643a9e7922d6e6fec3f86b4d Mon Sep 17 00:00:00 2001 From: Dave Date: Sun, 30 Aug 2026 12:58:36 +0200 Subject: [PATCH] feat(orders): detect delivery-note repeats at order intake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent try/catch blocks inside the existing runQuoteMatch closure in createOrder. Each matcher (quote + delivery-note) is its own scope, so a failure in one cannot starve the other. The intake-path after()/E468 guard from Task 6 stays in place — this adds the second matcher call alongside the first. The 3-test block in createOrder delivery-note auto-detection proves the independence in both directions: a quote matcher throw doesn't prevent the delivery-note match, and vice versa. Mutation-tested by merging the two try/catches — guards the design itself, not just the wiring. Co-Authored-By: Claude --- src/lib/server/orders-service.test.ts | 88 +++++++++++++++++++++++++++ src/lib/server/orders-service.ts | 20 +++++- 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/src/lib/server/orders-service.test.ts b/src/lib/server/orders-service.test.ts index 94b289f..753a09e 100644 --- a/src/lib/server/orders-service.test.ts +++ b/src/lib/server/orders-service.test.ts @@ -250,6 +250,13 @@ vi.mock("./order-quote-link-service", () => ({ suggestQuoteForOrder: vi.fn().mockResolvedValue({ best: null }), })); +vi.mock("./order-delivery-note-link-service", () => ({ + suggestDeliveryNoteForOrder: vi.fn().mockResolvedValue({ + best: null, + method: "no_match", + }), +})); + // `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 @@ -2968,3 +2975,84 @@ describe("createOrder quote auto-detection", () => { consoleErrorSpy.mockRestore(); }); }); + +describe("createOrder delivery-note auto-detection", () => { + it("also schedules a delivery-note match after the response", async () => { + const { suggestDeliveryNoteForOrder } = await import( + "./order-delivery-note-link-service" + ); + vi.mocked(suggestDeliveryNoteForOrder).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(suggestDeliveryNoteForOrder).toHaveBeenCalledWith(created.order.id); + }); + + it("a delivery-note match failure does not prevent the quote suggestion from running", async () => { + // The point of the two independent try/catch blocks — a delivery- + // note match failure (e.g. PB down for delivery_notes collection) + // must still leave the quote suggestion able to land. The two + // matchers are independent: one failing cannot starve the other. + const { suggestDeliveryNoteForOrder } = await import( + "./order-delivery-note-link-service" + ); + const { suggestQuoteForOrder } = await import("./order-quote-link-service"); + vi.mocked(suggestDeliveryNoteForOrder).mockClear(); + vi.mocked(suggestQuoteForOrder).mockClear(); + vi.mocked(suggestDeliveryNoteForOrder).mockRejectedValueOnce( + new Error("DN lookup failed"), + ); + + 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(suggestQuoteForOrder).toHaveBeenCalledWith(created.order.id); + }); + + it("a quote-match failure does not prevent the delivery-note match from running", async () => { + // The reverse direction — the quote matcher throws, the delivery- + // note matcher still runs. Proves the second try/catch is its + // own scope and not a continuation of the first. + const { suggestDeliveryNoteForOrder } = await import( + "./order-delivery-note-link-service" + ); + const { suggestQuoteForOrder } = await import("./order-quote-link-service"); + vi.mocked(suggestDeliveryNoteForOrder).mockClear(); + vi.mocked(suggestQuoteForOrder).mockClear(); + vi.mocked(suggestQuoteForOrder).mockRejectedValueOnce( + new Error("quote lookup failed"), + ); + + 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(suggestDeliveryNoteForOrder).toHaveBeenCalledWith(created.order.id); + }); +}); diff --git a/src/lib/server/orders-service.ts b/src/lib/server/orders-service.ts index 4cb1ffa..6cd6b76 100644 --- a/src/lib/server/orders-service.ts +++ b/src/lib/server/orders-service.ts @@ -1,6 +1,7 @@ import { after } from "next/server"; import { getAdminPocketBase } from "./pocketbase-admin"; import { pairOrderLines, suggestQuoteForOrder } from "./order-quote-link-service"; +import { suggestDeliveryNoteForOrder } from "./order-delivery-note-link-service"; import { TAB_GROUP_ITEM_STATUSES, TAB_GROUP_ORDER, @@ -2221,8 +2222,15 @@ export async function createOrder(input: CreateOrderInput): Promise { try { await suggestQuoteForOrder(order.id); @@ -2232,6 +2240,14 @@ export async function createOrder(input: CreateOrderInput): Promise