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 a8529efcd19..e6969815c27 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 @@ -538,6 +538,65 @@ class TestStockReservationEntry(ERPNextTestSuite): self.assertEqual(row.delivered_qty, 0, "DN cancel must restore the serial/batch reservation") self.assertEqual(row.status, "Reserved") + @ERPNextTestSuite.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", + "use_serial_batch_fields": 1, + }, + ) + def test_batch_shared_across_sales_orders_can_be_delivered(self) -> None: + # Regression (#57159): one batch reserved by two Sales Orders. Delivering each order's own + # reserved unit must not raise Reserved Batch Conflict — the remainder covers the other order. + item_doc = make_batch_item() + create_material_receipt(items={item_doc.name: item_doc}, warehouse=self.warehouse, qty=2) + + orders = [] + for _i in range(2): + so = make_sales_order(item_code=item_doc.name, warehouse=self.warehouse, qty=1, rate=100) + so.create_stock_reservation_entries() + orders.append(so) + + self.assertEqual( + len(get_reserved_batch_nos(orders[0].name) | get_reserved_batch_nos(orders[1].name)), 1 + ) + + for so in orders: + dn = make_delivery_note(so.name, kwargs={"for_reserved_stock": True}) + dn.save() + dn.submit() + self.assertEqual(dn.docstatus, 1) + + @ERPNextTestSuite.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", + "use_serial_batch_fields": 1, + }, + ) + def test_delivery_draining_a_batch_reserved_for_another_sales_order_is_blocked(self) -> None: + # Guard for #57159 fix: an order without a reservation must still be blocked from draining + # a batch below what another order has reserved from it, even if other batches have stock. + item_doc = make_batch_item() + create_material_receipt(items={item_doc.name: item_doc}, warehouse=self.warehouse, qty=2) + create_material_receipt(items={item_doc.name: item_doc}, warehouse=self.warehouse, qty=2) + + so_a = make_sales_order(item_code=item_doc.name, warehouse=self.warehouse, qty=2, rate=100) + so_a.create_stock_reservation_entries() + (reserved_batch_no,) = get_reserved_batch_nos(so_a.name) + + so_b = make_sales_order(item_code=item_doc.name, warehouse=self.warehouse, qty=2, rate=100) + dn = make_delivery_note(so_b.name) + dn.items[0].batch_no = reserved_batch_no + dn.save() + self.assertRaisesRegex(frappe.ValidationError, "is reserved for", dn.submit) + @ERPNextTestSuite.change_settings( "Stock Settings", { @@ -893,6 +952,33 @@ def create_items() -> dict: return items +def make_batch_item(): + return make_item( + properties={ + "is_stock_item": 1, + "valuation_rate": 100, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "SRBI-.#####.", + } + ) + + +def get_reserved_batch_nos(sales_order: str) -> set: + sre = frappe.qb.DocType("Stock Reservation Entry") + sb_entry = frappe.qb.DocType("Serial and Batch Entry") + + batch_nos = ( + frappe.qb.from_(sre) + .inner_join(sb_entry) + .on(sre.name == sb_entry.parent) + .select(sb_entry.batch_no) + .where((sre.voucher_no == sales_order) & (sre.docstatus == 1)) + ).run(pluck=True) + + return set(batch_nos) + + def create_material_receipt( items: dict, warehouse: str = "_Test Warehouse - _TC", qty: float = 100 ) -> StockEntry: diff --git a/erpnext/stock/services/serial_batch_bundle_service.py b/erpnext/stock/services/serial_batch_bundle_service.py index 2e752371ed5..2699c7e025f 100644 --- a/erpnext/stock/services/serial_batch_bundle_service.py +++ b/erpnext/stock/services/serial_batch_bundle_service.py @@ -9,6 +9,8 @@ delegators for methods reached from other doctypes / ``run_method``; internal helpers live here only. """ +from collections import defaultdict + import frappe from frappe import _, bold from frappe.utils import cstr, flt, get_link_to_form, getdate @@ -604,66 +606,57 @@ class SerialBatchBundleService: if not batches: return - field_mapper = { - "Sales Invoice": [["Sales Order", "sales_order"]], - "Delivery Note": [["Sales Order", "against_sales_order"]], - "Stock Entry": [ - ["Work Order", "work_order"], - ["Subcontracting Inward Order", "subcontracting_inward_order"], - ], + reference_fields = { + "Sales Invoice": ["sales_order"], + "Delivery Note": ["against_sales_order"], + "Stock Entry": ["work_order", "subcontracting_inward_order"], }.get(self.doc.doctype) - qty_field = { - "Sales Invoice": "qty", - "Delivery Note": "qty", - "Stock Entry": "fg_completed_qty", - }.get(self.doc.doctype) - - reserved_batches_data = self.get_reserved_batches(batches) items = self.doc.items if self.doc.doctype == "Stock Entry": items = [self.doc] - for item in items: - for field in field_mapper: - if not item.get(field[1]): - continue + own_vouchers = {item.get(field) for item in items for field in reference_fields if item.get(field)} - value = item.get(field[1]) - for row in reserved_batches_data: - if self.doc.doctype in ["Sales Invoice", "Delivery Note"] and row.item_code != item.get( - "item_code" - ): - continue + outstanding_qty = defaultdict(float) + reservations = {} + for row in self.get_reserved_batches(batches): + if row.voucher_no in own_vouchers: + continue - if row.voucher_no == value: - continue + key = (row.batch_no, row.warehouse) + outstanding_qty[key] += flt(row.qty) - flt(row.delivered_qty) + reservations.setdefault(key, row) - batch_qty = get_batch_qty( - row.batch_no, - row.warehouse, - posting_date=self.doc.posting_date, - posting_time=self.doc.posting_time, - consider_negative_batches=True, - ) + for (batch_no, warehouse), reserved_qty in outstanding_qty.items(): + if reserved_qty <= 0: + continue - if item.get(qty_field) < batch_qty: - continue + batch_qty = get_batch_qty( + batch_no, + warehouse, + posting_date=self.doc.posting_date, + posting_time=self.doc.posting_time, + consider_negative_batches=True, + ) - frappe.throw( - _( - "The batch {0} is already reserved in {1} {2}. So, cannot proceed with the {3} {4}, which is created against the {5} {6}." - ).format( - frappe.bold(row.batch_no), - frappe.bold(row.voucher_type), - frappe.bold(row.voucher_no), - frappe.bold(self.doc.doctype), - frappe.bold(self.doc.name), - frappe.bold(field[0]), - frappe.bold(value), - ), - title=_("Reserved Batch Conflict"), - ) + if flt(batch_qty, 6) >= flt(reserved_qty, 6): + continue + + row = reservations[(batch_no, warehouse)] + frappe.throw( + _( + "The batch {0} is reserved for {1} {2} in the warehouse {3} and the remaining quantity is not enough to cover the reservation. So, cannot proceed with the {4} {5}." + ).format( + frappe.bold(batch_no), + frappe.bold(row.voucher_type), + frappe.bold(row.voucher_no), + frappe.bold(warehouse), + frappe.bold(self.doc.doctype), + frappe.bold(self.doc.name), + ), + title=_("Reserved Batch Conflict"), + ) def get_reserved_batches(self, batches): doctype = frappe.qb.DocType("Stock Reservation Entry") @@ -675,9 +668,10 @@ class SerialBatchBundleService: .on(doctype.name == child_doc.parent) .select( child_doc.batch_no, + child_doc.qty, + child_doc.delivered_qty, doctype.voucher_type, doctype.voucher_no, - doctype.item_code, doctype.warehouse, ) .where((doctype.docstatus == 1) & (child_doc.batch_no.isin(batches)))