mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-03 12:19:12 +00:00
Merge pull request #55340 from nishkagosalia/gh-55106
feat: over order allowance setting
This commit is contained in:
@@ -17,6 +17,7 @@
|
|||||||
"section_break_vwgg",
|
"section_break_vwgg",
|
||||||
"maintain_same_rate",
|
"maintain_same_rate",
|
||||||
"column_break_lwxs",
|
"column_break_lwxs",
|
||||||
|
"set_landed_cost_based_on_purchase_invoice_rate",
|
||||||
"maintain_same_rate_action",
|
"maintain_same_rate_action",
|
||||||
"role_to_override_stop_action",
|
"role_to_override_stop_action",
|
||||||
"transaction_settings_section",
|
"transaction_settings_section",
|
||||||
@@ -24,7 +25,8 @@
|
|||||||
"po_required",
|
"po_required",
|
||||||
"pr_required",
|
"pr_required",
|
||||||
"project_update_frequency",
|
"project_update_frequency",
|
||||||
"column_break_12",
|
"over_order_allowance",
|
||||||
|
"column_break_kdcm",
|
||||||
"allow_multiple_items",
|
"allow_multiple_items",
|
||||||
"allow_negative_rates_for_items",
|
"allow_negative_rates_for_items",
|
||||||
"set_valuation_rate_for_rejected_materials",
|
"set_valuation_rate_for_rejected_materials",
|
||||||
@@ -33,7 +35,6 @@
|
|||||||
"purchase_invoice_settings_section",
|
"purchase_invoice_settings_section",
|
||||||
"bill_for_rejected_quantity_in_purchase_invoice",
|
"bill_for_rejected_quantity_in_purchase_invoice",
|
||||||
"use_transaction_date_exchange_rate",
|
"use_transaction_date_exchange_rate",
|
||||||
"set_landed_cost_based_on_purchase_invoice_rate",
|
|
||||||
"zero_quantity_line_items_section",
|
"zero_quantity_line_items_section",
|
||||||
"allow_zero_qty_in_supplier_quotation",
|
"allow_zero_qty_in_supplier_quotation",
|
||||||
"allow_zero_qty_in_request_for_quotation",
|
"allow_zero_qty_in_request_for_quotation",
|
||||||
@@ -156,10 +157,6 @@
|
|||||||
"fieldtype": "Tab Break",
|
"fieldtype": "Tab Break",
|
||||||
"label": "Transaction Settings"
|
"label": "Transaction Settings"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"fieldname": "column_break_12",
|
|
||||||
"fieldtype": "Column Break"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
"description": "Prevents the system from automatically using the rate from the last purchase transaction when creating new purchase orders or transactions.",
|
"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,
|
"hidden": 1,
|
||||||
"is_virtual": 1,
|
"is_virtual": 1,
|
||||||
"label": "Naming Series options"
|
"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,
|
"grid_page_length": 50,
|
||||||
@@ -343,7 +350,7 @@
|
|||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"issingle": 1,
|
"issingle": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-05-05 16:30:37.184607",
|
"modified": "2026-05-27 23:04:00.842393",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Buying",
|
"module": "Buying",
|
||||||
"name": "Buying Settings",
|
"name": "Buying Settings",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class BuyingSettings(Document):
|
|||||||
fixed_email: DF.Link | None
|
fixed_email: DF.Link | None
|
||||||
maintain_same_rate: DF.Check
|
maintain_same_rate: DF.Check
|
||||||
maintain_same_rate_action: DF.Literal["Stop", "Warn"]
|
maintain_same_rate_action: DF.Literal["Stop", "Warn"]
|
||||||
|
over_order_allowance: DF.Float
|
||||||
over_transfer_allowance: DF.Float
|
over_transfer_allowance: DF.Float
|
||||||
po_required: DF.Literal["No", "Yes"]
|
po_required: DF.Literal["No", "Yes"]
|
||||||
pr_required: DF.Literal["No", "Yes"]
|
pr_required: DF.Literal["No", "Yes"]
|
||||||
|
|||||||
@@ -178,6 +178,9 @@ class PurchaseOrder(BuyingController):
|
|||||||
"target_ref_field": "stock_qty",
|
"target_ref_field": "stock_qty",
|
||||||
"source_field": "stock_qty",
|
"source_field": "stock_qty",
|
||||||
"percent_join_field": "material_request",
|
"percent_join_field": "material_request",
|
||||||
|
"global_allowance_field": "over_order_allowance",
|
||||||
|
"global_allowance_doctype": "Buying Settings",
|
||||||
|
"item_allowance_field": "over_order_allowance",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,44 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
|||||||
frappe.db.set_value("Item", "_Test Item", "over_billing_allowance", 0)
|
frappe.db.set_value("Item", "_Test Item", "over_billing_allowance", 0)
|
||||||
frappe.db.set_single_value("Accounts Settings", "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):
|
def test_update_remove_child_linked_to_mr(self):
|
||||||
"""Test impact on linked PO and MR on deleting/updating row."""
|
"""Test impact on linked PO and MR on deleting/updating row."""
|
||||||
mr = make_material_request(qty=10)
|
mr = make_material_request(qty=10)
|
||||||
|
|||||||
@@ -262,15 +262,17 @@ class StatusUpdater(Document):
|
|||||||
|
|
||||||
def validate_qty(self):
|
def validate_qty(self):
|
||||||
"""Validates qty at row level"""
|
"""Validates qty at row level"""
|
||||||
self.item_allowance = {}
|
|
||||||
self.global_qty_allowance = None
|
|
||||||
self.global_amount_allowance = None
|
|
||||||
|
|
||||||
for args in self.status_updater:
|
for args in self.status_updater:
|
||||||
if "target_ref_field" not in args or args.get("validate_qty") is False:
|
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
|
# if target_ref_field is not specified or validate_qty is explicitly set to False, skip validation
|
||||||
continue
|
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 = []
|
items_to_validate = []
|
||||||
selling_negative_rate_allowed = frappe.get_single_value(
|
selling_negative_rate_allowed = frappe.get_single_value(
|
||||||
"Selling Settings", "allow_negative_rates_for_items"
|
"Selling Settings", "allow_negative_rates_for_items"
|
||||||
@@ -402,9 +404,12 @@ class StatusUpdater(Document):
|
|||||||
|
|
||||||
def check_overflow_with_allowance(self, item, args):
|
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"
|
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
|
# check if overflow is within allowance
|
||||||
(
|
(
|
||||||
@@ -419,6 +424,9 @@ class StatusUpdater(Document):
|
|||||||
self.global_qty_allowance,
|
self.global_qty_allowance,
|
||||||
self.global_amount_allowance,
|
self.global_amount_allowance,
|
||||||
qty_or_amount,
|
qty_or_amount,
|
||||||
|
global_qty_allowance_field,
|
||||||
|
global_qty_allowance_doctype,
|
||||||
|
item_qty_allowance_field,
|
||||||
)
|
)
|
||||||
if args["source_dt"] != "Pick List Item"
|
if args["source_dt"] != "Pick List Item"
|
||||||
else (0, {}, None, None)
|
else (0, {}, None, None)
|
||||||
@@ -463,7 +471,9 @@ class StatusUpdater(Document):
|
|||||||
"Quotation Item",
|
"Quotation Item",
|
||||||
"Packed 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 = _(
|
action_msg = _(
|
||||||
'To allow over receipt / delivery, update "Over Receipt/Delivery Allowance" in Stock Settings or the Item.'
|
'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)
|
ref_doc.set_status(update=True)
|
||||||
|
|
||||||
|
|
||||||
@frappe.request_cache
|
|
||||||
def get_allowance_for(
|
def get_allowance_for(
|
||||||
item_code,
|
item_code,
|
||||||
item_allowance=None,
|
item_allowance=None,
|
||||||
global_qty_allowance=None,
|
global_qty_allowance=None,
|
||||||
global_amount_allowance=None,
|
global_amount_allowance=None,
|
||||||
qty_or_amount="qty",
|
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:
|
if item_allowance is None:
|
||||||
item_allowance = {}
|
item_allowance = {}
|
||||||
@@ -755,13 +777,13 @@ def get_allowance_for(
|
|||||||
)
|
)
|
||||||
|
|
||||||
qty_allowance, over_billing_allowance = frappe.get_cached_value(
|
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 qty_or_amount == "qty" and not qty_allowance:
|
||||||
if global_qty_allowance is None:
|
if global_qty_allowance is None:
|
||||||
global_qty_allowance = flt(
|
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
|
qty_allowance = global_qty_allowance
|
||||||
elif qty_or_amount == "amount" and not over_billing_allowance:
|
elif qty_or_amount == "amount" and not over_billing_allowance:
|
||||||
|
|||||||
@@ -57,13 +57,13 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
|||||||
)
|
)
|
||||||
mr.insert()
|
mr.insert()
|
||||||
mr.submit()
|
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 = make_purchase_order(mr.name)
|
||||||
po.supplier = "_Test Supplier"
|
po.supplier = "_Test Supplier"
|
||||||
po.items[0].qty = 300
|
po.items[0].qty = 300
|
||||||
po.save()
|
po.save()
|
||||||
po.submit()
|
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 = make_purchase_receipt(qty=300, item_code=item.name, do_not_save=True)
|
||||||
pr.save()
|
pr.save()
|
||||||
pr.submit()
|
pr.submit()
|
||||||
|
|||||||
Reference in New Issue
Block a user