From 8b7e04eae14d636b234d2295fac98aeef2b4c0ec 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 --- 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()