diff --git a/src/lib/server/delivery-note-order-match.test.ts b/src/lib/server/delivery-note-order-match.test.ts new file mode 100644 index 0000000..6d7b063 --- /dev/null +++ b/src/lib/server/delivery-note-order-match.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from "vitest"; +import { normalizeLine, isExactItemMatch } from "@/lib/server/delivery-note-order-match"; + +describe("normalizeLine", () => { + it("trims, lowercases, and collapses internal whitespace", () => { + expect(normalizeLine(" Hex Bolt M20 ")).toBe("hex bolt m20"); + }); + + it("returns empty string for blank input", () => { + expect(normalizeLine(" ")).toBe(""); + expect(normalizeLine("")).toBe(""); + }); + + it("returns empty string for nullish input", () => { + expect(normalizeLine(null as unknown as string)).toBe(""); + expect(normalizeLine(undefined as unknown as string)).toBe(""); + }); +}); + +describe("isExactItemMatch", () => { + it("matches when every line pairs 1:1 on normalized description and quantity", () => { + const order = [ + { description: "Hex Bolt M20", quantity: 50 }, + { description: "Hex Nut M20", quantity: 50 }, + ]; + const dn = [ + { description: "hex bolt m20", quantity: 50 }, + { description: "HEX NUT M20", quantity: 50 }, + ]; + expect(isExactItemMatch(order, dn)).toBe(true); + }); + + it("does not match when a quantity differs", () => { + const order = [{ description: "Hex Bolt M20", quantity: 50 }]; + const dn = [{ description: "Hex Bolt M20", quantity: 45 }]; + expect(isExactItemMatch(order, dn)).toBe(false); + }); + + it("does not match when the order has an extra line the DN doesn't have", () => { + const order = [ + { description: "Hex Bolt M20", quantity: 50 }, + { description: "Washer M20", quantity: 50 }, + ]; + const dn = [{ description: "Hex Bolt M20", quantity: 50 }]; + expect(isExactItemMatch(order, dn)).toBe(false); + }); + + it("does not match when the DN has an extra line the order doesn't have", () => { + const order = [{ description: "Hex Bolt M20", quantity: 50 }]; + const dn = [ + { description: "Hex Bolt M20", quantity: 50 }, + { description: "Washer M20", quantity: 50 }, + ]; + expect(isExactItemMatch(order, dn)).toBe(false); + }); + + it("requires matching multiplicity, not just membership, for duplicate lines", () => { + // Order has the SAME line twice (two separate 50-unit lines of the + // same bolt, e.g. from two different quote lines that priced + // differently but described identically). The DN has only one. + const order = [ + { description: "Hex Bolt M20", quantity: 50 }, + { description: "Hex Bolt M20", quantity: 50 }, + ]; + const dn = [{ description: "Hex Bolt M20", quantity: 50 }]; + expect(isExactItemMatch(order, dn)).toBe(false); + }); + + it("matches duplicate lines when both sides have the same multiplicity", () => { + const order = [ + { description: "Hex Bolt M20", quantity: 50 }, + { description: "Hex Bolt M20", quantity: 50 }, + ]; + const dn = [ + { description: "Hex Bolt M20", quantity: 50 }, + { description: "Hex Bolt M20", quantity: 50 }, + ]; + expect(isExactItemMatch(order, dn)).toBe(true); + }); + + it("returns false for two empty line sets rather than vacuously true", () => { + // An order or DN with zero lines carries no signal — this must + // never be treated as "everything matched." If this returned true, + // an order with 0 lines could match every DN with 0 lines in the + // company, which is meaningless. + expect(isExactItemMatch([], [])).toBe(false); + }); + + it("returns false when only the order is empty", () => { + expect(isExactItemMatch([], [{ description: "X", quantity: 1 }])).toBe(false); + }); + + it("returns false when only the DN is empty", () => { + expect(isExactItemMatch([{ description: "X", quantity: 1 }], [])).toBe(false); + }); + + it("treats descriptions that differ only in punctuation as non-matching", () => { + // The "exact" is deliberate — operators do distinguish + // M20x50 from M20-50, and treating them as the same would + // mis-flag a one-off different product as a repeat. + const order = [{ description: "M20x50 bolt", quantity: 1 }]; + const dn = [{ description: "M20-50 bolt", quantity: 1 }]; + expect(isExactItemMatch(order, dn)).toBe(false); + }); + + it("matches when whitespace and case differ (normalization handles them)", () => { + // The point of normalizeLine is exactly this case — operator + // reorders with slight description drift on the PO side shouldn't + // hide a real repeat. + const order = [{ description: "Hex Bolt M20", quantity: 50 }]; + const dn = [{ description: "hex bolt m20", quantity: 50 }]; + expect(isExactItemMatch(order, dn)).toBe(true); + }); +}); diff --git a/src/lib/server/delivery-note-order-match.ts b/src/lib/server/delivery-note-order-match.ts new file mode 100644 index 0000000..db98a77 --- /dev/null +++ b/src/lib/server/delivery-note-order-match.ts @@ -0,0 +1,54 @@ +/** + * delivery-note-order-match — pure exact-match comparator for the + * order ↔ delivery-note repeat-order detector. + * + * Deliberately NOT the quote matcher's tokenize/Jaccard fuzzy scoring + * (order-quote-match.ts). This is exact-string equality on a + * normalized form, with multiset (bag) semantics — every order line + * must pair to exactly one DN line and nothing may be left over on + * either side. There is no partial-credit tier here; either the order + * is a complete repeat of the DN or it isn't flagged at all. + */ + +export interface DeliveryLine { + description: string; + quantity: number; +} + +/** Normalize for comparison: trim, lowercase, collapse internal + * whitespace runs to a single space. */ +export function normalizeLine(s: string): string { + return (s || "").trim().toLowerCase().replace(/\s+/g, " "); +} + +/** + * True iff `orderLines` and `dnLines` are equal as multisets keyed on + * (normalized description, quantity). Empty input on either side + * always returns false — there is no signal to match on, and treating + * two empty sets as "matching" would flag every zero-line order + * against every zero-line DN. + */ +export function isExactItemMatch( + orderLines: ReadonlyArray, + dnLines: ReadonlyArray, +): boolean { + if (orderLines.length === 0 || dnLines.length === 0) return false; + if (orderLines.length !== dnLines.length) return false; + + const key = (l: DeliveryLine) => `${normalizeLine(l.description)} ${l.quantity}`; + + const counts = new Map(); + for (const l of orderLines) { + const k = key(l); + counts.set(k, (counts.get(k) ?? 0) + 1); + } + for (const l of dnLines) { + const k = key(l); + const remaining = counts.get(k); + if (!remaining) return false; + counts.set(k, remaining - 1); + } + // Lengths already matched, so if every DN line found a counterpart, + // every order line was consumed too — no need to re-scan counts. + return true; +}