fix(stock): return packed items with serial / batch fields

A return of a product bundle failed with "No Serial / Batches are
available for return" when the original packed items had serial or
batch numbers in the fields, but no Serial and Batch Bundle. Delivery
notes from before v15 have this shape.

The return looked only for bundles of the original packed items. Now it
skips packed rows that have serial or batch fields, and the bundle is
made from these fields.

Also, a new document matched its stale packed rows by the idx of the
packed row and the idx of the item row. Only the first component of a
bundle matched, so the other components lost their copied serial and
batch fields. Now the stale rows are used in sequence per parent item
and component.
This commit is contained in:
barredterra
2026-10-27 23:24:37 +01:00
parent 6714207eee
commit c574ac75df
3 changed files with 56 additions and 3 deletions
+7 -1
View File
@@ -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):
@@ -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 (
@@ -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