diff --git a/src/lib/server/item-history-service.test.ts b/src/lib/server/item-history-service.test.ts index ec0e533..11e6d05 100644 --- a/src/lib/server/item-history-service.test.ts +++ b/src/lib/server/item-history-service.test.ts @@ -235,20 +235,163 @@ describe("getItemHistory service", () => { }); it("returns empty match when SKU is empty and no fuzzy hit", async () => { - const targetItem = { id: "qi-x", sku: "", description: "" }; + // Target has a description but no candidate clears the 0.6 Jaccard + // threshold, so the deterministic side produces nothing. + const targetItem = { id: "qi-x", sku: "", description: "Hammer 16oz claw" }; + const getList = vi.fn(async (page: number, perPage: number, opts?: { filter?: string }) => { + // The fuzzy-candidates call is the one that filters by `description != ""`. + // The other 4 SKU queries are skipped because effectiveSku is empty. + if (opts?.filter?.includes('description != ""')) { + return { + items: [ + { id: "c1", sku: "PAINT-BRUSH", description: "Paint brush 50mm" }, + { id: "c2", sku: "SCREW-M6", description: "M6 cheese head screw" }, + ], + }; + } + return { items: [] }; + }); + const getOne = vi.fn().mockResolvedValue(targetItem); const mockPb = { - collection: vi.fn(() => ({ - getOne: vi.fn().mockResolvedValue(targetItem), - getList: vi.fn().mockResolvedValue({ items: [] }), - })), + collection: vi.fn((name: string) => { + if (name === "quote_items") return { getOne, getList }; + return { getList: vi.fn().mockResolvedValue({ items: [] }), getOne: vi.fn() }; + }), }; vi.mocked(getAdminPocketBase).mockResolvedValue(mockPb as never); const history = await getItemHistory("qi-x"); expect(history.match.match_kind).toBe("none"); + expect(history.match.match_confidence).toBe(0); expect(history.suppliers).toEqual([]); expect(history.other_quotes).toEqual([]); expect(history.cheapest).toBeNull(); + // The fuzzy-candidates call was made (we should see description != "" filter). + const candidateCall = getList.mock.calls.find( + (call) => typeof call[2] === "object" && call[2]?.filter?.includes('description != ""') + ); + expect(candidateCall).toBeDefined(); + }); + + it("returns a populated history when SKU is empty but description matches via fuzzy", async () => { + // Target has a description and no SKU. A candidate's description is + // similar enough to clear the 0.6 threshold, so the matched SKU + // (ELEC-M20-BR) becomes the effective SKU for the 4 SKU queries. + const targetItem = { + id: "qi-1", + sku: "", + description: "Cable gland M20 brass", + }; + const fuzzyCandidates = { + items: [ + { id: "qi-c1", sku: "PAINT-BRUSH", description: "Paint brush 50mm" }, + { id: "qi-c2", sku: "ELEC-M20-BR", description: "Cable gland M20 brass nickel plated" }, + { id: "qi-c3", sku: "SCREW-M6", description: "M6 cheese head screw" }, + ], + }; + const ordersBySupplier = { + items: [ + { id: "po1", supplier: "sup-a", cost_price: 42, updated: "2026-07-11T10:00:00Z" }, + ], + }; + const otherQuotes = { items: [] }; + const lastSold = { items: [] }; + const stock = { items: [] }; + const suppliersById = { + items: [{ id: "sup-a", suppliername: "Fastener Co" }], + }; + + const getList = vi.fn(async (page: number, perPage: number, opts?: { filter?: string }) => { + if (opts?.filter?.includes('description != ""')) return fuzzyCandidates; + if (opts?.filter?.includes('ELEC-M20-BR')) { + // Whichever of the 4 SKU queries fired — return ordersBySupplier if + // it's the order_items one, otherwise the empty list. The + // service doesn't inspect the filter, so any non-empty result + // surfaces suppliers. + if (opts.filter.startsWith("sku = ")) return ordersBySupplier; + } + // The fuzzy-candidates path uses `description != ""` and returns + // fuzzyCandidates. Suppliers list is also a getList. + if (page === 1 && perPage === 200) return suppliersById; + return { items: [] }; + }); + const getOne = vi.fn().mockResolvedValue(targetItem); + const mockPb = { + collection: vi.fn((name: string) => { + if (name === "quote_items") return { getOne, getList }; + return { getList, getOne }; + }), + }; + vi.mocked(getAdminPocketBase).mockResolvedValue(mockPb as never); + + const history = await getItemHistory("qi-1"); + expect(history.match.match_kind).toBe("description_fuzzy"); + expect(history.match.match_confidence).toBeGreaterThan(0.6); + expect(history.match.sku).toBe("ELEC-M20-BR"); + expect(history.suppliers).toHaveLength(1); + expect(history.suppliers[0].supplier_name).toBe("Fastener Co"); + // Verify at least one of the 4 SKU queries was made with the effective SKU. + const effectiveSkuCall = getList.mock.calls.find( + (call) => typeof call[2] === "object" && call[2]?.filter?.includes("ELEC-M20-BR") + ); + expect(effectiveSkuCall).toBeDefined(); + }); + + it("uses SKU when both SKU and description are present (SKU wins)", async () => { + // Target has both. The fuzzy-candidates query should NOT be made — + // SKU is present so we skip the fuzzy path entirely. + const targetItem = { + id: "qi-2", + sku: "DRL-6MM", + description: "Drill bit 6mm HSS", + }; + const getList = vi.fn(async (_page: number, _perPage: number, opts?: { filter?: string }) => { + if (opts?.filter?.includes('description != ""')) { + throw new Error("fuzzy candidates should not be fetched when SKU is present"); + } + return { items: [] }; + }); + const getOne = vi.fn().mockResolvedValue(targetItem); + const mockPb = { + collection: vi.fn((name: string) => { + if (name === "quote_items") return { getOne, getList }; + return { getList, getOne }; + }), + }; + vi.mocked(getAdminPocketBase).mockResolvedValue(mockPb as never); + + const history = await getItemHistory("qi-2"); + expect(history.match.match_kind).toBe("sku_exact"); + expect(history.match.match_confidence).toBe(1); + expect(history.match.sku).toBe("DRL-6MM"); + // No fuzzy-candidates call. + const candidateCall = getList.mock.calls.find( + (call) => typeof call[2] === "object" && call[2]?.filter?.includes('description != ""') + ); + expect(candidateCall).toBeUndefined(); + }); + + it("bails out when target has no SKU and no description", async () => { + // Both empty — early-return, no PB queries after the target load. + const targetItem = { id: "qi-empty", sku: "", description: "" }; + const getList = vi.fn().mockResolvedValue({ items: [] }); + const getOne = vi.fn().mockResolvedValue(targetItem); + const mockPb = { + collection: vi.fn((name: string) => { + if (name === "quote_items") return { getOne, getList }; + return { getList, getOne }; + }), + }; + vi.mocked(getAdminPocketBase).mockResolvedValue(mockPb as never); + + const history = await getItemHistory("qi-empty"); + expect(history.match.match_kind).toBe("none"); + expect(history.suppliers).toEqual([]); + // The fuzzy-candidates call should NOT happen (no description to match on). + const candidateCall = getList.mock.calls.find( + (call) => typeof call[2] === "object" && call[2]?.filter?.includes('description != ""') + ); + expect(candidateCall).toBeUndefined(); }); }); diff --git a/src/lib/server/item-history-service.ts b/src/lib/server/item-history-service.ts index 198c058..41a66a9 100644 --- a/src/lib/server/item-history-service.ts +++ b/src/lib/server/item-history-service.ts @@ -3,9 +3,15 @@ * * Two layers: * 1. Pure matching helpers (testable in isolation, no PB). - * 2. `getItemHistory(itemId)` — runs the four PB queries, applies the + * 2. `getItemHistory(itemId)` — runs the PB queries, applies the * matchers, returns a typed `ItemHistory`. * + * When the target quote_item has its own SKU, the service runs the four + * SKU-based queries (order_items, other quotes, last sold, stock). + * When the target is description-only, it first runs a fuzzy match + * against recent quote_items and uses the matched item's SKU as the + * effective filter for those same four queries. + * * The matching helpers are exported so other features (process orders, * price-list backfill) can reuse them. Keep them PB-free. */ @@ -150,19 +156,58 @@ export async function getItemHistory(itemId: string): Promise { return emptyHistory(generated_at, match); } - // 2. Fan out 5 PB reads in parallel: order_items by SKU, quote_items by SKU + // 2a. Fuzzy candidates — only fetched when there's no SKU but a description + // to match on. 500 freshest quote_items with non-empty descriptions, + // excluding the target itself. Sorted by -created so the freshest items + // are tried first (best chance of recent supplier history). + const fuzzyCandidates = !target.sku && target.description + ? pb.collection("quote_items").getList<{ + id: string; + sku: string; + description: string; + }>(1, 500, { + filter: `id != "${itemId}" && description != ""`, + sort: "-created", + fields: "id,sku,description", + }) + : Promise.resolve({ + items: [] as Array<{ id: string; sku: string; description: string }>, + }); + + // 2b. Run the matcher. Threshold 0.6 (Jaccard) — tight enough to avoid + // "tangentially related" false positives, loose enough to catch the + // common "buyer wrote it slightly differently" case. + const fuzzyMatch = !target.sku && target.description + ? findBestDescriptionMatch( + { id: itemId, sku: "", description: target.description }, + (await fuzzyCandidates).items, + 0.6, + ) + : null; + + // 2c. The SKU the 4 SKU-based queries will actually filter on. If the + // target has its own SKU it wins (exact match). If not, fall back to + // the fuzzy-matched candidate's SKU (the "best guess" from PB). + const effectiveSku = target.sku || (fuzzyMatch?.item.sku ?? ""); + + // 3. Fan out 5 PB reads in parallel: order_items by SKU, quote_items by SKU // (other quotes), order_items delivered by SKU (last sold), - // pos by SKU (stock), suppliers (for name enrichment). + // pos by SKU (stock), suppliers (for name enrichment). All four SKU + // queries are gated on `effectiveSku` — either the target's own or the + // fuzzy-matched candidate's. + const skuFilter = effectiveSku + ? `sku = "${effectiveSku.replace(/"/g, '\\"')}"` + : ""; const [orderItems, quoteItems, lastSoldRows, stockRows, suppliersRows] = await Promise.all([ - target.sku + effectiveSku ? pb.collection("order_items").getList<{ id: string; supplier: string; cost_price: number; updated: string; - }>(1, 100, { filter: `sku = "${target.sku.replace(/"/g, '\\"')}"` }) + }>(1, 100, { filter: skuFilter }) : Promise.resolve({ items: [] as Array<{ id: string; supplier: string; cost_price: number; updated: string }> }), - target.sku + effectiveSku ? pb.collection("quote_items").getList<{ id: string; quote: string; @@ -170,11 +215,11 @@ export async function getItemHistory(itemId: string): Promise { created: string; expand?: { quote?: { quote_number: string; status: string; customer_name?: string; company?: string } }; }>(1, 100, { - filter: `sku = "${target.sku.replace(/"/g, '\\"')}" && id != "${itemId}"`, + filter: `${skuFilter} && id != "${itemId}"`, expand: "quote", }) : Promise.resolve({ items: [] as Array<{ id: string; quote: string; selling_price: number; created: string; expand?: { quote?: { quote_number: string; status: string; customer_name?: string; company?: string } } }> }), - target.sku + effectiveSku ? pb.collection("order_items").getList<{ id: string; selling_price: number; @@ -182,16 +227,16 @@ export async function getItemHistory(itemId: string): Promise { order: string; expand?: { order?: { order_number: string; company?: string } }; }>(1, 1, { - filter: `sku = "${target.sku.replace(/"/g, '\\"')}" && item_status = "delivered"`, + filter: `${skuFilter} && item_status = "delivered"`, sort: "-updated", expand: "order", }) : Promise.resolve({ items: [] as Array<{ id: string; selling_price: number; updated: string; order: string; expand?: { order?: { order_number: string; company?: string } } }> }), - target.sku + effectiveSku ? pb.collection("pos").getList<{ sku: string; stock_qty: number; - }>(1, 50, { filter: `sku = "${target.sku.replace(/"/g, '\\"')}"` }) + }>(1, 50, { filter: skuFilter }) : Promise.resolve({ items: [] as Array<{ sku: string; stock_qty: number }> }), // Suppliers list — capped at 200, full names for any ID we encounter. pb.collection("suppliers").getList<{ id: string; suppliername: string }>(1, 200), @@ -238,8 +283,15 @@ export async function getItemHistory(itemId: string): Promise { } : null; - match.match_kind = target.sku ? "sku_exact" : "description_fuzzy"; - match.match_confidence = target.sku ? 1 : 0.7; + if (target.sku) { + match.match_kind = "sku_exact"; + match.match_confidence = 1; + } else if (fuzzyMatch) { + match.match_kind = "description_fuzzy"; + match.match_confidence = fuzzyMatch.confidence; + match.sku = effectiveSku; + } + // else: match_kind stays "none", match_confidence stays 0 return { generated_at,