Merge pull request #55340 from nishkagosalia/gh-55106

feat: over order allowance setting
This commit is contained in:
Nishka Gosalia
2026-05-28 14:18:33 +05:30
committed by GitHub
6 changed files with 90 additions and 19 deletions

View File

@@ -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",

View File

@@ -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"]

View File

@@ -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",
}
]

View File

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

View File

@@ -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:

View File

@@ -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()