From f58abed935f17f7d4c6b79c1c097954ce5a0643b Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 8 Apr 2025 15:02:12 +0530 Subject: [PATCH 1/4] fix: group sub assemblies in production plan --- .../production_plan/production_plan.py | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 6f600cc08e3..3524283db1c 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -976,6 +976,7 @@ class ProductionPlan(Document): bom_data = [] get_sub_assembly_items( + [item.production_item for item in sub_assembly_items_store], row.bom_no, bom_data, row.planned_qty, @@ -1565,10 +1566,10 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d so_item_details = frappe._dict() - sub_assembly_items = {} + sub_assembly_items = defaultdict(int) if doc.get("skip_available_sub_assembly_item") and doc.get("sub_assembly_items"): for d in doc.get("sub_assembly_items"): - sub_assembly_items.setdefault((d.get("production_item"), d.get("bom_no")), d.get("qty")) + sub_assembly_items[(d.get("production_item"), d.get("bom_no"))] += d.get("qty") for data in po_items: if not data.get("include_exploded_items") and doc.get("sub_assembly_items"): @@ -1597,6 +1598,7 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d item_details = {} if doc.get("sub_assembly_items"): item_details = get_raw_materials_of_sub_assembly_items( + so_item_details[doc.get("sales_order")].keys() if so_item_details else [], item_details, company, bom_no, @@ -1774,6 +1776,7 @@ def get_item_data(item_code): def get_sub_assembly_items( + sub_assembly_items, bom_no, bom_data, to_produce_qty, @@ -1789,7 +1792,7 @@ def get_sub_assembly_items( stock_qty = (d.stock_qty / d.parent_bom_qty) * flt(to_produce_qty) bin_details = frappe._dict() - if skip_available_sub_assembly_item: + if skip_available_sub_assembly_item and d.item_code not in sub_assembly_items: bin_details = get_bin_details(d, company, for_warehouse=warehouse) for _bin_dict in bin_details: @@ -1824,6 +1827,7 @@ def get_sub_assembly_items( if d.value: get_sub_assembly_items( + sub_assembly_items, d.value, bom_data, stock_qty, @@ -1903,7 +1907,13 @@ def get_non_completed_production_plans(): def get_raw_materials_of_sub_assembly_items( - item_details, company, bom_no, include_non_stock_items, sub_assembly_items, planned_qty=1 + existing_sub_assembly_items, + item_details, + company, + bom_no, + include_non_stock_items, + sub_assembly_items, + planned_qty=1, ): bei = frappe.qb.DocType("BOM Item") bom = frappe.qb.DocType("BOM") @@ -1947,12 +1957,13 @@ def get_raw_materials_of_sub_assembly_items( for item in items: key = (item.item_code, item.bom_no) - if item.bom_no and key not in sub_assembly_items: + if (item.bom_no and key not in sub_assembly_items) or (item.item_code in existing_sub_assembly_items): continue if item.bom_no: planned_qty = flt(sub_assembly_items[key]) get_raw_materials_of_sub_assembly_items( + existing_sub_assembly_items, item_details, company, item.bom_no, From f071255340bfb6fa2dd1c2f58d819c73c36ee6ae Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 11 Apr 2025 16:22:28 +0530 Subject: [PATCH 2/4] fix: logic and added test case --- .../production_plan/production_plan.py | 18 ++++-- .../production_plan/test_production_plan.py | 58 +++++++++++++++++++ 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 3524283db1c..a5b8ec870f3 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -962,6 +962,7 @@ class ProductionPlan(Document): "Fetch sub assembly items and optionally combine them." self.sub_assembly_items = [] sub_assembly_items_store = [] # temporary store to process all subassembly items + bin_details = frappe._dict() for row in self.po_items: if self.skip_available_sub_assembly_item and not self.sub_assembly_warehouse: @@ -977,6 +978,7 @@ class ProductionPlan(Document): get_sub_assembly_items( [item.production_item for item in sub_assembly_items_store], + bin_details, row.bom_no, bom_data, row.planned_qty, @@ -1777,6 +1779,7 @@ def get_item_data(item_code): def get_sub_assembly_items( sub_assembly_items, + bin_details, bom_no, bom_data, to_produce_qty, @@ -1791,25 +1794,27 @@ def get_sub_assembly_items( parent_item_code = frappe.get_cached_value("BOM", bom_no, "item") stock_qty = (d.stock_qty / d.parent_bom_qty) * flt(to_produce_qty) - bin_details = frappe._dict() if skip_available_sub_assembly_item and d.item_code not in sub_assembly_items: - bin_details = get_bin_details(d, company, for_warehouse=warehouse) + bin_details.setdefault(d.item_code, get_bin_details(d, company, for_warehouse=warehouse)) - for _bin_dict in bin_details: + for _bin_dict in bin_details[d.item_code]: if _bin_dict.projected_qty > 0: - if _bin_dict.projected_qty > stock_qty: + if _bin_dict.projected_qty >= stock_qty: + _bin_dict.projected_qty -= stock_qty stock_qty = 0 continue else: stock_qty = stock_qty - _bin_dict.projected_qty elif warehouse: - bin_details = get_bin_details(d, company, for_warehouse=warehouse) + bin_details.setdefault(d.item_code, get_bin_details(d, company, for_warehouse=warehouse)) if stock_qty > 0: bom_data.append( frappe._dict( { - "actual_qty": bin_details[0].get("actual_qty", 0) if bin_details else 0, + "actual_qty": bin_details[d.item_code][0].get("actual_qty", 0) + if bin_details + else 0, "parent_item_code": parent_item_code, "description": d.description, "production_item": d.item_code, @@ -1828,6 +1833,7 @@ def get_sub_assembly_items( if d.value: get_sub_assembly_items( sub_assembly_items, + bin_details, d.value, bom_data, stock_qty, diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index eefd577e9e6..7c1dba8be91 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -1848,6 +1848,64 @@ class TestProductionPlan(IntegrationTestCase): self.assertEqual(row.production_item, sf_item) self.assertEqual(row.qty, 5.0) + def test_calculation_of_sub_assembly_items(self): + make_item("Sub Assembly Item ", properties={"is_stock_item": 1}) + make_item("RM Item 1", properties={"is_stock_item": 1}) + make_item("RM Item 2", properties={"is_stock_item": 1}) + make_bom(item="Sub Assembly Item", raw_materials=["RM Item 1", "RM Item 2"]) + make_bom(item="_Test FG Item", raw_materials=["Sub Assembly Item", "RM Item 1"]) + make_bom(item="_Test FG Item 2", raw_materials=["Sub Assembly Item"]) + + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + + make_stock_entry( + item_code="Sub Assembly Item", + qty=80, + purpose="Material Receipt", + to_warehouse="_Test Warehouse - _TC", + ) + make_stock_entry( + item_code="RM Item 1", qty=90, purpose="Material Receipt", to_warehouse="_Test Warehouse - _TC" + ) + + plan = create_production_plan( + skip_available_sub_assembly_item=1, + sub_assembly_warehouse="_Test Warehouse - _TC", + warehouse="_Test Warehouse - _TC", + item_code="_Test FG Item", + skip_getting_mr_items=1, + planned_qty=100, + do_not_save=1, + ) + plan.get_items_from = "" + plan.append( + "po_items", + { + "use_multi_level_bom": 1, + "item_code": "_Test FG Item 2", + "bom_no": frappe.db.get_value("Item", "_Test FG Item 2", "default_bom"), + "planned_qty": 50, + "planned_start_date": now_datetime(), + "stock_uom": "Nos", + "warehouse": "_Test Warehouse - _TC", + }, + ) + plan.save() + + plan.get_sub_assembly_items() + + self.assertEqual(plan.sub_assembly_items[0].qty, 20) + self.assertEqual(plan.sub_assembly_items[1].qty, 50) + + from erpnext.manufacturing.doctype.production_plan.production_plan import ( + get_items_for_material_requests, + ) + + mr_items = get_items_for_material_requests(plan.as_dict()) + + self.assertEqual(mr_items[0].get("quantity"), 80) + self.assertEqual(mr_items[1].get("quantity"), 70) + def create_production_plan(**args): """ From a7394329cab73a8203ab3444fd7d44f4056e896c Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 11 Apr 2025 19:30:35 +0530 Subject: [PATCH 3/4] fix: test cases --- .../manufacturing/doctype/production_plan/production_plan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index a5b8ec870f3..327ec5bdfcd 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -1813,7 +1813,7 @@ def get_sub_assembly_items( frappe._dict( { "actual_qty": bin_details[d.item_code][0].get("actual_qty", 0) - if bin_details + if bin_details[d.item_code] else 0, "parent_item_code": parent_item_code, "description": d.description, From 8df18762a9ded000fd82bef20789622ed60cb9d4 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 14 Apr 2025 11:09:45 +0530 Subject: [PATCH 4/4] fix: test cases error --- .../manufacturing/doctype/production_plan/production_plan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 327ec5bdfcd..208cec97579 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -1813,7 +1813,7 @@ def get_sub_assembly_items( frappe._dict( { "actual_qty": bin_details[d.item_code][0].get("actual_qty", 0) - if bin_details[d.item_code] + if bin_details.get(d.item_code) else 0, "parent_item_code": parent_item_code, "description": d.description,