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.`, }; } }