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: 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 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) 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