From 355d71dbd2725411728f8aefc7dc29590821d3cb Mon Sep 17 00:00:00 2001 From: nishkagosalia Date: Wed, 27 May 2026 16:47:40 +0530 Subject: [PATCH] feat: over order allowance setting --- .../buying_settings/buying_settings.json | 21 ++++++---- .../buying_settings/buying_settings.py | 1 + .../doctype/purchase_order/purchase_order.py | 3 ++ .../purchase_order/test_purchase_order.py | 38 +++++++++++++++++ erpnext/controllers/status_updater.py | 42 ++++++++++++++----- .../purchase_receipt/test_purchase_receipt.py | 4 +- 6 files changed, 90 insertions(+), 19 deletions(-) diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.json b/erpnext/buying/doctype/buying_settings/buying_settings.json index 6c2d2f1bb99..b219379368d 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.json +++ b/erpnext/buying/doctype/buying_settings/buying_settings.json @@ -17,6 +17,7 @@ "section_break_vwgg", "maintain_same_rate", "column_break_lwxs", + "set_landed_cost_based_on_purchase_invoice_rate", "maintain_same_rate_action", "role_to_override_stop_action", "transaction_settings_section", @@ -24,7 +25,8 @@ "po_required", "pr_required", "project_update_frequency", - "column_break_12", + "over_order_allowance", + "column_break_kdcm", "allow_multiple_items", "allow_negative_rates_for_items", "set_valuation_rate_for_rejected_materials", @@ -33,7 +35,6 @@ "purchase_invoice_settings_section", "bill_for_rejected_quantity_in_purchase_invoice", "use_transaction_date_exchange_rate", - "set_landed_cost_based_on_purchase_invoice_rate", "zero_quantity_line_items_section", "allow_zero_qty_in_supplier_quotation", "allow_zero_qty_in_request_for_quotation", @@ -156,10 +157,6 @@ "fieldtype": "Tab Break", "label": "Transaction Settings" }, - { - "fieldname": "column_break_12", - "fieldtype": "Column Break" - }, { "default": "0", "description": "Prevents the system from automatically using the rate from the last purchase transaction when creating new purchase orders or transactions.", @@ -335,6 +332,16 @@ "hidden": 1, "is_virtual": 1, "label": "Naming Series options" + }, + { + "description": "The percentage by which you are allowed to order more on a Purchase Order than the quantity requested on the originating Material Request. For example, if the Material Request has 100 units and the allowance is 10%, you can order up to 110 units", + "fieldname": "over_order_allowance", + "fieldtype": "Float", + "label": "Over Order Allowance (%)" + }, + { + "fieldname": "column_break_kdcm", + "fieldtype": "Column Break" } ], "grid_page_length": 50, @@ -343,7 +350,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-05-05 16:30:37.184607", + "modified": "2026-05-27 23:04:00.842393", "modified_by": "Administrator", "module": "Buying", "name": "Buying Settings", diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.py b/erpnext/buying/doctype/buying_settings/buying_settings.py index 8f358bb364b..91ba900873b 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.py +++ b/erpnext/buying/doctype/buying_settings/buying_settings.py @@ -34,6 +34,7 @@ class BuyingSettings(Document): fixed_email: DF.Link | None maintain_same_rate: DF.Check maintain_same_rate_action: DF.Literal["Stop", "Warn"] + over_order_allowance: DF.Float over_transfer_allowance: DF.Float po_required: DF.Literal["No", "Yes"] pr_required: DF.Literal["No", "Yes"] diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index e25332528e2..6e9306c6d73 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -178,6 +178,9 @@ class PurchaseOrder(BuyingController): "target_ref_field": "stock_qty", "source_field": "stock_qty", "percent_join_field": "material_request", + "global_allowance_field": "over_order_allowance", + "global_allowance_doctype": "Buying Settings", + "item_allowance_field": "over_order_allowance", } ] diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index c361e66229e..da352e2541c 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -128,6 +128,44 @@ class TestPurchaseOrder(ERPNextTestSuite): frappe.db.set_value("Item", "_Test Item", "over_billing_allowance", 0) frappe.db.set_single_value("Accounts Settings", "over_billing_allowance", 0) + def test_over_order_allowance_against_material_request(self) -> None: + """Over Order Allowance in Buying Settings must govern PO qty vs MR qty independently + from Over Delivery/Receipt Allowance which governs receipt/delivery against a PO.""" + mr = make_material_request(qty=100) + po = make_purchase_order(mr.name) + po.supplier = "_Test Supplier" + po.items[0].qty = 110 # 10% over the MR qty + + # Without any allowance, submitting should raise an OverAllowanceError + from erpnext.controllers.status_updater import OverAllowanceError + + frappe.db.set_single_value("Buying Settings", "over_order_allowance", 0) + frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 0) + self.assertRaises(OverAllowanceError, po.submit) + + # Granting 10% in Over Order Allowance (Buying Settings) must allow the submit + frappe.db.set_single_value("Buying Settings", "over_order_allowance", 10) + po.reload() + po.items[0].qty = 110 + po.submit() + self.assertEqual(po.docstatus, 1) + po.cancel() + + # Over Delivery/Receipt Allowance must remain independent — changing it must not + # affect the MR → PO validation when Over Order Allowance is 0. + frappe.db.set_single_value("Buying Settings", "over_order_allowance", 0) + frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 50) + + mr2 = make_material_request(qty=100) + po2 = make_purchase_order(mr2.name) + po2.supplier = "_Test Supplier" + po2.items[0].qty = 110 + self.assertRaises(OverAllowanceError, po2.submit) + + # cleanup + frappe.db.set_single_value("Buying Settings", "over_order_allowance", 0) + frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 0) + def test_update_remove_child_linked_to_mr(self): """Test impact on linked PO and MR on deleting/updating row.""" mr = make_material_request(qty=10) diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index a87a1bf4e99..e4a7e0d3b05 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -262,15 +262,17 @@ class StatusUpdater(Document): def validate_qty(self): """Validates qty at row level""" - self.item_allowance = {} - self.global_qty_allowance = None - self.global_amount_allowance = None - for args in self.status_updater: if "target_ref_field" not in args or args.get("validate_qty") is False: # if target_ref_field is not specified or validate_qty is explicitly set to False, skip validation continue + # Reset per-args so each config block uses its own allowance source without + # leaking cached values from a previous config block. + self.item_allowance = {} + self.global_qty_allowance = None + self.global_amount_allowance = None + items_to_validate = [] selling_negative_rate_allowed = frappe.get_single_value( "Selling Settings", "allow_negative_rates_for_items" @@ -402,9 +404,12 @@ class StatusUpdater(Document): def check_overflow_with_allowance(self, item, args): """ - Checks if there is overflow condering a relaxation allowance + Checks if there is overflow considering a relaxation allowance. """ qty_or_amount = "qty" if "qty" in args["target_ref_field"] else "amount" + global_qty_allowance_field = args.get("global_allowance_field", "over_delivery_receipt_allowance") + global_qty_allowance_doctype = args.get("global_allowance_doctype", "Stock Settings") + item_qty_allowance_field = args.get("item_allowance_field", "over_delivery_receipt_allowance") # check if overflow is within allowance ( @@ -419,6 +424,9 @@ class StatusUpdater(Document): self.global_qty_allowance, self.global_amount_allowance, qty_or_amount, + global_qty_allowance_field, + global_qty_allowance_doctype, + item_qty_allowance_field, ) if args["source_dt"] != "Pick List Item" else (0, {}, None, None) @@ -463,7 +471,9 @@ class StatusUpdater(Document): "Quotation Item", "Packed Item", ]: - if qty_or_amount == "qty": + if args.get("target_dt") == "Material Request Item": + action_msg = _('To allow over ordering, update "Over Order Allowance" in Buying Settings.') + elif qty_or_amount == "qty": action_msg = _( 'To allow over receipt / delivery, update "Over Receipt/Delivery Allowance" in Stock Settings or the Item.' ) @@ -724,16 +734,28 @@ class StatusUpdater(Document): ref_doc.set_status(update=True) -@frappe.request_cache def get_allowance_for( item_code, item_allowance=None, global_qty_allowance=None, global_amount_allowance=None, qty_or_amount="qty", + global_qty_allowance_field="over_delivery_receipt_allowance", + global_qty_allowance_doctype="Stock Settings", + item_qty_allowance_field="over_delivery_receipt_allowance", ): """ - Returns the allowance for the item, if not set, returns global allowance + Returns the allowance for the item, if not set, returns global allowance. + + Args: + item_code: The item to get allowance for. + item_allowance: Cached per-item allowances from a previous call. + global_qty_allowance: Cached global qty allowance from a previous call. + global_amount_allowance: Cached global amount allowance from a previous call. + qty_or_amount: Whether to return qty or amount allowance. + global_qty_allowance_field: The field name on the settings doctype to use for the global qty allowance. + global_qty_allowance_doctype: The settings doctype to read the global qty allowance from. + item_qty_allowance_field: The field name on the Item doctype to use for the item-level qty allowance override. """ if item_allowance is None: item_allowance = {} @@ -755,13 +777,13 @@ def get_allowance_for( ) qty_allowance, over_billing_allowance = frappe.get_cached_value( - "Item", item_code, ["over_delivery_receipt_allowance", "over_billing_allowance"] + "Item", item_code, [item_qty_allowance_field, "over_billing_allowance"] ) if qty_or_amount == "qty" and not qty_allowance: if global_qty_allowance is None: global_qty_allowance = flt( - frappe.get_cached_value("Stock Settings", None, "over_delivery_receipt_allowance") + frappe.get_cached_value(global_qty_allowance_doctype, None, global_qty_allowance_field) ) qty_allowance = global_qty_allowance elif qty_or_amount == "amount" and not over_billing_allowance: diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 6f217b98674..191f2812135 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -57,13 +57,13 @@ class TestPurchaseReceipt(ERPNextTestSuite): ) mr.insert() mr.submit() - frappe.db.set_value("Item", item.name, "over_delivery_receipt_allowance", 200) + frappe.db.set_single_value("Buying Settings", "over_order_allowance", 200) po = make_purchase_order(mr.name) po.supplier = "_Test Supplier" po.items[0].qty = 300 po.save() po.submit() - frappe.db.set_value("Item", item.name, "over_delivery_receipt_allowance", 20) + frappe.db.set_single_value("Buying Settings", "over_order_allowance", 0) pr = make_purchase_receipt(qty=300, item_code=item.name, do_not_save=True) pr.save() pr.submit()