From d84233028a748d86764152fd9104249107b78fa9 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 6 Nov 2023 11:07:09 +0530 Subject: [PATCH] feat: reserved production plan sub assembly items (#37884) (cherry picked from commit 34d3eb88b39ffb437fc111ed03527f2c3c4baf9b) --- .../production_plan/production_plan.json | 9 +- .../production_plan/production_plan.py | 44 ++++++++- .../production_plan/test_production_plan.py | 91 ++++++++++++++++++- .../production_plan_sub_assembly_item.json | 27 ++---- .../doctype/work_order/work_order.py | 36 +++++++- erpnext/stock/doctype/bin/bin.py | 30 +++++- 6 files changed, 212 insertions(+), 25 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.json b/erpnext/manufacturing/doctype/production_plan/production_plan.json index 4a0041662bc4..49386c4ebc4b 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.json +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.json @@ -36,6 +36,7 @@ "prod_plan_references", "section_break_24", "combine_sub_items", + "sub_assembly_warehouse", "section_break_ucc4", "skip_available_sub_assembly_item", "column_break_igxl", @@ -416,13 +417,19 @@ { "fieldname": "column_break_igxl", "fieldtype": "Column Break" + }, + { + "fieldname": "sub_assembly_warehouse", + "fieldtype": "Link", + "label": "Sub Assembly Warehouse", + "options": "Warehouse" } ], "icon": "fa fa-calendar", "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-09-29 11:41:03.246059", + "modified": "2023-11-03 14:08:11.928027", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan", diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 1850d1e09e13..6b12a29b508d 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -490,6 +490,12 @@ def update_bin_qty(self): bin = frappe.get_doc("Bin", bin_name, for_update=True) bin.update_reserved_qty_for_production_plan() + for d in self.sub_assembly_items: + if d.fg_warehouse and d.type_of_manufacturing == "In House": + bin_name = get_or_make_bin(d.production_item, d.fg_warehouse) + bin = frappe.get_doc("Bin", bin_name, for_update=True) + bin.update_reserved_qty_for_for_sub_assembly() + def delete_draft_work_order(self): for d in frappe.get_all( "Work Order", fields=["name"], filters={"docstatus": 0, "production_plan": ("=", self.name)} @@ -809,7 +815,11 @@ def get_sub_assembly_items(self, manufacturing_type=None): bom_data = [] - warehouse = row.warehouse if self.skip_available_sub_assembly_item else None + warehouse = ( + (self.sub_assembly_warehouse or row.warehouse) + if self.skip_available_sub_assembly_item + else None + ) get_sub_assembly_items(row.bom_no, bom_data, row.planned_qty, self.company, warehouse=warehouse) self.set_sub_assembly_items_based_on_level(row, bom_data, manufacturing_type) sub_assembly_items_store.extend(bom_data) @@ -831,7 +841,7 @@ def set_sub_assembly_items_based_on_level(self, row, bom_data, manufacturing_typ for data in bom_data: data.qty = data.stock_qty data.production_plan_item = row.name - data.fg_warehouse = row.warehouse + data.fg_warehouse = self.sub_assembly_warehouse or row.warehouse data.schedule_date = row.planned_start_date data.type_of_manufacturing = manufacturing_type or ( "Subcontract" if data.is_sub_contracted_item else "In House" @@ -1637,8 +1647,8 @@ def get_reserved_qty_for_production_plan(item_code, warehouse): query = query.run() - if not query: - return 0.0 + if not query or query[0][0] is None: + return None reserved_qty_for_production_plan = flt(query[0][0]) @@ -1780,3 +1790,29 @@ def sales_order_query( query = query.offset(start) return query.run() + + +def get_reserved_qty_for_sub_assembly(item_code, warehouse): + table = frappe.qb.DocType("Production Plan") + child = frappe.qb.DocType("Production Plan Sub Assembly Item") + + query = ( + frappe.qb.from_(table) + .inner_join(child) + .on(table.name == child.parent) + .select(Sum(child.qty - IfNull(child.wo_produced_qty, 0))) + .where( + (table.docstatus == 1) + & (child.production_item == item_code) + & (child.fg_warehouse == warehouse) + & (table.status.notin(["Completed", "Closed"])) + ) + ) + + query = query.run() + + if not query or query[0][0] is None: + return None + + qty = flt(query[0][0]) + return qty if qty > 0 else 0.0 diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index d414988f4119..e9c6ee3af20f 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -1042,13 +1042,14 @@ def test_resered_qty_for_production_plan_for_material_requests(self): after_qty = flt(frappe.db.get_value("Bin", bin_name, "reserved_qty_for_production_plan")) self.assertEqual(after_qty - before_qty, 1) - pln = frappe.get_doc("Production Plan", pln.name) pln.cancel() bin_name = get_or_make_bin("Raw Material Item 1", "_Test Warehouse - _TC") after_qty = flt(frappe.db.get_value("Bin", bin_name, "reserved_qty_for_production_plan")) + pln.reload() + self.assertEqual(pln.docstatus, 2) self.assertEqual(after_qty, before_qty) def test_resered_qty_for_production_plan_for_work_order(self): @@ -1359,6 +1360,93 @@ def test_mr_qty_for_same_rm_with_different_sub_assemblies(self): if row.item_code == "ChildPart2 For SUB Test": self.assertEqual(row.quantity, 2) + def test_reserve_sub_assembly_items(self): + from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + bom_tree = { + "Fininshed Goods Bicycle": { + "Frame Assembly": {"Frame": {}}, + "Chain Assembly": {"Chain": {}}, + } + } + parent_bom = create_nested_bom(bom_tree, prefix="") + + warehouse = "_Test Warehouse - _TC" + company = "_Test Company" + + sub_assembly_warehouse = create_warehouse("SUB ASSEMBLY WH", company=company) + + for item_code in ["Frame", "Chain"]: + make_stock_entry(item_code=item_code, target=warehouse, qty=2, basic_rate=100) + + before_qty = flt( + frappe.db.get_value( + "Bin", + {"item_code": "Frame Assembly", "warehouse": sub_assembly_warehouse}, + "reserved_qty_for_production_plan", + ) + ) + + plan = create_production_plan( + item_code=parent_bom.item, + planned_qty=2, + ignore_existing_ordered_qty=1, + do_not_submit=1, + skip_available_sub_assembly_item=1, + warehouse=warehouse, + sub_assembly_warehouse=sub_assembly_warehouse, + ) + + plan.get_sub_assembly_items() + plan.submit() + + after_qty = flt( + frappe.db.get_value( + "Bin", + {"item_code": "Frame Assembly", "warehouse": sub_assembly_warehouse}, + "reserved_qty_for_production_plan", + ) + ) + + self.assertEqual(after_qty, before_qty + 2) + + plan.make_work_order() + work_orders = frappe.get_all( + "Work Order", + fields=["name", "production_item"], + filters={"production_plan": plan.name}, + order_by="creation desc", + ) + + for d in work_orders: + wo_doc = frappe.get_doc("Work Order", d.name) + wo_doc.skip_transfer = 1 + wo_doc.from_wip_warehouse = 1 + + wo_doc.wip_warehouse = ( + warehouse + if d.production_item in ["Frame Assembly", "Chain Assembly"] + else sub_assembly_warehouse + ) + + wo_doc.submit() + + if d.production_item == "Frame Assembly": + self.assertEqual(wo_doc.fg_warehouse, sub_assembly_warehouse) + se_doc = frappe.get_doc(make_se_from_wo(wo_doc.name, "Manufacture", 2)) + se_doc.submit() + + after_qty = flt( + frappe.db.get_value( + "Bin", + {"item_code": "Frame Assembly", "warehouse": sub_assembly_warehouse}, + "reserved_qty_for_production_plan", + ) + ) + + self.assertEqual(after_qty, before_qty) + def create_production_plan(**args): """ @@ -1379,6 +1467,7 @@ def create_production_plan(**args): "ignore_existing_ordered_qty": args.ignore_existing_ordered_qty or 0, "get_items_from": "Sales Order", "skip_available_sub_assembly_item": args.skip_available_sub_assembly_item or 0, + "sub_assembly_warehouse": args.sub_assembly_warehouse, } ) diff --git a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json index fde0404c0191..aff740b732ea 100644 --- a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json +++ b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json @@ -17,11 +17,10 @@ "type_of_manufacturing", "supplier", "work_order_details_section", - "work_order", + "wo_produced_qty", "purchase_order", "production_plan_item", "column_break_7", - "produced_qty", "received_qty", "indent", "section_break_19", @@ -52,13 +51,6 @@ "fieldtype": "Section Break", "label": "Reference" }, - { - "fieldname": "work_order", - "fieldtype": "Link", - "label": "Work Order", - "options": "Work Order", - "read_only": 1 - }, { "fieldname": "column_break_7", "fieldtype": "Column Break" @@ -81,7 +73,8 @@ { "fieldname": "received_qty", "fieldtype": "Float", - "label": "Received Qty" + "label": "Received Qty", + "read_only": 1 }, { "fieldname": "bom_no", @@ -161,12 +154,6 @@ "label": "Target Warehouse", "options": "Warehouse" }, - { - "fieldname": "produced_qty", - "fieldtype": "Data", - "label": "Produced Quantity", - "read_only": 1 - }, { "default": "In House", "fieldname": "type_of_manufacturing", @@ -209,12 +196,18 @@ "label": "Projected Qty", "no_copy": 1, "read_only": 1 + }, + { + "fieldname": "wo_produced_qty", + "fieldtype": "Float", + "label": "Produced Qty", + "read_only": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-05-22 17:52:34.708879", + "modified": "2023-11-03 13:33:42.959387", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan Sub Assembly Item", diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index f9fddcbb5e92..36a0cae5cca5 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -293,6 +293,7 @@ def update_work_order_qty(self): update_produced_qty_in_so_item(self.sales_order, self.sales_order_item) if self.production_plan: + self.set_produced_qty_for_sub_assembly_item() self.update_production_plan_status() def get_transferred_or_manufactured_qty(self, purpose): @@ -569,16 +570,49 @@ def validate_cancel(self): ) def update_planned_qty(self): + from erpnext.manufacturing.doctype.production_plan.production_plan import ( + get_reserved_qty_for_sub_assembly, + ) + + qty_dict = {"planned_qty": get_planned_qty(self.production_item, self.fg_warehouse)} + + if self.production_plan_sub_assembly_item and self.production_plan: + qty_dict["reserved_qty_for_production_plan"] = get_reserved_qty_for_sub_assembly( + self.production_item, self.fg_warehouse + ) + update_bin_qty( self.production_item, self.fg_warehouse, - {"planned_qty": get_planned_qty(self.production_item, self.fg_warehouse)}, + qty_dict, ) if self.material_request: mr_obj = frappe.get_doc("Material Request", self.material_request) mr_obj.update_requested_qty([self.material_request_item]) + def set_produced_qty_for_sub_assembly_item(self): + table = frappe.qb.DocType("Work Order") + + query = ( + frappe.qb.from_(table) + .select(Sum(table.produced_qty)) + .where( + (table.production_plan == self.production_plan) + & (table.production_plan_sub_assembly_item == self.production_plan_sub_assembly_item) + & (table.docstatus == 1) + ) + ).run() + + produced_qty = flt(query[0][0]) if query else 0 + + frappe.db.set_value( + "Production Plan Sub Assembly Item", + self.production_plan_sub_assembly_item, + "wo_produced_qty", + produced_qty, + ) + def update_ordered_qty(self): if ( self.production_plan diff --git a/erpnext/stock/doctype/bin/bin.py b/erpnext/stock/doctype/bin/bin.py index df466ede682b..8b2e5cf9ecb7 100644 --- a/erpnext/stock/doctype/bin/bin.py +++ b/erpnext/stock/doctype/bin/bin.py @@ -34,10 +34,15 @@ def update_reserved_qty_for_production_plan(self, skip_project_qty_update=False) get_reserved_qty_for_production_plan, ) - self.reserved_qty_for_production_plan = get_reserved_qty_for_production_plan( + reserved_qty_for_production_plan = get_reserved_qty_for_production_plan( self.item_code, self.warehouse ) + if reserved_qty_for_production_plan is None and not self.reserved_qty_for_production_plan: + return + + self.reserved_qty_for_production_plan = flt(reserved_qty_for_production_plan) + self.db_set( "reserved_qty_for_production_plan", flt(self.reserved_qty_for_production_plan), @@ -48,6 +53,29 @@ def update_reserved_qty_for_production_plan(self, skip_project_qty_update=False) self.set_projected_qty() self.db_set("projected_qty", self.projected_qty, update_modified=True) + def update_reserved_qty_for_for_sub_assembly(self): + from erpnext.manufacturing.doctype.production_plan.production_plan import ( + get_reserved_qty_for_sub_assembly, + ) + + reserved_qty_for_production_plan = get_reserved_qty_for_sub_assembly( + self.item_code, self.warehouse + ) + + if reserved_qty_for_production_plan is None and not self.reserved_qty_for_production_plan: + return + + self.reserved_qty_for_production_plan = flt(reserved_qty_for_production_plan) + self.set_projected_qty() + + self.db_set( + { + "projected_qty": self.projected_qty, + "reserved_qty_for_production_plan": flt(self.reserved_qty_for_production_plan), + }, + update_modified=True, + ) + def update_reserved_qty_for_production(self): """Update qty reserved for production from Production Item tables in open work orders"""