From 7803998fce95cf34a128532416bdb3c7b38ef1e4 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 11 Aug 2026 18:39:52 +0530 Subject: [PATCH 1/2] fix: keep PO billed qty in sync when allocating amount to receipts The amount-capped allocation branch reduced the remaining PO-invoiced amount but left the invoiced qty untouched. A later receipt entering the qty-proportional branch then divided by the stale qty and was under-billed: PO 10 x 500, PO-level PI for 5 (2500), PR1 qty 3 with 500 billed directly consumes 1000 (pool 2500 -> 1500, qty stuck at 5), PR2 qty 3 got 1500 * 3/5 = 900 instead of its full 1500. Scale the remaining qty by the consumed fraction so both stay proportional. Follow-up to #58021. --- .../purchase_receipt/services/billing_status.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/erpnext/stock/doctype/purchase_receipt/services/billing_status.py b/erpnext/stock/doctype/purchase_receipt/services/billing_status.py index bdfde3fad21..3f78deb5ff5 100644 --- a/erpnext/stock/doctype/purchase_receipt/services/billing_status.py +++ b/erpnext/stock/doctype/purchase_receipt/services/billing_status.py @@ -72,14 +72,15 @@ def update_billed_amount_based_on_po(po_details: list, update_modified: bool = T ) else: pending_to_bill = flt(pr_item.amount) - billed_amt_against_pr - if pending_to_bill <= billed_amt_against_po: - billed_amt_against_pr += pending_to_bill - billed_amt_against_po -= pending_to_bill - else: - billed_amt_against_pr += billed_amt_against_po - billed_amt_against_po = 0 + consumed_amt_against_po = min(pending_to_bill, billed_amt_against_po) + billed_amt_against_pr += consumed_amt_against_po - po_billed_amt_details[pr_item.purchase_order_item]["billed_amt"] = billed_amt_against_po + po_billed_amt_details[pr_item.purchase_order_item]["billed_amt"] = ( + billed_amt_against_po - consumed_amt_against_po + ) + po_billed_amt_details[pr_item.purchase_order_item]["billed_qty"] = billed_qty_against_po * ( + 1 - consumed_amt_against_po / billed_amt_against_po + ) if pr_item.billed_amt != billed_amt_against_pr: # update existing doc if possible From 031e7c0eb77f612e4f12c2470c9ed77a786d5d9d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 11 Aug 2026 18:40:35 +0530 Subject: [PATCH 2/2] test: cover mixed direct and PO-invoice billing across receipts --- .../purchase_receipt/test_purchase_receipt.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 64dfb894f46..b6e1b8f1acb 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -947,6 +947,65 @@ class TestPurchaseReceipt(ERPNextTestSuite): po.reload() po.cancel() + 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. + + 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 + """ + 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.get("items")[0].received_qty = 3 + pr1.get("items")[0].qty = 3 + pr1.submit() + + direct_pi = make_purchase_invoice(pr1.name) + direct_pi.get("items")[0].qty = 1 + direct_pi.submit() + + pr2 = make_purchase_receipt(po.name) + pr2.get("items")[0].received_qty = 3 + pr2.get("items")[0].qty = 3 + pr2.submit() + + # PR1: 500 direct + 1000 from the PO invoice (2 qty worth) -> 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 gets the remaining 1500 (3 qty worth), not 1500 * 3/5 = 900. + pr2.load_from_db() + 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() + pi.reload() + pi.cancel() + pr1.reload() + pr1.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):