diff --git a/src/lib/dn-pipeline/match-po-item.test.ts b/src/lib/dn-pipeline/match-po-item.test.ts new file mode 100644 index 0000000..c022405 --- /dev/null +++ b/src/lib/dn-pipeline/match-po-item.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from "vitest"; +import { resolvePoItem, dims } from "./match-po-item"; + +const VULA = [ + { id: "v1", description: "IJ Pipes 450x2.5 100D", material_code: "IJ450" }, + { id: "v2", description: "IJ PIPES-600 X 2.5(100D)", material_code: "IJ600" }, + { id: "v3", description: "IJ PIPES-675 X 2.5(100D)", material_code: "IJ675" }, + { id: "v4", description: "IJ PIPES-750 X 2.5(100D)", material_code: "IJ750" }, + { id: "v5", description: "IJ PIPES-900 X 2.5(100D)", material_code: "IJ900" }, + { id: "v6", description: "SandS Pipes-1050 X 2.5(100D)RR", material_code: "SS1050" }, +]; + +const AVIDAN = [ + { id: "a1", description: "160mm Sleeves Black", material_code: "SLV160" }, + { id: "a2", description: "110mm Sleeves Black", material_code: "SLV110" }, + { id: "a3", description: "Roll Draw Wire", material_code: "DW" }, + { id: "a4", description: "160mm uPVC Cl 16 Pipe", material_code: "PVC160" }, + { id: "a5", description: "160mm HDPE PN 16 Pipe", material_code: "HDPE160" }, + { id: "a6", description: "160mm Flange Adapter", material_code: "FA160" }, + { id: "a7", description: "160mm End Cap Socketed", material_code: "EC160" }, + { id: "a8", description: "Roll Denso Tape", material_code: "DT" }, + { id: "a9", description: "90 Deg Elbow Galv", material_code: "ELB90" }, +]; + +describe("dims", () => { + it("takes integers of 100 or more as the product identity", () => { + expect(dims("IJ PIPES-450 X 2.5(100D)")).toEqual(new Set([450, 100])); + expect(dims("IJ Pipes 450x2.5 100D")).toEqual(new Set([450, 100])); + expect(dims("SandS Pipes-1050 X 2.5(100D)RR")).toEqual(new Set([1050, 100])); + expect(dims("50KG GALV DRAW WIRE 3.15mm")).toEqual(new Set()); + expect(dims("90 Deg Elbow Galv")).toEqual(new Set()); + }); +}); + +describe("resolvePoItem", () => { + const cases: [string, typeof VULA, string | null | "ambiguous"][] = [ + ["IJ PIPES-450 X 2.5(100D)", VULA, "IJ Pipes 450x2.5 100D"], + ["U PIPES-450 X 2.5(100D)", VULA, "IJ Pipes 450x2.5 100D"], + ["U PIPES-600 X 2.5(100D)", VULA, "IJ PIPES-600 X 2.5(100D)"], + ["U PIPES-675 X 2.5(100D)", VULA, "IJ PIPES-675 X 2.5(100D)"], + ["S&S Pipes-1050 X 2.5(100D)RR", VULA, "SandS Pipes-1050 X 2.5(100D)RR"], + ["S&S Pipes -1050 X 2.5(100D)RR", VULA, "SandS Pipes-1050 X 2.5(100D)RR"], + ["S&S Pipes-1050 X 2,5(100D)RR", VULA, "SandS Pipes-1050 X 2.5(100D)RR"], + // OCR read 100D as 1000 — no PO line carries {600, 1000}. + ["U PIPES-600 X 2.5(1000)", VULA, null], + // Rubber rings are not pipes, even at the same diameter. + ["RUBBER RINGS S & S 1050 DIA", VULA, null], + ]; + + for (const [slip, pois, expected] of cases) { + it(`resolves ${slip}`, () => { + const r = resolvePoItem(slip, pois); + if (expected === null) { + expect(r.kind).toBe("none"); + } else if (expected === "ambiguous") { + expect(r.kind).toBe("ambiguous"); + } else { + expect(r.kind).toBe("matched"); + if (r.kind === "matched") expect(r.poItem.description).toBe(expected); + } + }); + } + + it("matches on words when neither side states a dimension", () => { + const r = resolvePoItem("50KG GALV DRAW WIRE 3.15mm", AVIDAN); + expect(r.kind).toBe("matched"); + if (r.kind === "matched") expect(r.poItem.description).toBe("Roll Draw Wire"); + }); + + it("is ambiguous when many PO lines share the dimension", () => { + // MEGCPO9256 carries nine different 160mm lines; no text signal picks one. + const r = resolvePoItem("160x6M KABELFLEX PIPE & COUPLING", AVIDAN); + expect(r.kind).toBe("ambiguous"); + if (r.kind === "ambiguous") expect(r.candidates.length).toBeGreaterThan(1); + }); + + it("never returns a wrong match for any known input", () => { + // The regression that matters. Every wrong match seen during design + // is listed here with the description it must NOT resolve to. + const forbidden: [string, typeof VULA, string][] = [ + ["U PIPES-600 X 2.5(100D)", VULA, "SandS Pipes-1050 X 2.5(100D)RR"], + ["U PIPES-675 X 2.5(100D)", VULA, "SandS Pipes-1050 X 2.5(100D)RR"], + ]; + for (const [slip, pois, mustNotBe] of forbidden) { + const r = resolvePoItem(slip, pois); + if (r.kind === "matched") expect(r.poItem.description).not.toBe(mustNotBe); + } + const kab = resolvePoItem("160x6M KABELFLEX PIPE & COUPLING", AVIDAN); + expect(kab.kind).not.toBe("matched"); + }); + + it("uses an alias when one is supplied", () => { + const r = resolvePoItem("160x6M KABELFLEX PIPE & COUPLING", AVIDAN, { + aliasMaterialCode: "SLV160", + }); + expect(r.kind).toBe("matched"); + if (r.kind === "matched") { + expect(r.poItem.description).toBe("160mm Sleeves Black"); + expect(r.via).toBe("alias"); + } + }); + + it("falls through when the alias's material is not on this PO", () => { + const r = resolvePoItem("IJ PIPES-450 X 2.5(100D)", VULA, { + aliasMaterialCode: "NOTONTHISPO", + }); + expect(r.kind).toBe("matched"); + if (r.kind === "matched") expect(r.via).toBe("dimension"); + }); + + it("returns none for an empty description", () => { + expect(resolvePoItem("", VULA).kind).toBe("none"); + }); + + it("returns none when there are no PO lines", () => { + expect(resolvePoItem("anything", []).kind).toBe("none"); + }); +}); \ No newline at end of file diff --git a/src/lib/dn-pipeline/match-po-item.ts b/src/lib/dn-pipeline/match-po-item.ts new file mode 100644 index 0000000..e17b8a0 --- /dev/null +++ b/src/lib/dn-pipeline/match-po-item.ts @@ -0,0 +1,143 @@ +/** + * Resolves a slip line's description to a purchase order line. + * + * Three outcomes, not two. The old two-outcome matcher forced a choice + * between blocking items that plainly match and guessing at items that + * don't, and two attempts at loosening it during design produced WRONG + * matches: + * + * U PIPES-600 X 2.5(100D) -> S&S Pipes-1050 X 2.5(100D)RR + * 160x6M KABELFLEX PIPE -> 160mm uPVC Cl 16 Pipe + * + * A wrong match applies the wrong rate to money leaving the business, so + * it is strictly worse than a block. `ambiguous` exists so the matcher + * can decline without pretending the item is unknown. + * + * Pure. No I/O. + */ + +export interface PoItemRef { + id: string; + description: string; + material_code?: string; + unit?: string; +} + +export type PoItemMatch = + | { kind: "matched"; poItem: PoItemRef; via: "alias" | "exact" | "dimension" | "words" } + | { kind: "ambiguous"; candidates: PoItemRef[] } + | { kind: "none" }; + +export interface ResolveOptions { + /** From the alias table: the material code a human already chose for this wording. */ + aliasMaterialCode?: string | null; +} + +/** Minimum word overlap for a dimensionless match. Tuned on 23 real descriptions. */ +const WORD_FLOOR = 0.3; +/** How far the best candidate must beat the runner-up to be chosen. */ +const MARGIN = 0.15; + +export function norm(s: string): string { + return (s || "").toLowerCase().replace(/[^a-z0-9 ]/g, " ").replace(/\s+/g, " ").trim(); +} + +/** + * The integers >= 100 in a description, which for this catalogue are the + * product's identity: pipe diameters, pressure classes, grades. 450 and + * 1050 are different products; 2.5 and 6 are incidental. + * + * Glued dimension forms are split first, so "450x2.5" reads as 450 and + * 2.5 rather than as the token "450x2". + */ +export function dims(s: string): Set { + const ungluedString = (s || "").replace(/(?<=\d)[xX](?=\d)/g, " "); + const out = new Set(); + for (const m of ungluedString.match(/\d+/g) ?? []) { + const n = Number(m); + if (n >= 100) out.add(n); + } + return out; +} + +function words(s: string): Set { + return new Set(norm(s).split(" ").filter((w) => w.length >= 2 && !/^\d+$/.test(w))); +} + +function overlap(a: string, b: string): number { + const aw = words(a); + const bw = words(b); + if (aw.size === 0 && bw.size === 0) return 0; + let inter = 0; + for (const w of aw) if (bw.has(w)) inter++; + const union = new Set([...aw, ...bw]).size; + return union === 0 ? 0 : inter / union; +} + +function sameSet(a: Set, b: Set): boolean { + if (a.size !== b.size) return false; + for (const v of a) if (!b.has(v)) return false; + return true; +} + +/** Pick the best-scoring candidate, or declare ambiguity if it is a near tie. */ +function pickByWords( + description: string, + candidates: readonly PoItemRef[], + via: "dimension" | "words", + floor: number, +): PoItemMatch { + if (candidates.length === 0) return { kind: "none" }; + if (candidates.length === 1) return { kind: "matched", poItem: candidates[0], via }; + + const scored = candidates + .map((p) => ({ p, s: overlap(description, p.description) })) + .sort((x, y) => y.s - x.s); + + if (scored[0].s < floor) { + return { kind: "ambiguous", candidates: scored.map((x) => x.p) }; + } + if (scored[0].s - scored[1].s < MARGIN) { + return { kind: "ambiguous", candidates: scored.map((x) => x.p) }; + } + return { kind: "matched", poItem: scored[0].p, via }; +} + +export function resolvePoItem( + description: string, + poItems: readonly PoItemRef[], + opts: ResolveOptions = {}, +): PoItemMatch { + const target = norm(description); + if (!target || poItems.length === 0) return { kind: "none" }; + + // 1. Alias — a human already answered this question for this wording. + if (opts.aliasMaterialCode) { + const byCode = poItems.filter((p) => p.material_code === opts.aliasMaterialCode); + if (byCode.length === 1) return { kind: "matched", poItem: byCode[0], via: "alias" }; + if (byCode.length > 1) return { kind: "ambiguous", candidates: byCode }; + // Zero: the alias is known but this PO does not carry that material. + // Fall through rather than fail — the text may still resolve. + } + + // 2. Exact, after normalisation. + const exact = poItems.filter((p) => norm(p.description) === target); + if (exact.length === 1) return { kind: "matched", poItem: exact[0], via: "exact" }; + if (exact.length > 1) return { kind: "ambiguous", candidates: exact }; + + const slipDims = dims(description); + + if (slipDims.size > 0) { + // 3. Dimension gate. The dimension set must match EXACTLY. Falling + // through to word matching here is what produced + // "U PIPES-600" -> "S&S Pipes-1050": they share "pipes" and "100d". + const sameDims = poItems.filter((p) => sameSet(dims(p.description), slipDims)); + if (sameDims.length === 0) return { kind: "none" }; + return pickByWords(description, sameDims, "dimension", 0); + } + + // 4. Words only, and only against PO lines that are also dimensionless. + // A dimensionless slip line is not the same product as a 250mm fitting. + const dimensionless = poItems.filter((p) => dims(p.description).size === 0); + return pickByWords(description, dimensionless, "words", WORD_FLOOR); +} \ No newline at end of file