Merge pull request #30762 from ankush/bunle_pickng

feat: support product bundles in picklist
diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js
index 0b48f70..26c9996 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.js
+++ b/erpnext/selling/doctype/sales_order/sales_order.js
@@ -152,7 +152,9 @@
 						}
 					}
 
-					this.frm.add_custom_button(__('Pick List'), () => this.create_pick_list(), __('Create'));
+					if (flt(doc.per_picked, 6) < 100 && flt(doc.per_delivered, 6) < 100) {
+						this.frm.add_custom_button(__('Pick List'), () => this.create_pick_list(), __('Create'));
+					}
 
 					const order_is_a_sale = ["Sales", "Shopping Cart"].indexOf(doc.order_type) !== -1;
 					const order_is_maintenance = ["Maintenance"].indexOf(doc.order_type) !== -1;
diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json
index fe2f14e..1d0432b 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.json
+++ b/erpnext/selling/doctype/sales_order/sales_order.json
@@ -1520,6 +1520,7 @@
    "fieldname": "per_picked",
    "fieldtype": "Percent",
    "label": "% Picked",
+   "no_copy": 1,
    "read_only": 1
   }
  ],
@@ -1527,7 +1528,7 @@
  "idx": 105,
  "is_submittable": 1,
  "links": [],
- "modified": "2022-03-15 21:38:31.437586",
+ "modified": "2022-04-21 08:16:48.316074",
  "modified_by": "Administrator",
  "module": "Selling",
  "name": "Sales Order",
diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py
index d3b4286..b463213 100755
--- a/erpnext/selling/doctype/sales_order/sales_order.py
+++ b/erpnext/selling/doctype/sales_order/sales_order.py
@@ -385,6 +385,16 @@
 		if tot_qty != 0:
 			self.db_set("per_delivered", flt(delivered_qty / tot_qty) * 100, update_modified=False)
 
+	def update_picking_status(self):
+		total_picked_qty = 0.0
+		total_qty = 0.0
+		for so_item in self.items:
+			total_picked_qty += flt(so_item.picked_qty)
+			total_qty += flt(so_item.stock_qty)
+		per_picked = total_picked_qty / total_qty * 100
+
+		self.db_set("per_picked", flt(per_picked), update_modified=False)
+
 	def set_indicator(self):
 		"""Set indicator for portal"""
 		if self.per_billed < 100 and self.per_delivered < 100:
@@ -1232,9 +1242,30 @@
 
 @frappe.whitelist()
 def create_pick_list(source_name, target_doc=None):
-	def update_item_quantity(source, target, source_parent):
-		target.qty = flt(source.qty) - flt(source.delivered_qty)
-		target.stock_qty = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.conversion_factor)
+	from erpnext.stock.doctype.packed_item.packed_item import is_product_bundle
+
+	def update_item_quantity(source, target, source_parent) -> None:
+		picked_qty = flt(source.picked_qty) / (flt(source.conversion_factor) or 1)
+		qty_to_be_picked = flt(source.qty) - max(picked_qty, flt(source.delivered_qty))
+
+		target.qty = qty_to_be_picked
+		target.stock_qty = qty_to_be_picked * flt(source.conversion_factor)
+
+	def update_packed_item_qty(source, target, source_parent) -> None:
+		qty = flt(source.qty)
+		for item in source_parent.items:
+			if source.parent_detail_docname == item.name:
+				picked_qty = flt(item.picked_qty) / (flt(item.conversion_factor) or 1)
+				pending_percent = (item.qty - max(picked_qty, item.delivered_qty)) / item.qty
+				target.qty = target.stock_qty = qty * pending_percent
+				return
+
+	def should_pick_order_item(item) -> bool:
+		return (
+			abs(item.delivered_qty) < abs(item.qty)
+			and item.delivered_by_supplier != 1
+			and not is_product_bundle(item.item_code)
+		)
 
 	doc = get_mapped_doc(
 		"Sales Order",
@@ -1245,8 +1276,17 @@
 				"doctype": "Pick List Item",
 				"field_map": {"parent": "sales_order", "name": "sales_order_item"},
 				"postprocess": update_item_quantity,
-				"condition": lambda doc: abs(doc.delivered_qty) < abs(doc.qty)
-				and doc.delivered_by_supplier != 1,
+				"condition": should_pick_order_item,
+			},
+			"Packed Item": {
+				"doctype": "Pick List Item",
+				"field_map": {
+					"parent": "sales_order",
+					"name": "sales_order_item",
+					"parent_detail_docname": "product_bundle_item",
+				},
+				"field_no_map": ["picked_qty"],
+				"postprocess": update_packed_item_qty,
 			},
 		},
 		target_doc,
diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json
index 195e964..3797856 100644
--- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json
+++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json
@@ -803,13 +803,15 @@
   {
    "fieldname": "picked_qty",
    "fieldtype": "Float",
-   "label": "Picked Qty"
+   "label": "Picked Qty (in Stock UOM)",
+   "no_copy": 1,
+   "read_only": 1
   }
  ],
  "idx": 1,
  "istable": 1,
  "links": [],
- "modified": "2022-03-15 20:17:33.984799",
+ "modified": "2022-04-27 03:15:34.366563",
  "modified_by": "Administrator",
  "module": "Selling",
  "name": "Sales Order Item",
diff --git a/erpnext/stock/doctype/packed_item/packed_item.json b/erpnext/stock/doctype/packed_item/packed_item.json
index e94c34d..cb8eb30 100644
--- a/erpnext/stock/doctype/packed_item/packed_item.json
+++ b/erpnext/stock/doctype/packed_item/packed_item.json
@@ -29,6 +29,7 @@
   "ordered_qty",
   "column_break_16",
   "incoming_rate",
+  "picked_qty",
   "page_break",
   "prevdoc_doctype",
   "parent_detail_docname"
@@ -234,13 +235,20 @@
    "label": "Ordered Qty",
    "no_copy": 1,
    "read_only": 1
+  },
+  {
+   "fieldname": "picked_qty",
+   "fieldtype": "Float",
+   "label": "Picked Qty",
+   "no_copy": 1,
+   "read_only": 1
   }
  ],
  "idx": 1,
  "index_web_pages_for_search": 1,
  "istable": 1,
  "links": [],
- "modified": "2022-03-10 15:42:00.265915",
+ "modified": "2022-04-27 05:23:08.683245",
  "modified_by": "Administrator",
  "module": "Stock",
  "name": "Packed Item",
diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py
index 026dd4e..4d05d7a 100644
--- a/erpnext/stock/doctype/packed_item/packed_item.py
+++ b/erpnext/stock/doctype/packed_item/packed_item.py
@@ -32,7 +32,7 @@
 	reset = reset_packing_list(doc)
 
 	for item_row in doc.get("items"):
