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.
This commit is contained in:
Dave 2026-11-05 21:31:22 +02:00
parent 1ff82c2f7f
commit a85a6d9a9a
2 changed files with 96 additions and 14 deletions

View file

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

View file

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