From a85a6d9a9a3379c249c2b3eb8df56dd2d041ee67 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 5 Nov 2026 21:31:22 +0200 Subject: [PATCH] feat(stock): match the SKU text too, and name inactive best matches Three changes from the Task 2 review. The SKU string gets a second chance at matching by how the item is named. When the exact SKU misses, the line's description AND its SKU are joined and scored as text, so a line carrying only a SKU still finds its item. The match method stays "description_fuzzy" - it is a text match either way, and the threshold and the top-unless-tied rule are unchanged. An inactive best match now names itself. The text step scores every candidate, active or inactive, exactly as the SKU step does, so an inactive row is no longer filtered out before scoring - it is reported as unmatched with its name and the reason, instead of vanishing and leaving a bare "no match" behind. The resolver truncates the quantity. Quantities are whole units, so the dry-run plan and the stored value now agree instead of the plan reporting 12.7 for a write that stores 12. --- src/lib/server/stock-quantity-match.test.ts | 69 +++++++++++++++++++-- src/lib/server/stock-quantity-match.ts | 41 +++++++++--- 2 files changed, 96 insertions(+), 14 deletions(-) diff --git a/src/lib/server/stock-quantity-match.test.ts b/src/lib/server/stock-quantity-match.test.ts index 656e7f0..a3b5bdd 100644 --- a/src/lib/server/stock-quantity-match.test.ts +++ b/src/lib/server/stock-quantity-match.test.ts @@ -99,6 +99,18 @@ describe("resolveStockQuantityLines", () => { ); expect(out[0].status).toBe("ambiguous"); expect(out[0].candidates?.length).toBe(2); + // Nothing is written — the operator picks one of the two. + expect(out[0].item).toBeUndefined(); + expect(out[0].match_method).toBeUndefined(); + }); + + it("keeps the line index so callers can map rows back", () => { + const out = resolveStockQuantityLines( + [{ sku: "ZZZ-9", qty: 1 }, { sku: "M16-FLAT-WASHER", qty: 2 }], + CANDIDATES, + ); + expect(out[0].index).toBe(0); + expect(out[1].index).toBe(1); }); it("reports a tie between two equally good description matches as ambiguous", () => { @@ -132,12 +144,61 @@ describe("resolveStockQuantityLines", () => { expect(out[0].item).toBeUndefined(); }); - it("keeps the line index so callers can map rows back", () => { + it("falls through to the text match when the SKU matches nothing", () => { + // The SKU hits no row, so the line is matched on its text instead. const out = resolveStockQuantityLines( - [{ sku: "ZZZ-9", qty: 1 }, { sku: "M16-FLAT-WASHER", qty: 2 }], + [{ sku: "ZZZ-9", description: "Hex bolt M6 x 40mm", qty: 120 }], CANDIDATES, ); - expect(out[0].index).toBe(0); - expect(out[1].index).toBe(1); + expect(out[0].status).toBe("matched"); + expect(out[0].match_method).toBe("description_fuzzy"); + expect(out[0].item?.id).toBe("id-def"); + }); + + it("matches a SKU that hits nothing by how the item is named", () => { + // No description on the line — the SKU string itself is the match + // text, and it finds the row by its name. + const out = resolveStockQuantityLines( + [{ sku: "Flat Washer M16 Zinc Plated", qty: 20 }], + CANDIDATES, + ); + expect(out[0].status).toBe("matched"); + expect(out[0].match_method).toBe("description_fuzzy"); + expect(out[0].item?.id).toBe("id-abc"); + }); + + it("names the inactive item when the text match is inactive", () => { + // The best text match is an inactive row. It is never written to, + // and the reason is stated rather than swallowed. + const out = resolveStockQuantityLines( + [{ description: "Retired Widget Assembly", qty: 3 }], + [ + { + id: "id-old", + sku: "OLD-WIDGET", + name: "Retired Widget", + description: "Retired Widget assembly", + current_qty: 3, + is_active: false, + }, + ], + ); + expect(out[0].status).toBe("unmatched"); + expect(out[0].message).toMatch(/Retired Widget/); + expect(out[0].message).toMatch(/inactive/i); + expect(out[0].item).toBeUndefined(); + }); + + it("truncates the quantity to a whole unit", () => { + const out = resolveStockQuantityLines( + [ + { sku: "M16-FLAT-WASHER", qty: 12.7 }, + { sku: "BOLT-M6-40", qty: -3.7 }, + ], + CANDIDATES, + ); + expect(out[0].qty).toBe(12); + // Truncation is toward zero, so the negative lands on -3. + expect(out[1].qty).toBe(-3); }); }); diff --git a/src/lib/server/stock-quantity-match.ts b/src/lib/server/stock-quantity-match.ts index 86e16fb..96cf550 100644 --- a/src/lib/server/stock-quantity-match.ts +++ b/src/lib/server/stock-quantity-match.ts @@ -10,11 +10,16 @@ * 1. Exact SKU, normalised (uppercase, punctuation stripped). One * hit matches; two or more are ambiguous; a single inactive hit is * reported as unmatched with the reason. - * 2. Description match — tokenised Jaccard against the item's name - * and description. The top score wins unless two candidates are - * tied at the top, which is the genuine ambiguity. The operator - * asked for this to be used rather than refused, so it is flagged - * as a fuzzy match instead of being silent. + * 2. Text match — the line's description AND its SKU, joined, scored + * as tokenised Jaccard against the item's name and description. + * The top score wins unless two candidates are tied at the top, + * which is the genuine ambiguity. The operator asked for this to + * be used rather than refused, so it is flagged as a fuzzy match + * instead of being silent. + * + * Both steps score inactive rows and then refuse to write to them, so + * an inactive best match says so by name instead of vanishing and + * leaving a bare "no match" behind. */ import { normaliseSku } from "@/lib/stock-match"; @@ -59,7 +64,12 @@ export function resolveStockQuantityLines( return lines.map((line, index) => { const sku = (line.sku ?? "").trim(); const description = (line.description ?? "").trim(); - const base = { index, sku, description, qty: line.qty }; + // Quantities are whole units. Truncating here means the dry-run + // plan and the stored value agree, instead of the plan reporting + // 12.7 for a write that stores 12. Matches the `Math.trunc` in + // `stock-quantities-service.ts`. + const qty = Number.isFinite(line.qty) ? Math.trunc(line.qty) : line.qty; + const base = { index, sku, description, qty }; if (!sku && !description) { return { @@ -100,11 +110,15 @@ export function resolveStockQuantityLines( } } - if (description) { - const wanted = tokenize(description); + // The SKU gets a second chance here: when the exact SKU misses, + // the line's description AND its SKU are matched as text against + // how the item is named. The match method stays "description_fuzzy" + // — it is a text match either way. + const matchText = [description, sku].filter(Boolean).join(" "); + if (matchText) { + const wanted = tokenize(matchText); if (wanted.size > 0) { const scored = candidates - .filter((c) => c.is_active !== false) .map((c) => ({ item: c, score: jaccard(wanted, tokenize(`${c.name ?? ""} ${c.description ?? ""}`)), @@ -117,6 +131,13 @@ export function resolveStockQuantityLines( const runnerUp = scored[1]; const tied = runnerUp !== undefined && runnerUp.score === top.score; if (!tied) { + if (top.item.is_active === false) { + return { + ...base, + status: "unmatched", + message: `"${matchText}" matches "${top.item.name ?? top.item.id}", but that stock item is inactive.`, + }; + } return { ...base, status: "matched", @@ -129,7 +150,7 @@ export function resolveStockQuantityLines( ...base, status: "ambiguous", candidates: tiedRows.map((s) => s.item), - message: `"${description}" matches ${tiedRows.length} stock items equally well.`, + message: `"${matchText}" matches ${tiedRows.length} stock items equally well.`, }; } }