mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-16 16:08:39 +00:00
Merge pull request #55791 from mihir-kandoi/product-bundle-disabled
feat(selling): allow disabling a Product Bundle
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
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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