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:
Dave 2026-08-29 14:06:10 +02:00
parent 0a5f352784
commit a0e7e152f7
2 changed files with 131 additions and 1 deletions

View file

@ -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)
// ──────────────────────────────────────────────────────────────────────

View file

@ -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
// ──────────────────────────────────────────────────────────────────────