From ee8eb18daf075a9c0cd9dedaf95e52a4c112d95c Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 7 Aug 2026 22:20:30 +0530 Subject: [PATCH 1/4] fix: order smallest purchase UOM qty that meets min order qty A Production Plan with Consider Minimum Order Qty raises the requirement to the item's minimum in stock UOM, then converts it to the purchase UOM with round-to-nearest. Nearest rounding can land below the minimum it just applied: min order qty 50000 with purchase UOM conversion factor 453.592292197 becomes 110.231, which is 49999.932 in stock UOM, and the mapped Purchase Order is then rejected by validate_minimum_order_qty. When the minimum binds and the nearest-rounded value dips below it, quantize to the smallest representable purchase-UOM quantity whose stock equivalent meets the minimum, using Decimal grid-ceiling arithmetic. 110.232 converts to 50000.386: demand stays as planned and the overage is order-unit granularity, the standard MRP lot-sizing outcome. Ordinary conversions keep the historical round-to-nearest behavior. --- .../services/material_request.py | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/services/material_request.py b/erpnext/manufacturing/doctype/production_plan/services/material_request.py index ac689ba0a3f..7027c83af04 100644 --- a/erpnext/manufacturing/doctype/production_plan/services/material_request.py +++ b/erpnext/manufacturing/doctype/production_plan/services/material_request.py @@ -11,6 +11,7 @@ existing imports of ``...services.material_planning`` keep working through here. import copy import json from collections import defaultdict +from decimal import ROUND_CEILING, Decimal import frappe from frappe import _, msgprint @@ -493,8 +494,16 @@ def get_material_request_items( ) item_group_defaults = get_item_group_defaults(row.item_code, company) conversion_factor = _mr_purchase_conversion_factor(row) + min_order_qty = flt(row.get("min_order_qty")) if doc.get("consider_minimum_order_qty") else 0 return _material_request_item_row( - row, sales_order, target_warehouse, bin_dict, required_qty, conversion_factor, item_group_defaults + row, + sales_order, + target_warehouse, + bin_dict, + required_qty, + conversion_factor, + item_group_defaults, + min_order_qty, ) @@ -539,6 +548,18 @@ def _adjust_required_qty_for_uom(row, required_qty): return required_qty +def _quantity_in_purchase_uom(required_qty, conversion_factor, min_order_qty=0): + """Convert to purchase UOM; a binding minimum order qty takes the smallest + representable quantity whose stock equivalent still meets it.""" + precision = frappe.get_precision("Material Request Plan Item", "quantity") + quantity = flt(required_qty / conversion_factor, precision) + if min_order_qty and quantity * conversion_factor < min_order_qty <= required_qty: + grid = Decimal(10) ** -precision + exact = Decimal(str(min_order_qty)) / Decimal(str(conversion_factor)) + quantity = flt(exact.quantize(grid, rounding=ROUND_CEILING)) + return quantity + + def _mr_purchase_conversion_factor(row): item_details = frappe.get_cached_value("Item", row.item_code, ["purchase_uom", "stock_uom"], as_dict=1) if ( @@ -551,7 +572,14 @@ def _mr_purchase_conversion_factor(row): def _material_request_item_row( - row, sales_order, warehouse, bin_dict, required_qty, conversion_factor, item_group_defaults + row, + sales_order, + warehouse, + bin_dict, + required_qty, + conversion_factor, + item_group_defaults, + min_order_qty=0, ): warehouse = ( warehouse @@ -559,11 +587,10 @@ def _material_request_item_row( or row.get("default_warehouse") or item_group_defaults.get("default_warehouse") ) - precision = frappe.get_precision("Material Request Plan Item", "quantity") return { "item_code": row.item_code, "item_name": row.item_name, - "quantity": flt(required_qty / conversion_factor, precision), + "quantity": _quantity_in_purchase_uom(required_qty, conversion_factor, min_order_qty), "conversion_factor": conversion_factor, "required_bom_qty": row.get("qty"), "stock_uom": row.get("stock_uom"), @@ -640,7 +667,8 @@ def _add_remaining_purchase_request(item, new_mr_items, required_qty, consider_m if frappe.db.get_value("UOM", purchase_uom, "must_be_whole_number"): required_qty = ceil(required_qty) - item["quantity"] = flt(required_qty / item.get("conversion_factor"), precision) + min_order_qty = flt(item.get("min_order_qty")) if consider_minimum_order_qty else 0 + item["quantity"] = _quantity_in_purchase_uom(required_qty, item.get("conversion_factor"), min_order_qty) new_mr_items.append(item) From e4b66dc731572927741e05ac92d0f41a894e3392 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 7 Aug 2026 22:20:38 +0530 Subject: [PATCH 2/4] test: min order qty conversion takes the grid ceiling Covers both rounding brackets, an exactly representable conversion, the no-minimum path, and the ceiling through the plan items and materials from other locations flows. --- .../production_plan/test_production_plan.py | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index a7891b10be0..2732aa7046e 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -2384,6 +2384,73 @@ class TestProductionPlan(ERPNextTestSuite): self.assertEqual(items_by_type["Material Transfer"].get("quantity"), 7.0) self.assertEqual(items_by_type["Purchase"].get("quantity"), 1000.0) + def test_min_order_qty_conversion_takes_grid_ceiling(self): + from erpnext.manufacturing.doctype.production_plan.services.material_request import ( + _quantity_in_purchase_uom, + ) + + original_precision = frappe.db.get_default("float_precision") + frappe.db.set_default("float_precision", "3") + self.addCleanup(frappe.db.set_default, "float_precision", original_precision) + + self.assertEqual(_quantity_in_purchase_uom(50000, 453.592292197, 50000), 110.232) + self.assertEqual(_quantity_in_purchase_uom(2000, 0.453592, 2000), 4409.249) + self.assertEqual(_quantity_in_purchase_uom(10, 0.5, 10), 20.0) + self.assertEqual(_quantity_in_purchase_uom(50000, 453.592292197), 110.231) + + def test_min_order_qty_grid_ceiling_in_plan_items(self): + original_precision = frappe.db.get_default("float_precision") + frappe.db.set_default("float_precision", "3") + self.addCleanup(frappe.db.set_default, "float_precision", original_precision) + + conversion_factor = 453.592292197 + fg_item = make_item(properties={"is_stock_item": 1}).name + rm_item = make_item( + properties={"is_stock_item": 1, "min_order_qty": 50000, "purchase_uom": "_Test UOM 1"}, + uoms=[{"uom": "_Test UOM 1", "conversion_factor": conversion_factor}], + ).name + + make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse="_Test Warehouse - _TC") + + pln = create_production_plan(item_code=fg_item, planned_qty=1, do_not_submit=1) + pln.consider_minimum_order_qty = 1 + mr_items = get_items_for_material_requests(pln.as_dict()) + + self.assertEqual(mr_items[0].get("quantity"), 110.232) + self.assertGreaterEqual(mr_items[0].get("quantity") * conversion_factor, 50000) + + def test_min_order_qty_grid_ceiling_from_other_locations(self): + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + original_precision = frappe.db.get_default("float_precision") + frappe.db.set_default("float_precision", "3") + self.addCleanup(frappe.db.set_default, "float_precision", original_precision) + + conversion_factor = 453.592292197 + fg_item = make_item(properties={"is_stock_item": 1}).name + rm_item = make_item( + properties={"is_stock_item": 1, "min_order_qty": 50000, "purchase_uom": "_Test UOM 1"}, + uoms=[{"uom": "_Test UOM 1", "conversion_factor": conversion_factor}], + ).name + + rm_warehouse = create_warehouse("MOQ Ceiling RM Warehouse", company="_Test Company") + source_warehouse = create_warehouse("MOQ Ceiling Source Warehouse", company="_Test Company") + make_stock_entry(item_code=rm_item, qty=4, rate=100, target=source_warehouse) + + make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse="_Test Warehouse - _TC") + + pln = create_production_plan(item_code=fg_item, planned_qty=10, do_not_submit=1) + pln.for_warehouse = rm_warehouse + pln.consider_minimum_order_qty = 1 + pln.ignore_existing_ordered_qty = 1 + mr_items = get_items_for_material_requests( + pln.as_dict(), warehouses=[{"warehouse": source_warehouse}] + ) + + rows_by_type = {d.get("material_request_type"): d for d in mr_items} + self.assertEqual(rows_by_type["Material Transfer"].get("quantity"), 4) + self.assertEqual(rows_by_type["Purchase"].get("quantity"), 110.232) + def test_fg_item_quantity(self): fg_item = make_item(properties={"is_stock_item": 1}).name rm_item = make_item(properties={"is_stock_item": 1}).name From 1aa6f8d1a77f6011cf5a1fc2f0fa962e3fb3db01 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 7 Aug 2026 22:34:06 +0530 Subject: [PATCH 3/4] feat: warn when ordered qty exceeds min order qty only by UOM rounding A minimum order qty defined in stock UOM often has no exact representation in the purchase UOM, so the smallest valid order slightly exceeds the minimum. Surface that overage on the Purchase Order with a toast on first save when an item's ordered stock qty is above its minimum by less than one purchase-UOM step, so the buyer sees the marginal increase before sending the order. Sub-precision dust stays silent. --- .../doctype/purchase_order/purchase_order.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 637a86846f9..a27689d6052 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -319,6 +319,38 @@ class PurchaseOrder(BuyingController): ).format(item_code, flt(qty, precision), itemwise_min_order_qty.get(item_code)) ) + self.warn_marginal_min_order_qty(itemwise_qty, itemwise_min_order_qty) + + def warn_marginal_min_order_qty(self, itemwise_qty, itemwise_min_order_qty): + """Toast when an item's ordered qty exceeds its minimum only by purchase UOM rounding.""" + if not self.is_new(): + return + + precision = self.items[0].precision("stock_qty") + itemwise_step = frappe._dict() + itemwise_stock_uom = frappe._dict() + for d in self.get("items"): + step = 10 ** -d.precision("qty") * flt(d.conversion_factor) + itemwise_step[d.item_code] = max(itemwise_step.get(d.item_code, 0), step) + itemwise_stock_uom[d.item_code] = d.stock_uom + + for item_code, qty in itemwise_qty.items(): + min_order_qty = flt(itemwise_min_order_qty.get(item_code)) + overage = flt(qty) - min_order_qty + if min_order_qty and flt(overage, precision) > 0 and overage < itemwise_step[item_code]: + frappe.toast( + _( + "Item {0}: Ordered qty {1} {2} exceeds the minimum order qty {3} {2} by {4} {2} due to purchase UOM rounding." + ).format( + item_code, + flt(qty, precision), + itemwise_stock_uom[item_code], + min_order_qty, + flt(overage, precision), + ), + indicator="orange", + ) + def get_schedule_dates(self): for d in self.get("items"): if d.material_request_item and not d.schedule_date: From 1ca06024cb379c3ee903be4fb441cbb31e87a4e7 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 7 Aug 2026 22:34:07 +0530 Subject: [PATCH 4/4] test: marginal min order qty overage raises a toast --- .../purchase_order/test_purchase_order.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index a9da89a8306..43b441ee19e 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -724,6 +724,30 @@ class TestPurchaseOrder(ERPNextTestSuite): below_minimum.items[0].conversion_factor = 0.6 self.assertRaises(frappe.ValidationError, below_minimum.insert) + def test_marginal_min_order_qty_overage_toast(self): + original_precision = frappe.db.get_default("float_precision") + frappe.db.set_default("float_precision", "3") + self.addCleanup(frappe.db.set_default, "float_precision", original_precision) + + if not frappe.db.exists("UOM", "Gram"): + frappe.get_doc({"doctype": "UOM", "uom_name": "Gram"}).insert() + + item_doc = make_item(properties={"min_order_qty": 50000, "stock_uom": "Gram"}) + item_doc.append("uoms", {"uom": "Pound", "conversion_factor": 453.592292197}) + item_doc.save() + item = item_doc.name + + def insert_po(qty): + po = create_purchase_order(item_code=item, qty=qty, do_not_save=1) + po.items[0].uom = "Pound" + po.items[0].conversion_factor = 453.592292197 + frappe.clear_messages() + po.insert() + return any("minimum order qty" in d.get("message", "") for d in frappe.get_message_log()) + + self.assertTrue(insert_po(110.232)) + self.assertFalse(insert_po(150)) + def test_uom_integer_check_tolerates_conversion_dust(self): from erpnext.utilities.transaction_base import UOMMustBeIntegerError