From 00b7e6e9eb084456c0403260ed5b24c2d1b25b44 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 17 Sep 2026 13:58:02 +0530 Subject: [PATCH] fix(stock): reserve product bundle components from a Pick List (#59134) --- .../sales_order/services/reservation.py | 33 +++++++++----- erpnext/stock/doctype/pick_list/pick_list.py | 8 +++- .../test_stock_reservation_entry.py | 45 +++++++++++++++++++ 3 files changed, 73 insertions(+), 13 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/services/reservation.py b/erpnext/selling/doctype/sales_order/services/reservation.py index 238f68af879..c6f448808bd 100644 --- a/erpnext/selling/doctype/sales_order/services/reservation.py +++ b/erpnext/selling/doctype/sales_order/services/reservation.py @@ -85,7 +85,7 @@ class SalesOrderStockReservation: create_stock_reservation_entries_for_so_items as create_stock_reservation_entries, ) - packed_items = self._extract_packed_item_details(items_details) + packed_items = self._extract_packed_item_details(items_details, from_voucher_type) sre_count = 0 if items_details != []: @@ -100,17 +100,28 @@ class SalesOrderStockReservation: if items: self._reserve_packed_items(items, sre_count, notify) - def _extract_packed_item_details(self, items_details: list[dict] | None) -> list: - """Pull packed-item rows (whose Sales Order Item no longer exists) out of items_details.""" - packed_items = [] - if items_details: - for item in items_details: - if not frappe.db.exists("Sales Order Item", item.get("sales_order_item")): - item["qty"] = item.pop("qty_to_reserve") - packed_items.append(item) + def _extract_packed_item_details( + self, items_details: list[dict] | None, from_voucher_type: str | None = None + ) -> list: + """Pull packed-item rows (whose Sales Order Item no longer exists) out of items_details + and rewrite them into the payload StockReservation reads.""" + if not items_details: + return [] - for item in packed_items: - items_details.remove(item) + packed_items = [ + item + for item in items_details + if not frappe.db.exists("Sales Order Item", item.get("sales_order_item")) + ] + + for item in packed_items: + items_details.remove(item) + item["qty"] = item.pop("qty_to_reserve") + item["from_voucher_type"] = from_voucher_type + + picked_bundle = item.pop("serial_and_batch_bundle", None) + if picked_bundle: + item["serial_and_batch_bundles"] = [picked_bundle] return packed_items diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 3c8cde3e58f..441f2329250 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -546,14 +546,18 @@ class PickList(TransactionBase): @frappe.whitelist(methods=["POST"]) def create_stock_reservation_entries(self, notify: bool = True) -> None: - """Creates Stock Reservation Entries for Sales Order Items against Pick List.""" + """Creates Stock Reservation Entries for Sales Order Items against Pick List. + + A bundle component reserves against its Packed Item, because the bundle itself + is a non-stock Sales Order Item and can never hold reserved stock. + """ self.check_permission("write") so_items_details_map = {} for location in self.locations: if location.warehouse and location.sales_order and location.sales_order_item: item_details = { - "sales_order_item": location.sales_order_item, + "sales_order_item": location.product_bundle_item or location.sales_order_item, "item_code": location.item_code, "warehouse": location.warehouse, "qty_to_reserve": (flt(location.picked_qty) - flt(location.stock_reserved_qty)), 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 1e4145c141d..ed2de35b7ea 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 @@ -852,6 +852,51 @@ class TestStockReservationEntry(ERPNextTestSuite): # Test - 3: Reserved Serial/Batch Nos should be equal to Picked Serial/Batch Nos. self.assertSetEqual(picked_sb_details, reserved_sb_details) + @ERPNextTestSuite.change_settings( + "Stock Settings", + { + "allow_negative_stock": 0, + "enable_stock_reservation": 1, + "allow_partial_reservation": 1, + }, + ) + def test_stock_reservation_from_pick_list_for_product_bundle(self) -> None: + from erpnext.stock.doctype.packed_item.test_packed_item import create_product_bundle + + bundle, components = create_product_bundle(quantities=[2, 3], warehouse=self.warehouse) + so = make_sales_order(item_code=bundle, qty=2, warehouse=self.warehouse) + + pl = create_pick_list(so.name) + pl.save() + pl.submit() + pl.create_stock_reservation_entries() + pl.reload() + so.reload() + + packed_item_by_code = {row.item_code: row for row in so.packed_items} + self.assertEqual(len(pl.locations), len(components)) + + for location in pl.locations: + packed_item = packed_item_by_code[location.item_code] + + # Test - 1: Bundle components should be reserved against their Packed Item. + sre_details = _get_stock_reservation_entries_for_voucher( + "Sales Order", so.name, packed_item.name, fields=["reserved_qty", "from_voucher_type"] + ) + self.assertEqual(len(sre_details), 1) + self.assertEqual(sre_details[0].reserved_qty, packed_item.qty) + self.assertEqual(sre_details[0].from_voucher_type, "Pick List") + + # Test - 2: Reserved Qty should be updated in Pick List Item. + self.assertEqual(location.stock_reserved_qty, location.picked_qty) + + pl.cancel_stock_reservation_entries() + pl.reload() + + # Test - 3: Unreserving from the Pick List should clear the reserved qty. + for location in pl.locations: + self.assertEqual(location.stock_reserved_qty, 0) + @ERPNextTestSuite.change_settings( "Stock Settings", {