mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-16 07:58: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>
(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:
@@ -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",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"] &&
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user