fix: safety stock and MOQ over-ordering in Production Plan raw materials

This commit is contained in:
Mihir Kandoi
2026-07-23 15:16:11 +05:30
parent c0cb783603
commit 276f69498a
2 changed files with 69 additions and 18 deletions

View File

@@ -489,9 +489,8 @@ def get_material_request_items(
consumed_qty,
):
required_qty = _required_qty_for_mr(
doc, row, ignore_existing_ordered_qty, warehouse, bin_dict, consumed_qty
doc, row, ignore_existing_ordered_qty, warehouse, bin_dict, consumed_qty, include_safety_stock
)
required_qty = _adjust_required_qty_for_uom(row, required_qty, include_safety_stock)
item_group_defaults = get_item_group_defaults(row.item_code, company)
conversion_factor = _mr_purchase_conversion_factor(row)
return _material_request_item_row(
@@ -499,24 +498,31 @@ def get_material_request_items(
)
def _required_qty_for_mr(doc, row, ignore_existing_ordered_qty, warehouse, bin_dict, consumed_qty):
if not ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0:
required_qty = flt(row.get("qty"))
else:
key = (row.get("item_code"), warehouse)
available_qty = flt(bin_dict.get("projected_qty", 0)) - consumed_qty[key]
if available_qty > 0:
required_qty = max(0, flt(row.get("qty")) - available_qty)
consumed_qty[key] += min(flt(row.get("qty")), available_qty)
else:
required_qty = flt(row.get("qty"))
def _required_qty_for_mr(
doc, 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 doc.get("consider_minimum_order_qty") and 0 < required_qty < row["min_order_qty"]:
required_qty = row["min_order_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)
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 = _adjust_required_qty_for_uom(row, required_qty)
consumed_qty[key] += qty - required_qty
return required_qty
def _adjust_required_qty_for_uom(row, required_qty, include_safety_stock):
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 _adjust_required_qty_for_uom(row, required_qty):
if not row["purchase_uom"]:
row["purchase_uom"] = row["stock_uom"]
@@ -531,8 +537,6 @@ def _adjust_required_qty_for_uom(row, required_qty, include_safety_stock):
if frappe.db.get_value("UOM", row["purchase_uom"], "must_be_whole_number"):
required_qty = ceil(required_qty)
if include_safety_stock:
required_qty += flt(row["safety_stock"])
return required_qty

View File

@@ -1586,6 +1586,53 @@ class TestProductionPlan(ERPNextTestSuite):
after_cancel = flt(frappe.db.get_value("Bin", bin_name, "reserved_qty_for_production_plan"))
self.assertEqual(after_cancel, before_qty)
def _plan_with_shared_raw_material(self, rm_item, qty_per_order):
fg_item = make_item(properties={"is_stock_item": 1}).name
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse="_Test Warehouse - _TC")
pln = create_production_plan(
item_code=fg_item,
ignore_existing_ordered_qty=1,
do_not_save=1,
skip_getting_mr_items=1,
)
pln.get_items_from = "Sales Order"
for _ in range(2):
so = make_sales_order(item_code=fg_item, qty=qty_per_order)
pln.append(
"sales_orders",
{
"sales_order": so.name,
"sales_order_date": so.transaction_date,
"customer": so.customer,
"grand_total": so.grand_total,
},
)
pln.get_items()
return pln
def test_safety_stock_added_once_for_repeated_raw_material(self):
rm_item = make_item(properties={"is_stock_item": 1, "safety_stock": 10, "valuation_rate": 100}).name
make_stock_entry(item_code=rm_item, qty=100, rate=100, target="_Test Warehouse - _TC")
pln = self._plan_with_shared_raw_material(rm_item, qty_per_order=50)
pln.include_safety_stock = 1
items = get_items_for_material_requests(pln.as_dict())
quantities = sorted(flt(d.get("quantity")) for d in items if d.get("item_code") == rm_item)
self.assertEqual(quantities, [0, 10])
def test_minimum_order_qty_surplus_covers_later_rows(self):
rm_item = make_item(properties={"is_stock_item": 1, "min_order_qty": 100, "valuation_rate": 100}).name
make_stock_entry(item_code=rm_item, qty=40, rate=100, target="_Test Warehouse - _TC")
pln = self._plan_with_shared_raw_material(rm_item, qty_per_order=50)
pln.consider_minimum_order_qty = 1
items = get_items_for_material_requests(pln.as_dict())
quantities = sorted(flt(d.get("quantity")) for d in items if d.get("item_code") == rm_item)
self.assertEqual(quantities, [0, 100])
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