fix: submittable product bundle issues

(cherry picked from commit a218b8db8c)

# Conflicts:
#	erpnext/selling/doctype/product_bundle/product_bundle.js
#	erpnext/selling/doctype/product_bundle/product_bundle_list.js
#	erpnext/selling/doctype/product_bundle/test_product_bundle.py
#	erpnext/stock/report/item_where_used/item_where_used.py
This commit is contained in:
Mihir Kandoi
2026-06-22 15:40:16 +05:30
committed by Mergify
parent 0be5ba6bac
commit 5ff6071e7d
5 changed files with 202 additions and 12 deletions

View File

@@ -9,5 +9,23 @@ frappe.ui.form.on("Product Bundle", {
query: "erpnext.selling.doctype.product_bundle.product_bundle.get_new_item_code",
};
});
<<<<<<< HEAD
=======
// A submitted bundle is immutable. To change it, create a new version
// (a fresh draft copied from this one) and submit that instead.
if (frm.doc.docstatus === 1) {
frm.add_custom_button(
__("New Version"),
() => {
frappe.model.open_mapped_doc({
method: "erpnext.selling.doctype.product_bundle.product_bundle.make_new_version",
frm: frm,
});
},
__("Actions")
);
}
>>>>>>> a218b8db8c (fix: submittable product bundle issues)
},
});

View File

@@ -26,6 +26,7 @@
"fieldtype": "Link",
"in_global_search": 1,
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Parent Item",
"no_copy": 1,
"oldfieldname": "new_item_code",
@@ -116,6 +117,7 @@
"write": 1
}
],
"search_fields": "new_item_code,description",
"sort_field": "creation",
"sort_order": "ASC",
"states": []

View File

@@ -7,6 +7,15 @@ frappe.listview_settings["Product Bundle"] = {
if (doc.disabled) {
return [__("Disabled"), "grey", "disabled,=,1"];
}
<<<<<<< HEAD
return [__("Active"), "green", "disabled,=,0"];
=======
if (doc.docstatus === 1 && doc.is_active) {
return [__("Active"), "green", "is_active,=,1|disabled,=,0|docstatus,=,1"];
}
if (doc.docstatus === 1 && !doc.is_active) {
return [__("Inactive"), "gray", "is_active,=,0|disabled,=,0|docstatus,=,1"];
}
>>>>>>> a218b8db8c (fix: submittable product bundle issues)
},
};

View File

