From 22923666459a34f96e6b6fc61201f0f94952d12a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 10 Jun 2026 10:57:38 +0530 Subject: [PATCH] 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 (cherry picked from commit cf3747887060235964362afc0e471e65ea10d278) # 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 --- erpnext/controllers/selling_controller.py | 9 ++ erpnext/public/js/controllers/buying.js | 3 + erpnext/public/js/controllers/transaction.js | 18 +++ .../product_bundle/product_bundle.json | 34 ++++++ .../doctype/product_bundle/product_bundle.py | 110 ++++++++++++++++++ .../product_bundle/product_bundle_list.js | 17 +++ .../stock/doctype/packed_item/packed_item.py | 58 +++++++++ .../product_bundle_balance.py | 15 +++ 8 files changed, 264 insertions(+) create mode 100644 erpnext/selling/doctype/product_bundle/product_bundle_list.js 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\n

Aggregate 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.

\n

The package Item will have Is Stock Item as No and Is Sales Item as Yes.

\n

Example:

\n

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.

" }, { +<<<<<<< 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()