feat(stock): resolve pasted stock lines to stock items

Pure matching, no PocketBase, so the rules can be tested on their own.
Exact SKU first (normalised), then a tokenised Jaccard description
match against the item's name and description.

The description match takes the top candidate unless two are tied at
the top — a single threshold alone would report 'ambiguous' for almost
every description and defeat the feature.
This commit is contained in:
Dave 2026-11-05 08:05:21 +02:00
parent 00fbada312
commit 1ff82c2f7f
2 changed files with 287 additions and 0 deletions

View file

@ -0,0 +1,143 @@
import { describe, it, expect } from "vitest";
import { resolveStockQuantityLines } from "./stock-quantity-match";
const CANDIDATES = [
{
id: "id-abc",
sku: "M16-FLAT-WASHER",
name: "M16 Flat Washer",
description: "Flat washer M16 zinc plated",
current_qty: 12,
is_active: true,
},
{
id: "id-def",
sku: "BOLT-M6-40",
name: "Bolt M6 40mm",
description: "Hex bolt M6 x 40mm",
current_qty: 5,
is_active: true,
},
{
id: "id-ghi",
sku: "BOLT-M6-60",
name: "Bolt M6 60mm",
description: "Hex bolt M6 x 60mm",
current_qty: 0,
is_active: true,
},
{
id: "id-jkl",
sku: "OLD-ITEM",
name: "Retired Widget",
description: "No longer sold",
current_qty: 3,
is_active: false,
},
];
describe("resolveStockQuantityLines", () => {
it("matches an exact SKU", () => {
const out = resolveStockQuantityLines(
[{ sku: "M16-FLAT-WASHER", qty: 50 }],
CANDIDATES,
);
expect(out[0].status).toBe("matched");
expect(out[0].match_method).toBe("sku_exact");
expect(out[0].item?.id).toBe("id-abc");
});
it("matches a SKU that differs only in case and punctuation", () => {
const out = resolveStockQuantityLines(
[{ sku: "m16 flat washer", qty: 50 }],
CANDIDATES,
);
expect(out[0].status).toBe("matched");
expect(out[0].match_method).toBe("sku_exact");
expect(out[0].item?.id).toBe("id-abc");
});
it("matches on description and flags it as a fuzzy match", () => {
const out = resolveStockQuantityLines(
[{ description: "Hex bolt M6 x 40mm", qty: 120 }],
CANDIDATES,
);
expect(out[0].status).toBe("matched");
expect(out[0].match_method).toBe("description_fuzzy");
expect(out[0].item?.id).toBe("id-def");
});
it("reports a line with no identifier as unmatched", () => {
const out = resolveStockQuantityLines([{ qty: 5 }], CANDIDATES);
expect(out[0].status).toBe("unmatched");
expect(out[0].message).toMatch(/neither a sku nor a description/i);
});
it("reports a SKU that matches nothing as unmatched", () => {
const out = resolveStockQuantityLines([{ sku: "ZZZ-9", qty: 30 }], CANDIDATES);
expect(out[0].status).toBe("unmatched");
expect(out[0].message).toMatch(/no active stock item/i);
});
it("does not write to an inactive item and says why", () => {
const out = resolveStockQuantityLines([{ sku: "OLD-ITEM", qty: 3 }], CANDIDATES);
expect(out[0].status).toBe("unmatched");
expect(out[0].message).toMatch(/inactive/i);
});
it("reports two candidates with the same SKU as ambiguous", () => {
// Two catalogue rows carrying the SAME SKU. The dup row repeats
// "BOLT-M6-40" verbatim — SKU matching is on the normalised key
// (punctuation stripped), so "BOLT-M6" and "BOLT-M6-40" are
// different keys and would NOT collide here.
const out = resolveStockQuantityLines(
[{ sku: "BOLT-M6-40", qty: 10 }],
[
...CANDIDATES,
{ id: "id-dup", sku: "BOLT-M6-40", name: "Bolt M6 40mm", description: "", current_qty: 1 },
],
);
expect(out[0].status).toBe("ambiguous");
expect(out[0].candidates?.length).toBe(2);
});
it("reports a tie between two equally good description matches as ambiguous", () => {
// Two rows whose name + description are the same, so a description
// query scores identically against both. That tie is the genuine
// ambiguity — the operator picks, nothing is written.
const out = resolveStockQuantityLines(
[{ description: "Widget Alpha", qty: 10 }],
[
{
id: "id-tie-1",
sku: "TIE-A",
name: "Widget Alpha",
description: "Widget Alpha blue",
current_qty: 1,
is_active: true,
},
{
id: "id-tie-2",
sku: "TIE-B",
name: "Widget Alpha",
description: "Widget Alpha blue",
current_qty: 2,
is_active: true,
},
],
);
expect(out[0].status).toBe("ambiguous");
expect(out[0].match_method).toBeUndefined();
expect(out[0].candidates?.length).toBe(2);
expect(out[0].item).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);
});
});

