diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index bd94076a2af..feb064597a0 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -1347,66 +1347,63 @@ 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 = defaultdict(list) + 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 = flt(row.qty) - flt(row.delivered_qty) + outstanding_qty[key] += outstanding + if outstanding > 0: + reservations[key].append(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 flt(reserved_qty, 6) <= 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 + + 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} 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), + vouchers, + 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 +1415,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..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 @@ -697,6 +697,66 @@ 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. + # 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) + + 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) + + (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) + dn.items[0].batch_no = batch_no + 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 +797,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: