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:
parent
ba6ce2bf2e
commit
13a6122fa7
2 changed files with 261 additions and 0 deletions
118
src/lib/dn-pipeline/match-po-item.test.ts
Normal file
118
src/lib/dn-pipeline/match-po-item.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
143
src/lib/dn-pipeline/match-po-item.ts
Normal file
143
src/lib/dn-pipeline/match-po-item.ts
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue