feat(selling): allow disabling a Product Bundle

Un-deprecate the `disabled` checkbox: it is now editable (also after
submit) and parks a bundle version without ceding its active slot, so
re-enabling restores it without re-activation.

- `get_active_product_bundle` (the single resolution entry point) skips
  disabled bundles, so every consumer stops treating the item as a bundle
  while it is disabled
- the version pickers on transaction item rows and the buying "Get Items
  from Product Bundle" dialog filter out disabled bundles
- an explicitly selected disabled version blocks the transaction with a
  validation error instead of silently re-packing another version
- Product Bundle Balance report excludes disabled bundles
- list view indicator: Disabled (grey) / Active (green), falling back to
  docstatus for drafts, cancelled and inactive submitted versions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit cf37478870)

# Conflicts:
#	erpnext/controllers/selling_controller.py
#	erpnext/public/js/controllers/transaction.js
#	erpnext/selling/doctype/product_bundle/product_bundle.json
#	erpnext/selling/doctype/product_bundle/product_bundle.py
#	erpnext/stock/doctype/packed_item/packed_item.py
#	erpnext/stock/report/product_bundle_balance/product_bundle_balance.py
This commit is contained in:
Mihir Kandoi
2026-06-10 10:57:38 +05:30
committed by Mergify
parent 4fe7e958bf
commit 2292366645
8 changed files with 264 additions and 0 deletions

View File

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

View File

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

View File

@@ -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"] &&

View File

@@ -64,10 +64,40 @@
"options": "<h3>About Product Bundle</h3>\n\n<p>Aggregate group of <b>Items</b> into another <b>Item</b>. This is useful if you are bundling a certain <b>Items</b> into a package and you maintain stock of the packed <b>Items</b> and not the aggregate <b>Item</b>.</p>\n<p>The package <b>Item</b> will have <code>Is Stock Item</code> as <b>No</b> and <code>Is Sales Item</code> as <b>Yes</b>.</p>\n<h4>Example:</h4>\n<p>If 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.</p>"
},
{
<<<<<<< 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",

View File

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

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

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

View File

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