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: