From fa733e691b19e3e8fbf9ebd621109f05d2e1c0e5 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 11 Aug 2026 16:15:31 +0530 Subject: [PATCH] fix(stock): repair duplicated purchase receipt billing (cherry picked from commit 8b7e04eae14d636b234d2295fac98aeef2b4c0ec) --- erpnext/patches.txt | 1 + ...lculate_purchase_receipt_billing_status.py | 90 ++++++++++++++++ .../purchase_receipt/purchase_receipt.py | 9 ++ .../purchase_receipt/test_purchase_receipt.py | 100 ++++++++++++++++++ 4 files changed, 200 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 fc536cce751..a9fa908e29d 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -498,3 +498,4 @@ erpnext.patches.v16_0.backfill_repost_accounting_ledger_status erpnext.patches.v16_0.merge_seeded_item_group_root erpnext.patches.v16_0.rename_italy_customer_name_fields erpnext.patches.v16_0.set_stock_uom_in_job_card +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..4acbe0375f8 --- /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.purchase_receipt 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/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index d1a8cb16a70..a1073df1f1b 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -1164,6 +1164,15 @@ def update_billed_amount_based_on_po(po_details, update_modified=True, pr_doc=No 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 d1ec25650af..284573342bf 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 @@ -762,6 +764,104 @@ 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.purchase_order import ( + make_purchase_invoice as make_purchase_invoice_from_po, + ) + from erpnext.buying.doctype.purchase_order.purchase_order 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") + + 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() + 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):