diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 2cdf076de2f..d21fcacb4e0 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -524,7 +524,13 @@ class StockController(AccountsController): ): reference_ids.append(row.get(field)) - if table_name == "packed_items" and row.get("parent_detail_docname"): + # Packed items with serial / batch fields (e.g. delivered before v15) can have no bundle + # to return against, make_bundle_using_old_serial_batch_fields handles them + if ( + table_name == "packed_items" + and row.get("parent_detail_docname") + and not (row.serial_no or row.batch_no) + ): parent_rows = self.get("items", {"name": row.parent_detail_docname}) or [] for d in parent_rows: if d.get(field) and not d.get(bundle_field): diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 7c649325d83..b93a60c8349 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -3316,6 +3316,50 @@ class TestDeliveryNote(FrappeTestCase): serial_batch_map[row.item_code].batch_no_valuation[entry.batch_no], ) + def test_sales_return_for_product_bundle_with_legacy_serial_nos(self): + bundle_item = make_item("_Packed Legacy Bundle Item", {"is_stock_item": 0}).name + stock_item = make_item("_Packed Legacy Stock Item", {"is_stock_item": 1}).name + serial_items = [ + make_item( + f"_Packed Legacy Serial Item {i}", + {"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": f"SN-PACKED-LEGACY-{i}-.#####"}, + ).name + for i in (1, 2) + ] + for item_code in [stock_item, *serial_items]: + make_stock_entry(item=item_code, target="_Test Warehouse - _TC", qty=2, rate=100) + make_product_bundle(bundle_item, [stock_item, *serial_items]) + + dn = create_delivery_note(item_code=bundle_item, warehouse="_Test Warehouse - _TC", qty=2) + dn.reload() + + # Delivery notes from before v15 have serial nos in the packed items without a bundle + delivered_serial_nos = {} + for packed_item in dn.packed_items: + if packed_item.item_code not in serial_items: + continue + + serial_nos = get_serial_nos_from_bundle(packed_item.serial_and_batch_bundle) + delivered_serial_nos[packed_item.item_code] = sorted(serial_nos) + legacy_values = {"serial_no": "\n".join(serial_nos), "serial_and_batch_bundle": None} + packed_item.db_set(legacy_values) + frappe.db.set_value( + "Stock Ledger Entry", + {"voucher_no": dn.name, "item_code": packed_item.item_code}, + legacy_values, + ) + + return_dn = make_sales_return(dn.name) + return_dn.submit() + return_dn.reload() + + returned_serial_nos = { + row.item_code: sorted(get_serial_nos_from_bundle(row.serial_and_batch_bundle)) + for row in return_dn.packed_items + if row.item_code in serial_items + } + self.assertEqual(returned_serial_nos, delivered_serial_nos) + @change_settings("Stock Settings", {"allow_negative_stock": 0, "enable_stock_reservation": 1}) def test_partial_delivery_note_against_reserved_stock(self): from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index e742c8e703e..19b2d734cfd 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -107,13 +107,16 @@ def get_indexed_packed_items_table(doc): Use: to quickly retrieve/check if row existed in table instead of looping n times. A component repeated on a bundle shares one key, so rows are kept as a list and consumed per occurrence rather than collapsed onto a single row. + + A new doc (e.g. mapped) has no link to its item rows yet, so its packed rows are + consumed in order per parent item and component. """ indexed_table = {} for packed_item in doc.get("packed_items"): key = ( packed_item.parent_item, packed_item.item_code, - packed_item.idx if doc.is_new() else packed_item.parent_detail_docname, + None if doc.is_new() else packed_item.parent_detail_docname, ) indexed_table.setdefault(key, []).append(packed_item) @@ -179,7 +182,7 @@ def add_packed_item_row(doc, packing_item, main_item_row, packed_items_table, re key = ( main_item_row.item_code, packing_item.item_code, - main_item_row.idx if doc.is_new() else main_item_row.name, + None if doc.is_new() else main_item_row.name, ) # a component repeated on the bundle shares one key; consume stale rows in order so # each occurrence keeps its own packed row instead of overwriting a single one