feat(item-history): wire description-fuzzy matcher into getItemHistory
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
d6570dca71
commit
43011d4d5e
2 changed files with 213 additions and 18 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue