feat(orders): pair lines when a quote is linked to an order
setOrderQuote is the single choke point for creating orders.quote — used by the UI, REST API, and Hermes/n8n. Call pairOrderLines(orderId, quoteId) right after the hard link lands so confident order-line to quote-line pairs persist automatically instead of needing a separate manual step. Pairing also runs on the idempotent no-op path (order already points at the requested quote) — it's safe to repeat and lets an operator refresh pairing by re-running the same call after editing order lines. A pairing failure never fails the link itself (console.error only, by design) — the hard link is the operator's important intent and must land even if pairing can't complete.
This commit is contained in:
parent
0a5f352784
commit
a0e7e152f7
2 changed files with 131 additions and 1 deletions
|
|
@ -243,6 +243,13 @@ vi.mock("./pocketbase-admin", () => ({
|
|||
getAdminPocketBase: vi.fn().mockResolvedValue(pbMock),
|
||||
}));
|
||||
|
||||
vi.mock("./order-quote-link-service", () => ({
|
||||
pairOrderLines: vi.fn().mockResolvedValue({
|
||||
confident: [], possible: [], unmatchedOrderItemIds: [],
|
||||
}),
|
||||
suggestQuoteForOrder: vi.fn().mockResolvedValue({ best: null }),
|
||||
}));
|
||||
|
||||
import {
|
||||
createOrder,
|
||||
createOrderFromQuote,
|
||||
|
|
@ -2660,6 +2667,89 @@ describe("setOrderQuote", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// setOrderQuote line pairing (Task 5)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("setOrderQuote line pairing", () => {
|
||||
it("pairs order lines to the quote's lines after writing the hard link", async () => {
|
||||
const { pairOrderLines } = await import("./order-quote-link-service");
|
||||
await pbMock.collection("quotes").create({ id: "q-1" });
|
||||
await pbMock.collection("orders").create({
|
||||
id: "o-1", order_number: "ORD-1", quote: "", created: "2026-08-21T00:00:00Z",
|
||||
});
|
||||
await pbMock.collection("order_items").create({
|
||||
id: "oi-1", order: "o-1", description: "hex bolt m20", item_status: "pending",
|
||||
});
|
||||
|
||||
const result = await setOrderQuote({ orderId: "o-1", quoteId: "q-1" });
|
||||
|
||||
expect(pairOrderLines).toHaveBeenCalledWith("o-1", "q-1");
|
||||
expect(result.order.quote).toBe("q-1");
|
||||
});
|
||||
|
||||
it("does not fail the link when pairing throws", async () => {
|
||||
const { pairOrderLines } = await import("./order-quote-link-service");
|
||||
vi.mocked(pairOrderLines).mockRejectedValueOnce(new Error("PB down"));
|
||||
|
||||
await pbMock.collection("quotes").create({ id: "q-1" });
|
||||
await pbMock.collection("orders").create({
|
||||
id: "o-1", order_number: "ORD-1", quote: "", created: "2026-08-21T00:00:00Z",
|
||||
});
|
||||
await pbMock.collection("order_items").create({
|
||||
id: "oi-1", order: "o-1", description: "hex bolt m20", item_status: "pending",
|
||||
});
|
||||
|
||||
const result = await setOrderQuote({ orderId: "o-1", quoteId: "q-1" });
|
||||
|
||||
// The link still landed despite the pairing failure.
|
||||
expect(result.order.quote).toBe("q-1");
|
||||
});
|
||||
|
||||
it("does not pair lines when the link is refused (order_locked)", async () => {
|
||||
const { pairOrderLines } = await import("./order-quote-link-service");
|
||||
vi.mocked(pairOrderLines).mockClear();
|
||||
|
||||
await pbMock.collection("quotes").create({ id: "q-1" });
|
||||
await pbMock.collection("orders").create({
|
||||
id: "o-1", order_number: "ORD-1", quote: "",
|
||||
});
|
||||
await pbMock.collection("order_items").create({
|
||||
id: "oi-1", order: "o-1", description: "x", item_status: "po_generated",
|
||||
});
|
||||
|
||||
try {
|
||||
await setOrderQuote({ orderId: "o-1", quoteId: "q-1" });
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as Error & { status?: number }).status).toBe(409);
|
||||
}
|
||||
|
||||
expect(pairOrderLines).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("also pairs lines on the idempotent no-op path (order already points at the quote)", async () => {
|
||||
const { pairOrderLines } = await import("./order-quote-link-service");
|
||||
vi.mocked(pairOrderLines).mockClear();
|
||||
|
||||
await pbMock.collection("quotes").create({ id: "q-1" });
|
||||
await pbMock.collection("orders").create({
|
||||
id: "o-1", order_number: "ORD-1", quote: "q-1",
|
||||
});
|
||||
await pbMock.collection("order_items").create({
|
||||
id: "oi-1", order: "o-1", description: "hex bolt m20", item_status: "pending",
|
||||
});
|
||||
|
||||
await setOrderQuote({ orderId: "o-1", quoteId: "q-1" });
|
||||
|
||||
// Re-running pairing on an already-linked order is safe (it never
|
||||
// overwrites an existing `quote_item`) and picks up lines added
|
||||
// or edited since the original link — so pairing runs here too,
|
||||
// even though the hard link itself is a no-op.
|
||||
expect(pairOrderLines).toHaveBeenCalledWith("o-1", "q-1");
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// findOrderByOrderNumber (Hermes unblock)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { getAdminPocketBase } from "./pocketbase-admin";
|
||||
import { pairOrderLines } from "./order-quote-link-service";
|
||||
import {
|
||||
TAB_GROUP_ITEM_STATUSES,
|
||||
TAB_GROUP_ORDER,
|
||||
|
|
@ -3720,9 +3721,15 @@ export async function setOrderQuote(
|
|||
const previous: { quote: string } = { quote: existing.quote || "" };
|
||||
|
||||
// Idempotent no-op when the order already points at this quote.
|
||||
// Don't write, don't stamp — just return the order as-is.
|
||||
// Don't write, don't stamp — just return the order as-is. Pairing
|
||||
// still runs on this path: it's safe to repeat (confident pairs
|
||||
// are only ever added, never overwritten) and it's the only way
|
||||
// to pick up lines that were added or edited after the original
|
||||
// link was made — e.g. a re-run of `orders.set_quote` used purely
|
||||
// to refresh pairing.
|
||||
if (previous.quote === quoteId) {
|
||||
const fresh = await pb.collection(ORDER_COLLECTION).getOne<Order>(orderId);
|
||||
await pairOrderLinesSafely(orderId, quoteId);
|
||||
return { order: fresh, previous };
|
||||
}
|
||||
|
||||
|
|
@ -3740,9 +3747,42 @@ ${stamp}` : stamp;
|
|||
await pb.collection(ORDER_COLLECTION).update(orderId, update);
|
||||
const updated = await pb.collection(ORDER_COLLECTION).getOne<Order>(orderId);
|
||||
|
||||
// Pair the order's lines to the quote's lines now that the link is
|
||||
// real. Confident pairs persist onto `order_items.quote_item`;
|
||||
// doubtful ones are surfaced in the UI for confirmation instead.
|
||||
await pairOrderLinesSafely(orderId, quoteId);
|
||||
|
||||
return { order: updated, previous };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `pairOrderLines`, never letting it fail the caller.
|
||||
*
|
||||
* A pairing failure must not undo or fail a link the operator (or
|
||||
* agent) just made — the hard link is the important write, pairing
|
||||
* is a best-effort enhancement on top of it. The pairing can always
|
||||
* be redone later by re-running `setOrderQuote` with the same
|
||||
* quoteId (see the idempotent-path comment above).
|
||||
*
|
||||
* `console.error` is the only trace of a pairing failure right now —
|
||||
* there's no user-facing surface for "the link worked but pairing
|
||||
* didn't." That's an acceptable gap for this task (see the plan's
|
||||
* notes on watch-for #4) but worth revisiting if silent pairing
|
||||
* failures turn out to be common in practice — e.g. surfacing them
|
||||
* via the `potential_quote` suggestion machinery or an operator-visible
|
||||
* warning instead of only a server log line.
|
||||
*/
|
||||
async function pairOrderLinesSafely(orderId: string, quoteId: string): Promise<void> {
|
||||
try {
|
||||
await pairOrderLines(orderId, quoteId);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[setOrderQuote] line pairing failed for order ${orderId} / quote ${quoteId}:`,
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// createOrderItemForOrder (Phase H) — strict-pending-gated item create
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Reference in a new issue