From e99ae3796c00bf36890aa926de3ab4b17f82efbc Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 11 Aug 2026 19:21:38 +0530 Subject: [PATCH 1/2] fix(stock): repair existing underbilled purchase receipts The qty-sync fix corrects allocation going forward, but receipts billed before it can keep understated billed_amt, per_billed, and status. The earlier repair patch only selects over-billed PO items, so it never picks these up. Recompute every candidate PO item (multiple submitted receipts, PO-level invoicing, no invoice-created receipts). update_billed_amount_based_on_po only writes rows whose recomputed value differs, so already-correct items are untouched and the patch stays idempotent. This also converges receipts left with direct-only billed_amt by last-event-wins overwrites. --- erpnext/patches.txt | 1 + ...e_mixed_purchase_receipt_billing_status.py | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 erpnext/patches/v16_0/recalculate_mixed_purchase_receipt_billing_status.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index fd62d28a0d9..251e7eb83b7 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -511,3 +511,4 @@ 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 +erpnext.patches.v16_0.recalculate_mixed_purchase_receipt_billing_status diff --git a/erpnext/patches/v16_0/recalculate_mixed_purchase_receipt_billing_status.py b/erpnext/patches/v16_0/recalculate_mixed_purchase_receipt_billing_status.py new file mode 100644 index 00000000000..fc76d20588e --- /dev/null +++ b/erpnext/patches/v16_0/recalculate_mixed_purchase_receipt_billing_status.py @@ -0,0 +1,25 @@ +import frappe + +from erpnext.patches.v16_0.recalculate_purchase_receipt_billing_status import ( + exclude_purchase_order_items_with_invoice_created_receipts, + get_candidate_purchase_order_items, +) +from erpnext.stock.doctype.purchase_receipt.services.billing_status import ( + update_billed_amount_based_on_po, + update_billing_percentage, +) + + +def execute(): + 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 + + 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)) From 620465220bebc2b9df5b4997e0bb5c2b70c3600c Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 11 Aug 2026 19:21:38 +0530 Subject: [PATCH 2/2] test: cover repair of underbilled receipts from mixed billing --- .../purchase_receipt/test_purchase_receipt.py | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index b6e1b8f1acb..ed42af8a79a 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -950,12 +950,14 @@ class TestPurchaseReceipt(ERPNextTestSuite): def test_pr_billing_status_with_mixed_direct_and_po_invoice(self): """A receipt with partial direct billing consumes PO-invoiced amount through the amount-capped branch. The consumed qty must shrink along with the amount, - otherwise the next receipt divides by a stale qty and is under-billed. + otherwise the next receipt divides by a stale qty and is under-billed. The + repair patch must also recalculate values stored before the fix. Flow: 1. PO (Qty: 10, Rate: 500) -> PI for Qty 5 (Amount 2500) 2. PO -> PR1 (Qty 3), then a direct PI for 500 against PR1 3. PO -> PR2 (Qty 3) -> reallocation must leave both receipts fully billed + 4. Seed the old under-billed values -> the repair patch must restore them """ from erpnext.buying.doctype.purchase_order.mapper import ( make_purchase_invoice as make_purchase_invoice_from_po, @@ -996,6 +998,40 @@ class TestPurchaseReceipt(ERPNextTestSuite): self.assertEqual(pr2.per_billed, 100) self.assertEqual(pr2.status, "Completed") + from erpnext.patches.v16_0 import recalculate_mixed_purchase_receipt_billing_status + + purchase_order_item = po.items[0].name + frappe.db.set_value( + "Purchase Receipt Item", + pr2.items[0].name, + "billed_amt", + 900, + update_modified=False, + ) + frappe.db.set_value( + "Purchase Receipt", + pr2.name, + {"per_billed": 60, "status": "Partly Billed"}, + update_modified=False, + ) + + with patch.object( + recalculate_mixed_purchase_receipt_billing_status, + "get_candidate_purchase_order_items", + return_value=[purchase_order_item], + ): + recalculate_mixed_purchase_receipt_billing_status.execute() + + pr2.load_from_db() + modified_after_repair = pr2.modified + recalculate_mixed_purchase_receipt_billing_status.execute() + pr2.load_from_db() + self.assertEqual(pr2.modified, modified_after_repair) + + self.assertEqual(pr2.get("items")[0].billed_amt, 1500) + self.assertEqual(pr2.per_billed, 100) + self.assertEqual(pr2.status, "Completed") + pr2.cancel() direct_pi.reload() direct_pi.cancel()