From 5ff6071e7d9786d66368ca26589586116f975521 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 22 Jun 2026 15:40:16 +0530 Subject: [PATCH] fix: submittable product bundle issues (cherry picked from commit a218b8db8cd732803afd46c8b8707a3d1714b600) # 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 --- .../doctype/product_bundle/product_bundle.js | 18 +++ .../product_bundle/product_bundle.json | 2 + .../product_bundle/product_bundle_list.js | 9 ++ .../product_bundle/test_product_bundle.py | 136 ++++++++++++++++++ .../report/item_where_used/item_where_used.py | 49 +++++-- 5 files changed, 202 insertions(+), 12 deletions(-) diff --git a/erpnext/selling/doctype/product_bundle/product_bundle.js b/erpnext/selling/doctype/product_bundle/product_bundle.js index 3096b692a7e..d875900aad7 100644 --- a/erpnext/selling/doctype/product_bundle/product_bundle.js +++ b/erpnext/selling/doctype/product_bundle/product_bundle.js @@ -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) }, }); diff --git a/erpnext/selling/doctype/product_bundle/product_bundle.json b/erpnext/selling/doctype/product_bundle/product_bundle.json index de335c55fca..803dfd3a61e 100644 --- a/erpnext/selling/doctype/product_bundle/product_bundle.json +++ b/erpnext/selling/doctype/product_bundle/product_bundle.json @@ -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": [] diff --git a/erpnext/selling/doctype/product_bundle/product_bundle_list.js b/erpnext/selling/doctype/product_bundle/product_bundle_list.js index b6788bacf10..9830cd2d45c 100644 --- a/erpnext/selling/doctype/product_bundle/product_bundle_list.js +++ b/erpnext/selling/doctype/product_bundle/product_bundle_list.js @@ -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) }, }; diff --git a/erpnext/selling/doctype/product_bundle/test_product_bundle.py b/erpnext/selling/doctype/product_bundle/test_product_bundle.py index 4a5df94da54..13c92531c4b 100644 --- a/erpnext/selling/doctype/product_bundle/test_product_bundle.py +++ b/erpnext/selling/doctype/product_bundle/test_product_bundle.py @@ -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) diff --git a/erpnext/stock/report/item_where_used/item_where_used.py b/erpnext/stock/report/item_where_used/item_where_used.py index 2c193b4a6bd..13aba1685ec 100644 --- a/erpnext/stock/report/item_where_used/item_where_used.py +++ b/erpnext/stock/report/item_where_used/item_where_used.py @@ -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()