Merge pull request #55791 from mihir-kandoi/product-bundle-disabled

feat(selling): allow disabling a Product Bundle
This commit is contained in:
Mihir Kandoi
2026-06-10 11:42:25 +05:30
committed by GitHub
10 changed files with 121 additions and 15 deletions

View File

@@ -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",
)
}

View File

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

View File

@@ -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,
},
};
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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