From bddd1d0ebcd5cd85e1a33c5e55a90f4c635c96f7 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 10 Jun 2026 11:09:31 +0530 Subject: [PATCH 1/5] fix(buying): resolve Get Items from Product Bundle by document name Since Product Bundles became versioned, their names are PB-prefixed and no longer double as the parent item code. The buying dialog kept passing the picked bundle name as `item_code`, so the component lookup (which filters `new_item_code`) matched nothing and the dialog silently added no items. The dialog now sends the selection as `product_bundle` and the endpoint fetches that version's components by document name (rejecting unsubmitted versions); passing `item_code` still resolves the parent item's active version, preserving the legacy contract of the whitelisted endpoint. The picker is also restricted to submitted bundles. Co-Authored-By: Claude Fable 5 --- erpnext/public/js/controllers/buying.js | 2 +- .../stock/doctype/packed_item/packed_item.py | 22 +++++++- .../doctype/packed_item/test_packed_item.py | 50 +++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js index 972d3a64785..5c556287c6c 100644 --- a/erpnext/public/js/controllers/buying.js +++ b/erpnext/public/js/controllers/buying.js @@ -628,7 +628,7 @@ erpnext.buying.get_items_from_product_bundle = function (frm) { method: "erpnext.stock.doctype.packed_item.packed_item.get_items_from_product_bundle", args: { row: { - item_code: args.product_bundle, + product_bundle: args.product_bundle, quantity: args.quantity, parenttype: frm.doc.doctype, parent: frm.doc.name, diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index 98151428496..5f37063e5f5 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -458,9 +458,29 @@ def on_doctype_update(): @frappe.whitelist() def get_items_from_product_bundle(row: str): + """Item details for each component of a Product Bundle. + + ``row.product_bundle`` selects a specific version by document name (the buying + dialog passes this); ``row.item_code`` is the legacy contract, resolving the + parent item's active version. + """ row, items = ItemDetailsCtx(json.loads(row)), [] - bundled_items = get_product_bundle_items(row["item_code"]) + if bundle_name := row.get("product_bundle"): + bundle = frappe.db.get_value( + "Product Bundle", bundle_name, ["docstatus", "disabled"], as_dict=True + ) + if not bundle or bundle.docstatus != 1: + frappe.throw(_("Product Bundle {0} is not submitted").format(frappe.bold(bundle_name))) + if bundle.disabled: + frappe.throw( + _("Product Bundle {0} is disabled and cannot be used in transactions.").format( + frappe.bold(bundle_name) + ) + ) + bundled_items = get_product_bundle_items_by_name(bundle_name) + else: + bundled_items = get_product_bundle_items(row["item_code"]) for item in bundled_items: row.update( { diff --git a/erpnext/stock/doctype/packed_item/test_packed_item.py b/erpnext/stock/doctype/packed_item/test_packed_item.py index 98c5dc6a008..812596dd1e6 100644 --- a/erpnext/stock/doctype/packed_item/test_packed_item.py +++ b/erpnext/stock/doctype/packed_item/test_packed_item.py @@ -188,6 +188,56 @@ class TestPackedItem(ERPNextTestSuite): self.assertFalse(so.items[0].product_bundle) self.assertFalse(so.get("packed_items")) + def test_get_items_from_product_bundle_endpoint(self): + "The buying dialog passes the chosen version by document name (legacy: parent item code)." + import json + + from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle + from erpnext.stock.doctype.packed_item.packed_item import get_items_from_product_bundle + + ctx = { + "quantity": 2, + "doctype": "Purchase Order", + "parenttype": "Purchase Order", + "company": "_Test Company", + "currency": "INR", + "conversion_rate": 1, + "transaction_date": nowdate(), + } + + # by document name, as the buying dialog sends it (bundle names are PB-prefixed + # since versioning, so they no longer double as the parent item code) + version = get_active_product_bundle(self.bundle) + items = get_items_from_product_bundle(json.dumps({"product_bundle": version, **ctx})) + self.assertEqual(sorted(i.item_code for i in items), sorted(self.bundle_items)) + self.assertEqual([i.qty for i in items], [4, 4]) + + # legacy contract: the parent item code resolves to its active version + items = get_items_from_product_bundle(json.dumps({"item_code": self.bundle, **ctx})) + self.assertEqual(sorted(i.item_code for i in items), sorted(self.bundle_items)) + + # an unsubmitted version is rejected + draft = frappe.get_doc( + { + "doctype": "Product Bundle", + "new_item_code": make_item(properties={"is_stock_item": 0}).name, + "items": [{"item_code": self.bundle_items[0], "qty": 1}], + } + ).insert() + self.assertRaises( + frappe.ValidationError, + get_items_from_product_bundle, + json.dumps({"product_bundle": draft.name, **ctx}), + ) + + # a disabled version is rejected + frappe.db.set_value("Product Bundle", version, "disabled", 1) + self.assertRaises( + frappe.ValidationError, + get_items_from_product_bundle, + json.dumps({"product_bundle": version, **ctx}), + ) + @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." From bfee9df9aafa5ff8ac19347c3b8a3b2bc56a6e5e Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 10 Jun 2026 18:53:32 +0530 Subject: [PATCH 2/5] fix: linter error --- erpnext/stock/doctype/packed_item/packed_item.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index 5f37063e5f5..1eaa8163bbe 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -467,9 +467,7 @@ def get_items_from_product_bundle(row: str): row, items = ItemDetailsCtx(json.loads(row)), [] if bundle_name := row.get("product_bundle"): - bundle = frappe.db.get_value( - "Product Bundle", bundle_name, ["docstatus", "disabled"], as_dict=True - ) + bundle = frappe.db.get_value("Product Bundle", bundle_name, ["docstatus", "disabled"], as_dict=True) if not bundle or bundle.docstatus != 1: frappe.throw(_("Product Bundle {0} is not submitted").format(frappe.bold(bundle_name))) if bundle.disabled: From 0c6f7fed55a46930170145566b6c4b00a7314288 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 10 Jun 2026 21:42:30 +0530 Subject: [PATCH 3/5] fix(stock): permission check and test cleanup for bundle item fetch The whitelisted get_items_from_product_bundle endpoint now verifies read permission on Product Bundle (doc-level when a name is passed, doctype- level for the legacy item_code path) so authenticated users can't enumerate bundle components. The disabled-bundle test also restores the disabled flag via addCleanup. Co-Authored-By: Claude Fable 5 --- erpnext/stock/doctype/packed_item/packed_item.py | 1 + erpnext/stock/doctype/packed_item/test_packed_item.py | 1 + 2 files changed, 2 insertions(+) diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index 1eaa8163bbe..8045bbdaadb 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -465,6 +465,7 @@ def get_items_from_product_bundle(row: str): parent item's active version. """ row, items = ItemDetailsCtx(json.loads(row)), [] + frappe.has_permission("Product Bundle", "read", row.get("product_bundle"), throw=True) if bundle_name := row.get("product_bundle"): bundle = frappe.db.get_value("Product Bundle", bundle_name, ["docstatus", "disabled"], as_dict=True) diff --git a/erpnext/stock/doctype/packed_item/test_packed_item.py b/erpnext/stock/doctype/packed_item/test_packed_item.py index 812596dd1e6..5a91978e9d9 100644 --- a/erpnext/stock/doctype/packed_item/test_packed_item.py +++ b/erpnext/stock/doctype/packed_item/test_packed_item.py @@ -232,6 +232,7 @@ class TestPackedItem(ERPNextTestSuite): # a disabled version is rejected frappe.db.set_value("Product Bundle", version, "disabled", 1) + self.addCleanup(frappe.db.set_value, "Product Bundle", version, "disabled", 0) self.assertRaises( frappe.ValidationError, get_items_from_product_bundle, From 81a1c2c8ceb8d0c2fa8fb20cf09b5fe2f2441f4d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 10 Jun 2026 21:51:49 +0530 Subject: [PATCH 4/5] fix(stock): document-level permission check on the legacy bundle path The legacy item_code path now resolves the active bundle's name via get_active_product_bundle (same filters as the old joined query) so frappe.has_permission can validate the specific document on both branches. The orphaned get_product_bundle_items helper is removed. Co-Authored-By: Claude Fable 5 --- .../stock/doctype/packed_item/packed_item.py | 36 ++++--------------- 1 file changed, 7 insertions(+), 29 deletions(-) diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index 8045bbdaadb..d17928bc361 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -175,31 +175,6 @@ def reset_packing_list(doc): return reset_table -def get_product_bundle_items(item_code): - product_bundle = frappe.qb.DocType("Product Bundle") - product_bundle_item = frappe.qb.DocType("Product Bundle Item") - - query = ( - frappe.qb.from_(product_bundle_item) - .join(product_bundle) - .on(product_bundle_item.parent == product_bundle.name) - .select( - product_bundle_item.item_code, - product_bundle_item.qty, - product_bundle_item.uom, - product_bundle_item.description, - ) - .where( - (product_bundle.new_item_code == item_code) - & (product_bundle.is_active == 1) - & (product_bundle.docstatus == 1) - & (product_bundle.disabled == 0) - ) - .orderby(product_bundle_item.idx) - ) - 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") @@ -464,10 +439,12 @@ def get_items_from_product_bundle(row: str): dialog passes this); ``row.item_code`` is the legacy contract, resolving the parent item's active version. """ + from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle + row, items = ItemDetailsCtx(json.loads(row)), [] - frappe.has_permission("Product Bundle", "read", row.get("product_bundle"), throw=True) if bundle_name := row.get("product_bundle"): + frappe.has_permission("Product Bundle", "read", bundle_name, throw=True) bundle = frappe.db.get_value("Product Bundle", bundle_name, ["docstatus", "disabled"], as_dict=True) if not bundle or bundle.docstatus != 1: frappe.throw(_("Product Bundle {0} is not submitted").format(frappe.bold(bundle_name))) @@ -477,9 +454,10 @@ def get_items_from_product_bundle(row: str): frappe.bold(bundle_name) ) ) - bundled_items = get_product_bundle_items_by_name(bundle_name) - else: - bundled_items = get_product_bundle_items(row["item_code"]) + elif bundle_name := get_active_product_bundle(row["item_code"]): + frappe.has_permission("Product Bundle", "read", bundle_name, throw=True) + + bundled_items = get_product_bundle_items_by_name(bundle_name) if bundle_name else [] for item in bundled_items: row.update( { From a7d41f24a39227b7da72e647087f0377823433a5 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 10 Jun 2026 22:02:37 +0530 Subject: [PATCH 5/5] fix(stock): don't KeyError when neither bundle nor item_code is passed row is a plain dict subclass, so row["item_code"] raised an unhandled KeyError (500) when the payload had neither key. get_active_product_bundle already returns None for falsy input, yielding an empty item list. Co-Authored-By: Claude Fable 5 --- erpnext/stock/doctype/packed_item/packed_item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index d17928bc361..7dbeb109c59 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -454,7 +454,7 @@ def get_items_from_product_bundle(row: str): frappe.bold(bundle_name) ) ) - elif bundle_name := get_active_product_bundle(row["item_code"]): + elif bundle_name := get_active_product_bundle(row.get("item_code")): frappe.has_permission("Product Bundle", "read", bundle_name, throw=True) bundled_items = get_product_bundle_items_by_name(bundle_name) if bundle_name else []