From c0cb783603ad1d8105a8ffda9dc41c220327c32c Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 23 Jul 2026 14:46:55 +0530 Subject: [PATCH 1/3] fix: reserve full BOM consumption for Production Plan raw materials --- .../production_plan/services/reservation.py | 7 +---- .../production_plan/test_production_plan.py | 27 +++++++++++++++++++ erpnext/patches.txt | 1 + .../recompute_production_plan_reserved_qty.py | 23 ++++++++++++++++ 4 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 erpnext/patches/v16_0/recompute_production_plan_reserved_qty.py diff --git a/erpnext/manufacturing/doctype/production_plan/services/reservation.py b/erpnext/manufacturing/doctype/production_plan/services/reservation.py index ee2a1bceb34..615c8b179f3 100644 --- a/erpnext/manufacturing/doctype/production_plan/services/reservation.py +++ b/erpnext/manufacturing/doctype/production_plan/services/reservation.py @@ -3,7 +3,6 @@ """Stock reservation for Production Plan (extracted from production_plan.py).""" - import frappe from frappe import _ from frappe.model.document import Document @@ -48,15 +47,11 @@ def get_reserved_qty_for_production_plan(item_code, warehouse): def _production_plan_reserved_qty(item_code, warehouse, non_completed_production_plans): table = frappe.qb.DocType("Production Plan") child = frappe.qb.DocType("Material Request Plan Item") - qty = ( - Case().when(child.quantity == 0, child.required_bom_qty).else_(child.quantity) - * child.conversion_factor - ) query = ( frappe.qb.from_(table) .inner_join(child) .on(table.name == child.parent) - .select(Sum(qty)) + .select(Sum(child.required_bom_qty)) .where(_plan_reserved_filter(table, child, item_code, warehouse)) ) if non_completed_production_plans: diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 97d286e429d..d414755abd0 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -1559,6 +1559,33 @@ class TestProductionPlan(ERPNextTestSuite): reserved_qty_after_mr = flt(frappe.db.get_value("Bin", bin_name, "reserved_qty_for_production_plan")) self.assertEqual(reserved_qty_after_mr, before_qty) + def test_reserved_qty_for_production_plan_with_partial_stock(self): + from erpnext.stock.utils import get_or_make_bin + + fg_item = make_item(properties={"is_stock_item": 1}).name + rm_item = make_item(properties={"is_stock_item": 1}).name + make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse="_Test Warehouse - _TC") + + make_stock_entry(item_code=rm_item, qty=4, rate=100, target="_Test Warehouse - _TC") + + bin_name = get_or_make_bin(rm_item, "_Test Warehouse - _TC") + before_qty = flt(frappe.db.get_value("Bin", bin_name, "reserved_qty_for_production_plan")) + + pln = create_production_plan(item_code=fg_item, planned_qty=10, ignore_existing_ordered_qty=1) + + row = next(d for d in pln.mr_items if d.item_code == rm_item) + self.assertEqual(row.required_bom_qty, 10) + self.assertEqual(row.quantity, 6) + + after_qty = flt(frappe.db.get_value("Bin", bin_name, "reserved_qty_for_production_plan")) + self.assertEqual(after_qty - before_qty, 10) + + pln.reload() + pln.cancel() + + after_cancel = flt(frappe.db.get_value("Bin", bin_name, "reserved_qty_for_production_plan")) + self.assertEqual(after_cancel, before_qty) + 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 diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 419a42e0b52..4bc36ce882e 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -501,3 +501,4 @@ erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm erpnext.patches.v16_0.access_control_for_project_users erpnext.patches.v16_0.enable_book_stock_expense_gl_entries execute:frappe.db.set_single_value("Stock Settings", "use_inline_serial_batch_editor", 0) +erpnext.patches.v16_0.recompute_production_plan_reserved_qty diff --git a/erpnext/patches/v16_0/recompute_production_plan_reserved_qty.py b/erpnext/patches/v16_0/recompute_production_plan_reserved_qty.py new file mode 100644 index 00000000000..c33d59eea8f --- /dev/null +++ b/erpnext/patches/v16_0/recompute_production_plan_reserved_qty.py @@ -0,0 +1,23 @@ +import frappe + + +def execute(): + plans = frappe.get_all( + "Production Plan", + filters={"docstatus": 1, "status": ("not in", ["Completed", "Closed"])}, + pluck="name", + ) + if not plans: + return + + rows = frappe.get_all( + "Material Request Plan Item", + filters={"parent": ("in", plans)}, + fields=["item_code", "warehouse"], + ) + + for item_code, warehouse in {(row.item_code, row.warehouse) for row in rows}: + bin_name = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}) + if not bin_name: + continue + frappe.get_doc("Bin", bin_name, for_update=True).update_reserved_qty_for_production_plan() From 276f69498a199e748ecf4b344f316a0529dc07ad Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 23 Jul 2026 15:16:11 +0530 Subject: [PATCH 2/3] fix: safety stock and MOQ over-ordering in Production Plan raw materials --- .../services/material_request.py | 40 +++++++++------- .../production_plan/test_production_plan.py | 47 +++++++++++++++++++ 2 files changed, 69 insertions(+), 18 deletions(-) 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 From a370c9a348bfb94e075ac53e89f859d7544ed44c Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 23 Jul 2026 15:16:13 +0530 Subject: [PATCH 3/3] fix: AttributeError in sufficient sub assembly warning --- .../doctype/production_plan/services/sub_assembly.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/production_plan/services/sub_assembly.py b/erpnext/manufacturing/doctype/production_plan/services/sub_assembly.py index 0f445d557ef..310fb3e2638 100644 --- a/erpnext/manufacturing/doctype/production_plan/services/sub_assembly.py +++ b/erpnext/manufacturing/doctype/production_plan/services/sub_assembly.py @@ -82,7 +82,7 @@ class SubAssemblyService: frappe.throw(_("Row #{0}: Please select the BOM No in Assembly Items").format(row.idx)) def _warn_sufficient_sub_assembly(self): - label = self.meta.get_field("skip_available_sub_assembly_item").label + label = self.doc.meta.get_field("skip_available_sub_assembly_item").label message = ( _( "As there are sufficient Sub Assembly Items, Work Order is not required for Warehouse {0}."