feat: validate if removed item attributes exist in variants (#22911)
* feat: validate if removed item attributes exist in variants
* Update erpnext/stock/doctype/item/item.py
Co-authored-by: Marica <maricadsouza221197@gmail.com>
* Update erpnext/stock/doctype/item/item.py
Co-authored-by: Marica <maricadsouza221197@gmail.com>
* refactor: smaller loop
Co-authored-by: marination <maricadsouza221197@gmail.com>
* feat: don't run validation for new entries
Co-authored-by: Marica <maricadsouza221197@gmail.com>
* fix: use tuple as is
Co-authored-by: Marica <maricadsouza221197@gmail.com>
* refactor: error description
Co-authored-by: Marica <maricadsouza221197@gmail.com>
* refactor: remove unused variable
Co-authored-by: Marica <maricadsouza221197@gmail.com>
diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py
index d209f48..d22fda8 100644
--- a/erpnext/stock/doctype/item/item.py
+++ b/erpnext/stock/doctype/item/item.py
@@ -111,6 +111,7 @@
self.synced_with_hub = 0
self.validate_has_variants()
+ self.validate_attributes_in_variants()
self.validate_stock_exists_for_template_item()
self.validate_attributes()
self.validate_variant_attributes()
@@ -806,6 +807,77 @@
if frappe.db.exists("Item", {"variant_of": self.name}):
frappe.throw(_("Item has variants."))
+ def validate_attributes_in_variants(self):
+ if not self.has_variants or self.get("__islocal"):
+ return
+
+ old_doc = self.get_doc_before_save()
+ old_doc_attributes = set([attr.attribute for attr in old_doc.attributes])
+ own_attributes = [attr.attribute for attr in self.attributes]
+
+ # Check if old attributes were removed from the list
+ # Is old_attrs is a subset of new ones
+ # that means we need not check any changes
+ if old_doc_attributes.issubset(set(own_attributes)):
+ return
+
+ from collections import defaultdict
+
+ # get all item variants
+ items = [item["name"] for item in frappe.get_all("Item", {"variant_of": self.name})]
+
+ # get all deleted attributes
+ deleted_attribute = list(old_doc_attributes.difference(set(own_attributes)))
+
+ # fetch all attributes of these items
+ item_attributes = frappe.get_all(
+ "Item Variant Attribute",
+ filters={
+ "parent": ["in", items],
+ "attribute": ["in", deleted_attribute]
+ },
+ fields=["attribute", "parent"]
+ )
+ not_included = defaultdict(list)
+
+ for attr in item_attributes:
+ if attr["attribute"] not in own_attributes:
+ not_included[attr["parent"]].append(attr["attribute"])
+
+ if not len(not_included):
+ return
+
+ def body(docnames):
+ docnames.sort()
+ return "<br>".join(docnames)
+
+ def table_row(title, body):
+ return """<tr>
+ <td>{0}</td>
+ <td>{1}</td>
+ </tr>""".format(title, body)
+
+ rows = ''
+ for docname, attr_list in not_included.items():
+ link = "<a href='#Form/Item/{0}'>{0}</a>".format(frappe.bold(_(docname)))
+ rows += table_row(link, body(attr_list))
+
+ error_description = _('The following deleted attributes exist in Variants but not in the Template. You can either delete the Variants or keep the attribute(s) in template.')
+
+ message = """
+ <div>{0}</div><br>
+ <table class="table">
+ <thead>
+ <td>{1}</td>
+ <td>{2}</td>
+ </thead>
+ {3}
+ </table>
+ """.format(error_description, _('Variant Items'), _('Attributes'), rows)
+
+ frappe.throw(message, title=_("Variant Attribute Error"), is_minimizable=True, wide=True)
+
+
def validate_stock_exists_for_template_item(self):
if self.stock_ledger_created() and self._doc_before_save:
if (cint(self._doc_before_save.has_variants) != cint(self.has_variants)