170 lines
7 KiB
TypeScript
170 lines
7 KiB
TypeScript
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);
|
|
});
|
|
|
|
// -- Price-aware comparison -------------------------------------------
|
|
//
|
|
// The duplicate-detection matchers opt in to stricter checks by
|
|
// populating `price` on every line. Lines without `price` on either
|
|
// side keep the legacy description+quantity behaviour (above), so a
|
|
// single mixed-shape comparison falls back to the loose check
|
|
// rather than mis-flagging or missing entirely.
|
|
|
|
it("matches when description, quantity, and price all match", () => {
|
|
const order = [{ description: "Hex Bolt M20", quantity: 50, price: 12.5 }];
|
|
const dn = [{ description: "hex bolt m20", quantity: 50, price: 12.5 }];
|
|
expect(isExactItemMatch(order, dn)).toBe(true);
|
|
});
|
|
|
|
it("does not match when prices differ but description + quantity match", () => {
|
|
// The whole point of the opt-in price comparison: a re-quote at a
|
|
// different selling price is NOT a repeat, even if the line shape
|
|
// is identical.
|
|
const order = [{ description: "Hex Bolt M20", quantity: 50, price: 12.5 }];
|
|
const dn = [{ description: "hex bolt m20", quantity: 50, price: 13.0 }];
|
|
expect(isExactItemMatch(order, dn)).toBe(false);
|
|
});
|
|
|
|
it("falls back to description+quantity when one side has price and the other does not", () => {
|
|
// Mixed shape (some-with-price, some-without) is treated as a
|
|
// config bug — the loose comparison runs rather than producing a
|
|
// guaranteed false on the loose-shape side.
|
|
const order = [{ description: "Hex Bolt M20", quantity: 50, price: 12.5 }];
|
|
const dn = [{ description: "hex bolt m20", quantity: 50 }];
|
|
expect(isExactItemMatch(order, dn)).toBe(true);
|
|
});
|
|
|
|
it("uses the `|` separator so qty and price cannot alias each other", () => {
|
|
// Without the separator, "M16 10|50" (qty 10, price 50) would
|
|
// collide with "M16 1 0|50" (qty 1, price 0 concatenated) under a
|
|
// naive `${base}|${price}` key. Verify that does not happen.
|
|
const order = [
|
|
{ description: "M16", quantity: 10, price: 50 },
|
|
{ description: "M16", quantity: 1, price: 0 },
|
|
];
|
|
const dn = [
|
|
{ description: "M16", quantity: 1, price: 0 },
|
|
{ description: "M16", quantity: 10, price: 50 },
|
|
];
|
|
expect(isExactItemMatch(order, dn)).toBe(true);
|
|
|
|
// And the same line shape with a swap of just the qty/price
|
|
// pairing should NOT match (this is the case the separator is
|
|
// specifically defending against).
|
|
const mismatched = [
|
|
{ description: "M16", quantity: 10, price: 50 },
|
|
{ description: "M16", quantity: 1, price: 50 }, // different price
|
|
];
|
|
expect(isExactItemMatch(order, mismatched)).toBe(false);
|
|
});
|
|
});
|