feat: reserved production plan sub assembly items (#37884)

diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.json b/erpnext/manufacturing/doctype/production_plan/production_plan.json
index 4a00416..49386c4 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 1850d1e..6b12a29 100644
--- a/erpnext/manufacturing/doctype/production_plan/production_plan.py
+++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py
@@ -490,6 +490,12 @@
 				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 @@
 
 			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 @@
 		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 @@
 
 	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 @@
 		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 d414988..e9c6ee3 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 @@
 		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 @@
 			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 @@
 			"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 fde0404..aff740b 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",
@@ -53,13 +52,6 @@
    "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",
@@ -162,12 +155,6 @@
    "options": "Warehouse"
   },
   {
-   "fieldname": "produced_qty",
-   "fieldtype": "Data",
-   "label": "Produced Quantity",
-   "read_only": 1
-  },
-  {
    "default": "In House",
    "fieldname": "type_of_manufacturing",
    "fieldtype": "Select",
@@ -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 f9fddcb..36a0cae 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order.py
@@ -293,6 +293,7 @@
 				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 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 df466ed..8b2e5cf 100644
--- a/erpnext/stock/doctype/bin/bin.py
+++ b/erpnext/stock/doctype/bin/bin.py
@@ -34,10 +34,15 @@
 			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 @@
 			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"""