From 5e33a3c0bf7d5f0dcc42b3220e4ca54240bd7c27 Mon Sep 17 00:00:00 2001 From: harisansari008 Date: Tue, 11 Aug 2026 15:15:08 +0530 Subject: [PATCH 1/4] fix: distribute PO-invoice billed amount across receipts without duplication When a Purchase Invoice is raised directly from a Purchase Order (po_detail set, pr_detail null), update_billed_amount_based_on_po distributes the billed amount across the PO's Purchase Receipts in FIFO order. The proportional branch, taken when the invoiced qty exceeds a single receipt's qty, computed each receipt's share but never deducted the consumed amount/qty from the running po_billed_amt_details total. As a result every subsequent receipt was billed against the same amount again, so the receipts together showed more billed amount than was actually invoiced. A receipt with no invoice truly against it could reach 100% billed and become Completed, dropping out of pending-invoice reports. Deduct the consumed billed_amt and billed_qty in the proportional branch, mirroring the existing else branch, so each receipt only consumes what is left. Add a regression test covering a PO invoice spanning two receipts. Co-Authored-By: Claude Opus 4.8 --- .../services/billing_status.py | 9 +++ .../purchase_receipt/test_purchase_receipt.py | 58 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/erpnext/stock/doctype/purchase_receipt/services/billing_status.py b/erpnext/stock/doctype/purchase_receipt/services/billing_status.py index 519a6c9fc9e..bdfde3fad21 100644 --- a/erpnext/stock/doctype/purchase_receipt/services/billing_status.py +++ b/erpnext/stock/doctype/purchase_receipt/services/billing_status.py @@ -61,6 +61,15 @@ def update_billed_amount_based_on_po(po_details: list, update_modified: bool = T billed_amt_against_pr = flt(flt(billed_amt_against_po) * flt(pr_item.qty)) / flt( billed_qty_against_po ) + + # Deduct the amount and qty consumed by this PR so that the next PR + # against the same PO Item does not get billed for the same amount again. + po_billed_amt_details[pr_item.purchase_order_item]["billed_amt"] = ( + billed_amt_against_po - billed_amt_against_pr + ) + po_billed_amt_details[pr_item.purchase_order_item]["billed_qty"] = ( + billed_qty_against_po - pr_item.qty + ) else: pending_to_bill = flt(pr_item.amount) - billed_amt_against_pr if pending_to_bill <= billed_amt_against_po: diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 83dbeaa5886..b52167e5fd4 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -777,6 +777,64 @@ class TestPurchaseReceipt(ERPNextTestSuite): po.reload() po.cancel() + def test_pr_billing_status_for_po_invoice_across_multiple_receipts(self): + """When a Purchase Invoice is raised directly from a PO and the invoiced qty + spans more than one Purchase Receipt, the billed amount must be split between + the receipts (FIFO), not duplicated. A receipt with no amount left to consume + must not show as fully billed / Completed. + + Flow: + 1. PO (Qty: 10, Rate: 500) -> PI for Qty 5 (Amount 2500) + 2. PO -> PR1 (Qty 3) -> gets 1500 billed (fully billed) + 3. PO -> PR2 (Qty 3) -> gets the remaining 1000 billed (partly billed) + """ + from erpnext.buying.doctype.purchase_order.mapper import ( + make_purchase_invoice as make_purchase_invoice_from_po, + ) + from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + + # Qty: 10, Rate: 500 + po = create_purchase_order() + + pi = make_purchase_invoice_from_po(po.name) + pi.get("items")[0].qty = 5 + pi.submit() + + pr1 = make_purchase_receipt(po.name) + pr1.posting_date = today() + pr1.posting_time = "08:00" + pr1.get("items")[0].received_qty = 3 + pr1.get("items")[0].qty = 3 + pr1.submit() + + pr2 = make_purchase_receipt(po.name) + pr2.posting_date = today() + pr2.posting_time = "10:00" + pr2.get("items")[0].received_qty = 3 + pr2.get("items")[0].qty = 3 + pr2.submit() + + # PR1 consumes 3 * 500 = 1500 out of the 2500 invoiced -> fully billed. + pr1.load_from_db() + self.assertEqual(pr1.get("items")[0].billed_amt, 1500) + self.assertEqual(pr1.per_billed, 100) + self.assertEqual(pr1.status, "Completed") + + # PR2 must only get the remaining 1000 (not 1500 again) -> partly billed. + pr2.load_from_db() + self.assertEqual(pr2.get("items")[0].billed_amt, 1000) + self.assertEqual(flt(pr2.per_billed, 2), 66.67) + self.assertEqual(pr2.status, "Partly Billed") + + pr2.cancel() + pr1.reload() + pr1.cancel() + pi.reload() + pi.cancel() + po.reload() + po.cancel() + def test_serial_no_against_purchase_receipt(self): item_code = "Test Manual Created Serial No" if not frappe.db.exists("Item", item_code): From 8b7e04eae14d636b234d2295fac98aeef2b4c0ec Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 11 Aug 2026 16:15:31 +0530 Subject: [PATCH 2/4] fix(stock): repair duplicated purchase receipt billing --- erpnext/patches.txt | 1 + ...lculate_purchase_receipt_billing_status.py | 90 +++++++++++++++++++ .../purchase_receipt/test_purchase_receipt.py | 42 +++++++++ 3 files changed, 133 insertions(+) create mode 100644 erpnext/patches/v16_0/recalculate_purchase_receipt_billing_status.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index fa1838c4345..fd62d28a0d9 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -510,3 +510,4 @@ erpnext.patches.v16_0.merge_seeded_item_group_root erpnext.patches.v16_0.set_stock_uom_in_job_card erpnext.patches.v16_0.set_work_order_requested_and_picked_qty erpnext.patches.v16_0.rename_italy_customer_name_fields +erpnext.patches.v16_0.recalculate_purchase_receipt_billing_status diff --git a/erpnext/patches/v16_0/recalculate_purchase_receipt_billing_status.py b/erpnext/patches/v16_0/recalculate_purchase_receipt_billing_status.py new file mode 100644 index 00000000000..fbe85b9d125 --- /dev/null +++ b/erpnext/patches/v16_0/recalculate_purchase_receipt_billing_status.py @@ -0,0 +1,90 @@ +from collections import defaultdict + +import frappe +from frappe.query_builder.functions import Count +from frappe.utils import flt + +from erpnext.stock.doctype.purchase_receipt.services.billing_status import ( + get_billed_amount_against_po, + get_billed_amount_against_pr, + get_purchase_receipts_against_po_details, + update_billed_amount_based_on_po, + update_billing_percentage, +) + + +def execute(): + purchase_order_items = get_affected_purchase_order_items() + if not purchase_order_items: + return + + updated_purchase_receipts = update_billed_amount_based_on_po(purchase_order_items) + for purchase_receipt in set(updated_purchase_receipts): + update_billing_percentage(frappe.get_doc("Purchase Receipt", purchase_receipt)) + + +def get_affected_purchase_order_items() -> list[str]: + purchase_order_items = get_candidate_purchase_order_items() + if not purchase_order_items: + return [] + + purchase_receipt_items = get_purchase_receipts_against_po_details(purchase_order_items) + direct_billed_amounts = get_billed_amount_against_pr([item.name for item in purchase_receipt_items]) + po_billed_amounts = get_billed_amount_against_po(purchase_order_items) + + current_billed_amounts = defaultdict(float) + available_billed_amounts = defaultdict(float) + for purchase_order_item, billed_details in po_billed_amounts.items(): + available_billed_amounts[purchase_order_item] = flt(billed_details["billed_amt"]) + + for item in purchase_receipt_items: + current_billed_amounts[item.purchase_order_item] += flt(item.billed_amt) + available_billed_amounts[item.purchase_order_item] += flt(direct_billed_amounts.get(item.name)) + + precision = frappe.get_precision("Purchase Receipt Item", "billed_amt") or 2 + return [ + purchase_order_item + for purchase_order_item in purchase_order_items + if flt(po_billed_amounts.get(purchase_order_item, {}).get("billed_amt")) > 0 + and flt(po_billed_amounts.get(purchase_order_item, {}).get("billed_qty")) > 0 + and flt( + current_billed_amounts[purchase_order_item] - available_billed_amounts[purchase_order_item], + precision, + ) + > 0 + ] + + +def get_candidate_purchase_order_items() -> list[str]: + purchase_receipt = frappe.qb.DocType("Purchase Receipt") + purchase_receipt_item = frappe.qb.DocType("Purchase Receipt Item") + purchase_invoice = frappe.qb.DocType("Purchase Invoice") + purchase_invoice_item = frappe.qb.DocType("Purchase Invoice Item") + + purchase_order_items_with_multiple_receipts = ( + frappe.qb.from_(purchase_receipt_item) + .inner_join(purchase_receipt) + .on(purchase_receipt_item.parent == purchase_receipt.name) + .select(purchase_receipt_item.purchase_order_item) + .where( + (purchase_receipt.docstatus == 1) + & (purchase_receipt.is_return == 0) + & purchase_receipt_item.purchase_order_item.isnotnull() + ) + .groupby(purchase_receipt_item.purchase_order_item) + .having(Count(purchase_receipt_item.name) > 1) + ) + + return ( + frappe.qb.from_(purchase_invoice_item) + .inner_join(purchase_invoice) + .on(purchase_invoice_item.parent == purchase_invoice.name) + .select(purchase_invoice_item.po_detail) + .distinct() + .where( + (purchase_invoice.docstatus == 1) + & (purchase_invoice.update_stock == 0) + & purchase_invoice_item.pr_detail.isnull() + & purchase_invoice_item.po_detail.isin(purchase_order_items_with_multiple_receipts) + ) + ).run(pluck=True) diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index b52167e5fd4..03a6388ce13 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -1,6 +1,8 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +from unittest.mock import patch + import frappe from frappe.utils import add_days, cint, cstr, flt, get_datetime, getdate, nowtime, today from pypika import functions as fn @@ -827,6 +829,46 @@ class TestPurchaseReceipt(ERPNextTestSuite): self.assertEqual(flt(pr2.per_billed, 2), 66.67) self.assertEqual(pr2.status, "Partly Billed") + from erpnext.patches.v16_0 import recalculate_purchase_receipt_billing_status + + purchase_order_item = po.items[0].name + with patch.object( + recalculate_purchase_receipt_billing_status, + "get_candidate_purchase_order_items", + return_value=[purchase_order_item], + ): + self.assertEqual( + recalculate_purchase_receipt_billing_status.get_affected_purchase_order_items(), [] + ) + + frappe.db.set_value( + "Purchase Receipt Item", + pr2.items[0].name, + "billed_amt", + 1500, + update_modified=False, + ) + frappe.db.set_value( + "Purchase Receipt", + pr2.name, + {"per_billed": 100, "status": "Completed"}, + update_modified=False, + ) + + self.assertEqual( + recalculate_purchase_receipt_billing_status.get_affected_purchase_order_items(), + [purchase_order_item], + ) + recalculate_purchase_receipt_billing_status.execute() + self.assertEqual( + recalculate_purchase_receipt_billing_status.get_affected_purchase_order_items(), [] + ) + + pr2.load_from_db() + self.assertEqual(pr2.get("items")[0].billed_amt, 1000) + self.assertEqual(flt(pr2.per_billed, 2), 66.67) + self.assertEqual(pr2.status, "Partly Billed") + pr2.cancel() pr1.reload() pr1.cancel() From ace4230f973d13fbd9d67a2d11c18e4661ed13b5 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 11 Aug 2026 18:16:38 +0530 Subject: [PATCH 3/4] fix: skip PO items with invoice-created receipts in billing repair patch A Purchase Receipt row created from a Purchase Invoice carries both purchase_order_item and purchase_invoice_item, and its billed_amt is pinned to the row amount by update_billing_status. Redistributing the PO-invoiced pool over such rows zeroes the invoice-created receipt and flips it from Completed to To Bill, so the repair leaves those PO Items untouched. --- ...lculate_purchase_receipt_billing_status.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/erpnext/patches/v16_0/recalculate_purchase_receipt_billing_status.py b/erpnext/patches/v16_0/recalculate_purchase_receipt_billing_status.py index fbe85b9d125..cb2367f2ee3 100644 --- a/erpnext/patches/v16_0/recalculate_purchase_receipt_billing_status.py +++ b/erpnext/patches/v16_0/recalculate_purchase_receipt_billing_status.py @@ -25,6 +25,11 @@ def execute(): def get_affected_purchase_order_items() -> list[str]: purchase_order_items = get_candidate_purchase_order_items() + if purchase_order_items: + purchase_order_items = exclude_purchase_order_items_with_invoice_created_receipts( + purchase_order_items + ) + if not purchase_order_items: return [] @@ -55,6 +60,21 @@ def get_affected_purchase_order_items() -> list[str]: ] +def exclude_purchase_order_items_with_invoice_created_receipts(purchase_order_items: list[str]) -> list[str]: + invoice_created_receipt_items = set( + frappe.get_all( + "Purchase Receipt Item", + filters={ + "purchase_order_item": ("in", purchase_order_items), + "purchase_invoice_item": ("is", "set"), + "docstatus": 1, + }, + pluck="purchase_order_item", + ) + ) + return [item for item in purchase_order_items if item not in invoice_created_receipt_items] + + def get_candidate_purchase_order_items() -> list[str]: purchase_receipt = frappe.qb.DocType("Purchase Receipt") purchase_receipt_item = frappe.qb.DocType("Purchase Receipt Item") From d34519f536ac166e5b9cf5dccf18f261e35ad2e1 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 11 Aug 2026 18:16:38 +0530 Subject: [PATCH 4/4] test: cover repair patch exclusion for invoice-created receipts --- .../purchase_receipt/test_purchase_receipt.py | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 03a6388ce13..64dfb894f46 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -877,6 +877,76 @@ class TestPurchaseReceipt(ERPNextTestSuite): po.reload() po.cancel() + def test_billing_repair_patch_skips_invoice_created_receipts(self): + """A Purchase Receipt created from a Purchase Invoice keeps billed_amt = amount + by definition. When such a receipt coexists with a receipt made directly from + the PO, the stored total can exceed the PO-invoiced amount, but the repair + patch must leave those PO Items alone instead of stripping the invoice-created + receipt. + + Flow: + 1. PO (Qty: 10, Rate: 500) -> PI for Qty 5 (Amount 2500) + 2. PO -> PR1 (Qty 5, direct) -> absorbs the full 2500 (fully billed) + 3. PI -> PR2 (Qty 5, created from the invoice) -> billed 2500 via invoice link + """ + from erpnext.accounts.doctype.purchase_invoice.mapper import ( + make_purchase_receipt as make_purchase_receipt_from_pi, + ) + from erpnext.buying.doctype.purchase_order.mapper import ( + make_purchase_invoice as make_purchase_invoice_from_po, + ) + from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + + po = create_purchase_order() + + pi = make_purchase_invoice_from_po(po.name) + pi.get("items")[0].qty = 5 + pi.submit() + + pr_direct = make_purchase_receipt(po.name) + pr_direct.get("items")[0].received_qty = 5 + pr_direct.get("items")[0].qty = 5 + pr_direct.submit() + + pr_direct.load_from_db() + self.assertEqual(pr_direct.get("items")[0].billed_amt, 2500) + self.assertEqual(pr_direct.per_billed, 100) + + pr_from_invoice = make_purchase_receipt_from_pi(pi.name) + pr_from_invoice.submit() + + pr_from_invoice.load_from_db() + self.assertEqual(pr_from_invoice.get("items")[0].billed_amt, 2500) + self.assertEqual(pr_from_invoice.per_billed, 100) + + from erpnext.patches.v16_0 import recalculate_purchase_receipt_billing_status + + purchase_order_item = po.items[0].name + with patch.object( + recalculate_purchase_receipt_billing_status, + "get_candidate_purchase_order_items", + return_value=[purchase_order_item], + ): + self.assertEqual( + recalculate_purchase_receipt_billing_status.get_affected_purchase_order_items(), [] + ) + recalculate_purchase_receipt_billing_status.execute() + + pr_direct.load_from_db() + self.assertEqual(pr_direct.get("items")[0].billed_amt, 2500) + pr_from_invoice.load_from_db() + self.assertEqual(pr_from_invoice.get("items")[0].billed_amt, 2500) + self.assertEqual(pr_from_invoice.status, "Completed") + + pr_from_invoice.cancel() + pr_direct.reload() + pr_direct.cancel() + pi.reload() + pi.cancel() + po.reload() + po.cancel() + def test_serial_no_against_purchase_receipt(self): item_code = "Test Manual Created Serial No" if not frappe.db.exists("Item", item_code):