feat(orders): include selling_price in duplicate-detection line match

This commit is contained in:
Dave 2026-08-31 09:03:00 +02:00
parent bef2a08299
commit ff150bdf6e
5 changed files with 100 additions and 13 deletions

View file

@ -111,4 +111,60 @@ describe("isExactItemMatch", () => {
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);
});
});

View file

@ -13,6 +13,10 @@
export interface DeliveryLine {
description: string;
quantity: number;
/** Optional. When present, lines must also match on price to be
* considered equivalent. Without this, the comparison falls back
* to description + quantity (the legacy, looser check). */
price?: number;
}
/** Normalize for comparison: trim, lowercase, collapse internal
@ -23,10 +27,17 @@ export function normalizeLine(s: string): string {
/**
* 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.
* (normalized description, quantity [, price]). 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.
*
* Price-aware comparison is opt-in per call site: if every line on
* BOTH sides carries a `price` field, the comparison key includes the
* price and lines must match on price to be considered equivalent.
* Mixed (some-with-price, some-without) falls back to the legacy
* description+quantity comparison to avoid false negatives from
* misaligned price availability.
*/
export function isExactItemMatch(
orderLines: ReadonlyArray<DeliveryLine>,
@ -35,7 +46,17 @@ export function isExactItemMatch(
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}`;
// If ANY line on either side has a price, treat the whole comparison
// as price-aware. Mixed (some-with-price, some-without) is a config
// bug — fall back to description+quantity to avoid false negatives.
const priceAware =
orderLines.every((l) => l.price !== undefined) &&
dnLines.every((l) => l.price !== undefined);
const key = (l: DeliveryLine) => {
const base = `${normalizeLine(l.description)} ${l.quantity}`;
return priceAware && l.price !== undefined ? `${base}|${l.price}` : base;
};
const counts = new Map<string, number>();
for (const l of orderLines) {

View file

@ -183,18 +183,24 @@ describe("suggestDeliveryNoteForOrder", () => {
.mockResolvedValueOnce(orderItems) // order's own items
// Single bulk fetch for ALL candidate DN items — the
// matcher groups by delivery_note_number in memory.
// Both DNs carry selling_price so the price-aware
// comparator kicks in (mixed shape with one side having
// price and the other not would silently fall back to
// description+quantity and skip the tie-break).
.mockResolvedValueOnce([
{
id: "d1",
delivery_note_number: "DN1",
description: "hex bolt m20",
quantity: 50,
selling_price: 12,
},
{
id: "d2",
delivery_note_number: "DN2",
description: "hex bolt m20",
quantity: 50,
selling_price: 12,
},
]),
},

View file

@ -107,6 +107,7 @@ export async function suggestDeliveryNoteForOrder(
const orderLines: DeliveryLine[] = orderItemRows.map((r) => ({
description: r.description || "",
quantity: Number(r.quantity) || 0,
price: Number(r.selling_price) || 0,
}));
const WINDOW_MS = WINDOW_DAYS * 24 * 60 * 60 * 1000;
@ -136,14 +137,14 @@ export async function suggestDeliveryNoteForOrder(
// ~190x faster than the old N+1 loop, while never producing a single
// filter larger than ~8 KB. delivery_note_number is the join key —
// group rows by it.
const itemsByDn = new Map<string, Array<{ order?: string; description?: string; quantity?: number }>>();
const itemsByDn = new Map<string, Array<{ order?: string; description?: string; quantity?: number; selling_price?: number }>>();
for (let i = 0; i < candidatesToFetch.length; i += BULK_FILTER_CHUNK_SIZE) {
const chunk = candidatesToFetch.slice(i, i + BULK_FILTER_CHUNK_SIZE);
const chunkItems = await pb
.collection(ORDER_ITEMS_COLLECTION)
.getFullList<{ order?: string; delivery_note_number?: string; description?: string; quantity?: number }>({
.getFullList<{ order?: string; delivery_note_number?: string; description?: string; quantity?: number; selling_price?: number }>({
filter: chunk.map((d) => `delivery_note_number = "${esc(d.delivery_note_number)}"`).join(" || "),
fields: "order,delivery_note_number,description,quantity",
fields: "order,delivery_note_number,description,quantity,selling_price",
batch: 5000,
});
for (const row of chunkItems) {
@ -167,6 +168,7 @@ export async function suggestDeliveryNoteForOrder(
const dnLines: DeliveryLine[] = dnItemRows.map((r) => ({
description: r.description || "",
quantity: Number(r.quantity) || 0,
price: Number(r.selling_price) || 0,
}));
if (isExactItemMatch(orderLines, dnLines)) matched.push(dn);
}

View file

@ -71,14 +71,15 @@ export async function suggestOrderDuplicate(
const orderItemRows = await pb
.collection(ORDER_ITEMS_COLLECTION)
.getFullList<{ description?: string; quantity?: number }>({
.getFullList<{ description?: string; quantity?: number; selling_price?: number }>({
filter: `order = "${esc(orderId)}"`,
fields: "description,quantity",
fields: "description,quantity,selling_price",
batch: 1000,
});
const orderLines: DeliveryLine[] = orderItemRows.map((r) => ({
description: r.description || "",
quantity: Number(r.quantity) || 0,
price: Number(r.selling_price) || 0,
}));
const cutoff = new Date(Date.now() - WINDOW_DAYS * 24 * 60 * 60 * 1000);
@ -101,14 +102,14 @@ export async function suggestOrderDuplicate(
// than ~6 KB. Group items by candidate order id so we can compare each
// candidate's lines to the source order's lines in memory.
if (candidates.length > 0) {
const itemsByOrder = new Map<string, Array<{ description?: string; quantity?: number }>>();
const itemsByOrder = new Map<string, Array<{ description?: string; quantity?: number; selling_price?: number }>>();
for (let i = 0; i < candidates.length; i += BULK_FILTER_CHUNK_SIZE) {
const chunk = candidates.slice(i, i + BULK_FILTER_CHUNK_SIZE);
const chunkItems = await pb
.collection(ORDER_ITEMS_COLLECTION)
.getFullList<{ order?: string; description?: string; quantity?: number }>({
.getFullList<{ order?: string; description?: string; quantity?: number; selling_price?: number }>({
filter: chunk.map((c) => `order = "${esc(c.id)}"`).join(" || "),
fields: "order,description,quantity",
fields: "order,description,quantity,selling_price",
batch: 5000,
});
for (const row of chunkItems) {
@ -124,6 +125,7 @@ export async function suggestOrderDuplicate(
const candidateLines: DeliveryLine[] = candidateItemRows.map((r) => ({
description: r.description || "",
quantity: Number(r.quantity) || 0,
price: Number(r.selling_price) || 0,
}));
if (isExactItemMatch(orderLines, candidateLines)) {
match = candidate;