feat: record and select Product Bundle version on transactions (#55738)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Mihir Kandoi
2026-06-09 13:15:20 +05:30
committed by GitHub
parent 6988781f81
commit 08f6af867a
17 changed files with 391 additions and 36 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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

View File

@@ -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-<parent item>-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-<parent item>-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(

View File

@@ -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"] &&

View File

@@ -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",

View File

@@ -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()

View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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

View File

@@ -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."

View File

@@ -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",

View File

@@ -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