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.
165 lines
5.4 KiB
TypeScript
165 lines
5.4 KiB
TypeScript
/**
|
|
* 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. 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";
|
|
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();
|
|
// 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 {
|
|
...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,
|
|
};
|
|
}
|
|
}
|
|
|
|
// 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
|
|
.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) {
|
|
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",
|
|
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: `"${matchText}" matches ${tiedRows.length} stock items equally well.`,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
...base,
|
|
status: "unmatched",
|
|
message: "No active stock item matches this SKU or description.",
|
|
};
|
|
});
|
|
}
|