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