From ff150bdf6eedc65e206b256b2094a5c73894158f Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 31 Aug 2026 09:03:00 +0200 Subject: [PATCH] feat(orders): include selling_price in duplicate-detection line match --- .../server/delivery-note-order-match.test.ts | 56 +++++++++++++++++++ src/lib/server/delivery-note-order-match.ts | 31 ++++++++-- .../order-delivery-note-link-service.test.ts | 6 ++ .../order-delivery-note-link-service.ts | 8 ++- .../order-order-duplicate-link-service.ts | 12 ++-- 5 files changed, 100 insertions(+), 13 deletions(-) diff --git a/src/lib/server/delivery-note-order-match.test.ts b/src/lib/server/delivery-note-order-match.test.ts index 6d7b063..a4d5a74 100644 --- a/src/lib/server/delivery-note-order-match.test.ts +++ b/src/lib/server/delivery-note-order-match.test.ts @@ -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); + }); }); diff --git a/src/lib/server/delivery-note-order-match.ts b/src/lib/server/delivery-note-order-match.ts index db98a77..74f8513 100644 --- a/src/lib/server/delivery-note-order-match.ts +++ b/src/lib/server/delivery-note-order-match.ts @@ -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, @@ -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(); for (const l of orderLines) { diff --git a/src/lib/server/order-delivery-note-link-service.test.ts b/src/lib/server/order-delivery-note-link-service.test.ts index a4db2c8..7e7cadc 100644 --- a/src/lib/server/order-delivery-note-link-service.test.ts +++ b/src/lib/server/order-delivery-note-link-service.test.ts @@ -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, }, ]), }, diff --git a/src/lib/server/order-delivery-note-link-service.ts b/src/lib/server/order-delivery-note-link-service.ts index 067074a..1ef4622 100644 --- a/src/lib/server/order-delivery-note-link-service.ts +++ b/src/lib/server/order-delivery-note-link-service.ts @@ -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>(); + const itemsByDn = new Map>(); 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); } diff --git a/src/lib/server/order-order-duplicate-link-service.ts b/src/lib/server/order-order-duplicate-link-service.ts index 5b87910..8ca9be2 100644 --- a/src/lib/server/order-order-duplicate-link-service.ts +++ b/src/lib/server/order-order-duplicate-link-service.ts @@ -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>(); + const itemsByOrder = new Map>(); 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;