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..7dbeb109c59 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") @@ -458,9 +433,31 @@ 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. + """ + from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle + row, items = ItemDetailsCtx(json.loads(row)), [] - bundled_items = get_product_bundle_items(row["item_code"]) + 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))) + if bundle.disabled: + frappe.throw( + _("Product Bundle {0} is disabled and cannot be used in transactions.").format( + frappe.bold(bundle_name) + ) + ) + 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 [] 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..5a91978e9d9 100644 --- a/erpnext/stock/doctype/packed_item/test_packed_item.py +++ b/erpnext/stock/doctype/packed_item/test_packed_item.py @@ -188,6 +188,57 @@ 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.addCleanup(frappe.db.set_value, "Product Bundle", version, "disabled", 0) + 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."