From 1ff82c2f7f7ddda5654220c6a8f6e99ffa27aa77 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 5 Nov 2026 08:05:21 +0200 Subject: [PATCH] feat(stock): resolve pasted stock lines to stock items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/lib/server/stock-quantity-match.test.ts | 143 +++++++++++++++++++ src/lib/server/stock-quantity-match.ts | 144 ++++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 src/lib/server/stock-quantity-match.test.ts create mode 100644 src/lib/server/stock-quantity-match.ts diff --git a/src/lib/server/stock-quantity-match.test.ts b/src/lib/server/stock-quantity-match.test.ts new file mode 100644 index 0000000..656e7f0 --- /dev/null +++ b/src/lib/server/stock-quantity-match.test.ts @@ -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); + }); +}); diff --git a/src/lib/server/stock-quantity-match.ts b/src/lib/server/stock-quantity-match.ts new file mode 100644 index 0000000..86e16fb --- /dev/null +++ b/src/lib/server/stock-quantity-match.ts @@ -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.", + }; + }); +}