mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-15 15:38:39 +00:00
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:
committed by
Mihir Kandoi
parent
2f1548c0f8
commit
5e33a3c0bf
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user