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:
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)))

View File

@@ -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: