diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py
index 6cdcb7090d2..3b14f246cf3 100644
--- a/erpnext/controllers/selling_controller.py
+++ b/erpnext/controllers/selling_controller.py
@@ -425,7 +425,16 @@ class SellingController(StockController):
row.new_item_code
for row in frappe.get_all(
"Product Bundle",
+<<<<<<< HEAD
filters={"new_item_code": ("in", items_to_fetch), "disabled": 0},
+=======
+ filters={
+ "new_item_code": ("in", items_to_fetch),
+ "is_active": 1,
+ "docstatus": 1,
+ "disabled": 0,
+ },
+>>>>>>> cf37478870 (feat(selling): allow disabling a Product Bundle)
fields="new_item_code",
)
}
diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js
index aa4f5b70b9e..6482fc80a70 100644
--- a/erpnext/public/js/controllers/buying.js
+++ b/erpnext/public/js/controllers/buying.js
@@ -611,6 +611,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 dc97d90db7f..6015459be72 100644
--- a/erpnext/public/js/controllers/transaction.js
+++ b/erpnext/public/js/controllers/transaction.js
@@ -200,6 +200,24 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
});
}
+<<<<<<< HEAD
+=======
+ if (this.frm.fields_dict["items"].grid.get_field("product_bundle")) {
+ // 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];
+
+ return {
+ filters: {
+ new_item_code: row.item_code,
+ docstatus: 1,
+ disabled: 0,
+ },
+ };
+ });
+ }
+
+>>>>>>> cf37478870 (feat(selling): allow disabling a Product Bundle)
if (
this.frm.docstatus < 2 &&
this.frm.fields_dict["payment_terms_template"] &&
diff --git a/erpnext/selling/doctype/product_bundle/product_bundle.json b/erpnext/selling/doctype/product_bundle/product_bundle.json
index 9cb95aefe05..15ee82c8379 100644
--- a/erpnext/selling/doctype/product_bundle/product_bundle.json
+++ b/erpnext/selling/doctype/product_bundle/product_bundle.json
@@ -64,10 +64,40 @@
"options": "
About Product Bundle
\n\nAggregate group of Items into another Item. This is useful if you are bundling a certain Items into a package and you maintain stock of the packed Items and not the aggregate Item.
\nThe package Item will have Is Stock Item as No and Is Sales Item as Yes.
\nExample:
\nIf you are selling Laptops and Backpacks separately and have a special price if the customer buys both, then the Laptop + Backpack will be a new Product Bundle Item.
"
},
{
+<<<<<<< HEAD
"default": "0",
"fieldname": "disabled",
"fieldtype": "Check",
"label": "Disabled"
+=======
+ "allow_on_submit": 1,
+ "default": "1",
+ "description": "Only one version of a Product Bundle can be active at a time for a given Parent Item. Activating a version deactivates the previously active one.",
+ "fieldname": "is_active",
+ "fieldtype": "Check",
+ "in_list_view": 1,
+ "label": "Is Active",
+ "no_copy": 1
+ },
+ {
+ "allow_on_submit": 1,
+ "default": "0",
+ "description": "A disabled Product Bundle cannot be selected in transactions.",
+ "fieldname": "disabled",
+ "fieldtype": "Check",
+ "in_standard_filter": 1,
+ "label": "Disabled",
+ "no_copy": 1
+ },
+ {
+ "fieldname": "amended_from",
+ "fieldtype": "Link",
+ "label": "Amended From",
+ "no_copy": 1,
+ "options": "Product Bundle",
+ "print_hide": 1,
+ "read_only": 1
+>>>>>>> cf37478870 (feat(selling): allow disabling a Product Bundle)
},
{
"fieldname": "column_break_eonk",
@@ -77,7 +107,11 @@
"icon": "fa fa-sitemap",
"idx": 1,
"links": [],
+<<<<<<< HEAD
"modified": "2024-03-27 13:10:19.599302",
+=======
+ "modified": "2026-06-10 12:00:00.000000",
+>>>>>>> cf37478870 (feat(selling): allow disabling a Product Bundle)
"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 5c981244d10..b84ece2b923 100644
--- a/erpnext/selling/doctype/product_bundle/product_bundle.py
+++ b/erpnext/selling/doctype/product_bundle/product_bundle.py
@@ -37,6 +37,42 @@ class ProductBundle(Document):
validate_uom_is_integer(self, "uom", "qty")
+<<<<<<< HEAD
+=======
+ def on_submit(self):
+ self.make_active()
+
+ def on_cancel(self):
+ self.db_set("is_active", 0)
+
+ def on_update_after_submit(self):
+ # `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()
+
+ def make_active(self):
+ """Mark this version active and deactivate every other submitted version
+ of the same parent item."""
+ if not self.is_active:
+ self.db_set("is_active", 1)
+
+ others = frappe.get_all(
+ "Product Bundle",
+ filters={
+ "new_item_code": self.new_item_code,
+ "is_active": 1,
+ "docstatus": 1,
+ "name": ("!=", self.name),
+ },
+ pluck="name",
+ )
+ for name in others:
+ frappe.db.set_value("Product Bundle", name, "is_active", 0)
+
+>>>>>>> cf37478870 (feat(selling): allow disabling a Product Bundle)
def on_trash(self):
linked_doctypes = [
"Delivery Note",
@@ -99,6 +135,80 @@ class ProductBundle(Document):
)
+<<<<<<< HEAD
+=======
+def build_bundle_name(item_code: str, index: int) -> str:
+ """Build a ``PB-- -NNN`` name, truncating the item part to stay within 140 chars."""
+ suffix = "%.3i" % index
+ name = f"{NAME_PREFIX}-{item_code}-{suffix}"
+ if len(name) <= 140:
+ return name
+
+ truncated_length = 140 - (len(NAME_PREFIX) + len(suffix) + 2)
+ truncated_item = item_code[:truncated_length].rsplit(" ", 1)[0]
+ return f"{NAME_PREFIX}-{truncated_item}-{suffix}"
+
+
+def get_next_version_index(existing_names: list[str]) -> int:
+ """Highest trailing version index across ``existing_names`` plus one (1 if none)."""
+ pattern = "|".join(re.escape(delim) for delim in ("/", "-"))
+ parts = [re.split(pattern, name) for name in existing_names]
+ valid = [p for p in parts if len(p) > 1 and p[-1]]
+ if not valid:
+ return 1
+ return max(cint(p[-1]) for p in valid) + 1
+
+
+def get_active_product_bundle(item_code: str) -> str | 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. 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, "disabled": 0},
+ "name",
+ )
+
+
+@frappe.whitelist()
+def make_new_version(source_name: str, target_doc: str | None = None):
+ """Create a fresh draft bundle copied from an existing (typically submitted) one.
+
+ The copy keeps the same parent item and component rows but gets a new version
+ name on submit; it does not carry over docstatus or the active flag.
+ """
+ from frappe.model.mapper import get_mapped_doc
+
+ def post_process(source, target):
+ target.is_active = 1
+ target.disabled = 0
+
+ return get_mapped_doc(
+ "Product Bundle",
+ source_name,
+ {
+ "Product Bundle": {
+ "doctype": "Product Bundle",
+ "field_map": {"new_item_code": "new_item_code"},
+ "field_no_map": ["amended_from", "is_active", "disabled"],
+ },
+ "Product Bundle Item": {
+ "doctype": "Product Bundle Item",
+ },
+ },
+ target_doc,
+ post_process,
+ )
+
+
+>>>>>>> cf37478870 (feat(selling): allow disabling a Product Bundle)
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_new_item_code(doctype, txt, searchfield, start, page_len, filters):
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/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py
index 5a6531f1526..abeb6a2b9dd 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
@@ -171,12 +172,69 @@ def get_product_bundle_items(item_code):
product_bundle_item.uom,
product_bundle_item.description,
)
+<<<<<<< HEAD
.where((product_bundle.new_item_code == item_code) & (product_bundle.disabled == 0))
+=======
+ .where(
+ (product_bundle.new_item_code == item_code)
+ & (product_bundle.is_active == 1)
+ & (product_bundle.docstatus == 1)
+ & (product_bundle.disabled == 0)
+ )
+>>>>>>> cf37478870 (feat(selling): allow disabling a Product Bundle)
.orderby(product_bundle_item.idx)
)
return query.run(as_dict=True)
+<<<<<<< HEAD
+=======
+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")
+ return (
+ frappe.qb.from_(product_bundle_item)
+ .select(
+ product_bundle_item.item_code,
+ product_bundle_item.qty,
+ product_bundle_item.uom,
+ product_bundle_item.description,
+ )
+ .where(product_bundle_item.parent == bundle_name)
+ .orderby(product_bundle_item.idx)
+ ).run(as_dict=True)
+
+
+def get_bundle_version_for_row(item_row):
+ """Product Bundle version to pack ``item_row`` from.
+
+ 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, 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", "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)
+
+
+>>>>>>> cf37478870 (feat(selling): allow disabling a Product Bundle)
def add_packed_item_row(doc, packing_item, main_item_row, packed_items_table, reset):
"""Add and return packed item row.
doc: Transaction document
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 5a8960396b3..40081198d36 100644
--- a/erpnext/stock/report/product_bundle_balance/product_bundle_balance.py
+++ b/erpnext/stock/report/product_bundle_balance/product_bundle_balance.py
@@ -140,7 +140,13 @@ def get_items(filters):
item.brand,
item.stock_uom,
)
+<<<<<<< HEAD
.where(IfNull(item.disabled, 0) == 0)
+=======
+ .where(
+ (IfNull(item.disabled, 0) == 0) & (pb.is_active == 1) & (pb.docstatus == 1) & (pb.disabled == 0)
+ )
+>>>>>>> cf37478870 (feat(selling): allow disabling a Product Bundle)
)
if item_code := filters.get("item_code"):
@@ -182,7 +188,16 @@ def get_items(filters):
pbi.uom,
pbi.qty,
)
+<<<<<<< HEAD
.where(pb.new_item_code.isin(parent_items))
+=======
+ .where(
+ pb.new_item_code.isin(parent_items)
+ & (pb.is_active == 1)
+ & (pb.docstatus == 1)
+ & (pb.disabled == 0)
+ )
+>>>>>>> cf37478870 (feat(selling): allow disabling a Product Bundle)
).run(as_dict=1)
child_items = set()