-		if frappe.db.exists("Product Bundle", {"new_item_code": item_row.item_code}):
+		if is_product_bundle(item_row.item_code):
 			for bundle_item in get_product_bundle_items(item_row.item_code):
 				pi_row = add_packed_item_row(
 					doc=doc,
@@ -54,6 +54,10 @@
 		set_product_bundle_rate_amount(doc, parent_items_price)  # set price in bundle item
 
 
+def is_product_bundle(item_code: str) -> bool:
+	return bool(frappe.db.exists("Product Bundle", {"new_item_code": item_code}))
+
+
 def get_indexed_packed_items_table(doc):
 	"""
 	Create dict from stale packed items table like:
diff --git a/erpnext/stock/doctype/packed_item/test_packed_item.py b/erpnext/stock/doctype/packed_item/test_packed_item.py
index fe1b0d9..ad7fd9a 100644
--- a/erpnext/stock/doctype/packed_item/test_packed_item.py
+++ b/erpnext/stock/doctype/packed_item/test_packed_item.py
@@ -1,10 +1,12 @@
 # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
 # License: GNU General Public License v3. See license.txt
 
+from typing import List, Optional, Tuple
+
+import frappe
 from frappe.tests.utils import FrappeTestCase, change_settings
 from frappe.utils import add_to_date, nowdate
 
-from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
 from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
 from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
 from erpnext.stock.doctype.item.test_item import make_item
@@ -12,6 +14,33 @@
 from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
 
 
+def create_product_bundle(
+	quantities: Optional[List[int]] = None, warehouse: Optional[str] = None
+) -> Tuple[str, List[str]]:
+	"""Get a new product_bundle for use in tests.
+
+	Create 10x required stock if warehouse is specified.
+	"""
+	if not quantities:
+		quantities = [2, 2]
+
+	bundle = make_item(properties={"is_stock_item": 0}).name
+
+	bundle_doc = frappe.get_doc({"doctype": "Product Bundle", "new_item_code": bundle})
+
+	components = []
+	for qty in quantities:
+		compoenent = make_item().name
+		components.append(compoenent)
+		bundle_doc.append("items", {"item_code": compoenent, "qty": qty})
+		if warehouse:
+			make_stock_entry(item=compoenent, to_warehouse=warehouse, qty=10 * qty, rate=100)
+
+	bundle_doc.insert()
+
+	return bundle, components
+
+
 class TestPackedItem(FrappeTestCase):
 	"Test impact on Packed Items table in various scenarios."
 
@@ -19,24 +48,11 @@
 	def setUpClass(cls) -> None:
 		super().setUpClass()
 		cls.warehouse = "_Test Warehouse - _TC"
-		cls.bundle = "_Test Product Bundle X"
-		cls.bundle_items = ["_Test Bundle Item 1", "_Test Bundle Item 2"]
 
-		cls.bundle2 = "_Test Product Bundle Y"
-		cls.bundle2_items = ["_Test Bundle Item 3", "_Test Bundle Item 4"]
+		cls.bundle, cls.bundle_items = create_product_bundle(warehouse=cls.warehouse)
+		cls.bundle2, cls.bundle2_items = create_product_bundle(warehouse=cls.warehouse)
 
-		make_item(cls.bundle, {"is_stock_item": 0})
-		make_item(cls.bundle2, {"is_stock_item": 0})
-		for item in cls.bundle_items + cls.bundle2_items:
-			make_item(item, {"is_stock_item": 1})
-
-		make_item("_Test Normal Stock Item", {"is_stock_item": 1})
-
-		make_product_bundle(cls.bundle, cls.bundle_items, qty=2)
-		make_product_bundle(cls.bundle2, cls.bundle2_items, qty=2)
-
-		for item in cls.bundle_items + cls.bundle2_items:
-			make_stock_entry(item_code=item, to_warehouse=cls.warehouse, qty=100, rate=100)
+		cls.normal_item = make_item().name
 
 	def test_adding_bundle_item(self):
 		"Test impact on packed items if bundle item row is added."
@@ -58,7 +74,7 @@
 		self.assertEqual(so.packed_items[1].qty, 4)
 
 		# change item code to non bundle item
-		so.items[0].item_code = "_Test Normal Stock Item"
+		so.items[0].item_code = self.normal_item
 		so.save()
 
 		self.assertEqual(len(so.packed_items), 0)
diff --git a/erpnext/stock/doctype/pick_list/pick_list.json b/erpnext/stock/doctype/pick_list/pick_list.json
index c604c71..e984c08 100644
--- a/erpnext/stock/doctype/pick_list/pick_list.json
+++ b/erpnext/stock/doctype/pick_list/pick_list.json
@@ -114,6 +114,7 @@
    "set_only_once": 1
   },
   {
+   "collapsible": 1,
    "fieldname": "print_settings_section",
    "fieldtype": "Section Break",
    "label": "Print Settings"
@@ -129,7 +130,7 @@
  ],
  "is_submittable": 1,
  "links": [],
- "modified": "2021-10-05 15:08:40.369957",
+ "modified": "2022-04-21 07:56:40.646473",
  "modified_by": "Administrator",
  "module": "Stock",
  "name": "Pick List",
@@ -199,5 +200,6 @@
  ],
  "sort_field": "modified",
  "sort_order": "DESC",
+ "states": [],
  "track_changes": 1
 }
\ No newline at end of file
diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py
index 33d7745..70d2f23 100644
--- a/erpnext/stock/doctype/pick_list/pick_list.py
+++ b/erpnext/stock/doctype/pick_list/pick_list.py
@@ -4,13 +4,14 @@
 import json
 from collections import OrderedDict, defaultdict
 from itertools import groupby
-from operator import itemgetter
+from typing import Dict, List, Set
 
 import frappe
 from frappe import _
 from frappe.model.document import Document
 from frappe.model.mapper import map_child_doc
 from frappe.utils import cint, floor, flt, today
+from frappe.utils.nestedset import get_descendants_of
 
 from erpnext.selling.doctype.sales_order.sales_order import (
 	make_delivery_note as create_delivery_note_from_sales_order,
@@ -38,6 +39,7 @@
 				)
 
 	def before_submit(self):
+		update_sales_orders = set()
 		for item in self.locations:
 			# if the user has not entered any picked qty, set it to stock_qty, before submit
 			if item.picked_qty == 0:
@@ -45,7 +47,8 @@
 
 			if item.sales_order_item:
 				# update the picked_qty in SO Item
-				self.update_so(item.sales_order_item, item.picked_qty, item.item_code)
+				self.update_sales_order_item(item, item.picked_qty, item.item_code)
+				update_sales_orders.add(item.sales_order)
 
 			if not frappe.get_cached_value("Item", item.item_code, "has_serial_no"):
 				continue
@@ -65,18 +68,29 @@
 				title=_("Quantity Mismatch"),
 			)
 
+		self.update_bundle_picked_qty()
+		self.update_sales_order_picking_status(update_sales_orders)
+
 	def before_cancel(self):
-		# update picked_qty in SO Item on cancel of PL
+		"""Deduct picked qty on cancelling pick list"""
+		updated_sales_orders = set()
+
 		for item in self.get("locations"):
 			if item.sales_order_item:
-				self.update_so(item.sales_order_item, -1 * item.picked_qty, item.item_code)
+				self.update_sales_order_item(item, -1 * item.picked_qty, item.item_code)
+				updated_sales_orders.add(item.sales_order)
 
-	def update_so(self, so_item, picked_qty, item_code):
-		so_doc = frappe.get_doc(
-			"Sales Order", frappe.db.get_value("Sales Order Item", so_item, "parent")
-		)
+		self.update_bundle_picked_qty()
+		self.update_sales_order_picking_status(updated_sales_orders)
+
+	def update_sales_order_item(self, item, picked_qty, item_code):
+		item_table = "Sales Order Item" if not item.product_bundle_item else "Packed Item"
+		stock_qty_field = "stock_qty" if not item.product_bundle_item else "qty"
+
 		already_picked, actual_qty = frappe.db.get_value(
-			"Sales Order Item", so_item, ["picked_qty", "qty"]
+			item_table,
+			item.sales_order_item,
+			["picked_qty", stock_qty_field],
 		)
 
 		if self.docstatus == 1:
@@ -86,20 +100,16 @@
 				frappe.throw(
 					_(
 						"You are picking more than required quantity for {}. Check if there is any other pick list created for {}"
-					).format(item_code, so_doc.name)
+					).format(item_code, item.sales_order)
 				)
 
-		frappe.db.set_value("Sales Order Item", so_item, "picked_qty", already_picked + picked_qty)
+		frappe.db.set_value(item_table, item.sales_order_item, "picked_qty", already_picked + picked_qty)
 
-		total_picked_qty = 0
-		total_so_qty = 0
-		for item in so_doc.get("items"):
-			total_picked_qty += flt(item.picked_qty)
-			total_so_qty += flt(item.stock_qty)
-		total_picked_qty = total_picked_qty + picked_qty
-		per_picked = total_picked_qty / total_so_qty * 100
-
-		so_doc.db_set("per_picked", flt(per_picked), update_modified=False)
+	@staticmethod
+	def update_sales_order_picking_status(sales_orders: Set[str]) -> None:
+		for sales_order in sales_orders:
+			if sales_order:
+				frappe.get_doc("Sales Order", sales_order).update_picking_status()
 
 	@frappe.whitelist()
 	def set_item_locations(self, save=False):
@@ -109,7 +119,7 @@
 
 		from_warehouses = None
 		if self.parent_warehouse:
-			from_warehouses = frappe.db.get_descendants("Warehouse", self.parent_warehouse)
+			from_warehouses = get_descendants_of("Warehouse", self.parent_warehouse)
 
 		# Create replica before resetting, to handle empty table on update after submit.
 		locations_replica = self.get("locations")
@@ -190,8 +200,7 @@
 			frappe.throw(_("Qty of Finished Goods Item should be greater than 0."))
 
 	def before_print(self, settings=None):
-		if self.get("group_same_items"):
-			self.group_similar_items()
+		self.group_similar_items()
 
 	def group_similar_items(self):
 		group_item_qty = defaultdict(float)
@@ -217,6 +226,57 @@
 		for idx, item in enumerate(self.locations, start=1):
 			item.idx = idx
 
+	def update_bundle_picked_qty(self):
+		product_bundles = self._get_product_bundles()
+		product_bundle_qty_map = self._get_product_bundle_qty_map(product_bundles.values())
+
+		for so_row, item_code in product_bundles.items():
+			picked_qty = self._compute_picked_qty_for_bundle(so_row, product_bundle_qty_map[item_code])
+			item_table = "Sales Order Item"
+			already_picked = frappe.db.get_value(item_table, so_row, "picked_qty")
+			frappe.db.set_value(
+				item_table,
+				so_row,
+				"picked_qty",
+				already_picked + (picked_qty * (1 if self.docstatus == 1 else -1)),
+			)
+
+	def _get_product_bundles(self) -> Dict[str, str]:
+		# Dict[so_item_row: item_code]
+		product_bundles = {}
+		for item in self.locations:
+			if not item.product_bundle_item:
+				continue
+			product_bundles[item.product_bundle_item] = frappe.db.get_value(
+				"Sales Order Item",
+				item.product_bundle_item,
+				"item_code",
+			)
+		return product_bundles
+
+	def _get_product_bundle_qty_map(self, bundles: List[str]) -> Dict[str, Dict[str, float]]:
+		# bundle_item_code: Dict[component, qty]
+		product_bundle_qty_map = {}
+		for bundle_item_code in bundles:
+			bundle = frappe.get_last_doc("Product Bundle", {"new_item_code": bundle_item_code})
+			product_bundle_qty_map[bundle_item_code] = {item.item_code: item.qty for item in bundle.items}
+		return product_bundle_qty_map
+
+	def _compute_picked_qty_for_bundle(self, bundle_row, bundle_items) -> int:
+		"""Compute how many full bundles can be created from picked items."""
+		precision = frappe.get_precision("Stock Ledger Entry", "qty_after_transaction")
+
+		possible_bundles = []
+		for item in self.locations:
+			if item.product_bundle_item != bundle_row:
+				continue
+
+			if qty_in_bundle := bundle_items.get(item.item_code):
+				possible_bundles.append(item.picked_qty / qty_in_bundle)
+			else:
+				possible_bundles.append(0)
+		return int(flt(min(possible_bundles), precision or 6))
+
 
 def validate_item_locations(pick_list):
 	if not pick_list.locations:
@@ -450,22 +510,18 @@
 	for location in pick_list.locations:
 		if location.sales_order:
 			sales_orders.append(
-				[frappe.db.get_value("Sales Order", location.sales_order, "customer"), location.sales_order]
+				frappe.db.get_value(
+					"Sales Order", location.sales_order, ["customer", "name as sales_order"], as_dict=True
+				)
 			)
-	# Group sales orders by customer
-	for key, keydata in groupby(sales_orders, key=itemgetter(0)):
-		sales_dict[key] = set([d[1] for d in keydata])
+
+	for customer, rows in groupby(sales_orders, key=lambda so: so["customer"]):
+		sales_dict[customer] = {row.sales_order for row in rows}
 
 	if sales_dict:
 		delivery_note = create_dn_with_so(sales_dict, pick_list)
 
-	is_item_wo_so = 0
-	for location in pick_list.locations:
-		if not location.sales_order:
-			is_item_wo_so = 1
-			break
-	if is_item_wo_so == 1:
-		# Create a DN for items without sales orders as well
+	if not all(item.sales_order for item in pick_list.locations):
 		delivery_note = create_dn_wo_so(pick_list)
 
 	frappe.msgprint(_("Delivery Note(s) created for the Pick List"))
@@ -492,27 +548,30 @@
 def create_dn_with_so(sales_dict, pick_list):
 	delivery_note = None
 
+	item_table_mapper = {
+		"doctype": "Delivery Note Item",
+		"field_map": {
+			"rate": "rate",
+			"name": "so_detail",
+			"parent": "against_sales_order",
+		},
+		"condition": lambda doc: abs(doc.delivered_qty) < abs(doc.qty)
+		and doc.delivered_by_supplier != 1,
+	}
+
 	for customer in sales_dict:
 		for so in sales_dict[customer]:
 			delivery_note = None
 			delivery_note = create_delivery_note_from_sales_order(so, delivery_note, skip_item_mapping=True)
-
-			item_table_mapper = {
-				"doctype": "Delivery Note Item",
-				"field_map": {
-					"rate": "rate",
-					"name": "so_detail",
-					"parent": "against_sales_order",
-				},
-				"condition": lambda doc: abs(doc.delivered_qty) < abs(doc.qty)
-				and doc.delivered_by_supplier != 1,
-			}
 			break
 		if delivery_note:
 			# map all items of all sales orders of that customer
 			for so in sales_dict[customer]:
 				map_pl_locations(pick_list, item_table_mapper, delivery_note, so)
-			delivery_note.insert(ignore_mandatory=True)
+			delivery_note.flags.ignore_mandatory = True
+			delivery_note.insert()
+			update_packed_item_details(pick_list, delivery_note)
+			delivery_note.save()
 
 	return delivery_note
 
@@ -520,28 +579,28 @@
 def map_pl_locations(pick_list, item_mapper, delivery_note, sales_order=None):
 
 	for location in pick_list.locations:
-		if location.sales_order == sales_order:
-			if location.sales_order_item:
-				sales_order_item = frappe.get_cached_doc(
-					"Sales Order Item", {"name": location.sales_order_item}
-				)
-			else:
-				sales_order_item = None
+		if location.sales_order != sales_order or location.product_bundle_item:
+			continue
 
-			source_doc, table_mapper = (
-				[sales_order_item, item_mapper] if sales_order_item else [location, item_mapper]
-			)
+		if location.sales_order_item:
+			sales_order_item = frappe.get_doc("Sales Order Item", location.sales_order_item)
+		else:
+			sales_order_item = None
 
-			dn_item = map_child_doc(source_doc, delivery_note, table_mapper)
+		source_doc = sales_order_item or location
 
-			if dn_item:
-				dn_item.pick_list_item = location.name
-				dn_item.warehouse = location.warehouse
-				dn_item.qty = flt(location.picked_qty) / (flt(location.conversion_factor) or 1)
-				dn_item.batch_no = location.batch_no
-				dn_item.serial_no = location.serial_no
+		dn_item = map_child_doc(source_doc, delivery_note, item_mapper)
 
-				update_delivery_note_item(source_doc, dn_item, delivery_note)
+		if dn_item:
+			dn_item.pick_list_item = location.name
+			dn_item.warehouse = location.warehouse
+			dn_item.qty = flt(location.picked_qty) / (flt(location.conversion_factor) or 1)
+			dn_item.batch_no = location.batch_no
+			dn_item.serial_no = location.serial_no
+
+			update_delivery_note_item(source_doc, dn_item, delivery_note)
+
+	add_product_bundles_to_delivery_note(pick_list, delivery_note, item_mapper)
 	set_delivery_note_missing_values(delivery_note)
 
 	delivery_note.pick_list = pick_list.name
@@ -549,6 +608,50 @@
 	delivery_note.customer = frappe.get_value("Sales Order", sales_order, "customer")
 
 
+def add_product_bundles_to_delivery_note(
+	pick_list: "PickList", delivery_note, item_mapper
+) -> None:
+	"""Add product bundles found in pick list to delivery note.
+
+	When mapping pick list items, the bundle item itself isn't part of the
+	locations. Dynamically fetch and add parent bundle item into DN."""
+	product_bundles = pick_list._get_product_bundles()
+	product_bundle_qty_map = pick_list._get_product_bundle_qty_map(product_bundles.values())
+
+	for so_row, item_code in product_bundles.items():
+		sales_order_item = frappe.get_doc("Sales Order Item", so_row)
+		dn_bundle_item = map_child_doc(sales_order_item, delivery_note, item_mapper)
+		dn_bundle_item.qty = pick_list._compute_picked_qty_for_bundle(
+			so_row, product_bundle_qty_map[item_code]
+		)
+		update_delivery_note_item(sales_order_item, dn_bundle_item, delivery_note)
+
+
+def update_packed_item_details(pick_list: "PickList", delivery_note) -> None:
+	"""Update stock details on packed items table of delivery note."""
+
+	def _find_so_row(packed_item):
+		for item in delivery_note.items:
+			if packed_item.parent_detail_docname == item.name:
+				return item.so_detail
+
+	def _find_pick_list_location(bundle_row, packed_item):
+		if not bundle_row:
+			return
+		for loc in pick_list.locations:
+			if loc.product_bundle_item == bundle_row and loc.item_code == packed_item.item_code:
+				return loc
+
+	for packed_item in delivery_note.packed_items:
+		so_row = _find_so_row(packed_item)
+		location = _find_pick_list_location(so_row, packed_item)
+		if not location:
+			continue
+		packed_item.warehouse = location.warehouse
+		packed_item.batch_no = location.batch_no
+		packed_item.serial_no = location.serial_no
+
+
 @frappe.whitelist()
 def create_stock_entry(pick_list):
 	pick_list = frappe.get_doc(json.loads(pick_list))
diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py
index 27b06d2..f552299 100644
--- a/erpnext/stock/doctype/pick_list/test_pick_list.py
+++ b/erpnext/stock/doctype/pick_list/test_pick_list.py
@@ -3,18 +3,21 @@
 
 import frappe
 from frappe import _dict
-
-test_dependencies = ["Item", "Sales Invoice", "Stock Entry", "Batch"]
-
 from frappe.tests.utils import FrappeTestCase
 
+from erpnext.selling.doctype.sales_order.sales_order import create_pick_list
+from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
 from erpnext.stock.doctype.item.test_item import create_item, make_item
+from erpnext.stock.doctype.packed_item.test_packed_item import create_product_bundle
 from erpnext.stock.doctype.pick_list.pick_list import create_delivery_note
 from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
+from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
 from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
 	EmptyStockReconciliationItemsError,
 )
 
+test_dependencies = ["Item", "Sales Invoice", "Stock Entry", "Batch"]
+
 
 class TestPickList(FrappeTestCase):
 	def test_pick_list_picks_warehouse_for_each_item(self):
@@ -579,14 +582,79 @@
 				if dn_item.item_code == "_Test Item 2":
 					self.assertEqual(dn_item.qty, 2)
 
-	# def test_pick_list_skips_items_in_expired_batch(self):
-	# 	pass
+	def test_picklist_with_multi_uom(self):
+		warehouse = "_Test Warehouse - _TC"
+		item = make_item(properties={"uoms": [dict(uom="Box", conversion_factor=24)]}).name
+		make_stock_entry(item=item, to_warehouse=warehouse, qty=1000)
 
-	# def test_pick_list_from_sales_order(self):
-	# 	pass
+		so = make_sales_order(item_code=item, qty=10, rate=42, uom="Box")
+		pl = create_pick_list(so.name)
+		# pick half the qty
+		for loc in pl.locations:
+			loc.picked_qty = loc.stock_qty / 2
+		pl.save()
+		pl.submit()
 
-	# def test_pick_list_from_work_order(self):
-	# 	pass
+		so.reload()
+		self.assertEqual(so.per_picked, 50)
 
-	# def test_pick_list_from_material_request(self):
-	# 	pass
+	def test_picklist_with_bundles(self):
+		warehouse = "_Test Warehouse - _TC"
+
+		quantities = [5, 2]
+		bundle, components = create_product_bundle(quantities, warehouse=warehouse)
+		bundle_items = dict(zip(components, quantities))
+
+		so = make_sales_order(item_code=bundle, qty=3, rate=42)
+
+		pl = create_pick_list(so.name)
+		pl.save()
+		self.assertEqual(len(pl.locations), 2)
+		for item in pl.locations:
+			self.assertEqual(item.stock_qty, bundle_items[item.item_code] * 3)
+
+		# check picking status on sales order
+		pl.submit()
+		so.reload()
+		self.assertEqual(so.per_picked, 100)
+
+		# deliver
+		dn = create_delivery_note(pl.name).submit()
+		self.assertEqual(dn.items[0].rate, 42)
+		self.assertEqual(dn.packed_items[0].warehouse, warehouse)
+		so.reload()
+		self.assertEqual(so.per_delivered, 100)
+
+	def test_picklist_with_partial_bundles(self):
+		# from test_records.json
+		warehouse = "_Test Warehouse - _TC"
+
+		quantities = [5, 2]
+		bundle, components = create_product_bundle(quantities, warehouse=warehouse)
+
+		so = make_sales_order(item_code=bundle, qty=4, rate=42)
+
+		pl = create_pick_list(so.name)
+		for loc in pl.locations:
+			loc.picked_qty = loc.qty / 2
+
+		pl.save().submit()
+		so.reload()
+		self.assertEqual(so.per_picked, 50)
+
+		# deliver half qty
+		dn = create_delivery_note(pl.name).submit()
+		self.assertEqual(dn.items[0].rate, 42)
+		so.reload()
+		self.assertEqual(so.per_delivered, 50)
+
+		pl = create_pick_list(so.name)
+		pl.save().submit()
+		so.reload()
+		self.assertEqual(so.per_picked, 100)
+
+		# deliver remaining
+		dn = create_delivery_note(pl.name).submit()
+		self.assertEqual(dn.items[0].rate, 42)
+		so.reload()
+		self.assertEqual(so.per_delivered, 100)
diff --git a/erpnext/stock/doctype/pick_list_item/pick_list_item.json b/erpnext/stock/doctype/pick_list_item/pick_list_item.json
index 805286d..a96ebfc 100644
--- a/erpnext/stock/doctype/pick_list_item/pick_list_item.json
+++ b/erpnext/stock/doctype/pick_list_item/pick_list_item.json
@@ -27,6 +27,7 @@
   "column_break_15",
   "sales_order",
   "sales_order_item",
+  "product_bundle_item",
   "material_request",
   "material_request_item"
  ],
@@ -146,6 +147,7 @@
   {
    "fieldname": "sales_order_item",
    "fieldtype": "Data",
+   "hidden": 1,
    "label": "Sales Order Item",
    "read_only": 1
   },
@@ -177,11 +179,19 @@
    "fieldtype": "Data",
    "label": "Item Group",
    "read_only": 1
+  },
+  {
+   "description": "product bundle item row's name in sales order. Also indicates that picked item is to be used for a product bundle",
+   "fieldname": "product_bundle_item",
+   "fieldtype": "Data",
+   "hidden": 1,
+   "label": "Product Bundle Item",
+   "read_only": 1
   }
  ],
  "istable": 1,
  "links": [],
- "modified": "2021-09-28 12:02:16.923056",
+ "modified": "2022-04-22 05:27:38.497997",
  "modified_by": "Administrator",
  "module": "Stock",
  "name": "Pick List Item",
@@ -190,5 +200,6 @@
  "quick_entry": 1,
  "sort_field": "modified",
  "sort_order": "DESC",
+ "states": [],
  "track_changes": 1
 }
\ No newline at end of file