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 <noreply@anthropic.com>
This commit is contained in:
Mihir Kandoi
2026-06-10 11:09:31 +05:30
parent aa9f225c41
commit bddd1d0ebc
3 changed files with 72 additions and 2 deletions

View File

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

View File

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

View File

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