/** * 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.", }; }); }