diff --git a/src/app/dashboard/financial/delivery-notes/page.tsx b/src/app/dashboard/financial/delivery-notes/page.tsx index 87d591e..e1a4097 100644 --- a/src/app/dashboard/financial/delivery-notes/page.tsx +++ b/src/app/dashboard/financial/delivery-notes/page.tsx @@ -107,7 +107,7 @@ export default async function DeliveryNotesPage({ })); const posForAssign: PoChoice[] = buildPoChoices(pos, sites); - const possibleDuplicates = possibleDuplicateReceiptGroups(dns, pos); + const possibleDuplicates = possibleDuplicateReceiptGroups(dns, pos, itemsByDn); // KPIs describe the full site-scoped set of delivery notes, not the rows // the operator has filtered in the table. diff --git a/src/lib/dn-pipeline/possible-duplicate-receipts.test.ts b/src/lib/dn-pipeline/possible-duplicate-receipts.test.ts index bed8b39..f18b400 100644 --- a/src/lib/dn-pipeline/possible-duplicate-receipts.test.ts +++ b/src/lib/dn-pipeline/possible-duplicate-receipts.test.ts @@ -37,6 +37,18 @@ describe("possible duplicate posted receipts", () => { ]); }); + it("stops flagging a pair once the duplicate copy has been corrected to zero", () => { + const rows = [receipt("kept", "s", "DQ0010116"), receipt("zeroed", "s", "DQ0010116")]; + const booked = new Map([["kept", { count: 1, total: 30.26 }], ["zeroed", { count: 1, total: 0 }]]); + expect(possibleDuplicateReceiptGroups(rows, [], booked)).toEqual([]); + }); + + it("still flags receipts that have no lines, and pairs where both copies still book quantity", () => { + const rows = [receipt("a", "s", "DQ1"), receipt("b", "s", "DQ1"), receipt("c", "s", "DQ2"), receipt("d", "s", "DQ2")]; + const booked = new Map([["a", { count: 1, total: 30 }], ["b", { count: 1, total: 30 }]]); + expect(possibleDuplicateReceiptGroups(rows, [], booked).map((g) => g.receipts.map((r) => r.id))).toEqual([["a", "b"], ["c", "d"]]); + }); + it("also flags an invoice explicitly referencing an already posted delivery", () => { const invoice = receipt("invoice", "s", "INV1"); invoice.raw_ocr_data.documentReferences.push("DN1"); diff --git a/src/lib/dn-pipeline/possible-duplicate-receipts.ts b/src/lib/dn-pipeline/possible-duplicate-receipts.ts index fcf037e..ae90619 100644 --- a/src/lib/dn-pipeline/possible-duplicate-receipts.ts +++ b/src/lib/dn-pipeline/possible-duplicate-receipts.ts @@ -14,14 +14,27 @@ export type PossibleDuplicateReceiptGroup = { key: string; receipts: DuplicateRe const object = (value: unknown): Record => value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; const text = (value: unknown): string => typeof value === "string" ? value : ""; -/** Informational only: callers pass site-scoped receipts; values stay untouched. */ +/** + * Informational only: callers pass site-scoped receipts; values stay untouched. + * + * `bookedQty` (receipt id → total qty_received over its lines), when given, + * leaves out receipts whose lines have all been corrected to zero: a copy + * that books nothing cannot double-count, so once the office has zeroed the + * duplicate copy the pair stops being flagged. Receipts with no lines at all + * are kept, because nothing shows they were reviewed. + */ export function possibleDuplicateReceiptGroups( receipts: readonly ReceiptReviewRecord[], pos: readonly ReceiptPoLookup[] = [], + bookedQty?: ReadonlyMap, ): PossibleDuplicateReceiptGroup[] { const poById = new Map(pos.map((po) => [po.id, po])); - const posted: ReceiptReviewRecord[] = receipts.filter((receipt) => Boolean(text(receipt.posted_at)) || receipt.is_verified === true || - ["auto-verified", "manual-verified"].includes(text(receipt.processing_status))).map((receipt) => { + const zeroed = (receipt: ReceiptReviewRecord) => { + const booked = bookedQty?.get(receipt.id); + return Boolean(booked && booked.count > 0 && booked.total === 0); + }; + const posted: ReceiptReviewRecord[] = receipts.filter((receipt) => !zeroed(receipt) && (Boolean(text(receipt.posted_at)) || receipt.is_verified === true || + ["auto-verified", "manual-verified"].includes(text(receipt.processing_status)))).map((receipt) => { const expanded = object(receipt.expand); const expandedPo = object(expanded.purchase_order); const po = poById.get(text(receipt.purchase_order));