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:
parent
8e990e8a57
commit
b4ad67680a
2 changed files with 212 additions and 1 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue