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:
parent
00fbada312
commit
1ff82c2f7f
2 changed files with 287 additions and 0 deletions
143
src/lib/server/stock-quantity-match.test.ts
Normal file
143
src/lib/server/stock-quantity-match.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
144
src/lib/server/stock-quantity-match.ts
Normal file
144
src/lib/server/stock-quantity-match.ts
Normal 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.",
|
||||
};
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue