feat(orders): add exact-match comparator for delivery-note detection

Pure module — no I/O, mirrors the separation established by
order-quote-match.ts.

isExactItemMatch is multiset equality on (normalizeLine(description),
quantity). Empty sets return false (would otherwise vacuously flag
every zero-line order against every zero-line DN).

14 tests cover: exact match, qty diff, extra line on either side,
multiplicity enforcement, the empty-set case, normalization
collapsing whitespace + case, and punctuation-sensitive 'exact'.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Dave 2026-08-30 12:45:34 +02:00
parent 623cfefc0d
commit e09de3cc80
2 changed files with 168 additions and 0 deletions

View file

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

View file

@ -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<DeliveryLine>,
dnLines: ReadonlyArray<DeliveryLine>,
): 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<string, number>();
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;
}