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