Merge pull request #57169 from mihir-kandoi/fix-shared-reserved-batch-delivery

fix: allow delivery when a batch is reserved across multiple sales orders
This commit is contained in:
Mihir Kandoi
2026-07-15 11:10:58 +05:30
committed by GitHub
2 changed files with 130 additions and 50 deletions

View File

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

View File

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