From f06240314d41cb91685c58c67e63ca1c5b2821fd Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 10 Jun 2026 10:57:40 +0530 Subject: [PATCH] test(selling): cover disabled Product Bundle behaviour Resolution skips disabled bundles, transactions referencing a disabled version are blocked, rows without an explicit version stop packing, and the Item Where Used report surfaces the disabled flag on bundle rows. Co-Authored-By: Claude Fable 5 (cherry picked from commit c97c2d1e02467808b7d176d52583d9bf89f93da4) # Conflicts: # erpnext/selling/doctype/product_bundle/test_product_bundle.py # erpnext/stock/doctype/packed_item/test_packed_item.py --- .../product_bundle/test_product_bundle.py | 116 ++++++++++++++++++ .../doctype/packed_item/test_packed_item.py | 114 +++++++++++++++++ 2 files changed, 230 insertions(+) diff --git a/erpnext/selling/doctype/product_bundle/test_product_bundle.py b/erpnext/selling/doctype/product_bundle/test_product_bundle.py index 4a5df94da54..5bdf5af5829 100644 --- a/erpnext/selling/doctype/product_bundle/test_product_bundle.py +++ b/erpnext/selling/doctype/product_bundle/test_product_bundle.py @@ -16,3 +16,119 @@ 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) + + _, 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 + + 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) +>>>>>>> c97c2d1e02 (test(selling): cover disabled Product Bundle behaviour) diff --git a/erpnext/stock/doctype/packed_item/test_packed_item.py b/erpnext/stock/doctype/packed_item/test_packed_item.py index e7b22d04033..04f29fce22f 100644 --- a/erpnext/stock/doctype/packed_item/test_packed_item.py +++ b/erpnext/stock/doctype/packed_item/test_packed_item.py @@ -76,6 +76,120 @@ class TestPackedItem(ERPNextTestSuite): self.assertEqual(len(so.packed_items), 0) +<<<<<<< HEAD +======= + def test_item_and_packed_rows_record_bundle_version(self): + "The item row and its packed items record the resolved Product Bundle version." + from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle + + version = get_active_product_bundle(self.bundle) + self.assertTrue(version and version.startswith("PB-")) + + so = make_sales_order(item_code=self.bundle, qty=1, warehouse=self.warehouse) + self.assertEqual(so.items[0].product_bundle, version) + self.assertEqual(so.items[0].is_product_bundle, 1) + self.assertEqual(len(so.packed_items), 2) + for pi in so.packed_items: + self.assertEqual(pi.product_bundle, version) + + # the version carries onto a Delivery Note mapped from the Sales Order + dn = make_delivery_note(so.name) + self.assertEqual(dn.items[0].product_bundle, version) + for pi in dn.packed_items: + self.assertEqual(pi.product_bundle, version) + + def test_clearing_version_keeps_bundle_flag_and_redefaults(self): + "Clearing the version must not lose the bundle flag (keeps the field visible)." + so = make_sales_order(item_code=self.bundle, qty=1, warehouse=self.warehouse, do_not_submit=True) + version = so.items[0].product_bundle + self.assertEqual(so.items[0].is_product_bundle, 1) + + # user blanks the version field + so.items[0].product_bundle = None + so.save() + + # the flag stays set (so depends_on keeps the field visible) and the value + # re-defaults to the active version + self.assertEqual(so.items[0].is_product_bundle, 1) + self.assertEqual(so.items[0].product_bundle, version) + + def test_backfill_patch_stamps_existing_rows(self): + "The backfill patch stamps the version on rows that predate the field." + from erpnext.patches.v16_0.submit_existing_product_bundles import ( + stamp_versions_on_transactions as stamp_versions, + ) + 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, do_not_submit=True) + + # simulate pre-migration rows with no version recorded and no bundle flag + frappe.db.set_value("Sales Order Item", so.items[0].name, "product_bundle", None) + frappe.db.set_value("Sales Order Item", so.items[0].name, "is_product_bundle", 0) + for pi in so.packed_items: + frappe.db.set_value("Packed Item", pi.name, "product_bundle", None) + + stamp_versions() + + self.assertEqual(frappe.db.get_value("Sales Order Item", so.items[0].name, "product_bundle"), version) + self.assertEqual(frappe.db.get_value("Sales Order Item", so.items[0].name, "is_product_bundle"), 1) + for pi in so.packed_items: + self.assertEqual(frappe.db.get_value("Packed Item", pi.name, "product_bundle"), version) + + def test_choosing_an_older_version_packs_its_components(self): + "Default picks the active version; choosing an older version re-packs its components." + from erpnext.selling.doctype.product_bundle.product_bundle import ( + get_active_product_bundle, + make_new_version, + ) + + v1 = get_active_product_bundle(self.bundle) + + # new version with a different component becomes the active one + new_component = make_item().name + make_stock_entry(item=new_component, to_warehouse=self.warehouse, qty=50, rate=100) + v2 = make_new_version(v1) + v2.items = [] + v2.append("items", {"item_code": new_component, "qty": 1}) + v2.insert() + v2.submit() + self.assertEqual(get_active_product_bundle(self.bundle), v2.name) + + # default: the active version (v2) and its component + so = make_sales_order(item_code=self.bundle, qty=1, warehouse=self.warehouse, do_not_submit=True) + self.assertEqual(so.items[0].product_bundle, v2.name) + self.assertEqual([pi.item_code for pi in so.packed_items], [new_component]) + + # choose the older version -> its components are packed instead + so.items[0].product_bundle = v1 + so.save() + 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")) + +>>>>>>> c97c2d1e02 (test(selling): cover disabled Product Bundle behaviour) @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."