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.
This commit is contained in:
Mihir Kandoi
2026-07-15 11:04:38 +05:30
parent 478719b828
commit 56bbca0203
2 changed files with 128 additions and 50 deletions

View File

@@ -1347,66 +1347,57 @@ class StockController(AccountsController):
if not batches: if not batches:
return return
field_mapper = { reference_fields = {
"Sales Invoice": [["Sales Order", "sales_order"]], "Sales Invoice": ["sales_order"],
"Delivery Note": [["Sales Order", "against_sales_order"]], "Delivery Note": ["against_sales_order"],
"Stock Entry": [ "Stock Entry": ["work_order", "subcontracting_inward_order"],
["Work Order", "work_order"],
["Subcontracting Inward Order", "subcontracting_inward_order"],
],
}.get(self.doctype) }.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 items = self.items
if self.doctype == "Stock Entry": if self.doctype == "Stock Entry":
items = [self] items = [self]
for item in items: own_vouchers = {item.get(field) for item in items for field in reference_fields if item.get(field)}
for field in field_mapper:
if not item.get(field[1]):
continue
value = item.get(field[1]) outstanding_qty = defaultdict(float)
for row in reserved_batches_data: reservations = {}
if self.doctype in ["Sales Invoice", "Delivery Note"] and row.item_code != item.get( for row in self.get_reserved_batches(batches):
"item_code" if row.voucher_no in own_vouchers:
): continue
continue
if row.voucher_no == value: key = (row.batch_no, row.warehouse)
continue outstanding_qty[key] += flt(row.qty) - flt(row.delivered_qty)
reservations.setdefault(key, row)
batch_qty = get_batch_qty( for (batch_no, warehouse), reserved_qty in outstanding_qty.items():
row.batch_no, if reserved_qty <= 0:
row.warehouse, continue
posting_date=self.posting_date,
posting_time=self.posting_time,
consider_negative_batches=True,
)
if item.get(qty_field) < batch_qty: batch_qty = get_batch_qty(
continue batch_no,
warehouse,
posting_date=self.posting_date,
posting_time=self.posting_time,
consider_negative_batches=True,
)
frappe.throw( if flt(batch_qty, 6) >= flt(reserved_qty, 6):
_( continue
"The batch {0} is already reserved in {1} {2}. So, cannot proceed with the {3} {4}, which is created against the {5} {6}."
).format( row = reservations[(batch_no, warehouse)]
frappe.bold(row.batch_no), frappe.throw(
frappe.bold(row.voucher_type), _(
frappe.bold(row.voucher_no), "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}."
frappe.bold(self.doctype), ).format(
frappe.bold(self.name), frappe.bold(batch_no),
frappe.bold(field[0]), frappe.bold(row.voucher_type),
frappe.bold(value), frappe.bold(row.voucher_no),
), frappe.bold(warehouse),
title=_("Reserved Batch Conflict"), frappe.bold(self.doctype),
) frappe.bold(self.name),
),
title=_("Reserved Batch Conflict"),
)
def get_reserved_batches(self, batches): def get_reserved_batches(self, batches):
doctype = frappe.qb.DocType("Stock Reservation Entry") doctype = frappe.qb.DocType("Stock Reservation Entry")
@@ -1418,9 +1409,10 @@ class StockController(AccountsController):
.on(doctype.name == child_doc.parent) .on(doctype.name == child_doc.parent)
.select( .select(
child_doc.batch_no, child_doc.batch_no,
child_doc.qty,
child_doc.delivered_qty,
doctype.voucher_type, doctype.voucher_type,
doctype.voucher_no, doctype.voucher_no,
doctype.item_code,
doctype.warehouse, doctype.warehouse,
) )
.where((doctype.docstatus == 1) & (child_doc.batch_no.isin(batches))) .where((doctype.docstatus == 1) & (child_doc.batch_no.isin(batches)))

View File

@@ -697,6 +697,65 @@ class TestStockReservationEntry(ERPNextTestSuite):
# Test - 1: ValidationError should be thrown as the inwarded stock is reserved. # Test - 1: ValidationError should be thrown as the inwarded stock is reserved.
self.assertRaises(frappe.ValidationError, se.cancel) 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: def create_items() -> dict:
items_properties = [ items_properties = [
@@ -737,6 +796,33 @@ def create_items() -> dict:
return items 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( def create_material_receipt(
items: dict, warehouse: str = "_Test Warehouse - _TC", qty: float = 100 items: dict, warehouse: str = "_Test Warehouse - _TC", qty: float = 100
) -> StockEntry: ) -> StockEntry: