fix: order smallest purchase UOM qty that meets min order qty (backport #57883) (#58813)

* fix: round purchase quantities up to minimum order qty

Backport #57883 to version-16-hotfix. Adapt the purchase quantity conversion to the monolithic Production Plan controller and retain the Purchase Order rounding notice.

Add coverage for the complete Production Plan to Material Request to supplier-selected Purchase Order flow.

* fix(buying): skip rounding notice for mixed UOM increments

Require one shared rounding increment across the Purchase Order rows for each item before attributing the total excess to UOM rounding.

Cover mixed UOMs in both row orders, three mixed rows, and matching UOM rows that still require the notice.
This commit is contained in:
Mihir Kandoi
2026-09-07 18:15:39 +05:30
committed by GitHub
parent 6971c80743
commit 28f0ac5287
4 changed files with 241 additions and 3 deletions

View File

@@ -331,6 +331,43 @@ class PurchaseOrder(BuyingController):
).format(item_code, qty, 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_steps = {}
itemwise_stock_uom = frappe._dict()
for d in self.get("items"):
step = 10 ** -d.precision("qty") * flt(d.conversion_factor)
itemwise_steps.setdefault(d.item_code, set()).add(step)
itemwise_stock_uom[d.item_code] = d.stock_uom
for item_code, qty in itemwise_qty.items():
steps = itemwise_steps[item_code]
if len(steps) != 1:
continue
step = next(iter(steps))
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 < step:
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 validate_bom_for_subcontracting_items(self):
for item in self.items:
if not item.bom:

View File

@@ -724,6 +724,72 @@ class TestPurchaseOrder(ERPNextTestSuite):
po = create_purchase_order(company="_Test Company 1", do_not_save=True)
self.assertRaises(InvalidWarehouseCompany, po.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))
@ERPNextTestSuite.change_settings("Buying Settings", {"allow_multiple_items": 1})
def test_marginal_min_order_qty_toast_with_duplicate_rows(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)
item = make_item(
properties={"min_order_qty": 1000.5, "stock_uom": "_Test UOM 1"},
uoms=[{"uom": "Pound", "conversion_factor": 1000}],
)
conversion_factors = {"_Test UOM 1": 1, "Pound": 1000}
cases = [
([("Pound", 1), ("_Test UOM 1", 0.6)], False),
([("_Test UOM 1", 0.6), ("Pound", 1)], False),
([("Pound", 0.5), ("_Test UOM 1", 0.6), ("Pound", 0.5)], False),
([("Pound", 0.5), ("Pound", 0.501)], True),
]
for rows, expect_toast in cases:
with self.subTest(rows=rows):
po = create_purchase_order(
do_not_save=1,
rm_items=[
{
"item_code": item.name,
"uom": uom,
"conversion_factor": conversion_factors[uom],
"qty": qty,
"rate": 1,
"warehouse": "_Test Warehouse - _TC",
"schedule_date": add_days(nowdate(), 1),
}
for uom, qty in rows
],
)
frappe.clear_messages()
po.insert()
has_toast = any(
"due to purchase UOM rounding" in message.get("message", "")
for message in frappe.get_message_log()
)
self.assertEqual(has_toast, expect_toast)
def test_uom_integer_validation(self):
from erpnext.utilities.transaction_base import UOMMustBeIntegerError

View File

@@ -5,6 +5,7 @@
import copy
import json
from collections import defaultdict
from decimal import ROUND_CEILING, Decimal
import frappe
from frappe import _, msgprint
@@ -1496,11 +1497,11 @@ def get_material_request_items(
get_conversion_factor(row.item_code, item_details.purchase_uom).get("conversion_factor") or 1.0
)
precision = frappe.get_precision("Material Request Plan Item", "quantity")
min_order_qty = flt(row.get("min_order_qty")) if doc.get("consider_minimum_order_qty") else 0
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"),
@@ -1523,6 +1524,18 @@ def get_material_request_items(
}
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 get_sales_orders(self):
bom = frappe.qb.DocType("BOM")
pi = frappe.qb.DocType("Packed Item")
@@ -1909,7 +1922,10 @@ def get_materials_from_other_locations(
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)

View File

@@ -2262,6 +2262,125 @@ 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.production_plan 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_min_order_qty_round_trip_to_purchase_order(self):
from erpnext.stock.doctype.material_request.material_request import (
get_item_default_suppliers,
make_purchase_orders_by_supplier,
)
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)
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(
properties={
"is_stock_item": 1,
"stock_uom": "_Test UOM 1",
"purchase_uom": "Pound",
"min_order_qty": 50000,
},
uoms=[{"uom": "Pound", "conversion_factor": 453.592292197}],
).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, skip_getting_mr_items=1, do_not_submit=1
)
pln.consider_minimum_order_qty = 1
pln.set("mr_items", get_items_for_material_requests(pln.as_dict()))
pln.submit_material_request = 1
pln.save()
pln.submit()
pln.make_material_request()
mr_name = frappe.db.get_value(
"Material Request Item", {"production_plan": pln.name, "item_code": rm_item}, "parent"
)
self.assertTrue(mr_name)
pending_items = get_item_default_suppliers(mr_name)
self.assertEqual(len(pending_items), 1)
self.assertEqual(flt(pending_items[0]["pending_qty"], 3), 110.232)
purchase_orders = make_purchase_orders_by_supplier(
mr_name,
[
row | {"qty": flt(row["pending_qty"], 3), "supplier": "_Test Supplier"}
for row in pending_items
],
)
self.assertEqual(len(purchase_orders), 1)
po = frappe.get_doc("Purchase Order", purchase_orders[0])
self.assertEqual(po.items[0].qty, 110.232)
self.assertGreaterEqual(po.items[0].stock_qty, 50000)
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