diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index 561bcb0a59e..e30b3384129 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -425,7 +425,12 @@ class SellingController(StockController): row.new_item_code for row in frappe.get_all( "Product Bundle", - filters={"new_item_code": ("in", items_to_fetch), "is_active": 1, "docstatus": 1}, + filters={ + "new_item_code": ("in", items_to_fetch), + "is_active": 1, + "docstatus": 1, + "disabled": 0, + }, fields="new_item_code", ) } diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js index d8d812e7116..972d3a64785 100644 --- a/erpnext/public/js/controllers/buying.js +++ b/erpnext/public/js/controllers/buying.js @@ -607,6 +607,9 @@ erpnext.buying.get_items_from_product_bundle = function (frm) { fieldname: "product_bundle", options: "Product Bundle", reqd: 1, + get_query: () => { + return { filters: { docstatus: 1, disabled: 0 } }; + }, }, { fieldtype: "Currency", diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 4fa4c936b3e..deaf50846de 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -201,7 +201,7 @@ 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 + // restrict the version picker to enabled, submitted Product Bundles of the row's item this.frm.set_query("product_bundle", "items", function (doc, cdt, cdn) { let row = locals[cdt][cdn]; @@ -209,6 +209,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe filters: { new_item_code: row.item_code, docstatus: 1, + disabled: 0, }, }; }); diff --git a/erpnext/selling/doctype/product_bundle/product_bundle.json b/erpnext/selling/doctype/product_bundle/product_bundle.json index aece3cd70ce..365e9e4cce3 100644 --- a/erpnext/selling/doctype/product_bundle/product_bundle.json +++ b/erpnext/selling/doctype/product_bundle/product_bundle.json @@ -76,13 +76,14 @@ "no_copy": 1 }, { + "allow_on_submit": 1, "default": "0", - "depends_on": "disabled", - "description": "Deprecated: use Cancel / Is Active instead. Retained for backward compatibility.", + "description": "A disabled Product Bundle cannot be selected in transactions.", "fieldname": "disabled", "fieldtype": "Check", + "in_standard_filter": 1, "label": "Disabled", - "read_only": 1 + "no_copy": 1 }, { "fieldname": "amended_from", @@ -102,7 +103,7 @@ "idx": 1, "is_submittable": 1, "links": [], - "modified": "2026-06-08 00:00:00.000000", + "modified": "2026-06-10 12:00:00.000000", "modified_by": "Administrator", "module": "Selling", "name": "Product Bundle", diff --git a/erpnext/selling/doctype/product_bundle/product_bundle.py b/erpnext/selling/doctype/product_bundle/product_bundle.py index 993a9f64050..68d26494e37 100644 --- a/erpnext/selling/doctype/product_bundle/product_bundle.py +++ b/erpnext/selling/doctype/product_bundle/product_bundle.py @@ -62,8 +62,10 @@ class ProductBundle(Document): self.db_set("is_active", 0) def on_update_after_submit(self): - # `is_active` is the only field editable after submit; keep a single active - # version per parent item in sync when the user (re)activates a version. + # `is_active` and `disabled` are the only fields editable after submit; keep a + # single active version per parent item in sync when the user (re)activates a + # version. `disabled` is orthogonal: it parks a version without ceding the + # active slot, so re-enabling restores it without re-activation. if self.is_active: self.make_active() @@ -171,17 +173,19 @@ def get_next_version_index(existing_names: list[str]) -> int: def get_active_product_bundle(item_code: str) -> str | None: - """Return the name of the active, submitted Product Bundle for ``item_code``, else None. + """Return the name of the active, enabled, submitted Product Bundle for + ``item_code``, else None. This is the single resolution entry point for every consumer of bundles; it replaces the legacy ``exists("Product Bundle", {name/new_item_code, disabled: 0})`` - lookups that assumed one mutable bundle per item. + lookups that assumed one mutable bundle per item. A disabled bundle resolves to + None even if it still holds the active slot for its parent item. """ if not item_code: return None return frappe.db.get_value( "Product Bundle", - {"new_item_code": item_code, "is_active": 1, "docstatus": 1}, + {"new_item_code": item_code, "is_active": 1, "docstatus": 1, "disabled": 0}, "name", ) diff --git a/erpnext/selling/doctype/product_bundle/product_bundle_list.js b/erpnext/selling/doctype/product_bundle/product_bundle_list.js new file mode 100644 index 00000000000..7c1fa7d9161 --- /dev/null +++ b/erpnext/selling/doctype/product_bundle/product_bundle_list.js @@ -0,0 +1,17 @@ +// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +// License: GNU General Public License v3. See license.txt + +frappe.listview_settings["Product Bundle"] = { + add_fields: ["is_active", "disabled"], + get_indicator(doc) { + // Draft and Cancelled fall through to the standard docstatus indicators; + // this only refines submitted bundles. + if (doc.disabled) { + return [__("Disabled"), "grey", "disabled,=,1"]; + } + if (doc.docstatus === 1 && doc.is_active) { + return [__("Active"), "green", "is_active,=,1|disabled,=,0|docstatus,=,1"]; + } + // inactive submitted versions keep the default "Submitted" indicator + }, +}; diff --git a/erpnext/selling/doctype/product_bundle/test_product_bundle.py b/erpnext/selling/doctype/product_bundle/test_product_bundle.py index 97297e23f90..fa268c295a4 100644 --- a/erpnext/selling/doctype/product_bundle/test_product_bundle.py +++ b/erpnext/selling/doctype/product_bundle/test_product_bundle.py @@ -103,6 +103,38 @@ class TestProductBundle(ERPNextTestSuite): bundle.items[0].qty = 99 self.assertRaises(frappe.exceptions.UpdateAfterSubmitError, bundle.save) + def test_disabled_bundle_is_not_resolved(self): + bundle = make_product_bundle(self.parent, ["_Test PB Child A"]) + + bundle.disabled = 1 + bundle.save() + self.assertIsNone(get_active_product_bundle(self.parent)) + + # disabling parks the version without ceding the active slot, so re-enabling + # restores resolution without re-activation + self.assertEqual(frappe.db.get_value("Product Bundle", bundle.name, "is_active"), 1) + bundle.disabled = 0 + bundle.save() + self.assertEqual(get_active_product_bundle(self.parent), bundle.name) + + def test_item_where_used_report_shows_disabled_flag(self): + from erpnext.stock.report.item_where_used.item_where_used import execute + + bundle = make_product_bundle(self.parent, ["_Test PB Child A"]) + bundle.disabled = 1 + bundle.save() + + _, component_rows = execute({"item": "_Test PB Child A", "section": "Where Used"}) + rows = [r for r in component_rows if r.document_name == bundle.name] + self.assertTrue(rows) + self.assertEqual(rows[0].disabled, 1) + self.assertEqual(rows[0].is_active, 1) + + _, parent_rows = execute({"item": self.parent, "section": "References"}) + rows = [r for r in parent_rows if r.document_name == bundle.name] + self.assertTrue(rows) + self.assertEqual(rows[0].disabled, 1) + def test_child_cannot_be_active_bundle(self): make_product_bundle(self.parent, ["_Test PB Child A"]) outer = make_item("_Test PB Outer", {"is_stock_item": 0, "is_sales_item": 1}).name diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index 07a12176a4f..98151428496 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -8,6 +8,7 @@ import json import frappe import frappe.defaults +from frappe import _ from frappe.model.document import Document from frappe.utils import flt @@ -192,6 +193,7 @@ def get_product_bundle_items(item_code): (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) ) @@ -219,14 +221,25 @@ def get_bundle_version_for_row(item_row): 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. + choice (e.g. left over after changing the item) self-heals back to the active + one, but a disabled choice blocks the transaction instead of silently switching + versions behind the user's back. """ 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) + bundle = frappe.db.get_value( + "Product Bundle", chosen, ["new_item_code", "docstatus", "disabled"], as_dict=True + ) if bundle and bundle.new_item_code == item_row.item_code and bundle.docstatus == 1: + if bundle.disabled: + frappe.throw( + _("Row #{0}: Product Bundle {1} is disabled and cannot be used in transactions.").format( + item_row.idx, frappe.bold(chosen) + ), + title=_("Disabled Product Bundle"), + ) return chosen return get_active_product_bundle(item_row.item_code) diff --git a/erpnext/stock/doctype/packed_item/test_packed_item.py b/erpnext/stock/doctype/packed_item/test_packed_item.py index 29be9893b93..98c5dc6a008 100644 --- a/erpnext/stock/doctype/packed_item/test_packed_item.py +++ b/erpnext/stock/doctype/packed_item/test_packed_item.py @@ -165,6 +165,29 @@ class TestPackedItem(ERPNextTestSuite): self.assertEqual(so.items[0].product_bundle, v1) self.assertEqual(sorted(pi.item_code for pi in so.packed_items), sorted(self.bundle_items)) + def test_disabled_bundle_blocks_transaction(self): + "A row that explicitly references a disabled version cannot be saved." + 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, warehouse=self.warehouse, do_not_submit=True) + self.assertEqual(so.items[0].product_bundle, version) + + frappe.db.set_value("Product Bundle", version, "disabled", 1) + self.assertRaises(frappe.ValidationError, so.save) + + def test_disabled_bundle_is_not_packed(self): + "Without an explicit version, a disabled bundle is not treated as a bundle at all." + from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle + + version = get_active_product_bundle(self.bundle2) + frappe.db.set_value("Product Bundle", version, "disabled", 1) + + so = make_sales_order(item_code=self.bundle2, qty=1, warehouse=self.warehouse, do_not_submit=True) + self.assertEqual(so.items[0].is_product_bundle, 0) + self.assertFalse(so.items[0].product_bundle) + self.assertFalse(so.get("packed_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." diff --git a/erpnext/stock/report/product_bundle_balance/product_bundle_balance.py b/erpnext/stock/report/product_bundle_balance/product_bundle_balance.py index 3c1cce3216a..b247013b62a 100644 --- a/erpnext/stock/report/product_bundle_balance/product_bundle_balance.py +++ b/erpnext/stock/report/product_bundle_balance/product_bundle_balance.py @@ -139,7 +139,9 @@ def get_items(filters): item.brand, item.stock_uom, ) - .where((IfNull(item.disabled, 0) == 0) & (pb.is_active == 1) & (pb.docstatus == 1)) + .where( + (IfNull(item.disabled, 0) == 0) & (pb.is_active == 1) & (pb.docstatus == 1) & (pb.disabled == 0) + ) ) if item_code := filters.get("item_code"): @@ -181,7 +183,12 @@ def get_items(filters): pbi.uom, pbi.qty, ) - .where(pb.new_item_code.isin(parent_items) & (pb.is_active == 1) & (pb.docstatus == 1)) + .where( + pb.new_item_code.isin(parent_items) + & (pb.is_active == 1) + & (pb.docstatus == 1) + & (pb.disabled == 0) + ) ).run(as_dict=1) child_items = set()