feat(orders): persist confident order-line to quote-line pairs

Adds pairOrderLines to order-quote-link-service.ts: pairs an order's
lines against a quote's lines via pairQuoteLines, writes quote_item
only for confident (>=0.5 Jaccard) pairs, and never overwrites a
line's existing quote_item. Possible-tier pairs are returned but left
unwritten so an operator has to confirm them before a buyer sees a
note attached on a guess.

Reuses fetchQuoteItemsForQuotesAdmin (already used by the fuzzy pass
in this file) to fetch quote_items rather than reintroducing a raw
QUOTE_ITEMS_COLLECTION query. Writes stay sequential, matching the
existing bulk-update convention in orders-service.ts.
This commit is contained in:
Dave 2026-08-29 13:49:39 +02:00
parent 8e990e8a57
commit b4ad67680a
2 changed files with 212 additions and 1 deletions

View file

@ -1,5 +1,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { suggestQuoteForOrder } from "@/lib/server/order-quote-link-service";
import {
suggestQuoteForOrder,
pairOrderLines,
} from "@/lib/server/order-quote-link-service";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
vi.mock("@/lib/server/pocketbase-admin", () => ({
@ -294,3 +297,103 @@ describe("suggestQuoteForOrder", () => {
expect(update).not.toHaveBeenCalled();
});
});
describe("pairOrderLines", () => {
beforeEach(() => vi.clearAllMocks());
it("writes quote_item for a confident pair and leaves a possible pair unwritten", async () => {
// "M20 HEX BOLT GR8.8 ZINC" vs "Hex Bolt M20 Gr8.8 Zinc" is a
// confident pair (Jaccard 1.0, same token set once normalized).
// "cable gland brass M20" vs "cable gland stainless M25" is only
// possible (Jaccard 0.333 — shares "cable"/"gland", differs on
// material and size).
const update = vi.fn().mockResolvedValue({});
mockAdminPb(
makePb({
[ORDER_ITEMS]: {
getFullList: vi.fn().mockResolvedValue([
{ id: "oi_conf", description: "M20 HEX BOLT GR8.8 ZINC" },
{ id: "oi_poss", description: "cable gland brass M20" },
]),
update,
},
[QUOTE_ITEMS]: {
getFullList: vi.fn().mockResolvedValue([
{ id: "qi_conf", quote: "q1", description: "Hex Bolt M20 Gr8.8 Zinc" },
{ id: "qi_poss", quote: "q1", description: "cable gland stainless M25" },
]),
},
}),
);
const res = await pairOrderLines("ord1", "q1");
// Exactly one write, and only for the confident pair.
expect(update).toHaveBeenCalledTimes(1);
expect(update).toHaveBeenCalledWith("oi_conf", { quote_item: "qi_conf" });
expect(res.confident).toEqual([
expect.objectContaining({ orderItemId: "oi_conf", quoteItemId: "qi_conf" }),
]);
expect(res.possible).toEqual([
expect.objectContaining({ orderItemId: "oi_poss", quoteItemId: "qi_poss" }),
]);
expect(res.unmatchedOrderItemIds).toEqual([]);
});
it("reports order lines with no counterpart as unmatched", async () => {
mockAdminPb(
makePb({
[ORDER_ITEMS]: {
getFullList: vi.fn().mockResolvedValue([
{ id: "oi1", description: "galvanised threaded rod 3 metre" },
]),
update: vi.fn().mockResolvedValue({}),
},
[QUOTE_ITEMS]: {
getFullList: vi.fn().mockResolvedValue([
{ id: "qi1", quote: "q1", description: "silicone sealant clear cartridge" },
]),
},
}),
);
const res = await pairOrderLines("ord1", "q1");
expect(res.confident).toEqual([]);
expect(res.possible).toEqual([]);
expect(res.unmatchedOrderItemIds).toEqual(["oi1"]);
});
it("does not overwrite a quote_item that is already set", async () => {
const update = vi.fn().mockResolvedValue({});
mockAdminPb(
makePb({
[ORDER_ITEMS]: {
getFullList: vi.fn().mockResolvedValue([
{
id: "oi1",
description: "Hex Bolt M20 Gr8.8 Zinc",
quote_item: "qi_existing",
},
]),
update,
},
[QUOTE_ITEMS]: {
getFullList: vi.fn().mockResolvedValue([
{ id: "qi_new", quote: "q1", description: "Hex Bolt M20 Gr8.8 Zinc" },
]),
},
}),
);
const res = await pairOrderLines("ord1", "q1");
// Still reported as a confident pair for the caller's UI purposes...
expect(res.confident).toEqual([
expect.objectContaining({ orderItemId: "oi1", quoteItemId: "qi_new" }),
]);
// ...but the already-set relation is never clobbered.
expect(update).not.toHaveBeenCalled();
});
});

View file

@ -24,6 +24,7 @@ import { fetchQuoteItemsForQuotesAdmin } from "@/lib/server/quotes-service";
import type { QuoteItem } from "@/lib/types";
import {
scoreOrderVsQuote,
pairQuoteLines,
quoteWindowForOrder,
quoteToOrderAgeDays,
MATCH_CONFIDENCE_THRESHOLD,
@ -335,3 +336,110 @@ async function writeSuggestion(
potential_quote_confidence: best ? best.confidence : 0,
});
}
/* ============================================================================
* Per-line pairing
*
* `order_items.quote_item` has existed all along but was only ever
* written by `createOrderFromQuote`. When a quote is linked to an
* order after the fact, nothing paired the lines — so the process page
* re-guessed the pairing on every render with an exact/substring
* match, and any wording difference silently hid the buyer's note.
*
* Confident pairs are persisted here so the guess happens once.
* Possible pairs are returned but NOT written: the operator confirms
* them, so a buyer is never shown a note from the wrong line without
* being told it is a guess.
* ========================================================================== */
export interface PairedLine {
orderItemId: string;
quoteItemId: string;
/** Jaccard overlap of the two descriptions, 0..1. */
confidence: number;
}
export interface PairOrderLinesResult {
confident: PairedLine[];
possible: PairedLine[];
unmatchedOrderItemIds: string[];
}
/**
* Pair an order's lines against a quote's lines, persisting only the
* confident ones. Safe to re-run: a line whose `quote_item` is already
* set is never overwritten.
*/
export async function pairOrderLines(
orderId: string,
quoteId: string,
): Promise<PairOrderLinesResult> {
const pb = await getAdminPocketBase();
pb.autoCancellation(false);
const [orderItems, quoteItems] = await Promise.all([
pb
.collection(ORDER_ITEMS_COLLECTION)
.getFullList<LineRow & { quote_item?: string }>({
filter: `order = "${esc(orderId)}"`,
fields: "id,description,quote_item",
batch: 1000,
}),
// Reuses the same helper the fuzzy pass uses for quote_items — it
// already handles PB's IN(...) filter and the chunked-OR-chain
// fallback, so there is one code path in this file that knows how
// to fetch quote_items rather than two.
fetchQuoteItemsForQuotesAdmin(pb, [quoteId], "id,quote,description"),
]);
// `pairQuoteLines` returns indices into exactly the arrays passed to
// it. Both arrays below are built with a single `.map()` over
// `orderItems`/`quoteItems` — `.map()` is index-preserving by
// definition, so `orderLineIdx`/`quoteLineIdx` always refer back to
// the same position in `orderItems`/`quoteItems`. No sort or filter
// runs on either array between the fetch and this call, so the
// mapping can't be thrown off.
const pairs = pairQuoteLines(
orderItems.map((r) => ({ description: r.description || "" })),
quoteItems.map((r) => ({ description: r.description || "" })),
);
const confident: PairedLine[] = [];
const possible: PairedLine[] = [];
const pairedOrderIdx = new Set<number>();
for (const p of pairs) {
pairedOrderIdx.add(p.orderLineIdx);
const entry: PairedLine = {
orderItemId: orderItems[p.orderLineIdx].id,
quoteItemId: quoteItems[p.quoteLineIdx].id,
confidence: p.jaccard,
};
if (p.tier === "confident") confident.push(entry);
else possible.push(entry);
}
// Persist confident pairs only, and never clobber an existing link
// (a line paired by createOrderFromQuote, or confirmed by hand).
//
// Sequential, one write per line — same tradeoff as the bulk-cancel
// and delivery loops elsewhere in orders-service.ts ("one PB
// round-trip per write is fine here; we cap at a few dozen items
// per order in practice"). Parallelizing with Promise.all would add
// concurrent-write risk against PB for no real gain at this volume.
for (const c of confident) {
const row = orderItems.find((r) => r.id === c.orderItemId);
if (row?.quote_item) continue;
await pb.collection(ORDER_ITEMS_COLLECTION).update(c.orderItemId, {
quote_item: c.quoteItemId,
});
}
return {
confident,
possible,
unmatchedOrderItemIds: orderItems
.filter((_, idx) => !pairedOrderIdx.has(idx))
.map((r) => r.id),
};
}