From 86726bbd85edcaaae7a417a6c450d4115c181014 Mon Sep 17 00:00:00 2001 From: Arshad Qureshi <151866062+arshadqureshi93@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:22:48 +0530 Subject: [PATCH] fix(buying): honour over delivery/receipt allowance in PR mapper (#55247) --- .../doctype/purchase_order/purchase_order.js | 5 +- .../doctype/purchase_order/purchase_order.py | 43 ++++++++++++--- .../purchase_order/test_purchase_order.py | 54 +++++++++++++++++++ 3 files changed, 93 insertions(+), 9 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index 85c159ed491..39b2fa8e037 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -351,9 +351,10 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends ( if (doc.status != "Closed") { if (doc.status != "On Hold") { if ( - doc.items + (doc.items .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 ) { this.frm.add_custom_button( diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 165a434754d..4adfb60c35c 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -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.buying.utils import validate_for_items 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 ( validate_against_blanket_order, ) @@ -185,6 +186,7 @@ class PurchaseOrder(BuyingController): def onload(self): 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): self.set_has_unit_price_items() @@ -646,6 +648,19 @@ class PurchaseOrder(BuyingController): 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): """ 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): 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): - target.qty = flt(obj.qty) if is_unit_price_row(obj) else flt(obj.qty) - flt(obj.received_qty) - target.stock_qty = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.conversion_factor) - target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) - target.base_amount = ( - (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate) - ) + received_qty = flt(obj.received_qty) + qty = flt(obj.qty) + pending_qty = qty - received_qty + + 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): filtered_items = args.get("filtered_children", []) @@ -785,7 +812,9 @@ def make_purchase_receipt( }, "postprocess": update_item, "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 select_item(doc), diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 0ad52270ad9..0386c9022e2 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -98,6 +98,60 @@ class TestPurchaseOrder(ERPNextTestSuite): po.load_from_db() 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): existing_ordered_qty = get_ordered_qty() po = create_purchase_order()