diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 792bbf902fc..263d45599ed 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -17,6 +17,7 @@ from frappe.utils import cint, flt from erpnext.accounts.party import get_due_date from erpnext.controllers.accounts_controller import get_taxes_and_charges, merge_taxes from erpnext.controllers.selling_controller import SellingController +from erpnext.stock.stock_ledger import validate_reserved_stock form_grid_templates = {"items": "templates/form_grid/item_grid.html"} @@ -469,6 +470,10 @@ class DeliveryNote(SellingController): self.make_bundle_using_old_serial_batch_fields(table_name) self.validate_standalone_serial_nos_customer() + + if not self.is_return: + self.validate_reserved_stock() + self.update_stock_reservation_entries() # Updating stock ledger should always be called after updating prevdoc status, @@ -506,6 +511,66 @@ class DeliveryNote(SellingController): self.delete_auto_created_batches() + def validate_reserved_stock(self): + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + get_sre_against_so_for_dn, + ) + + if not frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"): + return + + # fetch reserved stock data from bin + reserved_stocks = self.get_reserved_stock_details() + + for row in self.items: + if reserved_stocks.get((row.item_code, row.warehouse)) > 0: + args = frappe._dict( + { + "item_code": row.item_code, + "warehouse": row.warehouse, + "batch_nos": [row.batch_no] if row.batch_no else [], + "serial_nos": row.serial_no.split("\n") if row.serial_no else [], + "serial_and_batch_bundle": row.serial_and_batch_bundle, + "voucher_type": self.doctype, + "voucher_no": self.name, + "voucher_detail_no": row.name, + "actual_qty": row.qty * -1, + "posting_date": self.posting_date, + "posting_time": self.posting_time, + } + ) + + if row.against_sales_order and row.so_detail: + args.ignore_voucher_nos = get_sre_against_so_for_dn( + row.against_sales_order, row.so_detail + ) + + validate_reserved_stock(args) + + def get_reserved_stock_details(self): + """ + Create dict from bin based on item and warehouse: + {(item_code, warehouse): reserved_stock} + + Use: to quickly retrieve/check reserved stock value instead of looping n times + """ + item_codes = set() + warehouses = set() + + for row in self.items: + item_codes.add(row.item_code) + warehouses.add(row.warehouse) + + bins = frappe.db.get_all( + "Bin", + {"item_code": ["in", item_codes], "warehouse": ["in", warehouses]}, + ["item_code", "warehouse", "reserved_stock"], + ) + + reserved_stock_lookup = {(b.item_code, b.warehouse): flt(b.reserved_stock) for b in bins} + + return reserved_stock_lookup + def validate_against_stock_reservation_entries(self): """Validates if Stock Reservation Entries are available for the Sales Order Item reference.""" diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index dd9d247902b..450c2243620 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -39,7 +39,7 @@ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import create_stock_reconciliation, set_valuation_method, ) -from erpnext.stock.doctype.warehouse.test_warehouse import get_warehouse +from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse, get_warehouse from erpnext.stock.stock_ledger import get_previous_sle @@ -2719,6 +2719,75 @@ class TestDeliveryNote(FrappeTestCase): serial_batch_map[row.item_code].batch_no_valuation[entry.batch_no], ) + @change_settings("Stock Settings", {"allow_negative_stock": 0, "enable_stock_reservation": 1}) + def test_partial_delivery_note_against_reserved_stock(self): + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + get_stock_reservation_entries_for_voucher, + ) + + # create batch item + batch_item = make_item( + "_Test Batch Item For DN Reserve Check", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TBDNR.#####", + }, + ) + serial_item = make_item( + "_Test Serial Item For DN Reserve Check", + { + "is_stock_item": 1, + "has_serial_no": 1, + "serial_no_series": "TSNDNR.#####", + }, + ) + + company = "_Test Company" + + warehouse = create_warehouse("Test Partial DN Reserved Stock", company=company) + customer = "_Test Customer" + + items = [batch_item.name, serial_item.name] + + for idx, item in enumerate(items): + # make inward entry for batch item + se = make_stock_entry(item_code=item, purpose="Material Receipt", qty=10, to_warehouse=warehouse) + sabb = se.items[0].serial_and_batch_bundle + + batch_no = get_batch_from_bundle(sabb) if not idx else None + serial_nos = get_serial_nos_from_bundle(sabb) if idx else None + + # make sales order and reserve the quantites against the so + so = make_sales_order(item_code=item, qty=10, rate=100, customer=customer, warehouse=warehouse) + so.submit() + so.create_stock_reservation_entries() + so.reload() + + # create a delivery note with partial quantity from resreved quantity + dn = create_dn_against_so(so=so.name, delivered_qty=5, do_not_submit=True) + dn.items[0].use_serial_batch_fields = 1 + if batch_no: + dn.items[0].batch_no = batch_no + else: + dn.items[0].serial_no = "\n".join(serial_nos[:5]) + + dn.save() + dn.submit() + + against_sales_order = dn.items[0].against_sales_order + so_detail = dn.items[0].so_detail + + sre_details = get_stock_reservation_entries_for_voucher( + so.doctype, against_sales_order, so_detail, ["reserved_qty", "delivered_qty", "status"] + ) + + # check partially delivered reserved stock + self.assertEqual(sre_details[0].status, "Partially Delivered") + self.assertEqual(sre_details[0].reserved_qty, so.items[0].qty) + self.assertEqual(sre_details[0].delivered_qty, dn.items[0].qty) + def create_delivery_note(**args): dn = frappe.new_doc("Delivery Note") diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index 1992b5dc49f..8d216171641 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -253,6 +253,9 @@ class SerialandBatchBundle(Document): } ) + if self.voucher_type == "Delivery Note": + kwargs["ignore_voucher_nos"] = self.get_sre_against_dn() + available_serial_nos = get_available_serial_nos(frappe._dict(kwargs)) serial_no_warehouse = {} @@ -1380,6 +1383,20 @@ class SerialandBatchBundle(Document): self.set("entries", []) + def get_sre_against_dn(self): + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + get_sre_against_so_for_dn, + ) + + so_name, so_detail_no = frappe.db.get_value( + "Delivery Note Item", self.voucher_detail_no, ["against_sales_order", "so_detail"] + ) + + if so_name and so_detail_no: + sre_names = get_sre_against_so_for_dn(so_name, so_detail_no) + + return sre_names + @frappe.whitelist() def download_blank_csv_template(content): 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 5d6aa9f68f8..a6a69f3271e 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -738,7 +738,7 @@ def get_sre_reserved_qty_for_voucher_detail_no( def get_sre_reserved_serial_nos_details( - item_code: str, warehouse: str, serial_nos: list | None = None + item_code: str, warehouse: str, serial_nos: list | None = None, ignore_voucher_nos: list | None = None ) -> dict: """Returns a dict of `Serial No` reserved in Stock Reservation Entry. The dict is like {serial_no: sre_name, ...}""" @@ -753,8 +753,7 @@ def get_sre_reserved_serial_nos_details( (sre.docstatus == 1) & (sre.item_code == item_code) & (sre.warehouse == warehouse) - & (sre.reserved_qty > sre.delivered_qty) - & (sre.status.notin(["Delivered", "Cancelled"])) + & (sre.delivered_qty < sre.reserved_qty) & (sre.reservation_based_on == "Serial and Batch") ) .orderby(sb_entry.creation) @@ -763,10 +762,15 @@ def get_sre_reserved_serial_nos_details( if serial_nos: query = query.where(sb_entry.serial_no.isin(serial_nos)) + if ignore_voucher_nos: + query = query.where(sre.name.notin(ignore_voucher_nos)) + return frappe._dict(query.run()) -def get_sre_reserved_batch_nos_details(item_code: str, warehouse: str, batch_nos: list | None = None) -> dict: +def get_sre_reserved_batch_nos_details( + item_code: str, warehouse: str, batch_nos: list | None = None, ignore_voucher_nos: list | None = None +) -> dict: """Returns a dict of `Batch Qty` reserved in Stock Reservation Entry. The dict is like {batch_no: qty, ...}""" sre = frappe.qb.DocType("Stock Reservation Entry") @@ -784,7 +788,7 @@ def get_sre_reserved_batch_nos_details(item_code: str, warehouse: str, batch_nos & (sre.item_code == item_code) & (sre.warehouse == warehouse) & ((sre.reserved_qty - sre.delivered_qty) > 0) - & (sre.status.notin(["Delivered", "Cancelled"])) + & (sre.delivered_qty < sre.reserved_qty) & (sre.reservation_based_on == "Serial and Batch") ) .groupby(sb_entry.batch_no) @@ -794,6 +798,9 @@ def get_sre_reserved_batch_nos_details(item_code: str, warehouse: str, batch_nos if batch_nos: query = query.where(sb_entry.batch_no.isin(batch_nos)) + if ignore_voucher_nos: + query = query.where(sre.name.notin(ignore_voucher_nos)) + return frappe._dict(query.run()) @@ -1175,3 +1182,24 @@ def get_stock_reservation_entries_for_voucher( query = query.where(sre.status.notin(["Delivered", "Cancelled"])) return query.run(as_dict=True) + + +@frappe.request_cache +def get_sre_against_so_for_dn(so_name: str, so_detail_no: str) -> list[str]: + """Returns list of Stock Reservation Entries against Delivery Note with Sales Order Reference.""" + + sre = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.from_(sre) + .select(sre.name) + .where( + (sre.docstatus == 1) + & (sre.voucher_type == "Sales Order") + & (sre.voucher_no == so_name) + & (sre.voucher_detail_no == so_detail_no) + ) + ) + + result = query.run(as_list=True) + + return result[0] if result else [] diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index bc54f88687e..34b442572a0 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -2166,7 +2166,7 @@ def validate_negative_qty_in_future_sle(args, allow_negative_stock=False): ) frappe.throw(message, NegativeStockError, title=_("Insufficient Stock for Batch")) - if args.reserved_stock: + if args.reserved_stock and args.voucher_type != "Delivery Note": validate_reserved_stock(args) @@ -2236,11 +2236,10 @@ def get_future_sle_with_negative_batch_qty(sle_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) + validate_reserved_serial_nos(kwargs) elif kwargs.batch_no: - validate_reserved_batch_nos(kwargs.item_code, kwargs.warehouse, [kwargs.batch_no]) + validate_reserved_batch_nos(kwargs) elif kwargs.serial_and_batch_bundle: sbb_entries = frappe.db.get_all( @@ -2254,9 +2253,11 @@ def validate_reserved_stock(kwargs): ) if serial_nos := [entry.serial_no for entry in sbb_entries if entry.serial_no]: - validate_reserved_serial_nos(kwargs.item_code, kwargs.warehouse, serial_nos) + kwargs.serial_nos = serial_nos + validate_reserved_serial_nos(kwargs) elif batch_nos := [entry.batch_no for entry in sbb_entries if entry.batch_no]: - validate_reserved_batch_nos(kwargs.item_code, kwargs.warehouse, batch_nos) + kwargs.batch_nos = batch_nos + validate_reserved_batch_nos(kwargs) # Qty based validation for non-serial-batch items OR SRE with Reservation Based On Qty. precision = cint(frappe.db.get_default("float_precision")) or 2 @@ -2274,9 +2275,13 @@ def validate_reserved_stock(kwargs): frappe.throw(msg, title=_("Reserved Stock")) -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()))): +def validate_reserved_serial_nos(kwargs): + if reserved_serial_nos_details := get_sre_reserved_serial_nos_details( + kwargs.item_code, kwargs.warehouse, kwargs.serial_nos, kwargs.ignore_voucher_nos + ): + if common_serial_nos := list( + set(kwargs.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." ) @@ -2290,22 +2295,25 @@ def validate_reserved_serial_nos(item_code, warehouse, serial_nos): frappe.throw(msg, title=_("Reserved Serial No.")) -def validate_reserved_batch_nos(item_code, warehouse, batch_nos): - if reserved_batches_map := get_sre_reserved_batch_nos_details(item_code, warehouse, batch_nos): +def validate_reserved_batch_nos(kwargs): + if reserved_batches_map := get_sre_reserved_batch_nos_details( + kwargs.item_code, kwargs.warehouse, kwargs.batch_nos, kwargs.ignore_voucher_nos + ): available_batches = get_auto_batch_nos( frappe._dict( { - "item_code": item_code, - "warehouse": warehouse, - "posting_date": nowdate(), - "posting_time": nowtime(), + "item_code": kwargs.item_code, + "warehouse": kwargs.warehouse, + "posting_date": kwargs.posting_date, + "posting_time": kwargs.posting_time, + "ignore_voucher_nos": kwargs.ignore_voucher_nos, } ) ) available_batches_map = {row.batch_no: row.qty for row in available_batches} precision = cint(frappe.db.get_default("float_precision")) or 2 - for batch_no in batch_nos: + for batch_no in kwargs.batch_nos: diff = flt( available_batches_map.get(batch_no, 0) - reserved_batches_map.get(batch_no, 0), precision ) @@ -2313,7 +2321,7 @@ def validate_reserved_batch_nos(item_code, warehouse, batch_nos): msg = _("{0} units of {1} needed in {2} on {3} {4} to complete this transaction.").format( abs(diff), frappe.get_desk_link("Batch", batch_no), - frappe.get_desk_link("Warehouse", warehouse), + frappe.get_desk_link("Warehouse", kwargs.warehouse), nowdate(), nowtime(), )