View file

@ -0,0 +1,144 @@
/**
* Stock quantity line resolution — pure. No PocketBase, no I/O.
*
* The operator pastes lines of "SKU description qty" into the Giellie
* chat. This module decides which stock item each line means. It is
* kept pure so the matching rules can be tested without a database and
* so the same rules can be reused by a future batch-import path.
*
* Resolution order per line:
* 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.
*/
import { normaliseSku } from "@/lib/stock-match";
import { jaccard, tokenize } from "@/lib/server/order-quote-match";
/** Tokenised Jaccard score at or above which a description counts as a match. */
export const DESCRIPTION_MATCH_THRESHOLD = 0.6;
export type StockQuantityLine = {
sku?: string;
description?: string;
qty: number;
};
export type StockQuantityCandidate = {
id: string;
sku?: string;
name?: string;
description?: string;
current_qty?: number;
is_active?: boolean;
};
export type StockQuantityMatchMethod = "sku_exact" | "description_fuzzy";
export type ResolvedStockQuantityLine = {
index: number;
sku: string;
description: string;
qty: number;
status: "matched" | "unmatched" | "ambiguous";
match_method?: StockQuantityMatchMethod;
item?: StockQuantityCandidate;
candidates?: StockQuantityCandidate[];
message?: string;
};
export function resolveStockQuantityLines(
lines: StockQuantityLine[],
candidates: StockQuantityCandidate[],
): ResolvedStockQuantityLine[] {
return lines.map((line, index) => {
const sku = (line.sku ?? "").trim();
const description = (line.description ?? "").trim();
const base = { index, sku, description, qty: line.qty };
if (!sku && !description) {
return {
...base,
status: "unmatched",
message: "Line has neither a sku nor a description.",
};
}
if (sku) {
const norm = normaliseSku(sku);
const hits = norm
? candidates.filter((c) => normaliseSku(c.sku) === norm)
: [];
if (hits.length > 1) {
return {
...base,
status: "ambiguous",
candidates: hits,
message: `${hits.length} stock items share the SKU "${sku}".`,
};
}
if (hits.length === 1) {
const hit = hits[0];
if (hit.is_active === false) {
return {
...base,
status: "unmatched",
message: `SKU "${sku}" matches "${hit.name ?? hit.id}", but that stock item is inactive.`,
};
}
return {
...base,
status: "matched",
match_method: "sku_exact",
item: hit,
};
}
}
if (description) {
const wanted = tokenize(description);
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 ?? ""}`)),
}))
.filter((s) => s.score >= DESCRIPTION_MATCH_THRESHOLD)
.sort((a, b) => b.score - a.score);
if (scored.length > 0) {
const top = scored[0];
const runnerUp = scored[1];
const tied = runnerUp !== undefined && runnerUp.score === top.score;
if (!tied) {
return {
...base,
status: "matched",
match_method: "description_fuzzy",
item: top.item,
};
}
const tiedRows = scored.filter((s) => s.score === top.score);
return {
...base,
status: "ambiguous",
candidates: tiedRows.map((s) => s.item),
message: `"${description}" matches ${tiedRows.length} stock items equally well.`,
};
}
}
}
return {
...base,
status: "unmatched",
message: "No active stock item matches this SKU or description.",
};
});
}