fix(manufacturing): apply MOQ once across Production Plan rows (#58805)

* fix(manufacturing): apply MOQ once across Production Plan rows

Apply Minimum Order Qty after stock and transfer allocation, grouped by item, warehouse, request type, supplier and Sales Order. Material Requests and Purchase Orders are raised per Sales Order and a Purchase Order rejects an item below its minimum, so each Sales Order buys at least the minimum or is covered by the surplus of an earlier one. The surplus is the quantity actually purchased beyond demand, so purchase UOM and whole-number rounding carry forward.

* test(manufacturing): cover MOQ once across Production Plan rows
This commit is contained in:
Mihir Kandoi
2026-09-07 19:04:25 +05:30
committed by GitHub
parent 9c4d03f26f
commit b1c0921c2b
2 changed files with 232 additions and 13 deletions

View File

@@ -162,6 +162,8 @@ def get_items_for_material_requests(
doc, mr_items, warehouses, ignore_ordered_qty, get_parent_warehouse_data
)
_set_default_suppliers(mr_items, doc.get("company"))
if doc.get("consider_minimum_order_qty"):
_apply_minimum_order_qty(mr_items)
if not mr_items:
_warn_no_mr_items(doc)
@@ -544,7 +546,7 @@ def get_material_request_items(
consumed_qty,
):
required_qty = _required_qty_for_mr(
doc, row, ignore_existing_ordered_qty, warehouse, bin_dict, consumed_qty, include_safety_stock
row, ignore_existing_ordered_qty, warehouse, bin_dict, consumed_qty, include_safety_stock
)
item_group_defaults = get_item_group_defaults(row.item_code, company)
conversion_factor = _mr_purchase_conversion_factor(row)
@@ -562,27 +564,91 @@ def get_material_request_items(
def _required_qty_for_mr(
doc, row, ignore_existing_ordered_qty, warehouse, bin_dict, consumed_qty, include_safety_stock
row, ignore_existing_ordered_qty, warehouse, bin_dict, consumed_qty, include_safety_stock
):
safety_stock = flt(row["safety_stock"]) if include_safety_stock else 0
qty = flt(row.get("qty"))
if not ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0:
required_qty = _apply_minimum_order_qty(doc, row, qty + safety_stock)
return _adjust_required_qty_for_uom(row, required_qty)
return _adjust_required_qty_for_uom(row, qty + safety_stock)
key = (row.get("item_code"), warehouse)
available_qty = flt(bin_dict.get("projected_qty", 0)) - consumed_qty[key]
required_qty = _apply_minimum_order_qty(doc, row, max(0, qty - (available_qty - safety_stock)))
required_qty = max(0, qty - (available_qty - safety_stock))
required_qty = _adjust_required_qty_for_uom(row, required_qty)
consumed_qty[key] += qty - required_qty
return required_qty
def _apply_minimum_order_qty(doc, row, required_qty):
if doc.get("consider_minimum_order_qty") and 0 < required_qty < row["min_order_qty"]:
return row["min_order_qty"]
return required_qty
def _apply_minimum_order_qty(mr_items):
for rows in _purchase_rows_by_item(mr_items).values():
surplus_qty = 0.0
for order_rows in _rows_by_sales_order(rows):
surplus_qty = _apply_minimum_order_qty_to_order(order_rows, surplus_qty)
def _purchase_rows_by_item(mr_items):
rows_by_item = defaultdict(list)
for row in mr_items:
if row.get("material_request_type") not in ("Purchase", "Subcontracting"):
continue
if flt(row.get("quantity")) <= 0:
continue
key = (
row.get("item_code"),
row.get("warehouse"),
row.get("material_request_type"),
row.get("supplier"),
)
rows_by_item[key].append(row)
return rows_by_item
def _rows_by_sales_order(rows):
rows_by_order = defaultdict(list)
for row in rows:
rows_by_order[row.get("sales_order")].append(row)
return rows_by_order.values()
def _apply_minimum_order_qty_to_order(rows, surplus_qty):
"""Cover the order from an earlier order's surplus, then raise the rest to the minimum.
Material Requests and Purchase Orders are raised per Sales Order and a Purchase
Order rejects an item below its minimum, so each order either buys at least the
minimum or is covered by what an earlier order over-purchased."""
demand_qty = sum(_stock_quantity(row) for row in rows)
_cover_from_surplus(rows, surplus_qty)
min_order_qty = max(flt(row.get("min_order_qty")) for row in rows)
total_qty = sum(_stock_quantity(row) for row in rows)
if 0 < total_qty < min_order_qty:
row = next(row for row in rows if _stock_quantity(row) > 0)
_set_stock_quantity(row, _stock_quantity(row) + min_order_qty - total_qty)
purchased_qty = sum(_stock_quantity(row) for row in rows)
return surplus_qty + purchased_qty - demand_qty
def _cover_from_surplus(rows, surplus_qty):
for row in rows:
covered_qty = min(surplus_qty, _stock_quantity(row))
if covered_qty <= 0:
break
_set_stock_quantity(row, _stock_quantity(row) - covered_qty)
surplus_qty -= covered_qty
def _stock_quantity(row):
return flt(row.get("quantity")) * (flt(row.get("conversion_factor")) or 1)
def _set_stock_quantity(row, stock_qty):
conversion_factor = flt(row.get("conversion_factor")) or 1
quantity = _quantity_in_purchase_uom(stock_qty, conversion_factor, stock_qty)
if frappe.get_cached_value("UOM", row.get("uom"), "must_be_whole_number"):
quantity = ceil(quantity)
row["quantity"] = quantity
def _adjust_required_qty_for_uom(row, required_qty):
@@ -604,7 +670,9 @@ def _adjust_required_qty_for_uom(row, 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."""
representable quantity whose stock equivalent still meets it. The minimum is
capped at the requirement so a small shortage never rounds down to zero."""
min_order_qty = min(min_order_qty, required_qty)
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:
@@ -714,9 +782,6 @@ def _add_remaining_purchase_request(item, new_mr_items, required_qty, consider_m
if flt(required_qty, precision) <= 0:
return
if consider_minimum_order_qty:
required_qty = max(required_qty, flt(item.get("min_order_qty")))
purchase_uom = frappe.db.get_value("Item", item.get("item_code"), "purchase_uom")
if frappe.db.get_value("UOM", purchase_uom, "must_be_whole_number"):
required_qty = ceil(required_qty)

View File

@@ -1740,6 +1740,146 @@ class TestProductionPlan(ERPNextTestSuite):
quantities = sorted(flt(d.get("quantity")) for d in items if d.get("item_code") == rm_item)
self.assertEqual(quantities, [0, 100])
def test_minimum_order_qty_surplus_carries_across_sales_orders(self):
from erpnext.stock.utils import get_or_make_bin
rm_item = make_item(properties={"is_stock_item": 1, "min_order_qty": 1234}).name
bin_name = get_or_make_bin(rm_item, "_Test Warehouse - _TC")
pln = self._plan_with_shared_raw_material(rm_item, qty_per_order=250)
pln.consider_minimum_order_qty = 1
for projected_qty in (0, -5):
frappe.db.set_value("Bin", bin_name, "projected_qty", projected_qty)
for consider_projected_qty in (0, 1):
pln.ignore_existing_ordered_qty = consider_projected_qty
for second_qty, expected_qty in ((500, [1234, 0]), (984, [1234, 0]), (1000, [1234, 1234])):
with self.subTest(
projected_qty=projected_qty,
consider_projected_qty=consider_projected_qty,
second_qty=second_qty,
):
pln.po_items[1].planned_qty = second_qty
items = get_items_for_material_requests(pln.as_dict())
self.assertEqual([row["quantity"] for row in items], expected_qty)
self.assertEqual([row["required_bom_qty"] for row in items], [250, second_qty])
self.assertEqual(
[row["sales_order"] for row in items], [row.sales_order for row in pln.po_items]
)
def test_minimum_order_qty_does_not_purchase_when_stock_covers_demand(self):
rm_item = make_item(properties={"is_stock_item": 1, "min_order_qty": 1234}).name
make_stock_entry(item_code=rm_item, qty=1000, rate=100, target="_Test Warehouse - _TC")
pln = self._plan_with_shared_raw_material(rm_item, qty_per_order=250)
pln.consider_minimum_order_qty = 1
items = get_items_for_material_requests(pln.as_dict())
self.assertEqual([row["quantity"] for row in items], [0, 0])
def test_minimum_order_qty_disabled_for_repeated_raw_material(self):
rm_item = make_item(properties={"is_stock_item": 1, "min_order_qty": 1234}).name
pln = self._plan_with_shared_raw_material(rm_item, qty_per_order=250)
pln.po_items[1].planned_qty = 500
items = get_items_for_material_requests(pln.as_dict())
self.assertEqual([row["quantity"] for row in items], [250, 500])
def test_minimum_order_qty_does_not_transfer_surplus_stock(self):
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
rm_item = make_item(properties={"is_stock_item": 1, "min_order_qty": 1234}).name
source_warehouse = create_warehouse("MOQ Sufficient Source Warehouse", company="_Test Company")
make_stock_entry(item_code=rm_item, qty=1500, rate=100, target=source_warehouse)
pln = self._plan_with_shared_raw_material(rm_item, qty_per_order=250)
pln.consider_minimum_order_qty = 1
pln.for_warehouse = "_Test Warehouse - _TC"
items = get_items_for_material_requests(pln.as_dict(), warehouses=[{"warehouse": source_warehouse}])
self.assertEqual([row["material_request_type"] for row in items], ["Material Transfer"] * 2)
self.assertEqual([row["quantity"] for row in items], [250, 250])
def test_minimum_order_qty_respects_purchase_groups_and_sales_orders(self):
from erpnext.manufacturing.doctype.production_plan.services.material_request import (
_apply_minimum_order_qty,
)
base_row = {
"item_code": "Raw Material Item 1",
"warehouse": "_Test Warehouse - _TC",
"supplier": "_Test Supplier",
"sales_order": "SO-1",
"material_request_type": "Purchase",
"uom": "Nos",
"conversion_factor": 1,
"min_order_qty": 1234,
"quantity": 250,
}
rows = [
base_row.copy(),
base_row | {"quantity": 500},
base_row | {"warehouse": "_Test Warehouse 1 - _TC"},
base_row | {"supplier": "_Test Supplier 1"},
base_row | {"item_code": "Raw Material Item 2"},
base_row | {"material_request_type": "Subcontracting"},
base_row | {"material_request_type": "Material Transfer", "quantity": 1000},
base_row | {"material_request_type": "Manufacture"},
base_row | {"sales_order": "SO-2", "quantity": 400},
base_row | {"sales_order": "SO-3", "quantity": 100},
]
expected_rows = [row.copy() for row in rows]
quantities = [734, 500, 1234, 1234, 1234, 1234, 1000, 250, 0, 1234]
for row, quantity in zip(expected_rows, quantities, strict=True):
row["quantity"] = quantity
_apply_minimum_order_qty(rows)
self.assertEqual(rows, expected_rows)
def test_minimum_order_qty_shortfall_uses_stock_uom(self):
from erpnext.manufacturing.doctype.production_plan.services.material_request import (
_apply_minimum_order_qty,
)
frappe.db.set_default("float_precision", "3")
rows = [
{
"item_code": "Raw Material Item 1",
"warehouse": "_Test Warehouse - _TC",
"material_request_type": "Purchase",
"uom": uom,
"conversion_factor": conversion_factor,
"min_order_qty": 1234,
"quantity": quantity,
}
for uom, conversion_factor, quantity in (("_Test UOM 1", 7, 10), ("Nos", 1, 500))
]
_apply_minimum_order_qty(rows)
self.assertEqual([row["quantity"] for row in rows], [104.858, 500])
self.assertGreaterEqual(sum(row["quantity"] * row["conversion_factor"] for row in rows), 1234)
rows[0].update(uom="Nos", quantity=10)
_apply_minimum_order_qty(rows)
self.assertEqual([row["quantity"] for row in rows], [105, 500])
def test_minimum_order_qty_surplus_includes_rounding(self):
from erpnext.manufacturing.doctype.production_plan.services.material_request import (
_apply_minimum_order_qty,
)
rows = [
{
"item_code": "Raw Material Item 1",
"warehouse": "_Test Warehouse - _TC",
"sales_order": sales_order,
"material_request_type": "Purchase",
"uom": "Nos",
"conversion_factor": 2,
"min_order_qty": 5,
"quantity": 1.5,
}
for sales_order in ("SO-1", "SO-2")
]
_apply_minimum_order_qty(rows)
self.assertEqual([row["quantity"] for row in rows], [3, 0])
def test_from_warehouse_for_purchase_material_request(self):
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.stock.utils import get_or_make_bin
@@ -2499,6 +2639,20 @@ class TestProductionPlan(ERPNextTestSuite):
self.assertEqual(mr_items[0].get("quantity"), 110.232)
self.assertGreaterEqual(mr_items[0].get("quantity") * conversion_factor, 50000)
def test_min_order_qty_keeps_small_shortages_in_purchase_uom(self):
frappe.db.set_default("float_precision", "3")
conversion_factor = 10000
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
pln = self._plan_with_shared_raw_material(rm_item, qty_per_order=1)
pln.consider_minimum_order_qty = 1
items = get_items_for_material_requests(pln.as_dict())
self.assertEqual([row["quantity"] for row in items], [5, 0])
self.assertEqual(sum(row["quantity"] * row["conversion_factor"] for row in items), 50000)
def test_min_order_qty_grid_ceiling_from_other_locations(self):
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse