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.
This commit is contained in:
Mihir Kandoi
2026-08-07 22:20:30 +05:30
parent b3300c20a3
commit ee8eb18daf

View File

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