feat(item-history): wire description-fuzzy matcher into getItemHistory

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-29 17:43:59 +02:00
parent d6570dca71
commit 43011d4d5e
2 changed files with 213 additions and 18 deletions

View file

@ -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();
});
});

View file

@ -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<ItemHistory> {
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<ItemHistory> {
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<ItemHistory> {
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<ItemHistory> {
}
: 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,