fix(stock): repair duplicated purchase receipt billing

(cherry picked from commit 8b7e04eae1)
This commit is contained in:
Mihir Kandoi
2026-08-11 16:15:31 +05:30
parent 675901d801
commit fa733e691b
4 changed files with 200 additions and 0 deletions

View File

@@ -498,3 +498,4 @@ erpnext.patches.v16_0.backfill_repost_accounting_ledger_status
erpnext.patches.v16_0.merge_seeded_item_group_root
erpnext.patches.v16_0.rename_italy_customer_name_fields
erpnext.patches.v16_0.set_stock_uom_in_job_card
erpnext.patches.v16_0.recalculate_purchase_receipt_billing_status

View File

@@ -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.purchase_receipt 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)

View File

@@ -1164,6 +1164,15 @@ def update_billed_amount_based_on_po(po_details, update_modified=True, pr_doc=No
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

@@ -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
@@ -762,6 +764,104 @@ 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.purchase_order import (
make_purchase_invoice as make_purchase_invoice_from_po,
)
from erpnext.buying.doctype.purchase_order.purchase_order 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")
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()
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):