mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-09 23:39:28 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user