@@ -16,3 +16,139 @@ def make_product_bundle(parent, items, qty=None):
product_bundle.insert()
return product_bundle
<<<<<<< HEAD
=======
class TestProductBundle(ERPNextTestSuite):
def setUp(self):
self.parent = make_item("_Test PB Parent", {"is_stock_item": 0, "is_sales_item": 1}).name
make_item("_Test PB Child A", {"is_stock_item": 1})
make_item("_Test PB Child B", {"is_stock_item": 1})
def test_submit_makes_bundle_active_and_versioned(self):
bundle = make_product_bundle(self.parent, ["_Test PB Child A"])
self.assertEqual(bundle.docstatus, 1)
self.assertEqual(bundle.is_active, 1)
self.assertTrue(bundle.name.startswith("PB-"))
self.assertEqual(get_active_product_bundle(self.parent), bundle.name)
def test_new_version_deactivates_previous(self):
v1 = make_product_bundle(self.parent, ["_Test PB Child A"])
v2 = make_new_version(v1.name)
v2.items[0].qty = 5
v2.insert()
v2.submit()
self.assertNotEqual(v1.name, v2.name)
self.assertEqual(get_active_product_bundle(self.parent), v2.name)
self.assertEqual(frappe.db.get_value("Product Bundle", v1.name, "is_active"), 0)
def test_reactivating_old_version_deactivates_current(self):
v1 = make_product_bundle(self.parent, ["_Test PB Child A"])
v2 = make_new_version(v1.name)
v2.items[0].qty = 5
v2.insert()
v2.submit()
self.assertEqual(get_active_product_bundle(self.parent), v2.name)
# switch back to v1 by toggling is_active on the submitted doc (allow_on_submit)
v1.reload()
v1.is_active = 1
v1.save()
self.assertEqual(get_active_product_bundle(self.parent), v1.name)
self.assertEqual(frappe.db.get_value("Product Bundle", v2.name, "is_active"), 0)
def test_new_bundle_from_scratch_supersedes_existing(self):
# An item that already has a bundle must remain selectable so a new version
# can be created straight from the New Product Bundle form.
from erpnext.selling.doctype.product_bundle.product_bundle import get_new_item_code
v1 = make_product_bundle(self.parent, ["_Test PB Child A"])
picker = [row[0] for row in get_new_item_code("Item", self.parent, "name", 0, 20, {})]
self.assertIn(self.parent, picker)
v2 = frappe.get_doc({"doctype": "Product Bundle", "new_item_code": self.parent})
v2.append("items", {"item_code": "_Test PB Child B", "qty": 1})
v2.insert()
v2.submit()
self.assertNotEqual(v1.name, v2.name)
self.assertEqual(get_active_product_bundle(self.parent), v2.name)
self.assertEqual(frappe.db.get_value("Product Bundle", v1.name, "is_active"), 0)
def test_cancel_clears_active(self):
bundle = make_product_bundle(self.parent, ["_Test PB Child A"])
bundle.cancel()
self.assertEqual(frappe.db.get_value("Product Bundle", bundle.name, "is_active"), 0)
self.assertIsNone(get_active_product_bundle(self.parent))
def test_submitted_bundle_is_immutable(self):
bundle = make_product_bundle(self.parent, ["_Test PB Child A"])
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)
self.assertEqual(rows[0].stock_quantity, rows[0].quantity)
self.assertEqual(rows[0].stock_uom, rows[0].uom)
_, 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_item_where_used_report_hides_internal_and_empty_columns(self):
from erpnext.stock.report.item_where_used.item_where_used import execute
bundle = make_product_bundle(self.parent, ["_Test PB Child A"])
columns, rows = execute({"item": "_Test PB Child A", "section": "Where Used"})
fieldnames = [column["fieldname"] for column in columns]
self.assertIn("stock_quantity", fieldnames)
self.assertIn("stock_uom", fieldnames)
self.assertNotIn("matched_field", fieldnames)
self.assertNotIn("company", fieldnames)
rows = [r for r in rows if r.document_name == bundle.name]
self.assertTrue(rows)
self.assertEqual(rows[0].stock_quantity, rows[0].quantity)
self.assertEqual(rows[0].stock_uom, rows[0].uom)
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
doc = frappe.get_doc({"doctype": "Product Bundle", "new_item_code": outer})
doc.append("items", {"item_code": self.parent, "qty": 1})
self.assertRaises(frappe.ValidationError, doc.insert)
>>>>>>> a218b8db8c (fix: submittable product bundle issues)

View File

@@ -10,16 +10,16 @@ REFERENCES_SECTION = "References"
def execute(filters=None):
filters = frappe._dict(filters or {})
columns = get_columns()
data = []
if not filters.get("item"):
return columns, []
if filters.get("item"):
data = get_data(filters)
return columns, get_data(filters)
return get_columns(data), data
def get_columns():
return [
def get_columns(data=None):
columns = [
{
"fieldname": "section",
"label": _("Section"),
@@ -52,12 +52,6 @@ def get_columns():
"options": "Item",
"width": 180,
},
{
"fieldname": "matched_field",
"label": _("Matched Field"),
"fieldtype": "Data",
"width": 180,
},
{
"fieldname": "row_index",
"label": _("Row"),
@@ -123,6 +117,8 @@ def get_columns():
},
]
return hide_empty_optional_columns(columns, data or [])
def get_data(filters):
data = []
@@ -283,7 +279,13 @@ def get_product_bundle_component_rows(item):
row_index=row.idx,
quantity=row.qty,
uom=row.uom,
<<<<<<< HEAD
is_active=0 if bundle.disabled else 1,
=======
stock_quantity=row.qty,
stock_uom=row.uom,
is_active=bundle.is_active,
>>>>>>> a218b8db8c (fix: submittable product bundle issues)
disabled=bundle.disabled,
)
)
@@ -473,6 +475,29 @@ def build_row(**kwargs):
return frappe._dict(kwargs)
def hide_empty_optional_columns(columns, data):
optional_fields = {"stock_quantity", "stock_uom", "company", "is_default", "details"}
if not data:
return columns
fields_with_values = set()
for row in data:
for fieldname in optional_fields:
if field_has_value(row, fieldname):
fields_with_values.add(fieldname)
return [column for column in columns if column["fieldname"] not in optional_fields - fields_with_values]
def field_has_value(row, fieldname):
if fieldname not in row:
return False
value = row.get(fieldname)
return value is not None and value != ""
def get_unique_names(names):
unique_names = []
seen = set()