diff --git a/erpnext/manufacturing/doctype/production_plan/services/material_request.py b/erpnext/manufacturing/doctype/production_plan/services/material_request.py index 1b6cc8a850b..9e6d26bd963 100644 --- a/erpnext/manufacturing/doctype/production_plan/services/material_request.py +++ b/erpnext/manufacturing/doctype/production_plan/services/material_request.py @@ -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 diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index d414755abd0..bc3c62bfffd 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -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