diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index d86b6d426e01..f034ed2b1a3d 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -1491,3 +1491,47 @@ def postprocess(source, doc): ) return doc + + +def get_op_cost_from_sub_assemblies(bom_no, op_cost=0): + # Get operating cost from sub-assemblies + + bom_items = frappe.get_all( + "BOM Item", filters={"parent": bom_no, "docstatus": 1}, fields=["bom_no"], order_by="idx asc" + ) + + for row in bom_items: + if not row.bom_no: + continue + + if cost := frappe.get_cached_value("BOM", row.bom_no, "operating_cost_per_bom_quantity"): + op_cost += flt(cost) + get_op_cost_from_sub_assemblies(row.bom_no, op_cost) + + return op_cost + + +def get_scrap_items_from_sub_assemblies(bom_no, company, qty, scrap_items=None): + if not scrap_items: + scrap_items = {} + + bom_items = frappe.get_all( + "BOM Item", + filters={"parent": bom_no, "docstatus": 1}, + fields=["bom_no", "qty"], + order_by="idx asc", + ) + + for row in bom_items: + if not row.bom_no: + continue + + qty = flt(row.qty) * flt(qty) + items = get_bom_items_as_dict( + row.bom_no, company, qty=qty, fetch_exploded=0, fetch_scrap_items=1 + ) + scrap_items.update(items) + + get_scrap_items_from_sub_assemblies(row.bom_no, company, qty, scrap_items) + + return scrap_items diff --git a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json index 01647d56c91b..d3ad51f72362 100644 --- a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json +++ b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json @@ -31,6 +31,7 @@ "job_card_excess_transfer", "other_settings_section", "update_bom_costs_automatically", + "set_op_cost_and_scrape_from_sub_assemblies", "column_break_23", "make_serial_no_batch_from_work_order" ], @@ -194,13 +195,20 @@ "fieldname": "job_card_excess_transfer", "fieldtype": "Check", "label": "Allow Excess Material Transfer" + }, + { + "default": "0", + "description": "In the case of 'Use Multi-Level BOM' in a work order, if the user wishes to add sub-assembly costs to Finished Goods items without using a job card as well the scrap items, then this option needs to be enable.", + "fieldname": "set_op_cost_and_scrape_from_sub_assemblies", + "fieldtype": "Check", + "label": "Set Operating Cost / Scrape Items From Sub-assemblies" } ], "icon": "icon-wrench", "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-09-13 22:09:09.401559", + "modified": "2023-12-28 16:37:44.874096", "modified_by": "Administrator", "module": "Manufacturing", "name": "Manufacturing Settings", @@ -216,5 +224,6 @@ ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py index bfc8f4e91501..463ba9fe4bf1 100644 --- a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py +++ b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py @@ -32,6 +32,7 @@ class ManufacturingSettings(Document): mins_between_operations: DF.Int overproduction_percentage_for_sales_order: DF.Percent overproduction_percentage_for_work_order: DF.Percent + set_op_cost_and_scrape_from_sub_assemblies: DF.Check update_bom_costs_automatically: DF.Check # end: auto-generated types diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index cb99b8845a3a..f6dfaa50586f 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -1602,6 +1602,10 @@ def make_bom(**args): } ) + if args.operating_cost_per_bom_quantity: + bom.fg_based_operating_cost = 1 + bom.operating_cost_per_bom_quantity = args.operating_cost_per_bom_quantity + for item in args.raw_materials: item_doc = frappe.get_doc("Item", item) bom.append( diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index e2c8f0798055..07c253b2323a 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -1732,6 +1732,93 @@ def test_job_card_extra_qty(self): job_card2.time_logs = [] job_card2.save() + def test_op_cost_and_scrap_based_on_sub_assemblies(self): + # Make Sub Assembly BOM 1 + + frappe.db.set_single_value( + "Manufacturing Settings", "set_op_cost_and_scrape_from_sub_assemblies", 1 + ) + + items = { + "Test Final FG Item": 0, + "Test Final SF Item 1": 0, + "Test Final SF Item 2": 0, + "Test Final RM Item 1": 100, + "Test Final RM Item 2": 200, + "Test Final Scrap Item 1": 50, + "Test Final Scrap Item 2": 60, + } + + for item in items: + if not frappe.db.exists("Item", item): + item_properties = {"is_stock_item": 1, "valuation_rate": items[item]} + + make_item(item_code=item, properties=item_properties), + + prepare_boms_for_sub_assembly_test() + + wo_order = make_wo_order_test_record( + production_item="Test Final FG Item", + qty=10, + use_multi_level_bom=1, + skip_transfer=1, + from_wip_warehouse=1, + ) + + se_doc = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10)) + se_doc.save() + + self.assertTrue(se_doc.additional_costs) + scrap_items = [] + for item in se_doc.items: + if item.is_scrap_item: + scrap_items.append(item.item_code) + + self.assertEqual( + sorted(scrap_items), sorted(["Test Final Scrap Item 1", "Test Final Scrap Item 2"]) + ) + for row in se_doc.additional_costs: + self.assertEqual(row.amount, 3000) + + frappe.db.set_single_value( + "Manufacturing Settings", "set_op_cost_and_scrape_from_sub_assemblies", 0 + ) + + +def prepare_boms_for_sub_assembly_test(): + if not frappe.db.exists("BOM", {"item": "Test Final SF Item 1"}): + bom = make_bom( + item="Test Final SF Item 1", + source_warehouse="Stores - _TC", + raw_materials=["Test Final RM Item 1"], + operating_cost_per_bom_quantity=100, + do_not_submit=True, + ) + + bom.append("scrap_items", {"item_code": "Test Final Scrap Item 1", "qty": 1}) + + bom.submit() + + if not frappe.db.exists("BOM", {"item": "Test Final SF Item 2"}): + bom = make_bom( + item="Test Final SF Item 2", + source_warehouse="Stores - _TC", + raw_materials=["Test Final RM Item 2"], + operating_cost_per_bom_quantity=200, + do_not_submit=True, + ) + + bom.append("scrap_items", {"item_code": "Test Final Scrap Item 2", "qty": 1}) + + bom.submit() + + if not frappe.db.exists("BOM", {"item": "Test Final FG Item"}): + bom = make_bom( + item="Test Final FG Item", + source_warehouse="Stores - _TC", + raw_materials=["Test Final SF Item 1", "Test Final SF Item 2"], + ) + def prepare_data_for_workstation_type_check(): from erpnext.manufacturing.doctype.operation.test_operation import make_operation @@ -1978,6 +2065,7 @@ def make_wo_order_test_record(**args): wo_order.sales_order = args.sales_order or None wo_order.planned_start_date = args.planned_start_date or now() wo_order.transfer_material_against = args.transfer_material_against or "Work Order" + wo_order.from_wip_warehouse = args.from_wip_warehouse or 0 if args.source_warehouse: for item in wo_order.get("required_items"): diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 6521394eef3c..2ccee9407863 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -25,7 +25,12 @@ import erpnext from erpnext.accounts.general_ledger import process_gl_map from erpnext.controllers.taxes_and_totals import init_landed_taxes_and_totals -from erpnext.manufacturing.doctype.bom.bom import add_additional_cost, validate_bom_no +from erpnext.manufacturing.doctype.bom.bom import ( + add_additional_cost, + get_op_cost_from_sub_assemblies, + get_scrap_items_from_sub_assemblies, + validate_bom_no, +) from erpnext.setup.doctype.brand.brand import get_brand_defaults from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults from erpnext.stock.doctype.batch.batch import get_batch_qty @@ -1908,11 +1913,22 @@ def get_bom_raw_materials(self, qty): def get_bom_scrap_material(self, qty): from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict - # item dict = { item_code: {qty, description, stock_uom} } - item_dict = ( - get_bom_items_as_dict(self.bom_no, self.company, qty=qty, fetch_exploded=0, fetch_scrap_items=1) - or {} - ) + if ( + frappe.db.get_single_value( + "Manufacturing Settings", "set_op_cost_and_scrape_from_sub_assemblies" + ) + and self.work_order + and frappe.get_cached_value("Work Order", self.work_order, "use_multi_level_bom") + ): + item_dict = get_scrap_items_from_sub_assemblies(self.bom_no, self.company, qty) + else: + # item dict = { item_code: {qty, description, stock_uom} } + item_dict = ( + get_bom_items_as_dict( + self.bom_no, self.company, qty=qty, fetch_exploded=0, fetch_scrap_items=1 + ) + or {} + ) for item in item_dict.values(): item.from_warehouse = "" @@ -2663,6 +2679,15 @@ def get_work_order_details(work_order, company): def get_operating_cost_per_unit(work_order=None, bom_no=None): operating_cost_per_unit = 0 if work_order: + if ( + bom_no + and frappe.db.get_single_value( + "Manufacturing Settings", "set_op_cost_and_scrape_from_sub_assemblies" + ) + and frappe.get_cached_value("Work Order", work_order, "use_multi_level_bom") + ): + return get_op_cost_from_sub_assemblies(bom_no) + if not bom_no: bom_no = work_order.bom_no