From 56bbca0203fe6bb63cd6937e2c10dd908cb4e443 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 15 Jul 2026 11:04:38 +0530 Subject: [PATCH 1/6] fix: allow delivery when a batch is reserved across multiple sales orders (backport #57169) validate_reserved_batches compared the voucher's own qty against the remaining batch qty, so delivering one order's reserved unit threw Reserved Batch Conflict whenever the remainder exactly matched another order's reservation. Compare the remaining batch qty against the aggregated outstanding reserved qty (qty - delivered_qty) of other vouchers instead, excluding reservations the voucher itself delivers. --- erpnext/controllers/stock_controller.py | 92 +++++++++---------- .../test_stock_reservation_entry.py | 86 +++++++++++++++++ 2 files changed, 128 insertions(+), 50 deletions(-) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index bd94076a2af..a49f3654b81 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -1347,66 +1347,57 @@ class StockController(AccountsController): 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.doctype) - qty_field = { - "Sales Invoice": "qty", - "Delivery Note": "qty", - "Stock Entry": "fg_completed_qty", - }.get(self.doctype) - - reserved_batches_data = self.get_reserved_batches(batches) items = self.items if self.doctype == "Stock Entry": items = [self] - 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.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.posting_date, - posting_time=self.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.posting_date, + posting_time=self.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.doctype), - frappe.bold(self.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.doctype), + frappe.bold(self.name), + ), + title=_("Reserved Batch Conflict"), + ) def get_reserved_batches(self, batches): doctype = frappe.qb.DocType("Stock Reservation Entry") @@ -1418,9 +1409,10 @@ class StockController(AccountsController): .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))) 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 3f33b0a2da8..43e28367695 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 @@ -697,6 +697,65 @@ class TestStockReservationEntry(ERPNextTestSuite): # Test - 1: ValidationError should be thrown as the inwarded stock is reserved. self.assertRaises(frappe.ValidationError, se.cancel) + @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": 1}) + 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) + def create_items() -> dict: items_properties = [ @@ -737,6 +796,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: From 839fd5e3b67a166d7e8f4bec4d0dabb34caaeaaa Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 15 Jul 2026 11:16:03 +0530 Subject: [PATCH 2/6] fix: name every conflicting voucher in the reserved batch error --- erpnext/controllers/stock_controller.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index a49f3654b81..9351423541b 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -1360,14 +1360,14 @@ class StockController(AccountsController): own_vouchers = {item.get(field) for item in items for field in reference_fields if item.get(field)} outstanding_qty = defaultdict(float) - reservations = {} + reservations = defaultdict(list) for row in self.get_reserved_batches(batches): if row.voucher_no in own_vouchers: continue key = (row.batch_no, row.warehouse) outstanding_qty[key] += flt(row.qty) - flt(row.delivered_qty) - reservations.setdefault(key, row) + reservations[key].append(row) for (batch_no, warehouse), reserved_qty in outstanding_qty.items(): if reserved_qty <= 0: @@ -1384,14 +1384,18 @@ class StockController(AccountsController): if flt(batch_qty, 6) >= flt(reserved_qty, 6): continue - row = reservations[(batch_no, warehouse)] + vouchers = ", ".join( + f"{frappe.bold(voucher_type)} {frappe.bold(voucher_no)}" + for voucher_type, voucher_no in dict.fromkeys( + (row.voucher_type, row.voucher_no) for row in 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}." + "The batch {0} is reserved for {1} in the warehouse {2} and the remaining quantity is not enough to cover the reservations. So, cannot proceed with the {3} {4}." ).format( frappe.bold(batch_no), - frappe.bold(row.voucher_type), - frappe.bold(row.voucher_no), + vouchers, frappe.bold(warehouse), frappe.bold(self.doctype), frappe.bold(self.name), From 7cd7e4ab0f1cd885a1cbd3be243c3ef95bb84191 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 15 Jul 2026 11:17:59 +0530 Subject: [PATCH 3/6] test: deliver reserved batches via bundles on v16 use_serial_batch_fields delivery of reserved stock crashes on v16 with 'Serial and Batch Bundle None not found' (fixed on develop only), so deliver through auto-created bundles like test_auto_reserve_serial_and_batch. --- .../stock_reservation_entry/test_stock_reservation_entry.py | 1 - 1 file changed, 1 deletion(-) 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 43e28367695..008e9305775 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 @@ -704,7 +704,6 @@ class TestStockReservationEntry(ERPNextTestSuite): "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: From 555c607f2f38a19a99f21977c61f6ff4930ac1f6 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 15 Jul 2026 11:27:19 +0530 Subject: [PATCH 4/6] fix: exclude fully-delivered reservations from the conflict message --- erpnext/controllers/stock_controller.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 9351423541b..9623b36d2c9 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -1366,8 +1366,10 @@ class StockController(AccountsController): continue key = (row.batch_no, row.warehouse) - outstanding_qty[key] += flt(row.qty) - flt(row.delivered_qty) - reservations[key].append(row) + outstanding = flt(row.qty) - flt(row.delivered_qty) + outstanding_qty[key] += outstanding + if outstanding > 0: + reservations[key].append(row) for (batch_no, warehouse), reserved_qty in outstanding_qty.items(): if reserved_qty <= 0: From f3e1b3fca7e96e93ace7aed0f3a5099e547d8d24 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 15 Jul 2026 11:29:53 +0530 Subject: [PATCH 5/6] fix: round outstanding qty guard consistently with the conflict gate --- erpnext/controllers/stock_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 9623b36d2c9..feb064597a0 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -1372,7 +1372,7 @@ class StockController(AccountsController): reservations[key].append(row) for (batch_no, warehouse), reserved_qty in outstanding_qty.items(): - if reserved_qty <= 0: + if flt(reserved_qty, 6) <= 0: continue batch_qty = get_batch_qty( From 7ccb2584c480747c2062896a245502f5a3d3832c Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 15 Jul 2026 12:19:21 +0530 Subject: [PATCH 6/6] test: set delivered batch on the row explicitly on v16 The v16 reserved-stock mapper attaches neither a bundle nor row serial/batch fields when use_serial_batch_fields is enabled, and update_stock_reservation_entries crashes on the missing bundle (fixed on develop by 9c5f9218b5, not backported). Deliver via an explicit row batch_no like the guard test so the bundle is built from row fields before the reservation update runs. --- .../test_stock_reservation_entry.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 008e9305775..c17e5131669 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 @@ -704,11 +704,14 @@ class TestStockReservationEntry(ERPNextTestSuite): "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. + # The batch is set on the row explicitly as the v16 reserved-stock mapper does not carry + # the reserved batch onto the Delivery Note row. item_doc = make_batch_item() create_material_receipt(items={item_doc.name: item_doc}, warehouse=self.warehouse, qty=2) @@ -718,12 +721,11 @@ class TestStockReservationEntry(ERPNextTestSuite): 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 - ) + (batch_no,) = get_reserved_batch_nos(orders[0].name) | get_reserved_batch_nos(orders[1].name) for so in orders: - dn = make_delivery_note(so.name, kwargs={"for_reserved_stock": 1}) + dn = make_delivery_note(so.name) + dn.items[0].batch_no = batch_no dn.save() dn.submit() self.assertEqual(dn.docstatus, 1)