fix(buying): honour over delivery/receipt allowance in PR mapper (#55247)

This commit is contained in:
Arshad Qureshi
2026-06-03 12:22:48 +05:30
committed by GitHub
parent 8164782263
commit 86726bbd85
3 changed files with 93 additions and 9 deletions

View File

@@ -351,9 +351,10 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
if (doc.status != "Closed") { if (doc.status != "Closed") {
if (doc.status != "On Hold") { if (doc.status != "On Hold") {
if ( if (
doc.items (doc.items
.filter((item) => !item.delivered_by_supplier) .filter((item) => !item.delivered_by_supplier)
.some((item) => item.received_qty < item.qty) && .some((item) => item.received_qty < item.qty) ||
doc.__onload?.has_pending_receivable_qty) &&
allow_receipt allow_receipt
) { ) {
this.frm.add_custom_button( this.frm.add_custom_button(

View File

@@ -19,6 +19,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
from erpnext.accounts.party import get_party_account, get_party_account_currency from erpnext.accounts.party import get_party_account, get_party_account_currency
from erpnext.buying.utils import validate_for_items from erpnext.buying.utils import validate_for_items
from erpnext.controllers.buying_controller import BuyingController from erpnext.controllers.buying_controller import BuyingController
from erpnext.controllers.status_updater import get_allowance_for
from erpnext.manufacturing.doctype.blanket_order.blanket_order import ( from erpnext.manufacturing.doctype.blanket_order.blanket_order import (
validate_against_blanket_order, validate_against_blanket_order,
) )
@@ -185,6 +186,7 @@ class PurchaseOrder(BuyingController):
def onload(self): def onload(self):
self.set_onload("can_update_items", self.can_update_items()) self.set_onload("can_update_items", self.can_update_items())
self.set_onload("has_pending_receivable_qty", self.has_pending_receivable_qty())
def before_validate(self): def before_validate(self):
self.set_has_unit_price_items() self.set_has_unit_price_items()
@@ -646,6 +648,19 @@ class PurchaseOrder(BuyingController):
return result return result
def has_pending_receivable_qty(self) -> bool:
"""Return True if any non-drop-ship item can still be received,
considering the configured over_delivery_receipt_allowance.
"""
for item in self.get("items", []):
if item.delivered_by_supplier:
continue
tolerance = flt(get_allowance_for(item.item_code, qty_or_amount="qty")[0])
max_receivable_qty = flt(item.qty) * (100 + tolerance) / 100
if abs(flt(item.received_qty)) < abs(max_receivable_qty):
return True
return False
def update_ordered_qty_in_so_for_removed_items(self, removed_items): def update_ordered_qty_in_so_for_removed_items(self, removed_items):
""" """
Updates ordered_qty in linked SO when item rows are removed using Update Items Updates ordered_qty in linked SO when item rows are removed using Update Items
@@ -747,13 +762,25 @@ def make_purchase_receipt(
def is_unit_price_row(source): def is_unit_price_row(source):
return has_unit_price_items and source.qty == 0 return has_unit_price_items and source.qty == 0
def get_max_receivable_qty(source):
tolerance = flt(get_allowance_for(source.item_code, qty_or_amount="qty")[0])
return flt(source.qty) * (100 + tolerance) / 100
def update_item(obj, target, source_parent): def update_item(obj, target, source_parent):
target.qty = flt(obj.qty) if is_unit_price_row(obj) else flt(obj.qty) - flt(obj.received_qty) received_qty = flt(obj.received_qty)
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.conversion_factor) qty = flt(obj.qty)
target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) pending_qty = qty - received_qty
target.base_amount = (
(flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate) if is_unit_price_row(obj):
) target.qty = qty
elif pending_qty > 0:
target.qty = pending_qty
else:
target.qty = max(get_max_receivable_qty(obj) - received_qty, 0)
target.stock_qty = target.qty * flt(obj.conversion_factor)
target.amount = target.qty * flt(obj.rate)
target.base_amount = target.qty * flt(obj.rate) * flt(source_parent.conversion_rate)
def select_item(d): def select_item(d):
filtered_items = args.get("filtered_children", []) filtered_items = args.get("filtered_children", [])
@@ -785,7 +812,9 @@ def make_purchase_receipt(
}, },
"postprocess": update_item, "postprocess": update_item,
"condition": lambda doc: ( "condition": lambda doc: (
True if is_unit_price_row(doc) else abs(doc.received_qty) < abs(doc.qty) True
if is_unit_price_row(doc)
else abs(doc.received_qty) < abs(get_max_receivable_qty(doc))
) )
and doc.delivered_by_supplier != 1 and doc.delivered_by_supplier != 1
and select_item(doc), and select_item(doc),

View File

@@ -98,6 +98,60 @@ class TestPurchaseOrder(ERPNextTestSuite):
po.load_from_db() po.load_from_db()
self.assertEqual(po.get("items")[0].received_qty, 4) self.assertEqual(po.get("items")[0].received_qty, 4)
def test_make_purchase_receipt_respects_over_receipt_allowance(self):
"""make_purchase_receipt must include fully-received PO lines when
over_delivery_receipt_allowance permits further receipt.
Regression test for #55246: the mapper dropped rows once
received_qty >= qty, ignoring the configured tolerance.
"""
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt
# 50% tolerance — 10 ordered allows up to 15 received
frappe.db.set_value("Item", "_Test Item", "over_delivery_receipt_allowance", 50)
try:
po = create_purchase_order()
create_pr_against_po(po.name, received_qty=10)
po.load_from_db()
self.assertEqual(po.get("items")[0].received_qty, 10)
# onload must flag pending receivable qty so the UI keeps the
# "Create > Purchase Receipt" button visible even at per_received = 100
po.run_method("onload")
self.assertTrue(
po.get_onload("has_pending_receivable_qty"),
"onload should flag pending receivable qty while tolerance is available",
)
# Re-mapping the same PO must yield a PR with the row present
# and qty pre-filled to the remaining tolerance (15 - 10 = 5)
pr = make_purchase_receipt(po.name)
self.assertEqual(
len(pr.get("items")), 1, "Fully-received row dropped despite available tolerance"
)
self.assertEqual(pr.get("items")[0].item_code, "_Test Item")
self.assertEqual(pr.get("items")[0].qty, 5)
self.assertEqual(pr.get("items")[0].purchase_order_item, po.get("items")[0].name)
# Tolerance exhausted → row must be filtered out as before
create_pr_against_po(po.name, received_qty=5)
po.load_from_db()
self.assertEqual(po.get("items")[0].received_qty, 15)
po.run_method("onload")
self.assertFalse(
po.get_onload("has_pending_receivable_qty"),
"onload should clear pending receivable flag once tolerance is exhausted",
)
pr_empty = make_purchase_receipt(po.name)
self.assertEqual(
len(pr_empty.get("items")), 0, "Row should be dropped once tolerance is exhausted"
)
finally:
frappe.db.set_value("Item", "_Test Item", "over_delivery_receipt_allowance", 0)
def test_ordered_qty_against_pi_with_update_stock(self): def test_ordered_qty_against_pi_with_update_stock(self):
existing_ordered_qty = get_ordered_qty() existing_ordered_qty = get_ordered_qty()
po = create_purchase_order() po = create_purchase_order()