fix: miscellaneous

fix: don't reserve stock in group warehouse
fix: partial reservation in multiple warehouses
feat: add prompt to select warehouse and qty for reservation in SO
diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js
index acde31e..6203a56 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.js
+++ b/erpnext/selling/doctype/sales_order/sales_order.js
@@ -169,19 +169,82 @@
 	},
 
 	create_stock_reservation_entries(frm) {
-		frappe.call({
-			doc: frm.doc,
-			method: 'create_stock_reservation_entries',
-			args: {
-				notify: true
-			},
-			freeze: true,
-			freeze_message: __('Reserving Stock...'),
-			callback: (r) => {
-				frm.doc.__onload.has_unreserved_stock = false;
-				frm.refresh();
+		let items_data = [];
+
+		const dialog = frappe.prompt({fieldname: 'items', fieldtype: 'Table', label: __('Items to Reserve'),
+			fields: [
+				{
+					fieldtype: 'Data',
+					fieldname: 'name',
+					label: __('Name'),
+					reqd: 1,
+					read_only: 1,
+				},
+				{
+					fieldtype: 'Link',
+					fieldname: 'item_code',
+					label: __('Item Code'),
+					options: 'Item',
+					reqd: 1,
+					read_only: 1,
+					in_list_view: 1,
+				},
+				{
+					fieldtype: 'Link',
+					fieldname: 'warehouse',
+					label: __('Warehouse'),
+					options: 'Warehouse',
+					reqd: 1,
+					in_list_view: 1
+				},
+				{
+					fieldtype: 'Float',
+					fieldname: 'qty_to_reserve',
+					label: __('Qty'),
+					reqd: 1,
+					in_list_view: 1
+				}
+			],
+			data: items_data,
+			in_place_edit: true,
+			get_data: function() {
+				return items_data;
 			}
-		})
+		}, function(data) {
+			if (data.items.length > 0) {
+				frappe.call({
+					doc: frm.doc,
+					method: 'create_stock_reservation_entries',
+					args: {
+						items_details: data.items,
+						notify: true
+					},
+					freeze: true,
+					freeze_message: __('Reserving Stock...'),
+					callback: (r) => {
+						frm.doc.__onload.has_unreserved_stock = false;
+						frm.reload_doc();
+					}
+				});
+			}
+		}, __("Stock Reservation"), __("Reserve Stock"));
+
+		frm.doc.items.forEach(item => {
+			if (item.reserve_stock) {
+				let unreserved_qty = (flt(item.stock_qty) - (flt(item.delivered_qty) * flt(item.conversion_factor)) - flt(item.stock_reserved_qty))
+
+				if (unreserved_qty > 0) {
+					dialog.fields_dict.items.df.data.push({
+						'name': item.name,
+						'item_code': item.item_code,
+						'warehouse': item.warehouse,
+						'qty_to_reserve': (unreserved_qty / flt(item.conversion_factor))
+					});
+				}
+			}
+		});
+
+		dialog.fields_dict.items.grid.refresh();
 	},
 
 	cancel_stock_reservation_entries(frm) {
@@ -195,7 +258,7 @@
 			freeze_message: __('Unreserving Stock...'),
 			callback: (r) => {
 				frm.doc.__onload.has_reserved_stock = false;
-				frm.refresh();
+				frm.reload_doc();
 			}
 		})
 	}
diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py
index 131a091..5a8810b 100755
--- a/erpnext/selling/doctype/sales_order/sales_order.py
+++ b/erpnext/selling/doctype/sales_order/sales_order.py
@@ -518,7 +518,7 @@
 		return False
 
 	@frappe.whitelist()
-	def create_stock_reservation_entries(self, notify=True):
+	def create_stock_reservation_entries(self, items_details=None, notify=True):
 		"""Creates Stock Reservation Entries for Sales Order Items."""
 
 		from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
@@ -532,9 +532,18 @@
 			"Stock Settings", "allow_partial_reservation"
 		)
 
+		items = []
+		if items_details:
+			for item in items_details:
+				so_item = frappe.get_doc("Sales Order Item", item["name"])
+				so_item.reserve_stock = 1
+				so_item.warehouse = item["warehouse"]
+				so_item.qty_to_reserve = flt(item["qty_to_reserve"]) * flt(so_item.conversion_factor)
+				items.append(so_item)
+
 		sre_count = 0
 		reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", self.name)
-		for item in self.get("items"):
+		for item in items or self.get("items"):
 			# Skip if `Reserved Stock` is not checked for the item.
 			if not item.get("reserve_stock"):
 				continue
@@ -551,15 +560,27 @@
 				item.db_set("reserve_stock", 0)
 				continue
 
