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 <noreply@anthropic.com>
This commit is contained in:
harisansari008
2026-08-11 15:15:08 +05:30
committed by Mihir Kandoi
parent 2f1548c0f8
commit 5e33a3c0bf
2 changed files with 67 additions and 0 deletions

View File

@@ -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:

View File

@@ -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):