From dffd5d9cdd8e2f1d30a69472687de1230575f811 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Fri, 28 Nov 2025 11:37:25 +0530 Subject: [PATCH 1/5] fix: cascade projected quantity across multiple items in material requests (cherry picked from commit d344be32a0d4a1de0225df0f6f9ddcf7949e900c) # Conflicts: # erpnext/manufacturing/doctype/production_plan/production_plan.py --- .../production_plan/production_plan.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 4aa723a2ac4..587988eb3ec 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -1303,14 +1303,33 @@ def get_material_request_items( include_safety_stock, warehouse, bin_dict, +<<<<<<< HEAD +======= + consumed_qty, +>>>>>>> d344be32a0 (fix: cascade projected quantity across multiple items in material requests) ): total_qty = row["qty"] required_qty = 0 +<<<<<<< HEAD if ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0: required_qty = total_qty elif total_qty > bin_dict.get("projected_qty", 0): required_qty = total_qty - bin_dict.get("projected_qty", 0) +======= + item_code = row.get("item_code") + + if not ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0: + required_qty = flt(row.get("qty")) + else: + key = (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")) +>>>>>>> d344be32a0 (fix: cascade projected quantity across multiple items in material requests) if doc.get("consider_minimum_order_qty") and required_qty > 0 and required_qty < row["min_order_qty"]: required_qty = row["min_order_qty"] @@ -1648,9 +1667,15 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d so_item_details[sales_order][item_code] = details mr_items = [] + consumed_qty = defaultdict(float) + for sales_order in so_item_details: item_dict = so_item_details[sales_order] for details in item_dict.values(): +<<<<<<< HEAD +======= + warehouse = warehouse or details.get("source_warehouse") or details.get("default_warehouse") +>>>>>>> d344be32a0 (fix: cascade projected quantity across multiple items in material requests) bin_dict = get_bin_details(details, doc.company, warehouse) bin_dict = bin_dict[0] if bin_dict else {} @@ -1664,6 +1689,10 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d include_safety_stock, warehouse, bin_dict, +<<<<<<< HEAD +======= + consumed_qty, +>>>>>>> d344be32a0 (fix: cascade projected quantity across multiple items in material requests) ) if items: mr_items.append(items) From e403dfe73af690e7d4aad3d2c17a60544218b0c2 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Fri, 28 Nov 2025 13:45:20 +0530 Subject: [PATCH 2/5] test: add test for projected quantity cascading across multiple sales orders (cherry picked from commit 92fdec9b928a78a67b51dd58803598f3858346df) --- .../production_plan/test_production_plan.py | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 28c9e63bc1f..20ae8ba6763 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -145,6 +145,73 @@ class TestProductionPlan(FrappeTestCase): sr2.cancel() pln.cancel() + def test_projected_qty_cascading_across_multiple_sales_orders(self): + rm_item = make_item( + "_Test RM For Cascading", + {"is_stock_item": 1, "valuation_rate": 100}, + ).name + + fg_item_a = make_item( + "_Test FG A For Cascading", + {"is_stock_item": 1, "valuation_rate": 200}, + ).name + + if not frappe.db.exists("BOM", {"item": fg_item_a, "docstatus": 1}): + make_bom(item=fg_item_a, raw_materials=[rm_item], rm_qty=1) + + # Stock for RM + sr = create_stock_reconciliation(item_code=rm_item, target="_Test Warehouse - _TC", qty=1, rate=100) + + # Sales orders + so1 = make_sales_order(item_code=fg_item_a, qty=1) + so2 = make_sales_order(item_code=fg_item_a, qty=1) + + # Production plan + pln = frappe.get_doc( + { + "doctype": "Production Plan", + "company": "_Test Company", + "posting_date": nowdate(), + "get_items_from": "Sales Order", + "ignore_existing_ordered_qty": 1, + } + ) + pln.append( + "sales_orders", + { + "sales_order": so1.name, + "sales_order_date": so1.transaction_date, + "customer": so1.customer, + "grand_total": so1.grand_total, + }, + ) + pln.append( + "sales_orders", + { + "sales_order": so2.name, + "sales_order_date": so2.transaction_date, + "customer": so2.customer, + "grand_total": so2.grand_total, + }, + ) + + pln.get_items() + pln.insert() + + mr_items = get_items_for_material_requests(pln.as_dict()) + quantities = [d["quantity"] for d in mr_items] + rm_qty = sum(quantities) + + self.assertEqual(len(mr_items), 2) # one for each SO + self.assertEqual(rm_qty, 1, "Cascading failed: total MR qty should be 1 (2 needed - 1 in stock)") + self.assertEqual( + quantities, + [0, 1], + "Cascading failed: first item should consume stock (qty=0), second should need procurement (qty=1)", + ) + + sr.cancel() + def test_production_plan_with_non_stock_item(self): "Test if MR Planning table includes Non Stock RM." pln = create_production_plan(item_code="Test Production Item 1", include_non_stock_items=1) From edcf24afa99326a039ffa5b93649b763d34a7d32 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Mon, 15 Dec 2025 14:59:06 +0530 Subject: [PATCH 3/5] chore: resolve conflicts --- .../production_plan/production_plan.py | 20 +------------------ 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 587988eb3ec..39c21089f3b 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -1303,20 +1303,9 @@ def get_material_request_items( include_safety_stock, warehouse, bin_dict, -<<<<<<< HEAD -======= consumed_qty, ->>>>>>> d344be32a0 (fix: cascade projected quantity across multiple items in material requests) ): - total_qty = row["qty"] - required_qty = 0 -<<<<<<< HEAD - if ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0: - required_qty = total_qty - elif total_qty > bin_dict.get("projected_qty", 0): - required_qty = total_qty - bin_dict.get("projected_qty", 0) -======= item_code = row.get("item_code") if not ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0: @@ -1329,7 +1318,6 @@ def get_material_request_items( consumed_qty[key] += min(flt(row.get("qty")), available_qty) else: required_qty = flt(row.get("qty")) ->>>>>>> d344be32a0 (fix: cascade projected quantity across multiple items in material requests) if doc.get("consider_minimum_order_qty") and required_qty > 0 and required_qty < row["min_order_qty"]: required_qty = row["min_order_qty"] @@ -1373,7 +1361,7 @@ def get_material_request_items( "item_name": row.item_name, "quantity": required_qty / conversion_factor, "conversion_factor": conversion_factor, - "required_bom_qty": total_qty, + "required_bom_qty": row.get("qty"), "stock_uom": row.get("stock_uom"), "warehouse": warehouse or row.get("source_warehouse") @@ -1672,10 +1660,7 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d for sales_order in so_item_details: item_dict = so_item_details[sales_order] for details in item_dict.values(): -<<<<<<< HEAD -======= warehouse = warehouse or details.get("source_warehouse") or details.get("default_warehouse") ->>>>>>> d344be32a0 (fix: cascade projected quantity across multiple items in material requests) bin_dict = get_bin_details(details, doc.company, warehouse) bin_dict = bin_dict[0] if bin_dict else {} @@ -1689,10 +1674,7 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d include_safety_stock, warehouse, bin_dict, -<<<<<<< HEAD -======= consumed_qty, ->>>>>>> d344be32a0 (fix: cascade projected quantity across multiple items in material requests) ) if items: mr_items.append(items) From 0452b22aa663c89fd954b0c1590a1dc5507ddd52 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Mon, 15 Dec 2025 17:20:57 +0530 Subject: [PATCH 4/5] fix: use original logic for v15 - inverted wrt v16 --- .../manufacturing/doctype/production_plan/production_plan.py | 2 +- .../doctype/production_plan/test_production_plan.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 39c21089f3b..c4b0aa26fce 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -1308,7 +1308,7 @@ def get_material_request_items( required_qty = 0 item_code = row.get("item_code") - if not ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0: + if ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0: required_qty = flt(row.get("qty")) else: key = (item_code, warehouse) diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 20ae8ba6763..5505be3cafa 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -173,7 +173,7 @@ class TestProductionPlan(FrappeTestCase): "company": "_Test Company", "posting_date": nowdate(), "get_items_from": "Sales Order", - "ignore_existing_ordered_qty": 1, + "ignore_existing_ordered_qty": 0, } ) pln.append( From f13db03c9b801508ab0344fc722b05186805abae Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Fri, 19 Dec 2025 10:33:44 +0530 Subject: [PATCH 5/5] test: make corrections to tests based on v15 functionality --- .../production_plan/test_production_plan.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 5505be3cafa..85e175af2da 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -165,6 +165,7 @@ class TestProductionPlan(FrappeTestCase): # Sales orders so1 = make_sales_order(item_code=fg_item_a, qty=1) so2 = make_sales_order(item_code=fg_item_a, qty=1) + so3 = make_sales_order(item_code=fg_item_a, qty=1) # Production plan pln = frappe.get_doc( @@ -194,6 +195,15 @@ class TestProductionPlan(FrappeTestCase): "grand_total": so2.grand_total, }, ) + pln.append( + "sales_orders", + { + "sales_order": so3.name, + "sales_order_date": so3.transaction_date, + "customer": so3.customer, + "grand_total": so3.grand_total, + }, + ) pln.get_items() pln.insert() @@ -202,12 +212,13 @@ class TestProductionPlan(FrappeTestCase): quantities = [d["quantity"] for d in mr_items] rm_qty = sum(quantities) - self.assertEqual(len(mr_items), 2) # one for each SO - self.assertEqual(rm_qty, 1, "Cascading failed: total MR qty should be 1 (2 needed - 1 in stock)") + # Only 2 MR item created - the first SO's requirement is fully covered by stock (v15 behaviour) + self.assertEqual(len(mr_items), 2) + self.assertEqual(rm_qty, 2, "Cascading failed: total MR qty should be 2 (3 needed - 1 in stock)") self.assertEqual( quantities, - [0, 1], - "Cascading failed: first item should consume stock (qty=0), second should need procurement (qty=1)", + [1, 1], + "Cascading failed: only second and third SO should need procurement (qty=1) since first SO consumed stock", ) sr.cancel()