feat: add option to reserve stock in SO
diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py
index 58891c1..1e4fabe 100644
--- a/erpnext/controllers/stock_controller.py
+++ b/erpnext/controllers/stock_controller.py
@@ -783,72 +783,6 @@
 
 		gl_entries.append(self.get_gl_dict(gl_entry, item=item))
 
-	def make_sr_entries(self):
-		from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
-			get_available_qty_to_reserve,
-		)
-
-		if not self.get("reserve_stock"):
-			return
-
-		if not frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"):
-			frappe.throw(
-				_("Please enable {0} in the {1}.").format(
-					frappe.bold("Stock Reservation"), frappe.bold("Stock Settings")
-				)
-			)
-
-		if self.doctype != "Sales Order":
-			frappe.throw(
-				_("Stock Reservation can only be created against a {0}.").format(frappe.bold("Sales Order"))
-			)
-
-		for item in self.get("items"):
-			if not item.get("reserve_stock"):
-				continue
-
-			available_qty = get_available_qty_to_reserve(item.item_code, item.warehouse)
-			reserved_qty = min(item.stock_qty, available_qty)
-
-			if not reserved_qty:
-				frappe.msgprint(
-					_("Row {0}: No available stock to reserve for the Item {1}").format(
-						item.idx, frappe.bold(item.item_code)
-					),
-					title=_("Stock Reservation"),
-					indicator="orange",
-				)
-				continue
-
-			elif reserved_qty < item.stock_qty:
-				frappe.msgprint(
-					_("Row {0}: Only {1} available to reserve for the Item {2}").format(
-						item.idx,
-						frappe.bold(str(reserved_qty / item.conversion_factor) + " " + item.uom),
-						frappe.bold(item.item_code),
-					),
-					title=_("Stock Reservation"),
-					indicator="orange",
-				)
-
-				if not frappe.db.get_single_value("Stock Settings", "allow_partial_reservation"):
-					continue
-
-			sre = frappe.new_doc("Stock Reservation Entry")
-			sre.item_code = item.item_code
-			sre.warehouse = item.warehouse
-			sre.voucher_type = self.doctype
-			sre.voucher_no = self.name
-			sre.voucher_detail_no = item.name
-			sre.available_qty = available_qty
-			sre.voucher_qty = item.stock_qty
-			sre.reserved_qty = reserved_qty
-			sre.company = self.company
-			sre.stock_uom = item.stock_uom
-			sre.project = self.project
-			sre.save()
-			sre.submit()
-
 
 def repost_required_for_queue(doc: StockController) -> bool:
 	"""check if stock document contains repeated item-warehouse with queue based valuation.
diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js
index 1bb181b..a4578bc 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.js
+++ b/erpnext/selling/doctype/sales_order/sales_order.js
@@ -293,6 +293,10 @@
 			}
 
 			// Stock Reservation
+			if (this.frm.doc.__onload && this.frm.doc.__onload.has_unreserved_stock) {
+				this.frm.add_custom_button(__('Reserve'), () => this.reserve_stock_against_sales_order(), __('Stock Reservation'));
+			}
+
 			if (this.frm.doc.__onload && this.frm.doc.__onload.has_reserved_stock) {
 				this.frm.add_custom_button(__('Unreserve'), () => this.cancel_stock_reservation_entries(), __('Stock Reservation'));
 			}
@@ -335,6 +339,21 @@
 		this.order_type(doc);
 	}
 
+	reserve_stock_against_sales_order() {
+		frappe.call({
+			method: "erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry.reserve_stock_against_sales_order",
+			args: {
+				sales_order: this.frm.docname
+			},
+			freeze: true,
+			freeze_message: __("Reserving Stock..."),
+			callback: (r) => {
+				this.frm.doc.__onload.has_unreserved_stock = false;
+				this.frm.refresh();
+			}
+		})
+	}
+
 	cancel_stock_reservation_entries() {
 		frappe.call({
 			method: "erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry.cancel_stock_reservation_entries",
diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py
index 06c84b0..3a8b65a 100755
--- a/erpnext/selling/doctype/sales_order/sales_order.py
+++ b/erpnext/selling/doctype/sales_order/sales_order.py
@@ -52,6 +52,9 @@
 		if has_reserved_stock(self.doctype, self.name):
 			self.set_onload("has_reserved_stock", True)
 
+		if self.has_unreserved_stock():
+			self.set_onload("has_unreserved_stock", True)
+
 	def validate(self):
 		super(SalesOrder, self).validate()
 		self.validate_delivery_date()
@@ -249,7 +252,12 @@
 
 			update_coupon_code_count(self.coupon_code, "used")
 
-		self.make_sr_entries()
+		if self.get("reserve_stock"):
+			from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+				reserve_stock_against_sales_order,
+			)
+
+			reserve_stock_against_sales_order(self)
 
 	def on_cancel(self):
 		self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry")
@@ -495,6 +503,34 @@
 					).format(item.item_code)
 				)
 
+	def has_unreserved_stock(self):
+		from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+			get_sre_reserved_qty_details_for_voucher_detail_no,
+		)
+
+		for item in self.items:
+			if not item.get("reserve_stock"):
+				continue
+
+			reserved_qty_details = get_sre_reserved_qty_details_for_voucher_detail_no(
+				"Sales Order", self.name, item.name
+			)
+
+			existing_reserved_qty = 0.0
+			if reserved_qty_details:
+				existing_reserved_qty = reserved_qty_details[1]
+
+			unreserved_qty = (
+				item.stock_qty
+				- flt(item.delivered_qty) * item.get("conversion_factor", 1)
+				- existing_reserved_qty
+			)
+
+			if unreserved_qty > 0:
+				return True
+
+		return False
+
 
 def get_list_context(context=None):
 	from erpnext.controllers.website_list_for_contact import get_list_context
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 1b6388d..75aa2a6 100644
--- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py
+++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py
@@ -5,6 +5,7 @@
 from frappe import _
 from frappe.model.document import Document
 from frappe.query_builder.functions import Sum
+from frappe.utils import flt
 
 
 class StockReservationEntry(Document):
@@ -85,6 +86,21 @@
 			)
 
 
+def validate_stock_reservation_settings(voucher):
+	if not frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"):
+		frappe.throw(
+			_("Please enable {0} in the {1}.").format(
+				frappe.bold("Stock Reservation"), frappe.bold("Stock Settings")
+			)
+		)
+
+	allowed_voucher_types = ["Sales Order"]
+	if voucher.doctype not in allowed_voucher_types:
+		frappe.throw(
+			_("Stock Reservation can only be created against {0}.").format(", ".join(allowed_voucher_types))
+		)
+
+
 def get_available_qty_to_reserve(item_code, warehouse):
 	from frappe.query_builder.functions import Sum
 
@@ -155,7 +171,7 @@
 	voucher_type: str, voucher_no: str, voucher_detail_no: str
 ) -> list:
 	sre = frappe.qb.DocType("Stock Reservation Entry")
-	return (
+	reserved_qty_details = (
 		frappe.qb.from_(sre)
 		.select(sre.warehouse, (Sum(sre.reserved_qty) - Sum(sre.delivered_qty)).as_("reserved_qty"))
 		.where(
@@ -166,7 +182,12 @@
 			& (sre.status.notin(["Delivered", "Cancelled"]))
 		)
 		.groupby(sre.warehouse)
-	).run(as_list=True)[0]
+	).run(as_list=True)
+
+	if reserved_qty_details:
+		return reserved_qty_details[0]
+
+	return reserved_qty_details
 
 
 def get_sre_reserved_qty_details(item_code: str | list, warehouse: str | list) -> dict:
@@ -212,6 +233,84 @@
 
 
 @frappe.whitelist()
+def reserve_stock_against_sales_order(sales_order: object | str) -> None:
+	if isinstance(sales_order, str):
+		sales_order = frappe.get_doc("Sales Order", sales_order)
+
+	validate_stock_reservation_settings(sales_order)
+
+	for item in sales_order.get("items"):
+		if not item.get("reserve_stock"):
+			continue
+
+		reserved_qty_details = get_sre_reserved_qty_details_for_voucher_detail_no(
+			"Sales Order", sales_order.name, item.name
+		)
+
+		existing_reserved_qty = 0.0
+		if reserved_qty_details:
+			existing_reserved_qty = reserved_qty_details[1]
+
+		unreserved_qty = (
+			item.stock_qty
+			- flt(item.delivered_qty) * item.get("conversion_factor", 1)
+			- existing_reserved_qty
+		)
+
+		if unreserved_qty <= 0:
+			frappe.msgprint(
+				_("Row #{0}: Stock is already reserved for the Item {1}").format(
+					item.idx, frappe.bold(item.item_code)
+				),
+				title=_("Stock Reservation"),
+			)
+			continue
+
+		available_qty_to_reserve = get_available_qty_to_reserve(item.item_code, item.warehouse)
+
+		if available_qty_to_reserve <= 0:
+			frappe.msgprint(
+				_("Row #{0}: No available stock to reserve for the Item {1}").format(
+					item.idx, frappe.bold(item.item_code)
+				),
+				title=_("Stock Reservation"),
+				indicator="orange",
+			)
+			continue
+
+		qty_to_be_reserved = min(unreserved_qty, available_qty_to_reserve)
+
+		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 frappe.db.get_single_value("Stock Settings", "allow_partial_reservation"):
+				continue
+
+		sre = frappe.new_doc("Stock Reservation Entry")
+		sre.item_code = item.item_code
+		sre.warehouse = item.warehouse
+		sre.voucher_type = sales_order.doctype
+		sre.voucher_no = sales_order.name
+		sre.voucher_detail_no = item.name
+		sre.available_qty = available_qty_to_reserve
+		sre.voucher_qty = item.stock_qty
+		sre.reserved_qty = qty_to_be_reserved
+		sre.company = sales_order.company
+		sre.stock_uom = item.stock_uom
+		sre.project = sales_order.project
+		sre.save()
+		sre.submit()
+
+
+@frappe.whitelist()
 def cancel_stock_reservation_entries(
 	voucher_type: str, voucher_no: str, voucher_detail_no: str = None
 ) -> None: