mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-03 17:54:38 +00:00
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:
@@ -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)))
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user