From 696f0df81167abad1f1d3c5a419e5e1f30ad922d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 19 Aug 2026 20:52:47 +0530 Subject: [PATCH] fix(stock): update pick list status for product bundles (#58306) --- .../doctype/delivery_note/delivery_note.py | 2 +- erpnext/stock/doctype/pick_list/mapper.py | 1 - erpnext/stock/doctype/pick_list/pick_list.py | 112 +++++++++++++++++- .../stock/doctype/pick_list/test_pick_list.py | 111 +++++++++++++++++ 4 files changed, 221 insertions(+), 5 deletions(-) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 6131758b18f..4f4ac843a27 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -463,7 +463,6 @@ class DeliveryNote(SellingController): def on_submit(self): self.validate_packed_qty() - self.update_pick_list_status() # Check for Approving Authority frappe.get_cached_doc("Authorization Control").validate_approving_authority( @@ -472,6 +471,7 @@ class DeliveryNote(SellingController): # update delivered qty in sales order self.update_prevdoc_status() + self.update_pick_list_status() self.update_billing_status() if not self.is_return: diff --git a/erpnext/stock/doctype/pick_list/mapper.py b/erpnext/stock/doctype/pick_list/mapper.py index 6acdd574f81..306789d39e5 100644 --- a/erpnext/stock/doctype/pick_list/mapper.py +++ b/erpnext/stock/doctype/pick_list/mapper.py @@ -280,7 +280,6 @@ def add_product_bundles_to_target(pick_list, target_doc, item_mapper, sales_orde target_bundle_item.qty = pick_list._compute_picked_qty_for_bundle( so_row, product_bundle_qty_map[value.item_code] ) - target_bundle_item.pick_list_item = value.pick_list_item target_bundle_item.against_pick_list = pick_list.name update_child_item(sales_order_item, target_bundle_item, target_doc) diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 5418979bf86..ab2dcba3ebc 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -897,8 +897,7 @@ class PickList(TransactionBase): return query.run(as_dict=True) - def _get_product_bundles(self) -> dict[str, str]: - # Dict[so_item_row: item_code] + def _get_product_bundles(self) -> dict[str, frappe._dict]: product_bundles = {} for item in self.locations: if not item.product_bundle_item: @@ -911,7 +910,6 @@ class PickList(TransactionBase): item.sales_order_item, "item_code", ), - "pick_list_item": item.name, } ) return product_bundles @@ -937,6 +935,113 @@ class PickList(TransactionBase): return int(flt(min(possible_bundles.values()), precision or 6)) if possible_bundles else 0 + def update_bundle_delivered_qty(self): + bundle_locations = defaultdict(list) + for location in self.locations: + if location.product_bundle_item: + bundle_locations[ + ( + location.sales_order_item, + location.item_code, + location.warehouse, + location.batch_no or "", + location.serial_no or "", + ) + ].append(location) + + if not bundle_locations: + return + + delivered_component_qty = self._get_delivered_bundle_component_qty( + {bundle_key[0] for bundle_key in bundle_locations} + ) + updates = {} + for bundle_key, locations in bundle_locations.items(): + remaining_qty = max(delivered_component_qty.get(bundle_key, 0), 0) + for location in locations: + precision = location.precision("delivered_qty") + delivered_qty = flt(min(flt(location.picked_qty), remaining_qty), precision) + remaining_qty -= delivered_qty + if flt(location.delivered_qty, precision) == delivered_qty: + continue + + location.delivered_qty = delivered_qty + updates[location.name] = {"delivered_qty": delivered_qty} + + if updates: + frappe.db.bulk_update("Pick List Item", updates, update_modified=False) + + self._set_delivery_status_from_items() + + def _get_delivered_bundle_component_qty(self, sales_order_items): + delivered_qty = defaultdict(float) + for parenttype in ("Delivery Note", "Sales Invoice"): + for row in self._get_delivered_packed_items(parenttype, sales_order_items): + key = ( + row.so_detail, + row.item_code, + row.warehouse, + row.batch_no or "", + row.serial_no or "", + ) + delivered_qty[key] += flt(row.delivered_qty) + + return delivered_qty + + def _get_delivered_packed_items(self, parenttype, sales_order_items): + packed_item = frappe.qb.DocType("Packed Item") + transaction_item = frappe.qb.DocType(f"{parenttype} Item") + query = ( + frappe.qb.from_(transaction_item) + .inner_join(packed_item) + .on( + (packed_item.parent == transaction_item.parent) + & (packed_item.parent_detail_docname == transaction_item.name) + & (packed_item.parenttype == parenttype) + ) + ) + conditions = ( + (transaction_item.docstatus == 1) + & (transaction_item.against_pick_list == self.name) + & transaction_item.so_detail.isin(sales_order_items) + ) + + if parenttype == "Sales Invoice": + transaction = frappe.qb.DocType(parenttype) + query = query.inner_join(transaction).on(transaction.name == transaction_item.parent) + conditions &= transaction.update_stock == 1 + + return ( + query.select( + transaction_item.so_detail, + packed_item.item_code, + packed_item.warehouse, + packed_item.batch_no, + packed_item.serial_no, + Sum(packed_item.qty).as_("delivered_qty"), + ) + .where(conditions) + .groupby( + transaction_item.so_detail, + packed_item.item_code, + packed_item.warehouse, + packed_item.batch_no, + packed_item.serial_no, + ) + ).run(as_dict=True) + + def _set_delivery_status_from_items(self): + per_delivered = self._calculate_target_parent_percentage( + self.name, "Pick List", "Pick List Item", "picked_qty", "delivered_qty" + ) + delivery_status = self._determine_status(per_delivered, "Delivered") + self.per_delivered = per_delivered + self.delivery_status = delivery_status + self.db_set( + {"per_delivered": per_delivered, "delivery_status": delivery_status}, + update_modified=False, + ) + def has_unreserved_stock(self): if self.purpose == "Delivery": for location in self.locations: @@ -965,6 +1070,7 @@ class PickList(TransactionBase): def update_pick_list_status(pick_list): if pick_list: doc = frappe.get_doc("Pick List", pick_list) + doc.update_bundle_delivered_qty() doc.run_method("update_status") doc.update_picked_qty_in_work_order() diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index c292710edbd..a2ca611aa53 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -1215,6 +1215,117 @@ class TestPickList(ERPNextTestSuite): self.assertEqual(dn.packed_items[0].warehouse, warehouse) so.reload() self.assertEqual(so.per_delivered, 100) + pl.reload() + for location in pl.locations: + self.assertEqual(location.delivered_qty, location.picked_qty) + self.assertEqual(pl.per_delivered, 100) + self.assertEqual(pl.delivery_status, "Fully Delivered") + self.assertEqual(pl.status, "Completed") + + dn.cancel() + pl.reload() + for location in pl.locations: + self.assertEqual(location.delivered_qty, 0) + self.assertEqual(pl.per_delivered, 0) + self.assertEqual(pl.delivery_status, "Not Delivered") + self.assertEqual(pl.status, "Open") + + def test_picklist_with_bundle_from_sales_invoice(self): + warehouse = "_Test Warehouse - _TC" + bundle, _components = create_product_bundle([1, 1], warehouse=warehouse) + so = make_sales_order(item_code=bundle, qty=1, rate=42) + pl = create_pick_list(so.name).save().submit() + + sales_invoice = create_delivery(pl.name, target="Sales Invoice").submit() + + pl.reload() + for location in pl.locations: + self.assertEqual(location.delivered_qty, location.picked_qty) + self.assertEqual(pl.per_delivered, 100) + self.assertEqual(pl.delivery_status, "Fully Delivered") + self.assertEqual(pl.status, "Completed") + + sales_invoice.cancel() + pl.reload() + for location in pl.locations: + self.assertEqual(location.delivered_qty, 0) + self.assertEqual(pl.per_delivered, 0) + self.assertEqual(pl.delivery_status, "Not Delivered") + self.assertEqual(pl.status, "Open") + + def test_partial_delivery_of_picklist_with_bundle(self): + warehouse = "_Test Warehouse - _TC" + bundle, _components = create_product_bundle([1, 1], warehouse=warehouse) + so = make_sales_order(item_code=bundle, qty=2, rate=42) + + pl = create_pick_list(so.name).save().submit() + dn = create_delivery_note(pl.name) + dn.items[0].qty = 1 + dn.save().submit() + + pl.reload() + for location in pl.locations: + self.assertEqual(location.delivered_qty, 1) + self.assertEqual(pl.per_delivered, 50) + self.assertEqual(pl.delivery_status, "Partly Delivered") + self.assertEqual(pl.status, "Partly Delivered") + + dn.cancel() + pl.reload() + for location in pl.locations: + self.assertEqual(location.delivered_qty, 0) + self.assertEqual(pl.per_delivered, 0) + self.assertEqual(pl.delivery_status, "Not Delivered") + self.assertEqual(pl.status, "Open") + + @ERPNextTestSuite.change_settings("Stock Settings", {"use_serial_batch_fields": 1}) + def test_partial_bundle_delivery_with_split_pick_list_rows(self): + primary_warehouse = "_Test Warehouse - _TC" + secondary_warehouse = "_Test Warehouse 2 - _TC" + bundle = make_item(properties={"is_stock_item": 0}).name + serialized_component = make_item( + properties={ + "has_serial_no": 1, + "serial_no_series": f"PL-BUNDLE-{frappe.generate_hash(length=6)}-.#####", + } + ).name + other_component = make_item().name + make_product_bundle(bundle, [serialized_component, other_component]) + make_stock_entry(item=serialized_component, to_warehouse=primary_warehouse, qty=1) + make_stock_entry(item=serialized_component, to_warehouse=secondary_warehouse, qty=2) + make_stock_entry(item=other_component, to_warehouse=primary_warehouse, qty=3) + so = make_sales_order(item_code=bundle, qty=3, rate=42) + + pl = create_pick_list(so.name).save().submit() + serialized_locations = [row for row in pl.locations if row.item_code == serialized_component] + self.assertEqual(len(serialized_locations), 2) + + dn = create_delivery_note(pl.name) + dn.items[0].qty = 2 + dn.save().submit() + delivered_component = next(row for row in dn.packed_items if row.item_code == serialized_component) + self.assertEqual(delivered_component.warehouse, secondary_warehouse) + delivered_location = next(row for row in serialized_locations if row.warehouse == secondary_warehouse) + self.assertEqual( + set(delivered_component.serial_no.split("\n")), + set(delivered_location.serial_no.split("\n")), + ) + + pl.reload() + for location in pl.locations: + if location.item_code == serialized_component: + expected_qty = 2 if location.warehouse == secondary_warehouse else 0 + self.assertEqual(location.delivered_qty, expected_qty) + else: + self.assertEqual(location.delivered_qty, 2) + self.assertEqual(pl.per_delivered, 66.666667) + self.assertEqual(pl.delivery_status, "Partly Delivered") + self.assertEqual(pl.status, "Partly Delivered") + + dn.cancel() + pl.reload() + for location in pl.locations: + self.assertEqual(location.delivered_qty, 0) def test_picklist_with_partial_bundles(self): # from self.globalTestRecords