+			# Skip if Group Warehouse.
+			if frappe.get_cached_value("Warehouse", item.warehouse, "is_group"):
+				frappe.msgprint(
+					_("Row #{0}: Stock cannot be reserved in group warehouse {1}.").format(
+						item.idx, frappe.bold(item.warehouse)
+					),
+					title=_("Stock Reservation"),
+					indicator="yellow",
+				)
+				continue
+
 			unreserved_qty = get_unreserved_qty(item, reserved_qty_details)
 
 			# Stock is already reserved for the item, notify the user and skip the item.
 			if unreserved_qty <= 0:
 				frappe.msgprint(
-					_("Row #{0}: Stock is already reserved for the Item {1} in Warehouse {2}.").format(
-						item.idx, frappe.bold(item.item_code), frappe.bold(item.warehouse)
+					_("Row #{0}: Stock is already reserved for the Item {1}.").format(
+						item.idx, frappe.bold(item.item_code)
 					),
 					title=_("Stock Reservation"),
+					indicator="yellow",
 				)
 				continue
 
@@ -579,17 +600,31 @@
 			# The quantity which can be reserved.
 			qty_to_be_reserved = min(unreserved_qty, available_qty_to_reserve)
 
+			if hasattr(item, "qty_to_reserve"):
+				if item.qty_to_reserve <= 0:
+					frappe.msgprint(
+						_("Row #{0}: Quantity to reserve for the Item {1} should be greater than 0.").format(
+							item.idx, frappe.bold(item.item_code)
+						),
+						title=_("Stock Reservation"),
+						indicator="orange",
+					)
+					continue
+				else:
+					qty_to_be_reserved = min(qty_to_be_reserved, item.qty_to_reserve)
+
 			# Partial Reservation
 			if qty_to_be_reserved < unreserved_qty:
-				frappe.msgprint(
-					_("Row #{0}: Only {1} available to reserve for the Item {2}").format(
-						item.idx,
-						frappe.bold(str(qty_to_be_reserved / item.conversion_factor) + " " + item.uom),
-						frappe.bold(item.item_code),
-					),
-					title=_("Stock Reservation"),
-					indicator="orange",
-				)
+				if not item.get("qty_to_reserve") or qty_to_be_reserved < flt(item.get("qty_to_reserve")):
+					frappe.msgprint(
+						_("Row #{0}: Only {1} available to reserve for the Item {2}").format(
+							item.idx,
+							frappe.bold(str(qty_to_be_reserved / item.conversion_factor) + " " + item.uom),
+							frappe.bold(item.item_code),
+						),
+						title=_("Stock Reservation"),
+						indicator="orange",
+					)
 
 				# Skip the item if `Partial Reservation` is disabled in the Stock Settings.
 				if not allow_partial_reservation:
@@ -620,7 +655,7 @@
 def get_unreserved_qty(item: object, reserved_qty_details: dict) -> float:
 	"""Returns the unreserved quantity for the Sales Order Item."""
 
-	existing_reserved_qty = reserved_qty_details.get((item.name, item.warehouse), 0)
+	existing_reserved_qty = reserved_qty_details.get(item.name, 0)
 	return (
 		item.stock_qty
 		- flt(item.delivered_qty) * item.get("conversion_factor", 1)
diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py
index 51b791f..aa0d5e8 100644
--- a/erpnext/selling/doctype/sales_order/test_sales_order.py
+++ b/erpnext/selling/doctype/sales_order/test_sales_order.py
@@ -1939,7 +1939,7 @@
 
 			reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", so.name)
 			for item in so.items:
-				reserved_qty = reserved_qty_details[(item.name, item.warehouse)]
+				reserved_qty = reserved_qty_details[item.name]
 				self.assertEqual(item.stock_reserved_qty, reserved_qty)
 				self.assertEqual(item.stock_qty, item.stock_reserved_qty)
 
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py
index b222560..1a728e1 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note.py
+++ b/erpnext/stock/doctype/delivery_note/delivery_note.py
@@ -343,31 +343,18 @@
 			if not sre_data:
 				continue
 
-			is_group_warehouse = frappe.get_cached_value("Warehouse", sre_data[0], "is_group")
-
+			# Set `Warehouse` from SRE if not set.
 			if not item.warehouse:
-				if not is_group_warehouse:
-					item.warehouse = sre_data[0]
-				else:
-					frappe.throw(_("Row #{0}: Warehouse is mandatory").format(item.idx, item.item_code))
+				item.warehouse = sre_data[0]
 			else:
-				if not is_group_warehouse:
-					if item.warehouse != sre_data[0]:
-						frappe.throw(
-							_("Row #{0}: Stock is reserved for Warehouse {1}").format(item.idx, sre_data[0]),
-							title=_("Stock Reservation Warehouse Mismatch"),
-						)
-				else:
-					from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses
-
-					warehouses = get_child_warehouses(sre_data[0])
-					if item.warehouse not in warehouses:
-						frappe.throw(
-							_(
-								"Row #{0}: Stock is reserved for Group Warehouse {1}, please select its child Warehouse"
-							).format(item.idx, sre_data[0]),
-							title=_("Stock Reservation Group Warehouse"),
-						)
+				# Throw if `Warehouse` is different from SRE.
+				if item.warehouse != sre_data[0]:
+					frappe.throw(
+						_("Row #{0}: Stock is reserved for Item {1} in Warehouse {2}.").format(
+							item.idx, frappe.bold(item.item_code), frappe.bold(sre_data[0])
+						),
+						title=_("Stock Reservation Warehouse Mismatch"),
+					)
 
 	def check_credit_limit(self):
 		from erpnext.selling.doctype.customer.customer import check_credit_limit
diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py
index f55e640..5819dd7 100644
--- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py
+++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py
@@ -12,6 +12,7 @@
 		from erpnext.stock.utils import validate_disabled_warehouse, validate_warehouse_company
 
 		self.validate_mandatory()
+		self.validate_for_group_warehouse()
 		validate_disabled_warehouse(self.warehouse)
 		validate_warehouse_company(self.warehouse, self.company)
 
@@ -42,6 +43,15 @@
 			if not self.get(d):
 				frappe.throw(_("{0} is required").format(self.meta.get_label(d)))
 
+	def validate_for_group_warehouse(self) -> None:
+		"""Raises exception if `Warehouse` is a Group Warehouse."""
+
+		if frappe.get_cached_value("Warehouse", self.warehouse, "is_group"):
+			frappe.throw(
+				_("Stock cannot be reserved in group warehouse {0}.").format(frappe.bold(self.warehouse)),
+				title=_("Invalid Warehouse"),
+			)
+
 	def update_status(self, status: str = None, update_modified: bool = True) -> None:
 		"""Updates status based on Voucher Qty, Reserved Qty and Delivered Qty."""
 
@@ -113,16 +123,11 @@
 def get_available_qty_to_reserve(item_code: str, warehouse: str) -> float:
 	"""Returns `Available Qty to Reserve (Actual Qty - Reserved Qty)` for Item and Warehouse combination."""
 
-	from erpnext.stock.get_item_details import get_bin_details
+	from erpnext.stock.utils import get_stock_balance
 
-	available_qty = get_bin_details(item_code, warehouse, include_child_warehouses=True).get(
-		"actual_qty"
-	)
+	available_qty = get_stock_balance(item_code, warehouse)
 
 	if available_qty:
-		from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses
-
-		warehouses = get_child_warehouses(warehouse)
 		sre = frappe.qb.DocType("Stock Reservation Entry")
 		reserved_qty = (
 			frappe.qb.from_(sre)
@@ -130,7 +135,7 @@
 			.where(
 				(sre.docstatus == 1)
 				& (sre.item_code == item_code)
-				& (sre.warehouse.isin(warehouses))
+				& (sre.warehouse == warehouse)
 				& (sre.status.notin(["Delivered", "Cancelled"]))
 			)
 		).run()[0][0] or 0.0
@@ -230,19 +235,14 @@
 	return reserved_qty
 
 
-def get_sre_reserved_qty_details_for_voucher(
-	voucher_type: str, voucher_no: str, voucher_detail_no: str = None
-) -> dict:
-	"""Returns a dict like {("voucher_detail_no", "warehouse"): "reserved_qty", ... }."""
-
-	reserved_qty_details = {}
+def get_sre_reserved_qty_details_for_voucher(voucher_type: str, voucher_no: str) -> dict:
+	"""Returns a dict like {"voucher_detail_no": "reserved_qty", ... }."""
 
 	sre = frappe.qb.DocType("Stock Reservation Entry")
-	query = (
+	data = (
 		frappe.qb.from_(sre)
 		.select(
 			sre.voucher_detail_no,
-			sre.warehouse,
 			(Sum(sre.reserved_qty) - Sum(sre.delivered_qty)).as_("reserved_qty"),
 		)
 		.where(
@@ -251,18 +251,10 @@
 			& (sre.voucher_no == voucher_no)
 			& (sre.status.notin(["Delivered", "Cancelled"]))
 		)
-		.groupby(sre.voucher_detail_no, sre.warehouse)
-	)
+		.groupby(sre.voucher_detail_no)
+	).run(as_list=True)
 
-	if voucher_detail_no:
-		query = query.where(sre.voucher_detail_no == voucher_detail_no)
-
-	data = query.run(as_dict=True)
-
-	for d in data:
-		reserved_qty_details[(d["voucher_detail_no"], d["warehouse"])] = d["reserved_qty"]
-
-	return reserved_qty_details
+	return frappe._dict(data)
 
 
 def get_sre_reserved_qty_details_for_voucher_detail_no(
@@ -281,6 +273,7 @@
 			& (sre.voucher_detail_no == voucher_detail_no)
 			& (sre.status.notin(["Delivered", "Cancelled"]))
 		)
+		.orderby(sre.creation)
 		.groupby(sre.warehouse)
 	).run(as_list=True)