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 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-08-29 14:22:21 +02:00
parent 1453f14419
commit 21bc57797b
2 changed files with 87 additions and 1 deletions

View file

@ -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<typeof import("next/server")>();
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);
});
});

View file

@ -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<CreateOrderR
}
}
// Quote auto-detection. ~85% of orders arrive against a quote we
// already produced, but nothing recorded that unless an operator
// linked it by hand — this is what closes that gap.
//
// Runs via `after()` so the candidate scan never sits in the intake
// response's critical path. Wrapped because a matcher failure must
// 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.
const runQuoteMatch = async () => {
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,