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:
parent
1453f14419
commit
21bc57797b
2 changed files with 87 additions and 1 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue