diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 58891c113f..1e4fabe0d2 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -783,72 +783,6 @@ class StockController(AccountsController): 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 1bb181b496..a4578bcf2c 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -293,6 +293,10 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex } // 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 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex 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 06c84b0b78..3a8b65a479 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -52,6 +52,9 @@ class SalesOrder(SellingController): 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 @@ class SalesOrder(SellingController): 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 @@ class SalesOrder(SellingController): ).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 1b6388d53d..75aa2a65cc 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 @@ import frappe 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 @@ class StockReservationEntry(Document): ) +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 @@ def get_sre_reserved_qty_details_for_voucher_detail_no( 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 @@ def get_sre_reserved_qty_details_for_voucher_detail_no( & (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: @@ -211,6 +232,84 @@ def has_reserved_stock(voucher_type: str, voucher_no: str, voucher_detail_no: st return False +@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