fix: reject self-referencing and nested product bundles

This commit is contained in:
Shllokkk
2026-10-24 11:56:12 +05:30
parent 0f3b73faaa
commit 696f037472
2 changed files with 45 additions and 0 deletions
@@ -32,6 +32,7 @@ class ProductBundle(Document):
def validate(self):
self.validate_main_item()
self.validate_child_items()
self.validate_not_nested()
self.validate_child_items_uom()
self.validate_child_items_qty_non_zero()
from erpnext.utilities.transaction_base import validate_uom_is_integer
@@ -83,6 +84,12 @@ class ProductBundle(Document):
def validate_child_items(self):
for item in self.items:
if item.item_code == self.new_item_code:
frappe.throw(
_("Row #{0}: A Product Bundle cannot contain its own parent Item {1}.").format(
item.idx, frappe.bold(item.item_code)
)
)
if frappe.db.exists("Product Bundle", {"name": item.item_code, "disabled": 0}):
frappe.throw(
_(
@@ -90,6 +97,27 @@ class ProductBundle(Document):
).format(item.idx, frappe.bold(item.item_code))
)
def validate_not_nested(self):
# the parent item must not already be a component of another enabled bundle,
# otherwise that bundle would silently nest this one
pb = frappe.qb.DocType("Product Bundle")
pbi = frappe.qb.DocType("Product Bundle Item")
outer = (
frappe.qb.from_(pbi)
.join(pb)
.on(pbi.parent == pb.name)
.select(pb.name)
.where((pbi.item_code == self.new_item_code) & (pb.name != self.name) & (pb.disabled == 0))
.limit(1)
).run(pluck=True)
if outer:
frappe.throw(
_(
"Item {0} is already a component of Product Bundle {1}; nested bundles are not supported."
).format(frappe.bold(self.new_item_code), frappe.bold(outer[0])),
title=_("Nested Product Bundle"),
)
def validate_child_items_uom(self):
# a component's UOM must resolve the same way packing's get_conversion_factor does:
# the item or its template's UOM Conversion Detail, else a global UOM Conversion Factor
@@ -47,3 +47,20 @@ class TestProductBundle(FrappeTestCase):
doc.append("items", {"item_code": child, "qty": 1, "uom": "Kg"})
doc.insert() # must not raise
self.assertTrue(doc.name)
def test_bundle_cannot_reference_itself(self):
doc = frappe.get_doc({"doctype": "Product Bundle", "new_item_code": self.parent})
doc.append("items", {"item_code": self.parent, "qty": 1})
self.assertRaises(frappe.ValidationError, doc.insert)
def test_parent_used_as_component_cannot_become_a_bundle(self):
# OUTER bundles INNER while INNER is still a plain item; turning INNER into its
# own bundle afterwards would nest it under OUTER, so it must be rejected
suffix = frappe.generate_hash(length=8)
inner = make_item(f"_Test PB Nest Inner {suffix}", {"is_stock_item": 0, "is_sales_item": 1}).name
outer = make_item(f"_Test PB Nest Outer {suffix}", {"is_stock_item": 0, "is_sales_item": 1}).name
make_product_bundle(outer, [inner])
doc = frappe.get_doc({"doctype": "Product Bundle", "new_item_code": inner})
doc.append("items", {"item_code": self.child, "qty": 1})
self.assertRaises(frappe.ValidationError, doc.insert)