feat(orders): detect delivery-note repeats at order intake

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 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-08-30 12:58:36 +02:00
parent e0d1356da5
commit fa28aca8bc
2 changed files with 106 additions and 2 deletions

View file

@ -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);
});
});

View file

@ -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<CreateOrderR
// never fail an order creation: an unmatched order is a minor
// inconvenience, a lost order is not.
//
// Writes only the soft suggestion (`potential_quote`). Promoting it
// to a hard link stays a deliberate human click.
// Two independent `try/catch` blocks below, not one wrapping both —
// a delivery-note match failure must not prevent the quote
// suggestion from landing (and vice versa). Each matcher is
// visibility-only, so the worst-case outcome of either failing is a
// missing pill, never lost data.
//
// Writes only the soft suggestions (`potential_quote` +
// `potential_delivery_note`). Promoting either to a hard link stays
// a deliberate human click.
const runQuoteMatch = async () => {
try {
await suggestQuoteForOrder(order.id);
@ -2232,6 +2240,14 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
err instanceof Error ? err.message : err,
);
}
try {
await suggestDeliveryNoteForOrder(order.id);
} catch (err) {
console.error(
`[createOrder] delivery-note match failed for order ${order.id}:`,
err instanceof Error ? err.message : err,
);
}
};
try {
after(runQuoteMatch);