diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 1e5b08bf36f1..e0f32c55da36 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -347,5 +347,6 @@ execute:frappe.db.set_single_value("Payment Reconciliation", "invoice_limit", 50 execute:frappe.db.set_single_value("Payment Reconciliation", "payment_limit", 50) erpnext.patches.v15_0.rename_daily_depreciation_to_depreciation_amount_based_on_num_days_in_month erpnext.patches.v15_0.rename_depreciation_amount_based_on_num_days_in_month_to_daily_prorata_based +erpnext.patches.v15_0.set_reserved_stock_in_bin # below migration patch should always run last erpnext.patches.v14_0.migrate_gl_to_payment_ledger diff --git a/erpnext/patches/v15_0/set_reserved_stock_in_bin.py b/erpnext/patches/v15_0/set_reserved_stock_in_bin.py new file mode 100644 index 000000000000..fd0a23333ede --- /dev/null +++ b/erpnext/patches/v15_0/set_reserved_stock_in_bin.py @@ -0,0 +1,24 @@ +import frappe +from frappe.query_builder.functions import Sum + + +def execute(): + sre = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.from_(sre) + .select( + sre.item_code, + sre.warehouse, + Sum(sre.reserved_qty - sre.delivered_qty).as_("reserved_stock"), + ) + .where((sre.docstatus == 1) & (sre.status.notin(["Delivered", "Cancelled"]))) + .groupby(sre.item_code, sre.warehouse) + ) + + for d in query.run(as_dict=True): + frappe.db.set_value( + "Bin", + {"item_code": d.item_code, "warehouse": d.warehouse}, + "reserved_stock", + d.reserved_stock, + ) diff --git a/erpnext/stock/doctype/bin/bin.json b/erpnext/stock/doctype/bin/bin.json index 02684a72419e..312470d50ea7 100644 --- a/erpnext/stock/doctype/bin/bin.json +++ b/erpnext/stock/doctype/bin/bin.json @@ -13,12 +13,13 @@ "planned_qty", "indented_qty", "ordered_qty", + "projected_qty", "column_break_xn5j", "reserved_qty", "reserved_qty_for_production", "reserved_qty_for_sub_contract", "reserved_qty_for_production_plan", - "projected_qty", + "reserved_stock", "section_break_pmrs", "stock_uom", "column_break_0slj", @@ -173,13 +174,20 @@ { "fieldname": "column_break_0slj", "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "reserved_stock", + "fieldtype": "Float", + "label": "Reserved Stock", + "read_only": 1 } ], "hide_toolbar": 1, "idx": 1, "in_create": 1, "links": [], - "modified": "2023-11-01 15:35:51.722534", + "modified": "2023-11-01 16:51:17.079107", "modified_by": "Administrator", "module": "Stock", "name": "Bin", diff --git a/erpnext/stock/doctype/bin/bin.py b/erpnext/stock/doctype/bin/bin.py index 5abea9e69fee..df466ede682b 100644 --- a/erpnext/stock/doctype/bin/bin.py +++ b/erpnext/stock/doctype/bin/bin.py @@ -148,6 +148,17 @@ def update_reserved_qty_for_sub_contracting(self, subcontract_doctype="Subcontra self.set_projected_qty() self.db_set("projected_qty", self.projected_qty, update_modified=True) + def update_reserved_stock(self): + """Update `Reserved Stock` on change in Reserved Qty of Stock Reservation Entry""" + + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + get_sre_reserved_qty_for_item_and_warehouse, + ) + + reserved_stock = get_sre_reserved_qty_for_item_and_warehouse(self.item_code, self.warehouse) + + self.db_set("reserved_stock", flt(reserved_stock), update_modified=True) + def on_doctype_update(): frappe.db.add_unique("Bin", ["item_code", "warehouse"], constraint_name="unique_item_warehouse") diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 190575eb94ee..66dd33a40005 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -365,6 +365,9 @@ def update_stock_reservation_entries(self) -> None: # Update Stock Reservation Entry `Status` based on `Delivered Qty`. sre_doc.update_status() + # Update Reserved Stock in Bin. + sre_doc.update_reserved_stock_in_bin() + qty_to_deliver -= qty_can_be_deliver if self._action == "cancel": @@ -427,6 +430,9 @@ def update_stock_reservation_entries(self) -> None: # Update Stock Reservation Entry `Status` based on `Delivered Qty`. sre_doc.update_status() + # Update Reserved Stock in Bin. + sre_doc.update_reserved_stock_in_bin() + qty_to_undelivered -= qty_can_be_undelivered def validate_against_stock_reservation_entries(self): 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..09542826f3cf 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -9,6 +9,8 @@ from frappe.query_builder.functions import Sum from frappe.utils import cint, flt +from erpnext.stock.utils import get_or_make_bin + class StockReservationEntry(Document): def validate(self) -> None: @@ -31,6 +33,7 @@ def on_submit(self) -> None: self.update_reserved_qty_in_voucher() self.update_reserved_qty_in_pick_list() self.update_status() + self.update_reserved_stock_in_bin() def on_update_after_submit(self) -> None: self.can_be_updated() @@ -40,12 +43,14 @@ def on_update_after_submit(self) -> None: self.validate_reservation_based_on_serial_and_batch() self.update_reserved_qty_in_voucher() self.update_status() + self.update_reserved_stock_in_bin() self.reload() def on_cancel(self) -> None: self.update_reserved_qty_in_voucher() self.update_reserved_qty_in_pick_list() self.update_status() + self.update_reserved_stock_in_bin() def validate_amended_doc(self) -> None: """Raises an exception if document is amended.""" @@ -341,6 +346,13 @@ def update_reserved_qty_in_pick_list( update_modified=update_modified, ) + def update_reserved_stock_in_bin(self) -> None: + """Updates `Reserved Stock` in Bin.""" + + bin_name = get_or_make_bin(self.item_code, self.warehouse) + bin_doc = frappe.get_cached_doc("Bin", bin_name) + bin_doc.update_reserved_stock() + def update_status(self, status: str = None, update_modified: bool = True) -> None: """Updates status based on Voucher Qty, Reserved Qty and Delivered Qty.""" @@ -681,6 +693,68 @@ 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_reserved_batch_nos_details( + item_code: str, warehouse: str, batch_nos: list = 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") + 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.batch_no, + Sum(sb_entry.qty - sb_entry.delivered_qty), + ) + .where( + (sre.docstatus == 1) + & (sre.item_code == item_code) + & (sre.warehouse == warehouse) + & ((sre.reserved_qty - sre.delivered_qty) > 0) + & (sre.status.notin(["Delivered", "Cancelled"])) + & (sre.reservation_based_on == "Serial and Batch") + ) + .groupby(sb_entry.batch_no) + .orderby(sb_entry.creation) + ) + + if batch_nos: + query = query.where(sb_entry.batch_no.isin(batch_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/doctype/stock_reservation_entry/test_stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py index f4c74a8aacbd..dd023e208024 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py @@ -286,6 +286,7 @@ def test_stock_reservation_against_sales_order(self) -> None: self.assertEqual(item.stock_reserved_qty, sre_details.reserved_qty) self.assertEqual(sre_details.status, "Partially Reserved") + cancel_stock_reservation_entries("Sales Order", so.name) se.cancel() # Test - 3: Stock should be fully Reserved if the Available Qty to Reserve is greater than the Un-reserved Qty. @@ -493,7 +494,7 @@ def test_auto_reserve_serial_and_batch(self) -> None: "pick_serial_and_batch_based_on": "FIFO", }, ) - def test_stock_reservation_from_pick_list(self): + def test_stock_reservation_from_pick_list(self) -> None: items_details = create_items() create_material_receipt(items_details, self.warehouse, qty=100) @@ -575,7 +576,7 @@ def test_stock_reservation_from_pick_list(self): "auto_reserve_stock_for_sales_order_on_purchase": 1, }, ) - def test_stock_reservation_from_purchase_receipt(self): + def test_stock_reservation_from_purchase_receipt(self) -> None: from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt from erpnext.selling.doctype.sales_order.sales_order import make_material_request from erpnext.stock.doctype.material_request.material_request import make_purchase_order @@ -645,6 +646,40 @@ def test_stock_reservation_from_purchase_receipt(self): # Test - 3: Reserved Serial/Batch Nos should be equal to PR Item Serial/Batch Nos. self.assertEqual(set(sb_details), set(reserved_sb_details)) + @change_settings( + "Stock Settings", + { + "allow_negative_stock": 0, + "enable_stock_reservation": 1, + "auto_reserve_serial_and_batch": 1, + "pick_serial_and_batch_based_on": "FIFO", + }, + ) + def test_consider_reserved_stock_while_cancelling_an_inward_transaction(self) -> None: + items_details = create_items() + se = create_material_receipt(items_details, self.warehouse, qty=100) + + item_list = [] + for item_code, properties in items_details.items(): + item_list.append( + { + "item_code": item_code, + "warehouse": self.warehouse, + "qty": randint(11, 100), + "uom": properties.stock_uom, + "rate": randint(10, 400), + } + ) + + so = make_sales_order( + item_list=item_list, + warehouse=self.warehouse, + ) + so.create_stock_reservation_entries() + + # Test - 1: ValidationError should be thrown as the inwarded stock is reserved. + self.assertRaises(frappe.ValidationError, se.cancel) + def tearDown(self) -> None: cancel_all_stock_reservation_entries() return super().tearDown() diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index b950f1881078..f2db5755bc79 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -11,17 +11,22 @@ from frappe.model.meta import get_field_precision from frappe.query_builder import Case from frappe.query_builder.functions import CombineDatetime, Sum -from frappe.utils import cint, flt, get_link_to_form, getdate, now, nowdate, parse_json +from frappe.utils import cint, flt, get_link_to_form, getdate, now, nowdate, nowtime, parse_json import erpnext from erpnext.stock.doctype.bin.bin import update_qty as update_bin_qty from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions +from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + get_available_batches, +) from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - get_sre_reserved_qty_for_item_and_warehouse as get_reserved_stock, + get_sre_reserved_batch_nos_details, + get_sre_reserved_serial_nos_details, ) from erpnext.stock.utils import ( get_incoming_outgoing_rate_for_cancel, get_or_make_bin, + get_stock_balance, get_valuation_method, ) from erpnext.stock.valuation import FIFOValuation, LIFOValuation, round_off_if_near_zero @@ -88,6 +93,7 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc is_stock_item = frappe.get_cached_value("Item", args.get("item_code"), "is_stock_item") if is_stock_item: bin_name = get_or_make_bin(args.get("item_code"), args.get("warehouse")) + args.reserved_stock = flt(frappe.db.get_value("Bin", bin_name, "reserved_stock")) repost_current_voucher(args, allow_negative_stock, via_landed_cost_voucher) update_bin_qty(bin_name, args) else: @@ -114,6 +120,7 @@ def repost_current_voucher(args, allow_negative_stock=False, via_landed_cost_vou "voucher_no": args.get("voucher_no"), "sle_id": args.get("name"), "creation": args.get("creation"), + "reserved_stock": args.get("reserved_stock"), }, allow_negative_stock=allow_negative_stock, via_landed_cost_voucher=via_landed_cost_voucher, @@ -506,7 +513,7 @@ def __init__( self.new_items_found = False self.distinct_item_warehouses = args.get("distinct_item_warehouses", frappe._dict()) self.affected_transactions: Set[Tuple[str, str]] = set() - self.reserved_stock = get_reserved_stock(self.args.item_code, self.args.warehouse) + self.reserved_stock = flt(self.args.reserved_stock) self.data = frappe._dict() self.initialize_previous_data(self.args) @@ -1709,22 +1716,23 @@ 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")) + if args.reserved_stock: + validate_reserved_stock(args) def is_negative_with_precision(neg_sle, is_batch=False): @@ -1791,6 +1799,96 @@ 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.batch_no: + validate_reserved_batch_nos(kwargs.item_code, kwargs.warehouse, [kwargs.batch_no]) + + 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"], + ) + + 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) + 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) + + # 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 + balance_qty = get_stock_balance(kwargs.item_code, kwargs.warehouse) + + diff = flt(balance_qty - kwargs.get("reserved_stock", 0), precision) + if diff < 0 and abs(diff) > 0.0001: + msg = _("{0} units of {1} needed in {2} on {3} {4} to complete this transaction.").format( + abs(diff), + frappe.get_desk_link("Item", kwargs.item_code), + frappe.get_desk_link("Warehouse", kwargs.warehouse), + nowdate(), + nowtime(), + ) + 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())) + ): + 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 validate_reserved_batch_nos(item_code, warehouse, batch_nos): + if reserved_batches_map := get_sre_reserved_batch_nos_details(item_code, warehouse, batch_nos): + available_batches = get_available_batches( + frappe._dict( + { + "item_code": item_code, + "warehouse": warehouse, + "posting_date": nowdate(), + "posting_time": nowtime(), + } + ) + ) + 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: + diff = flt( + available_batches_map.get(batch_no, 0) - reserved_batches_map.get(batch_no, 0), precision + ) + if diff < 0 and abs(diff) > 0.0001: + 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), + nowdate(), + nowtime(), + ) + frappe.throw(msg, title=_("Reserved Stock for Batch")) + + 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