From d9e284366d6c67ebd41b914b248c6ac94e973b7e Mon Sep 17 00:00:00 2001 From: s-aga-r Date: Fri, 27 Oct 2023 16:35:35 +0530 Subject: [PATCH] fix: consider reserved serial nos while cancelling a stock transaction --- .../stock_reservation_entry.py | 29 ++++++++ erpnext/stock/stock_ledger.py | 74 +++++++++++++++---- 2 files changed, 88 insertions(+), 15 deletions(-) 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 6b39965f9b0e..9e79702d8204 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -681,6 +681,35 @@ def get_sre_reserved_qty_for_voucher_detail_no( return flt(reserved_qty[0][0]) +def get_sre_reserved_serial_nos_details( + item_code: str, warehouse: str, serial_nos: list = None +) -> dict: + """Returns a dict of `Serial No` reserved in Stock Reservation Entry. The dict is like {serial_no: sre_name, ...}""" + + sre = frappe.qb.DocType("Stock Reservation Entry") + sb_entry = frappe.qb.DocType("Serial and Batch Entry") + query = ( + frappe.qb.from_(sre) + .inner_join(sb_entry) + .on(sre.name == sb_entry.parent) + .select(sb_entry.serial_no, sre.name) + .where( + (sre.docstatus == 1) + & (sre.item_code == item_code) + & (sre.warehouse == warehouse) + & (sre.reserved_qty > sre.delivered_qty) + & (sre.status.notin(["Delivered", "Cancelled"])) + & (sre.reservation_based_on == "Serial and Batch") + ) + .orderby(sb_entry.creation) + ) + + if serial_nos: + query = query.where(sb_entry.serial_no.isin(serial_nos)) + + return frappe._dict(query.run()) + + def get_sre_details_for_voucher(voucher_type: str, voucher_no: str) -> list[dict]: """Returns a list of SREs for the provided voucher.""" diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 551701b47a6b..ab88381d6563 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -19,6 +19,9 @@ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( get_sre_reserved_qty_for_item_and_warehouse as get_reserved_stock, ) +from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + get_sre_reserved_serial_nos_details, +) from erpnext.stock.utils import ( get_incoming_outgoing_rate_for_cancel, get_or_make_bin, @@ -1719,22 +1722,22 @@ def validate_negative_qty_in_future_sle(args, allow_negative_stock=False): frappe.throw(message, NegativeStockError, title=_("Insufficient Stock")) - if not args.batch_no: - return + if args.batch_no: + neg_batch_sle = get_future_sle_with_negative_batch_qty(args) + if is_negative_with_precision(neg_batch_sle, is_batch=True): + message = _( + "{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction." + ).format( + abs(neg_batch_sle[0]["cumulative_total"]), + frappe.get_desk_link("Batch", args.batch_no), + frappe.get_desk_link("Warehouse", args.warehouse), + neg_batch_sle[0]["posting_date"], + neg_batch_sle[0]["posting_time"], + frappe.get_desk_link(neg_batch_sle[0]["voucher_type"], neg_batch_sle[0]["voucher_no"]), + ) + frappe.throw(message, NegativeStockError, title=_("Insufficient Stock for Batch")) - neg_batch_sle = get_future_sle_with_negative_batch_qty(args) - if is_negative_with_precision(neg_batch_sle, is_batch=True): - message = _( - "{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction." - ).format( - abs(neg_batch_sle[0]["cumulative_total"]), - frappe.get_desk_link("Batch", args.batch_no), - frappe.get_desk_link("Warehouse", args.warehouse), - neg_batch_sle[0]["posting_date"], - neg_batch_sle[0]["posting_time"], - frappe.get_desk_link(neg_batch_sle[0]["voucher_type"], neg_batch_sle[0]["voucher_no"]), - ) - frappe.throw(message, NegativeStockError, title=_("Insufficient Stock for Batch")) + validate_reserved_stock(args) def is_negative_with_precision(neg_sle, is_batch=False): @@ -1801,6 +1804,47 @@ def get_future_sle_with_negative_batch_qty(args): ) +def validate_reserved_stock(kwargs): + if kwargs.serial_no: + serial_nos = kwargs.serial_no.split("\n") + validate_reserved_serial_nos(kwargs.item_code, kwargs.warehouse, serial_nos) + + elif kwargs.serial_and_batch_bundle: + sbb_entries = frappe.db.get_all( + "Serial and Batch Entry", + { + "parenttype": "Serial and Batch Bundle", + "parent": kwargs.serial_and_batch_bundle, + "docstatus": 1, + }, + ["batch_no", "serial_no", "qty"], + ) + serial_nos = [entry.serial_no for entry in sbb_entries if entry.serial_no] + + if serial_nos: + validate_reserved_serial_nos(kwargs.item_code, kwargs.warehouse, serial_nos) + + +def validate_reserved_serial_nos(item_code, warehouse, serial_nos): + if reserved_serial_nos_details := get_sre_reserved_serial_nos_details( + item_code, warehouse, serial_nos + ): + if common_serial_nos := list( + set(serial_nos).intersection(set(reserved_serial_nos_details.keys())) + ): + msg = _( + "Serial Nos are reserved in Stock Reservation Entries, you need to unreserve them before proceeding." + ) + msg += "
" + msg += _("Example: Serial No {0} reserved in {1}.").format( + frappe.bold(common_serial_nos[0]), + frappe.get_desk_link( + "Stock Reservation Entry", reserved_serial_nos_details[common_serial_nos[0]] + ), + ) + frappe.throw(msg, title=_("Reserved Serial No.")) + + def is_negative_stock_allowed(*, item_code: Optional[str] = None) -> bool: if cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock", cache=True)): return True