From 28f0ac52870fd241e6539d531a35d355e754d792 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 7 Sep 2026 18:15:39 +0530 Subject: [PATCH] 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. --- .../doctype/purchase_order/purchase_order.py | 37 ++++++ .../purchase_order/test_purchase_order.py | 66 ++++++++++ .../production_plan/production_plan.py | 22 +++- .../production_plan/test_production_plan.py | 119 ++++++++++++++++++ 4 files changed, 241 insertions(+), 3 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 592ed31a51b..300afac41c5 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -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: diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 409e02f9eda..a37f450c05d 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -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 diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 0301fe2ba06..540cc348138 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -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) diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 1f95609499a..0a6e953710f 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -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