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:
parent
e0d1356da5
commit
fa28aca8bc
2 changed files with 106 additions and 2 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue