diff --git a/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json b/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json index ad9f8751942..afab0d66c96 100644 --- a/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json +++ b/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json @@ -10,6 +10,8 @@ "barcode", "has_item_scanned", "item_code", + "is_product_bundle", + "product_bundle", "col_break1", "item_name", "customer_item_code", @@ -125,6 +127,23 @@ "options": "Item", "search_index": 1 }, + { + "default": "0", + "fieldname": "is_product_bundle", + "fieldtype": "Check", + "hidden": 1, + "label": "Is Product Bundle", + "print_hide": 1, + "read_only": 1 + }, + { + "depends_on": "eval:doc.is_product_bundle", + "fieldname": "product_bundle", + "fieldtype": "Link", + "label": "Product Bundle", + "options": "Product Bundle", + "read_only_depends_on": "eval:doc.so_detail" + }, { "fieldname": "col_break1", "fieldtype": "Column Break" @@ -858,7 +877,7 @@ ], "istable": 1, "links": [], - "modified": "2026-04-20 16:16:12.322024", + "modified": "2026-06-08 20:00:00.000000", "modified_by": "Administrator", "module": "Accounts", "name": "POS Invoice Item", diff --git a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json index c0bbdf37682..5269fec916c 100644 --- a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json +++ b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json @@ -886,11 +886,12 @@ "read_only": 1 }, { - "description": "Parent item of the Product Bundle this row was packed from", + "description": "Product Bundle version this row was packed from", "fieldname": "product_bundle", "fieldtype": "Link", + "hidden": 1, "label": "Product Bundle", - "options": "Item", + "options": "Product Bundle", "read_only": 1 }, { @@ -1009,7 +1010,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2026-05-06 08:08:40.782395", + "modified": "2026-06-08 21:00:00.000000", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice Item", diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json index 5f1e0b1444b..903803aa79f 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json @@ -10,6 +10,8 @@ "barcode", "has_item_scanned", "item_code", + "is_product_bundle", + "product_bundle", "col_break1", "item_name", "customer_item_code", @@ -144,6 +146,23 @@ "options": "Item", "search_index": 1 }, + { + "default": "0", + "fieldname": "is_product_bundle", + "fieldtype": "Check", + "hidden": 1, + "label": "Is Product Bundle", + "print_hide": 1, + "read_only": 1 + }, + { + "depends_on": "eval:doc.is_product_bundle", + "fieldname": "product_bundle", + "fieldtype": "Link", + "label": "Product Bundle", + "options": "Product Bundle", + "read_only_depends_on": "eval:doc.so_detail || doc.dn_detail" + }, { "fieldname": "col_break1", "fieldtype": "Column Break" @@ -1036,7 +1055,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2026-06-03 13:17:36.145788", + "modified": "2026-06-08 20:00:00.000000", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice Item", diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json index 87873acd6b8..b0c75c49d9e 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json @@ -845,11 +845,12 @@ "read_only": 1 }, { - "description": "Parent item of the Product Bundle this row was packed from", + "description": "Product Bundle version this row was packed from", "fieldname": "product_bundle", "fieldtype": "Link", + "hidden": 1, "label": "Product Bundle", - "options": "Item", + "options": "Product Bundle", "read_only": 1 }, { @@ -940,7 +941,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-05-20 00:50:16.192936", + "modified": "2026-06-08 21:00:00.000000", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order Item", diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 9e56a6777e9..f330b0b38e3 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -485,4 +485,4 @@ erpnext.patches.v16_0.set_default_letter_head_for_doctype_and_report erpnext.patches.v16_0.clear_procedures_from_receivable_report erpnext.patches.v16_0.migrate_address_contact_custom_fields erpnext.patches.v16_0.rename_secondary_item_type_field -erpnext.patches.v16_0.submit_existing_product_bundles +erpnext.patches.v16_0.submit_existing_product_bundles #1 diff --git a/erpnext/patches/v16_0/submit_existing_product_bundles.py b/erpnext/patches/v16_0/submit_existing_product_bundles.py index da7a1205d0d..3b6c13a2802 100644 --- a/erpnext/patches/v16_0/submit_existing_product_bundles.py +++ b/erpnext/patches/v16_0/submit_existing_product_bundles.py @@ -1,25 +1,42 @@ -"""Make existing Product Bundles submittable & versioned. +"""Migrate Product Bundles to the submittable, versioned model (issue #29462). -Product Bundle became a submittable, versioned doctype (issue #29462). Pre-existing -bundles were drafts named after their parent item (``name == new_item_code``). This -patch migrates them to the new model: +Pre-existing bundles were editable drafts named after their parent item +(``name == new_item_code``). This patch: -1. rename each legacy bundle to the versioned name ``PB--001`` -2. mark it submitted (``docstatus = 1``) -3. seed ``is_active`` from the legacy ``disabled`` flag (active = not disabled) +1. renames each legacy bundle to the versioned name ``PB--001``, + marks it submitted (``docstatus = 1``) and seeds ``is_active`` from the legacy + ``disabled`` flag (active = not disabled), and +2. stamps the resolved version onto existing transaction rows, so documents keep a + reference to the exact bundle version they were packed from. -No transaction stores a bundle's *name* (they snapshot components into their own -``packed_items`` tables and reference the parent item code), so renaming is -reference-safe. The patch is idempotent: already-migrated bundles (docstatus != 0 or -already prefixed) are skipped. +Both steps ship together (v16 is unreleased), so they are a single migration. No +transaction stores a bundle's *name* (they snapshot components and reference the +parent item code), so renaming is reference-safe. The whole patch is idempotent. """ import frappe from erpnext.selling.doctype.product_bundle.product_bundle import NAME_PREFIX, build_bundle_name +# doctype -> column holding the bundle parent item code +SELLING_ITEM_TABLES = { + "Sales Order Item": "item_code", + "Delivery Note Item": "item_code", + "Sales Invoice Item": "item_code", + "POS Invoice Item": "item_code", + "Quotation Item": "item_code", + "Packed Item": "parent_item", +} + +BUYING_ITEM_TABLES = ["Purchase Order Item", "Purchase Invoice Item", "Purchase Receipt Item"] + def execute(): + submit_existing_bundles() + stamp_versions_on_transactions() + + +def submit_existing_bundles(): legacy_bundles = frappe.get_all( "Product Bundle", filters={"docstatus": 0}, @@ -51,6 +68,63 @@ def execute(): _enforce_single_active_version() +def stamp_versions_on_transactions(): + """Backfill the ``product_bundle`` version link onto existing transaction rows. + + - Selling / packed rows: a row whose item is a bundle parent is stamped with that + bundle's version (the field was newly added, so only blank rows are touched) and + flagged via ``is_product_bundle`` so the version field stays visible. + - Buying rows: the ``product_bundle`` field previously stored the parent *item code*; + convert those legacy values to the bundle version name. Idempotent: once converted, + the value is a bundle name and no longer matches a ``new_item_code``. + """ + # parent item code -> migrated bundle version name (active version preferred) + version_by_item = {} + for bundle in frappe.get_all( + "Product Bundle", + filters={"docstatus": 1}, + fields=["name", "new_item_code"], + order_by="is_active desc, creation asc", + ): + version_by_item.setdefault(bundle.new_item_code, bundle.name) + + if not version_by_item: + return + + for doctype, item_field in SELLING_ITEM_TABLES.items(): + if not frappe.db.has_column(doctype, "product_bundle"): + continue + table = frappe.qb.DocType(doctype) + item_column = getattr(table, item_field) + flag_bundle_rows = frappe.db.has_column(doctype, "is_product_bundle") + for item_code, version in version_by_item.items(): + ( + frappe.qb.update(table) + .set(table.product_bundle, version) + .where( + (item_column == item_code) + & ((table.product_bundle.isnull()) | (table.product_bundle == "")) + ) + ).run() + if flag_bundle_rows: + # keep the version field visible on bundle rows even if its value is cleared + ( + frappe.qb.update(table).set(table.is_product_bundle, 1).where(item_column == item_code) + ).run() + + for doctype in BUYING_ITEM_TABLES: + if not frappe.db.has_column(doctype, "product_bundle"): + continue + table = frappe.qb.DocType(doctype) + for item_code, version in version_by_item.items(): + # only legacy rows still holding the item code are matched + ( + frappe.qb.update(table) + .set(table.product_bundle, version) + .where(table.product_bundle == item_code) + ).run() + + def _next_index(item_code: str) -> int: """Next free version index for a parent item among already-migrated bundles.""" existing = frappe.get_all( diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 60462148223..a05775a80b5 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -200,6 +200,20 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe }); } + if (this.frm.fields_dict["items"].grid.get_field("product_bundle")) { + // restrict the version picker to submitted Product Bundles of the row's item + this.frm.set_query("product_bundle", "items", function (doc, cdt, cdn) { + let row = locals[cdt][cdn]; + + return { + filters: { + new_item_code: row.item_code, + docstatus: 1, + }, + }; + }); + } + if ( this.frm.docstatus < 2 && this.frm.fields_dict["payment_terms_template"] && diff --git a/erpnext/selling/doctype/quotation_item/quotation_item.json b/erpnext/selling/doctype/quotation_item/quotation_item.json index 92d7895c57b..4ef5bdd928a 100644 --- a/erpnext/selling/doctype/quotation_item/quotation_item.json +++ b/erpnext/selling/doctype/quotation_item/quotation_item.json @@ -7,6 +7,8 @@ "engine": "InnoDB", "field_order": [ "item_code", + "is_product_bundle", + "product_bundle", "item_name", "customer_item_code", "col_break1", @@ -100,6 +102,22 @@ "search_index": 1, "width": "150px" }, + { + "default": "0", + "fieldname": "is_product_bundle", + "fieldtype": "Check", + "hidden": 1, + "label": "Is Product Bundle", + "print_hide": 1, + "read_only": 1 + }, + { + "depends_on": "eval:doc.is_product_bundle", + "fieldname": "product_bundle", + "fieldtype": "Link", + "label": "Product Bundle", + "options": "Product Bundle" + }, { "fieldname": "customer_item_code", "fieldtype": "Data", @@ -711,7 +729,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2026-01-30 12:56:08.320190", + "modified": "2026-06-08 19:00:00.000000", "modified_by": "Administrator", "module": "Selling", "name": "Quotation Item", diff --git a/erpnext/selling/doctype/sales_order/mapper.py b/erpnext/selling/doctype/sales_order/mapper.py index a4d36e909c5..6a23490c482 100644 --- a/erpnext/selling/doctype/sales_order/mapper.py +++ b/erpnext/selling/doctype/sales_order/mapper.py @@ -775,7 +775,7 @@ def make_purchase_order( ["parent", "sales_order"], ["uom", "uom"], ["conversion_factor", "conversion_factor"], - ["parent_item", "product_bundle"], + ["product_bundle", "product_bundle"], ["rate", "rate"], ], "field_no_map": [ @@ -804,17 +804,20 @@ def make_purchase_order( def set_delivery_date(items: list, sales_order: str) -> None: + # `product_bundle` now holds the Product Bundle *version*, so match the Purchase + # Order rows to their originating Sales Order rows by that version. delivery_dates = frappe.get_all( - "Sales Order Item", filters={"parent": sales_order}, fields=["delivery_date", "item_code"] + "Sales Order Item", filters={"parent": sales_order}, fields=["delivery_date", "product_bundle"] ) - delivery_by_item = frappe._dict() + delivery_by_bundle = frappe._dict() for date in delivery_dates: - delivery_by_item[date.item_code] = date.delivery_date + if date.product_bundle: + delivery_by_bundle[date.product_bundle] = date.delivery_date for item in items: if item.product_bundle: - item.schedule_date = delivery_by_item[item.product_bundle] + item.schedule_date = delivery_by_bundle.get(item.product_bundle) @frappe.whitelist() diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 9dc451a44e5..45d3933a017 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -1415,6 +1415,14 @@ class TestSalesOrder(ERPNextTestSuite): self.assertEqual(purchase_order.items[0].item_code, "_Test Bundle Item 1") self.assertEqual(purchase_order.items[1].item_code, "_Test Bundle Item 2") + # each Purchase Order row records the Product Bundle version it was packed from + from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle + + version = get_active_product_bundle("_Test Product Bundle") + self.assertTrue(version and version.startswith("PB-")) + self.assertEqual(purchase_order.items[0].product_bundle, version) + self.assertEqual(purchase_order.items[1].product_bundle, version) + def test_purchase_order_updates_packed_item_ordered_qty(self): """ Tests if the packed item's `ordered_qty` is updated with the quantity of the Purchase Order diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json index ec9aaf2381a..df5d4b76617 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -11,6 +11,8 @@ "fg_item", "fg_item_qty", "item_code", + "is_product_bundle", + "product_bundle", "customer_item_code", "ensure_delivery_based_on_produced_serial_no", "is_stock_item", @@ -136,6 +138,23 @@ "reqd": 1, "width": "150px" }, + { + "default": "0", + "fieldname": "is_product_bundle", + "fieldtype": "Check", + "hidden": 1, + "label": "Is Product Bundle", + "print_hide": 1, + "read_only": 1 + }, + { + "depends_on": "eval:doc.is_product_bundle", + "fieldname": "product_bundle", + "fieldtype": "Link", + "label": "Product Bundle", + "options": "Product Bundle", + "read_only_depends_on": "eval:doc.quotation_item" + }, { "fieldname": "customer_item_code", "fieldtype": "Data", @@ -1036,7 +1055,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2026-05-06 12:03:40.472277", + "modified": "2026-06-08 20:00:00.000000", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json index 0175b790887..4b38b5a5633 100644 --- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json +++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json @@ -10,6 +10,8 @@ "barcode", "has_item_scanned", "item_code", + "is_product_bundle", + "product_bundle", "item_name", "col_break1", "customer_item_code", @@ -136,6 +138,23 @@ "search_index": 1, "width": "150px" }, + { + "default": "0", + "fieldname": "is_product_bundle", + "fieldtype": "Check", + "hidden": 1, + "label": "Is Product Bundle", + "print_hide": 1, + "read_only": 1 + }, + { + "depends_on": "eval:doc.is_product_bundle", + "fieldname": "product_bundle", + "fieldtype": "Link", + "label": "Product Bundle", + "options": "Product Bundle", + "read_only_depends_on": "eval:doc.so_detail" + }, { "fieldname": "item_name", "fieldtype": "Data", @@ -952,7 +971,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-04-07 15:43:20.892151", + "modified": "2026-06-08 20:00:00.000000", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note Item", diff --git a/erpnext/stock/doctype/packed_item/packed_item.json b/erpnext/stock/doctype/packed_item/packed_item.json index dedd8e03a7d..2bf4112c1a3 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.json +++ b/erpnext/stock/doctype/packed_item/packed_item.json @@ -7,6 +7,7 @@ "field_order": [ "parent_item", "item_code", + "product_bundle", "item_name", "delivered_by_supplier", "reserve_stock", @@ -65,6 +66,16 @@ "options": "Item", "read_only": 1 }, + { + "depends_on": "eval:doc.product_bundle", + "description": "Product Bundle version this row was packed from", + "fieldname": "product_bundle", + "fieldtype": "Link", + "label": "Product Bundle", + "no_copy": 1, + "options": "Product Bundle", + "read_only": 1 + }, { "fieldname": "item_name", "fieldtype": "Data", @@ -327,7 +338,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-05-05 16:16:12.856629", + "modified": "2026-06-08 15:00:00.000000", "modified_by": "Administrator", "module": "Stock", "name": "Packed Item", diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index 9e95bdbd5eb..07a12176a4f 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -84,8 +84,16 @@ def make_packing_list(doc): reset = reset_packing_list(doc) for item_row in doc.get("items"): - if is_product_bundle(item_row.item_code): - for bundle_item in get_product_bundle_items(item_row.item_code): + # Pack from the version chosen on the row (default: the item's active version) + # and record it so the document keeps a reference to the exact version used. + bundle_name = get_bundle_version_for_row(item_row) + if item_row.meta.has_field("product_bundle"): + item_row.product_bundle = bundle_name + if item_row.meta.has_field("is_product_bundle"): + item_row.is_product_bundle = 1 if bundle_name else 0 + + if bundle_name: + for bundle_item in get_product_bundle_items_by_name(bundle_name): pi_row = add_packed_item_row( doc=doc, packing_item=bundle_item, @@ -93,6 +101,7 @@ def make_packing_list(doc): packed_items_table=stale_packed_items_table, reset=reset, ) + pi_row.product_bundle = bundle_name item_data = get_packed_item_details(bundle_item.item_code, doc.company) update_packed_item_basic_data(item_row, pi_row, bundle_item, item_data) update_packed_item_stock_data(item_row, pi_row, bundle_item, item_data, doc) @@ -146,8 +155,13 @@ def reset_packing_list(doc): # 1. items were deleted # 2. if bundle item replaced by another item (same no. of items but different items) # we maintain list to track recurring item rows as well - items_before_save = [(item.name, item.item_code) for item in doc_before_save.get("items")] - items_after_save = [(item.name, item.item_code) for item in doc.get("items")] + # include product_bundle so picking a different version re-packs the components + items_before_save = [ + (item.name, item.item_code, item.get("product_bundle")) for item in doc_before_save.get("items") + ] + items_after_save = [ + (item.name, item.item_code, item.get("product_bundle")) for item in doc.get("items") + ] reset_table = items_before_save != items_after_save else: # reset: if via Update Items OR @@ -184,6 +198,40 @@ def get_product_bundle_items(item_code): return query.run(as_dict=True) +def get_product_bundle_items_by_name(bundle_name): + "Component rows of a specific Product Bundle version." + product_bundle_item = frappe.qb.DocType("Product Bundle Item") + return ( + frappe.qb.from_(product_bundle_item) + .select( + product_bundle_item.item_code, + product_bundle_item.qty, + product_bundle_item.uom, + product_bundle_item.description, + ) + .where(product_bundle_item.parent == bundle_name) + .orderby(product_bundle_item.idx) + ).run(as_dict=True) + + +def get_bundle_version_for_row(item_row): + """Product Bundle version to pack ``item_row`` from. + + Honours a version explicitly chosen on the row (validated to be a submitted + bundle of that item); otherwise falls back to the item's active version. A stale + choice (e.g. left over after changing the item) self-heals back to the active one. + """ + from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle + + chosen = item_row.get("product_bundle") if item_row.meta.has_field("product_bundle") else None + if chosen: + bundle = frappe.db.get_value("Product Bundle", chosen, ["new_item_code", "docstatus"], as_dict=True) + if bundle and bundle.new_item_code == item_row.item_code and bundle.docstatus == 1: + return chosen + + return get_active_product_bundle(item_row.item_code) + + def add_packed_item_row(doc, packing_item, main_item_row, packed_items_table, reset): """Add and return packed item row. doc: Transaction document diff --git a/erpnext/stock/doctype/packed_item/test_packed_item.py b/erpnext/stock/doctype/packed_item/test_packed_item.py index 0095c970f9c..29be9893b93 100644 --- a/erpnext/stock/doctype/packed_item/test_packed_item.py +++ b/erpnext/stock/doctype/packed_item/test_packed_item.py @@ -77,6 +77,94 @@ class TestPackedItem(ERPNextTestSuite): self.assertEqual(len(so.packed_items), 0) + def test_item_and_packed_rows_record_bundle_version(self): + "The item row and its packed items record the resolved Product Bundle version." + from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle + + version = get_active_product_bundle(self.bundle) + self.assertTrue(version and version.startswith("PB-")) + + so = make_sales_order(item_code=self.bundle, qty=1, warehouse=self.warehouse) + self.assertEqual(so.items[0].product_bundle, version) + self.assertEqual(so.items[0].is_product_bundle, 1) + self.assertEqual(len(so.packed_items), 2) + for pi in so.packed_items: + self.assertEqual(pi.product_bundle, version) + + # the version carries onto a Delivery Note mapped from the Sales Order + dn = make_delivery_note(so.name) + self.assertEqual(dn.items[0].product_bundle, version) + for pi in dn.packed_items: + self.assertEqual(pi.product_bundle, version) + + def test_clearing_version_keeps_bundle_flag_and_redefaults(self): + "Clearing the version must not lose the bundle flag (keeps the field visible)." + so = make_sales_order(item_code=self.bundle, qty=1, warehouse=self.warehouse, do_not_submit=True) + version = so.items[0].product_bundle + self.assertEqual(so.items[0].is_product_bundle, 1) + + # user blanks the version field + so.items[0].product_bundle = None + so.save() + + # the flag stays set (so depends_on keeps the field visible) and the value + # re-defaults to the active version + self.assertEqual(so.items[0].is_product_bundle, 1) + self.assertEqual(so.items[0].product_bundle, version) + + def test_backfill_patch_stamps_existing_rows(self): + "The backfill patch stamps the version on rows that predate the field." + from erpnext.patches.v16_0.submit_existing_product_bundles import ( + stamp_versions_on_transactions as stamp_versions, + ) + from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle + + version = get_active_product_bundle(self.bundle) + so = make_sales_order(item_code=self.bundle, qty=1, do_not_submit=True) + + # simulate pre-migration rows with no version recorded and no bundle flag + frappe.db.set_value("Sales Order Item", so.items[0].name, "product_bundle", None) + frappe.db.set_value("Sales Order Item", so.items[0].name, "is_product_bundle", 0) + for pi in so.packed_items: + frappe.db.set_value("Packed Item", pi.name, "product_bundle", None) + + stamp_versions() + + self.assertEqual(frappe.db.get_value("Sales Order Item", so.items[0].name, "product_bundle"), version) + self.assertEqual(frappe.db.get_value("Sales Order Item", so.items[0].name, "is_product_bundle"), 1) + for pi in so.packed_items: + self.assertEqual(frappe.db.get_value("Packed Item", pi.name, "product_bundle"), version) + + def test_choosing_an_older_version_packs_its_components(self): + "Default picks the active version; choosing an older version re-packs its components." + from erpnext.selling.doctype.product_bundle.product_bundle import ( + get_active_product_bundle, + make_new_version, + ) + + v1 = get_active_product_bundle(self.bundle) + + # new version with a different component becomes the active one + new_component = make_item().name + make_stock_entry(item=new_component, to_warehouse=self.warehouse, qty=50, rate=100) + v2 = make_new_version(v1) + v2.items = [] + v2.append("items", {"item_code": new_component, "qty": 1}) + v2.insert() + v2.submit() + self.assertEqual(get_active_product_bundle(self.bundle), v2.name) + + # default: the active version (v2) and its component + so = make_sales_order(item_code=self.bundle, qty=1, warehouse=self.warehouse, do_not_submit=True) + self.assertEqual(so.items[0].product_bundle, v2.name) + self.assertEqual([pi.item_code for pi in so.packed_items], [new_component]) + + # choose the older version -> its components are packed instead + so.items[0].product_bundle = v1 + so.save() + self.assertEqual(so.items[0].product_bundle, v1) + self.assertEqual(sorted(pi.item_code for pi in so.packed_items), sorted(self.bundle_items)) + @ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1}) def test_recurring_bundle_item(self): "Test impact on packed items if same bundle item is added and removed." diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json index 4938654690d..6409e05724b 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json @@ -953,11 +953,12 @@ "search_index": 1 }, { - "description": "Parent item of the Product Bundle this row was packed from", + "description": "Product Bundle version this row was packed from", "fieldname": "product_bundle", "fieldtype": "Link", + "hidden": 1, "label": "Product Bundle", - "options": "Item", + "options": "Product Bundle", "read_only": 1 }, { @@ -1121,7 +1122,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2026-05-04 10:52:43.188507", + "modified": "2026-06-08 21:00:00.000000", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt Item", diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index e3ceea3038d..1025bbf5355 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -164,6 +164,18 @@ def get_item_details( if ctx.is_subcontracted: out.bom = ctx.bom or get_default_bom(ctx.item_code) + from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle + + active_bundle = get_active_product_bundle(ctx.item_code) + # flag bundle rows so the version field stays visible regardless of its value + out.is_product_bundle = 1 if active_bundle else 0 + if not active_bundle: + out.product_bundle = None + elif not ctx.get("product_bundle"): + # prefill the active version so a freshly added bundle row defaults to it + # (guarded so re-fetches never overwrite a version the user picked) + out.product_bundle = active_bundle + get_gross_profit(out) if ctx.doctype == "Material Request": out.rate = ctx.rate or out.price_list_rate