diff --git a/src/lib/server/order-quote-link-service.test.ts b/src/lib/server/order-quote-link-service.test.ts index 9cd0085..c5b205b 100644 --- a/src/lib/server/order-quote-link-service.test.ts +++ b/src/lib/server/order-quote-link-service.test.ts @@ -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(); + }); +}); diff --git a/src/lib/server/order-quote-link-service.ts b/src/lib/server/order-quote-link-service.ts index 72e5b1b..0a68f02 100644 --- a/src/lib/server/order-quote-link-service.ts +++ b/src/lib/server/order-quote-link-service.ts @@ -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 { + const pb = await getAdminPocketBase(); + pb.autoCancellation(false); + + const [orderItems, quoteItems] = await Promise.all([ + pb + .collection(ORDER_ITEMS_COLLECTION) + .getFullList({ + 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(); + + 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), + }; +}