feat(dn-pipeline): three-outcome PO item matcher with a dimension gate

matched / ambiguous / none. The dimension set must match exactly, so
U PIPES-600 can no longer resolve to S&S Pipes-1050 on shared tokens.
Includes a regression test asserting no known input returns a wrong match.

Co-Authored-By: MiniMax <noreply@minimax.io>
This commit is contained in:
DEls 2026-09-17 21:33:47 +02:00
parent ba6ce2bf2e
commit 13a6122fa7
2 changed files with 261 additions and 0 deletions

View file

@ -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");
});
});

View file

@ -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<number> {
const ungluedString = (s || "").replace(/(?<=\d)[xX](?=\d)/g, " ");
const out = new Set<number>();
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<string> {
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<number>, b: Set<number>): 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);
}