feat(orders): include selling_price in duplicate-detection line match
This commit is contained in:
parent
bef2a08299
commit
ff150bdf6e
5 changed files with 100 additions and 13 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue