mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-24 04:26:38 +00:00
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>
This commit is contained in:
@@ -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",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user