From 0fb37ad7924bc0e6e1709b387cfaca542ff602e8 Mon Sep 17 00:00:00 2001 From: Henning Wendtland <156231187+HenningWendtland@users.noreply.github.com> Date: Sun, 25 Jan 2026 09:50:28 +0100 Subject: [PATCH] feat(Transaction Deletion Record): Editable "DocTypes To Delete" List with CSV import/export (#50592) * feat: add editable DocTypes To Delete list with import/export Add user control over transaction deletion with reviewable and reusable deletion templates. - New "DocTypes To Delete" table allows users to review and customize what will be deleted before submission - Import/Export CSV templates for reusability across environments - Company field rule: only filter by company if field is specifically named "company", otherwise delete all records - Child tables (istable=1) automatically excluded from selection - "Remove Zero Counts" helper button to clean up list - Backward compatible with existing deletion records * refactor: improve Transaction Deletion Record code quality - Remove unnecessary chatty comments from AI-generated code - Add concise docstrings to all new methods - Remove redundant @frappe.whitelist() decorators from internal methods - Improve CSV import validation (header check, child table filtering) - Add better error feedback with consolidated skip messages - Reorder form fields: To Delete list now appears before Excluded list - Add conditional visibility for Summary table (legacy records only) - Improve architectural clarity: single API entry point per feature Technical improvements: - export_to_delete_template_method and import_to_delete_template_method are now internal helpers without whitelist decorators - CSV import now validates format and provides detailed skip reasons - Summary table only shows for submitted records without To Delete list - Maintains backward compatibility for existing deletion records * fix: field order * test: fix broken tests and add new ones * fix: adapt create_transaction_deletion_request * test: fix assertRaises trigger * fix: conditionally execute Transaction Deletion pre-tasks based on selected DocTypes * refactor: replace boolean task flags with status fields * fix: remove UI comment * fix: don't allow virtual doctype selection and improve protected Doctype List * fix: replace outdated frappe.db.sql by frappe.qb * feat: add support for multiple company fields * fix: autofill comapny field, add docstrings, filter for company_field * fix: add edge case handling for update_naming_series and add tests for prefix extraction * fix: use redis for running deletion validation, check per doctype instead of company --- .../transaction_deletion_record_details.json | 6 +- erpnext/patches.txt | 1 + ...ansaction_deletion_task_flags_to_status.py | 42 + erpnext/setup/demo.py | 4 + erpnext/setup/doctype/company/company.py | 14 +- .../test_transaction_deletion_record.py | 368 ++++++++- .../transaction_deletion_record.js | 301 ++++++- .../transaction_deletion_record.json | 90 +- .../transaction_deletion_record.py | 778 ++++++++++++++++-- .../transaction_deletion_record_item.json | 6 +- .../__init__.py | 0 ...transaction_deletion_record_to_delete.json | 67 ++ .../transaction_deletion_record_to_delete.py | 27 + transaction-deletion-import-logic-summary.md | 230 ++++++ 14 files changed, 1784 insertions(+), 150 deletions(-) create mode 100644 erpnext/patches/v16_0/migrate_transaction_deletion_task_flags_to_status.py create mode 100644 erpnext/setup/doctype/transaction_deletion_record_to_delete/__init__.py create mode 100644 erpnext/setup/doctype/transaction_deletion_record_to_delete/transaction_deletion_record_to_delete.json create mode 100644 erpnext/setup/doctype/transaction_deletion_record_to_delete/transaction_deletion_record_to_delete.py create mode 100644 transaction-deletion-import-logic-summary.md diff --git a/erpnext/accounts/doctype/transaction_deletion_record_details/transaction_deletion_record_details.json b/erpnext/accounts/doctype/transaction_deletion_record_details/transaction_deletion_record_details.json index 9023ee91000..89fa09b7f95 100644 --- a/erpnext/accounts/doctype/transaction_deletion_record_details/transaction_deletion_record_details.json +++ b/erpnext/accounts/doctype/transaction_deletion_record_details/transaction_deletion_record_details.json @@ -43,16 +43,18 @@ "read_only": 1 } ], + "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-03-27 13:10:55.008837", + "modified": "2025-11-14 16:17:25.584675", "modified_by": "Administrator", "module": "Accounts", "name": "Transaction Deletion Record Details", "owner": "Administrator", "permissions": [], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [] -} \ No newline at end of file +} diff --git a/erpnext/patches.txt b/erpnext/patches.txt index b45e65a4a79..eb7c7605aac 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -459,3 +459,4 @@ erpnext.patches.v16_0.fix_barcode_typo erpnext.patches.v16_0.set_post_change_gl_entries_on_pos_settings erpnext.patches.v15_0.create_accounting_dimensions_in_advance_taxes_and_charges execute:frappe.delete_doc_if_exists("Workspace Sidebar", "Opening & Closing") +erpnext.patches.v16_0.migrate_transaction_deletion_task_flags_to_status # 2 diff --git a/erpnext/patches/v16_0/migrate_transaction_deletion_task_flags_to_status.py b/erpnext/patches/v16_0/migrate_transaction_deletion_task_flags_to_status.py new file mode 100644 index 00000000000..1943650565c --- /dev/null +++ b/erpnext/patches/v16_0/migrate_transaction_deletion_task_flags_to_status.py @@ -0,0 +1,42 @@ +import frappe + + +def execute(): + """ + Migrate Transaction Deletion Record boolean task flags to status Select fields. + Renames fields from old names to new names with _status suffix. + Maps: 0 -> "Pending", 1 -> "Completed" + """ + if not frappe.db.table_exists("tabTransaction Deletion Record"): + return + + # Field mapping: old boolean field name -> new status field name + field_mapping = { + "delete_bin_data": "delete_bin_data_status", + "delete_leads_and_addresses": "delete_leads_and_addresses_status", + "reset_company_default_values": "reset_company_default_values_status", + "clear_notifications": "clear_notifications_status", + "initialize_doctypes_table": "initialize_doctypes_table_status", + "delete_transactions": "delete_transactions_status", + } + + # Get all Transaction Deletion Records + records = frappe.db.get_all("Transaction Deletion Record", pluck="name") + + for name in records or []: + updates = {} + + for old_field, new_field in field_mapping.items(): + # Read from old boolean field + current_value = frappe.db.get_value("Transaction Deletion Record", name, old_field) + + # Map to new status and write to new field name + if current_value in (1, "1", True): + updates[new_field] = "Completed" + else: + # Handle 0, "0", False, None, empty string + updates[new_field] = "Pending" + + # Update all fields at once + if updates: + frappe.db.set_value("Transaction Deletion Record", name, updates, update_modified=False) diff --git a/erpnext/setup/demo.py b/erpnext/setup/demo.py index b21fb96546a..7835aeb9a9e 100644 --- a/erpnext/setup/demo.py +++ b/erpnext/setup/demo.py @@ -182,6 +182,10 @@ def create_transaction_deletion_record(company): transaction_deletion_record.company = company transaction_deletion_record.process_in_single_transaction = True transaction_deletion_record.save(ignore_permissions=True) + + transaction_deletion_record.generate_to_delete_list() + transaction_deletion_record.reload() + transaction_deletion_record.submit() transaction_deletion_record.start_deletion_tasks() diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index f68ba664173..60bb64f46d5 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -1081,6 +1081,8 @@ def get_billing_shipping_address(name, billing_address=None, shipping_address=No @frappe.whitelist() def create_transaction_deletion_request(company): + frappe.only_for("System Manager") + from erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record import ( is_deletion_doc_running, ) @@ -1088,12 +1090,16 @@ def create_transaction_deletion_request(company): is_deletion_doc_running(company) tdr = frappe.get_doc({"doctype": "Transaction Deletion Record", "company": company}) + tdr.insert() + + tdr.generate_to_delete_list() + tdr.reload() + tdr.submit() tdr.start_deletion_tasks() frappe.msgprint( - _("A Transaction Deletion Document: {0} is triggered for {0}").format( - get_link_to_form("Transaction Deletion Record", tdr.name) - ), - frappe.bold(company), + _("Transaction Deletion Document {0} has been triggered for company {1}").format( + get_link_to_form("Transaction Deletion Record", tdr.name), frappe.bold(company) + ) ) diff --git a/erpnext/setup/doctype/transaction_deletion_record/test_transaction_deletion_record.py b/erpnext/setup/doctype/transaction_deletion_record/test_transaction_deletion_record.py index fcfb006860c..b1c96fc66b0 100644 --- a/erpnext/setup/doctype/transaction_deletion_record/test_transaction_deletion_record.py +++ b/erpnext/setup/doctype/transaction_deletion_record/test_transaction_deletion_record.py @@ -8,38 +8,77 @@ from frappe.tests import IntegrationTestCase class TestTransactionDeletionRecord(IntegrationTestCase): def setUp(self): + # Clear all deletion cache flags from previous tests + self._clear_all_deletion_cache_flags() create_company("Dunder Mifflin Paper Co") def tearDown(self): + # Clean up all deletion cache flags after each test + self._clear_all_deletion_cache_flags() frappe.db.rollback() + def _clear_all_deletion_cache_flags(self): + """Clear all deletion_running_doctype:* cache keys""" + # Get all keys matching the pattern + cache_keys = frappe.cache.get_keys("deletion_running_doctype:*") + if cache_keys: + for key in cache_keys: + # Decode bytes to string if needed + key_str = key.decode() if isinstance(key, bytes) else key + # Extract just the key name (remove site prefix if present) + # Keys are in format: site_prefix|deletion_running_doctype:DocType + if "|" in key_str: + key_name = key_str.split("|")[1] + else: + key_name = key_str + frappe.cache.delete_value(key_name) + def test_doctypes_contain_company_field(self): - tdr = create_transaction_deletion_doc("Dunder Mifflin Paper Co") - for doctype in tdr.doctypes: - contains_company = False - doctype_fields = frappe.get_meta(doctype.doctype_name).as_dict()["fields"] - for doctype_field in doctype_fields: - if doctype_field["fieldtype"] == "Link" and doctype_field["options"] == "Company": - contains_company = True - break - self.assertTrue(contains_company) + """Test that all DocTypes in To Delete list have a valid company link field""" + tdr = create_and_submit_transaction_deletion_doc("Dunder Mifflin Paper Co") + for doctype_row in tdr.doctypes_to_delete: + # If company_field is specified, verify it's a valid Company link field + if doctype_row.company_field: + field_found = False + doctype_fields = frappe.get_meta(doctype_row.doctype_name).as_dict()["fields"] + for doctype_field in doctype_fields: + if ( + doctype_field["fieldname"] == doctype_row.company_field + and doctype_field["fieldtype"] == "Link" + and doctype_field["options"] == "Company" + ): + field_found = True + break + self.assertTrue( + field_found, + f"DocType {doctype_row.doctype_name} should have company field '{doctype_row.company_field}'", + ) def test_no_of_docs_is_correct(self): - for _i in range(5): + """Test that document counts are calculated correctly in To Delete list""" + for _ in range(5): create_task("Dunder Mifflin Paper Co") - tdr = create_transaction_deletion_doc("Dunder Mifflin Paper Co") + tdr = create_and_submit_transaction_deletion_doc("Dunder Mifflin Paper Co") tdr.reload() - for doctype in tdr.doctypes: + + # Check To Delete list has correct count + task_found = False + for doctype in tdr.doctypes_to_delete: if doctype.doctype_name == "Task": - self.assertEqual(doctype.no_of_docs, 5) + self.assertEqual(doctype.document_count, 5) + task_found = True + break + self.assertTrue(task_found, "Task should be in To Delete list") def test_deletion_is_successful(self): + """Test that deletion actually removes documents""" create_task("Dunder Mifflin Paper Co") - create_transaction_deletion_doc("Dunder Mifflin Paper Co") + create_and_submit_transaction_deletion_doc("Dunder Mifflin Paper Co") tasks_containing_company = frappe.get_all("Task", filters={"company": "Dunder Mifflin Paper Co"}) self.assertEqual(tasks_containing_company, []) def test_company_transaction_deletion_request(self): + """Test creation via company deletion request method""" from erpnext.setup.doctype.company.company import create_transaction_deletion_request # don't reuse below company for other test cases @@ -49,15 +88,314 @@ class TestTransactionDeletionRecord(IntegrationTestCase): # below call should not raise any exceptions or throw errors create_transaction_deletion_request(company) + def test_generate_to_delete_list(self): + """Test automatic generation of To Delete list""" + company = "Dunder Mifflin Paper Co" + create_task(company) + + tdr = frappe.new_doc("Transaction Deletion Record") + tdr.company = company + tdr.insert() + + # Generate To Delete list + tdr.generate_to_delete_list() + tdr.reload() + + # Should have at least Task in the list + self.assertGreater(len(tdr.doctypes_to_delete), 0) + task_in_list = any(d.doctype_name == "Task" for d in tdr.doctypes_to_delete) + self.assertTrue(task_in_list, "Task should be in To Delete list") + + def test_validation_prevents_child_tables(self): + """Test that child tables cannot be added to To Delete list""" + company = "Dunder Mifflin Paper Co" + + tdr = frappe.new_doc("Transaction Deletion Record") + tdr.company = company + tdr.append("doctypes_to_delete", {"doctype_name": "Sales Invoice Item"}) # Child table + + # Should throw validation error + with self.assertRaises(frappe.ValidationError): + tdr.insert() + + def test_validation_prevents_protected_doctypes(self): + """Test that protected DocTypes cannot be added to To Delete list""" + company = "Dunder Mifflin Paper Co" + + tdr = frappe.new_doc("Transaction Deletion Record") + tdr.company = company + tdr.append("doctypes_to_delete", {"doctype_name": "DocType"}) # Protected + + # Should throw validation error + with self.assertRaises(frappe.ValidationError): + tdr.insert() + + def test_csv_export_import(self): + """Test CSV export and import functionality with company_field column""" + company = "Dunder Mifflin Paper Co" + create_task(company) + + # Create and generate To Delete list + tdr = frappe.new_doc("Transaction Deletion Record") + tdr.company = company + tdr.insert() + tdr.generate_to_delete_list() + tdr.reload() + + original_count = len(tdr.doctypes_to_delete) + self.assertGreater(original_count, 0) + + # Export as CSV + tdr.export_to_delete_template_method() + csv_content = frappe.response.get("result") + self.assertIsNotNone(csv_content) + self.assertIn("doctype_name", csv_content) + self.assertIn("company_field", csv_content) # New: verify company_field column exists + + # Create new record and import + tdr2 = frappe.new_doc("Transaction Deletion Record") + tdr2.company = company + tdr2.insert() + result = tdr2.import_to_delete_template_method(csv_content) + tdr2.reload() + + # Should have same entries (counts may differ due to new task) + self.assertEqual(len(tdr2.doctypes_to_delete), original_count) + self.assertGreaterEqual(result["imported"], 1) + + # Verify company_field values are preserved + for row in tdr2.doctypes_to_delete: + if row.doctype_name == "Task": + # Task should have company field set + self.assertIsNotNone(row.company_field, "Task should have company_field set after import") + + def test_progress_tracking(self): + """Test that deleted checkbox is marked when DocType deletion completes""" + company = "Dunder Mifflin Paper Co" + create_task(company) + + tdr = create_and_submit_transaction_deletion_doc(company) + tdr.reload() + + # After deletion, Task should be marked as deleted in To Delete list + # Note: Must match using composite key (doctype_name + company_field) + task_row = None + for doctype in tdr.doctypes_to_delete: + if doctype.doctype_name == "Task": + task_row = doctype + break + + if task_row: + self.assertEqual(task_row.deleted, 1, "Task should be marked as deleted") + + def test_composite_key_validation(self): + """Test that duplicate (doctype_name + company_field) combinations are prevented""" + company = "Dunder Mifflin Paper Co" + + tdr = frappe.new_doc("Transaction Deletion Record") + tdr.company = company + tdr.append("doctypes_to_delete", {"doctype_name": "Task", "company_field": "company"}) + tdr.append("doctypes_to_delete", {"doctype_name": "Task", "company_field": "company"}) # Duplicate! + + # Should throw validation error for duplicate composite key + with self.assertRaises(frappe.ValidationError): + tdr.insert() + + def test_same_doctype_different_company_field_allowed(self): + """Test that same DocType can be added with different company_field values""" + company = "Dunder Mifflin Paper Co" + + tdr = frappe.new_doc("Transaction Deletion Record") + tdr.company = company + # Same DocType but one with company field, one without (None) + tdr.append("doctypes_to_delete", {"doctype_name": "Task", "company_field": "company"}) + tdr.append("doctypes_to_delete", {"doctype_name": "Task", "company_field": None}) + + # Should NOT throw error - different company_field values are allowed + try: + tdr.insert() + self.assertEqual( + len(tdr.doctypes_to_delete), + 2, + "Should allow 2 Task entries with different company_field values", + ) + except frappe.ValidationError as e: + self.fail(f"Should allow same DocType with different company_field values, but got error: {e}") + + def test_company_field_validation(self): + """Test that invalid company_field values are rejected""" + company = "Dunder Mifflin Paper Co" + + tdr = frappe.new_doc("Transaction Deletion Record") + tdr.company = company + # Add Task with invalid company field + tdr.append("doctypes_to_delete", {"doctype_name": "Task", "company_field": "nonexistent_field"}) + + # Should throw validation error for invalid company field + with self.assertRaises(frappe.ValidationError): + tdr.insert() + + def test_get_naming_series_prefix_with_dot(self): + """Test prefix extraction for standard dot-separated naming series""" + from erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record import ( + TransactionDeletionRecord, + ) + + # Standard patterns with dot separator + self.assertEqual(TransactionDeletionRecord.get_naming_series_prefix("TDL.####", "Task"), "TDL") + self.assertEqual(TransactionDeletionRecord.get_naming_series_prefix("PREFIX.#####", "Task"), "PREFIX") + self.assertEqual( + TransactionDeletionRecord.get_naming_series_prefix("TASK-.YYYY.-.#####", "Task"), "TASK-.YYYY.-" + ) + + def test_get_naming_series_prefix_with_brace(self): + """Test prefix extraction for format patterns with brace separators""" + from erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record import ( + TransactionDeletionRecord, + ) + + # Format patterns with brace separator + self.assertEqual( + TransactionDeletionRecord.get_naming_series_prefix("QA-ACT-{#####}", "Quality Action"), "QA-ACT-" + ) + self.assertEqual( + TransactionDeletionRecord.get_naming_series_prefix("PREFIX-{####}", "Task"), "PREFIX-" + ) + self.assertEqual(TransactionDeletionRecord.get_naming_series_prefix("{####}", "Task"), "") + + def test_get_naming_series_prefix_fallback(self): + """Test prefix extraction fallback for patterns without standard separators""" + from erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record import ( + TransactionDeletionRecord, + ) + + # Edge case: pattern with # but no dot or brace (shouldn't happen in practice) + self.assertEqual(TransactionDeletionRecord.get_naming_series_prefix("PREFIX####", "Task"), "PREFIX") + # Edge case: pattern with no # at all + self.assertEqual( + TransactionDeletionRecord.get_naming_series_prefix("JUSTPREFIX", "Task"), "JUSTPREFIX" + ) + + def test_cache_flag_management(self): + """Test that cache flags can be set and cleared correctly""" + company = "Dunder Mifflin Paper Co" + create_task(company) + + tdr = frappe.new_doc("Transaction Deletion Record") + tdr.company = company + tdr.insert() + tdr.generate_to_delete_list() + tdr.reload() + + # Test _set_deletion_cache + tdr._set_deletion_cache() + + # Verify flag is set for Task specifically + cached_value = frappe.cache.get_value("deletion_running_doctype:Task") + self.assertEqual(cached_value, tdr.name, "Cache flag should be set for Task") + + # Test _clear_deletion_cache + tdr._clear_deletion_cache() + + # Verify flag is cleared + cached_value = frappe.cache.get_value("deletion_running_doctype:Task") + self.assertIsNone(cached_value, "Cache flag should be cleared for Task") + + def test_check_for_running_deletion_blocks_save(self): + """Test that check_for_running_deletion_job blocks saves when cache flag exists""" + from erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record import ( + check_for_running_deletion_job, + ) + + company = "Dunder Mifflin Paper Co" + + # Manually set cache flag to simulate running deletion + frappe.cache.set_value("deletion_running_doctype:Task", "TDR-00001", expires_in_sec=60) + + try: + # Try to validate a new Task + new_task = frappe.new_doc("Task") + new_task.company = company + new_task.subject = "Should be blocked" + + # Should throw error when cache flag exists + with self.assertRaises(frappe.ValidationError) as context: + check_for_running_deletion_job(new_task) + + error_message = str(context.exception) + self.assertIn("currently deleting", error_message) + self.assertIn("TDR-00001", error_message) + finally: + # Cleanup: clear the manually set flag + frappe.cache.delete_value("deletion_running_doctype:Task") + + def test_check_for_running_deletion_allows_save_when_no_flag(self): + """Test that documents can be saved when no deletion is running""" + company = "Dunder Mifflin Paper Co" + + # Ensure no cache flag exists + frappe.cache.delete_value("deletion_running_doctype:Task") + + # Try to create and save a new Task + new_task = frappe.new_doc("Task") + new_task.company = company + new_task.subject = "Should be allowed" + + # Should NOT throw error when no cache flag - actually save it + try: + new_task.insert() + # Cleanup + frappe.delete_doc("Task", new_task.name) + except frappe.ValidationError as e: + self.fail(f"Should allow save when no deletion is running, but got: {e}") + + def test_only_one_deletion_allowed_globally(self): + """Test that only one deletion can be submitted at a time (global enforcement)""" + company1 = "Dunder Mifflin Paper Co" + company2 = "Sabre Corporation" + + create_company(company2) + + # Create and submit first deletion (but don't start it) + tdr1 = frappe.new_doc("Transaction Deletion Record") + tdr1.company = company1 + tdr1.insert() + tdr1.append("doctypes_to_delete", {"doctype_name": "Task", "company_field": "company"}) + tdr1.save() + tdr1.submit() # Status becomes "Queued" + + try: + # Try to submit second deletion for different company + tdr2 = frappe.new_doc("Transaction Deletion Record") + tdr2.company = company2 # Different company! + tdr2.insert() + tdr2.append("doctypes_to_delete", {"doctype_name": "Lead", "company_field": "company"}) + tdr2.save() + + # Should throw error - only one deletion allowed globally + with self.assertRaises(frappe.ValidationError) as context: + tdr2.submit() + + self.assertIn("already", str(context.exception).lower()) + self.assertIn(tdr1.name, str(context.exception)) + finally: + # Cleanup + tdr1.cancel() + def create_company(company_name): company = frappe.get_doc({"doctype": "Company", "company_name": company_name, "default_currency": "INR"}) company.insert(ignore_if_duplicate=True) -def create_transaction_deletion_doc(company): +def create_and_submit_transaction_deletion_doc(company): + """Create and execute a transaction deletion record""" tdr = frappe.get_doc({"doctype": "Transaction Deletion Record", "company": company}) tdr.insert() + + tdr.generate_to_delete_list() + tdr.reload() + tdr.process_in_single_transaction = True tdr.submit() tdr.start_deletion_tasks() diff --git a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.js b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.js index 9aa02784165..e1d5c52ba02 100644 --- a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.js +++ b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.js @@ -2,13 +2,58 @@ // For license information, please see license.txt frappe.ui.form.on("Transaction Deletion Record", { + setup: function (frm) { + // Set up query for DocTypes to exclude child tables and virtual doctypes + // Note: Same DocType can be added multiple times with different company_field values + frm.set_query("doctype_name", "doctypes_to_delete", function () { + // Build exclusion list from protected and ignored doctypes + let excluded_doctypes = ["Transaction Deletion Record"]; // Always exclude self + + // Add protected doctypes (fetched in onload) + if (frm.protected_doctypes_list && frm.protected_doctypes_list.length > 0) { + excluded_doctypes = excluded_doctypes.concat(frm.protected_doctypes_list); + } + + // Add doctypes from the ignore list + if (frm.doc.doctypes_to_be_ignored && frm.doc.doctypes_to_be_ignored.length > 0) { + frm.doc.doctypes_to_be_ignored.forEach((row) => { + if (row.doctype_name) { + excluded_doctypes.push(row.doctype_name); + } + }); + } + + let filters = [ + ["DocType", "istable", "=", 0], // Exclude child tables + ["DocType", "is_virtual", "=", 0], // Exclude virtual doctypes + ]; + + // Only add "not in" filter if we have items to exclude + if (excluded_doctypes.length > 0) { + filters.push(["DocType", "name", "not in", excluded_doctypes]); + } + + return { filters: filters }; + }); + }, + onload: function (frm) { if (frm.doc.docstatus == 0) { - let doctypes_to_be_ignored_array; + // Fetch protected doctypes list for filtering + frappe.call({ + method: "erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record.get_protected_doctypes", + callback: function (r) { + if (r.message) { + frm.protected_doctypes_list = r.message; + } + }, + }); + + // Fetch ignored doctypes and populate table frappe.call({ method: "erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record.get_doctypes_to_be_ignored", callback: function (r) { - doctypes_to_be_ignored_array = r.message; + let doctypes_to_be_ignored_array = r.message; populate_doctypes_to_be_ignored(doctypes_to_be_ignored_array, frm); frm.refresh_field("doctypes_to_be_ignored"); }, @@ -17,20 +62,264 @@ frappe.ui.form.on("Transaction Deletion Record", { }, refresh: function (frm) { - if (frm.doc.docstatus == 1 && ["Queued", "Failed"].find((x) => x == frm.doc.status)) { - let execute_btn = frm.doc.status == "Queued" ? __("Start Deletion") : __("Retry"); + // Override submit button to show custom confirmation + if (frm.doc.docstatus === 0 && !frm.is_new()) { + frm.page.clear_primary_action(); + frm.page.set_primary_action(__("Submit"), () => { + if (!frm.doc.doctypes_to_delete || frm.doc.doctypes_to_delete.length === 0) { + frappe.msgprint(__("Please generate the To Delete list before submitting")); + return; + } - frm.add_custom_button(execute_btn, () => { - // Entry point for chain of events + let message = + `
⚠ ${__( + "Warning: This action cannot be undone!" + )}
` + + `
${__( + "You are about to permanently delete data for {0} entries for company {1}.", + [`${frm.doc.doctypes_to_delete.length}`, `${frm.doc.company}`] + )}
` + + `
${__("What will be deleted:")}
` + + `` + + `
` + + `📦 ${__( + "IMPORTANT: Create a backup before proceeding!" + )}` + + `
` + + `
${__( + "Deletion will start automatically after submission." + )}
`; + + frappe.confirm( + message, + () => { + frm.save("Submit"); + }, + () => {} + ); + }); + } + + if (frm.doc.docstatus == 0) { + frm.add_custom_button(__("Generate To Delete List"), () => { + frm.call({ + method: "generate_to_delete_list", + doc: frm.doc, + callback: (r) => { + frappe.show_alert({ + message: __("To Delete list generated with {0} DocTypes", [r.message.count]), + indicator: "green", + }); + frm.refresh(); + }, + }); + }); + + if (frm.doc.doctypes_to_delete && frm.doc.doctypes_to_delete.length > 0) { + frm.add_custom_button( + __("Export"), + () => { + open_url_post( + "/api/method/erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record.export_to_delete_template", + { + name: frm.doc.name, + } + ); + }, + __("Template") + ); + + frm.add_custom_button(__("Remove Zero Counts"), () => { + let removed_count = 0; + let rows_to_keep = []; + frm.doc.doctypes_to_delete.forEach((row) => { + if (row.document_count && row.document_count > 0) { + rows_to_keep.push(row); + } else { + removed_count++; + } + }); + + if (removed_count === 0) { + frappe.msgprint(__("No rows with zero document count found")); + return; + } + + frm.doc.doctypes_to_delete = rows_to_keep; + frm.refresh_field("doctypes_to_delete"); + frm.dirty(); + + frappe.show_alert({ + message: __( + "Removed {0} rows with zero document count. Please save to persist changes.", + [removed_count] + ), + indicator: "orange", + }); + }); + } + + frm.add_custom_button( + __("Import"), + () => { + new frappe.ui.FileUploader({ + doctype: "Transaction Deletion Record", + docname: frm.doc.name, + folder: "Home/Attachments", + restrictions: { + allowed_file_types: [".csv"], + }, + on_success: (file_doc) => { + frappe.call({ + method: "erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record.process_import_template", + args: { + transaction_deletion_record_name: frm.doc.name, + file_url: file_doc.file_url, + }, + freeze: true, + freeze_message: __("Processing import..."), + callback: (r) => { + if (r.message) { + frappe.show_alert({ + message: __("Imported {0} DocTypes", [r.message.imported]), + indicator: "green", + }); + + frappe.model.clear_doc(frm.doctype, frm.docname); + frm.reload_doc(); + } + }, + }); + }, + }); + }, + __("Template") + ); + } + + // Only show Retry button for Failed status (deletion starts automatically on submit) + if (frm.doc.docstatus == 1 && frm.doc.status == "Failed") { + frm.add_custom_button(__("Retry"), () => { frm.call({ method: "start_deletion_tasks", doc: frm.doc, + callback: () => { + frappe.show_alert({ + message: __("Deletion process restarted"), + indicator: "blue", + }); + frm.reload_doc(); + }, }); }); } }, }); +frappe.ui.form.on("Transaction Deletion Record To Delete", { + doctype_name: function (frm, cdt, cdn) { + let row = locals[cdt][cdn]; + if (row.doctype_name) { + // Fetch company fields for auto-selection (only if exactly 1 field exists) + frappe.call({ + method: "erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record.get_company_link_fields", + args: { + doctype_name: row.doctype_name, + }, + callback: function (r) { + if (r.message && r.message.length === 1 && !row.company_field) { + frappe.model.set_value(cdt, cdn, "company_field", r.message[0]); + } else if (r.message && r.message.length > 1) { + // Show message with available options when multiple company fields exist + frappe.show_alert({ + message: __("Multiple company fields available: {0}. Please select manually.", [ + r.message.join(", "), + ]), + indicator: "blue", + }); + } + }, + }); + + // Auto-populate child DocTypes and document count + frm.call({ + method: "populate_doctype_details", + doc: frm.doc, + args: { + doctype_name: row.doctype_name, + company: frm.doc.company, + company_field: row.company_field, + }, + callback: function (r) { + if (r.message) { + if (r.message.error) { + frappe.msgprint({ + title: __("Error"), + indicator: "red", + message: __("Error getting details for {0}: {1}", [ + row.doctype_name, + r.message.error, + ]), + }); + } + frappe.model.set_value(cdt, cdn, "child_doctypes", r.message.child_doctypes || ""); + frappe.model.set_value(cdt, cdn, "document_count", r.message.document_count || 0); + } + }, + }); + } + }, + + company_field: function (frm, cdt, cdn) { + let row = locals[cdt][cdn]; + if (row.doctype_name && row.company_field !== undefined) { + // Check for duplicates using composite key (doctype_name + company_field) + let duplicates = frm.doc.doctypes_to_delete.filter( + (r) => + r.doctype_name === row.doctype_name && + r.company_field === row.company_field && + r.name !== row.name + ); + if (duplicates.length > 0) { + frappe.msgprint( + __("DocType {0} with company field '{1}' is already in the list", [ + row.doctype_name, + row.company_field || __("(none)"), + ]) + ); + frappe.model.set_value(cdt, cdn, "company_field", ""); + return; + } + + // Recalculate document count if company_field changes + if (row.doctype_name) { + frm.call({ + method: "populate_doctype_details", + doc: frm.doc, + args: { + doctype_name: row.doctype_name, + company: frm.doc.company, + company_field: row.company_field, + }, + callback: function (r) { + if (r.message && r.message.document_count !== undefined) { + frappe.model.set_value(cdt, cdn, "document_count", r.message.document_count || 0); + } + }, + }); + } + } + }, +}); + function populate_doctypes_to_be_ignored(doctypes_to_be_ignored_array, frm) { if (frm.doc.doctypes_to_be_ignored.length === 0) { var i; diff --git a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.json b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.json index 16d23f8e3e3..f309139bb5d 100644 --- a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.json +++ b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.json @@ -11,14 +11,17 @@ "status", "error_log", "tasks_section", - "delete_bin_data", - "delete_leads_and_addresses", - "reset_company_default_values", - "clear_notifications", - "initialize_doctypes_table", - "delete_transactions", + "delete_bin_data_status", + "delete_leads_and_addresses_status", + "column_break_tasks_1", + "reset_company_default_values_status", + "clear_notifications_status", + "column_break_tasks_2", + "initialize_doctypes_table_status", + "delete_transactions_status", "section_break_tbej", "doctypes", + "doctypes_to_delete", "doctypes_to_be_ignored", "amended_from", "process_in_single_transaction" @@ -33,6 +36,7 @@ "reqd": 1 }, { + "depends_on": "eval:doc.docstatus > 0 && (!doc.doctypes_to_delete || doc.doctypes_to_delete.length == 0)", "fieldname": "doctypes", "fieldtype": "Table", "label": "Summary", @@ -41,11 +45,17 @@ "read_only": 1 }, { + "fieldname": "doctypes_to_delete", + "fieldtype": "Table", + "label": "DocTypes To Delete", + "options": "Transaction Deletion Record To Delete" + }, + { + "description": "DocTypes that will NOT be deleted.", "fieldname": "doctypes_to_be_ignored", "fieldtype": "Table", "label": "Excluded DocTypes", - "options": "Transaction Deletion Record Item", - "read_only": 1 + "options": "Transaction Deletion Record Item" }, { "fieldname": "amended_from", @@ -69,56 +79,71 @@ "fieldtype": "Section Break" }, { + "depends_on": "eval:doc.docstatus==1", "fieldname": "tasks_section", "fieldtype": "Section Break", "label": "Tasks" }, { - "default": "0", - "fieldname": "delete_bin_data", - "fieldtype": "Check", + "default": "Pending", + "fieldname": "delete_bin_data_status", + "fieldtype": "Select", "label": "Delete Bins", "no_copy": 1, + "options": "Pending\nCompleted\nSkipped", "read_only": 1 }, { - "default": "0", - "fieldname": "delete_leads_and_addresses", - "fieldtype": "Check", + "default": "Pending", + "fieldname": "delete_leads_and_addresses_status", + "fieldtype": "Select", "label": "Delete Leads and Addresses", "no_copy": 1, + "options": "Pending\nCompleted\nSkipped", "read_only": 1 }, { - "default": "0", - "fieldname": "clear_notifications", - "fieldtype": "Check", - "label": "Clear Notifications", - "no_copy": 1, - "read_only": 1 + "fieldname": "column_break_tasks_1", + "fieldtype": "Column Break" }, { - "default": "0", - "fieldname": "reset_company_default_values", - "fieldtype": "Check", + "default": "Pending", + "fieldname": "reset_company_default_values_status", + "fieldtype": "Select", "label": "Reset Company Default Values", "no_copy": 1, + "options": "Pending\nCompleted\nSkipped", "read_only": 1 }, { - "default": "0", - "fieldname": "delete_transactions", - "fieldtype": "Check", - "label": "Delete Transactions", + "default": "Pending", + "fieldname": "clear_notifications_status", + "fieldtype": "Select", + "label": "Clear Notifications", "no_copy": 1, + "options": "Pending\nCompleted\nSkipped", "read_only": 1 }, { - "default": "0", - "fieldname": "initialize_doctypes_table", - "fieldtype": "Check", + "fieldname": "column_break_tasks_2", + "fieldtype": "Column Break" + }, + { + "default": "Pending", + "fieldname": "initialize_doctypes_table_status", + "fieldtype": "Select", "label": "Initialize Summary Table", "no_copy": 1, + "options": "Pending\nCompleted\nSkipped", + "read_only": 1 + }, + { + "default": "Pending", + "fieldname": "delete_transactions_status", + "fieldtype": "Select", + "label": "Delete Transactions", + "no_copy": 1, + "options": "Pending\nCompleted\nSkipped", "read_only": 1 }, { @@ -144,7 +169,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2024-03-27 13:10:54.828051", + "modified": "2025-11-18 15:02:46.427695", "modified_by": "Administrator", "module": "Setup", "name": "Transaction Deletion Record", @@ -165,8 +190,9 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py index 25459ee8567..b308453c847 100644 --- a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py +++ b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py @@ -7,6 +7,7 @@ import frappe from frappe import _, qb from frappe.desk.notifications import clear_notifications from frappe.model.document import Document +from frappe.query_builder.functions import Max from frappe.utils import cint, comma_and, create_batch, get_link_to_form from frappe.utils.background_jobs import get_job, is_job_enqueued from frappe.utils.caching import request_cache @@ -19,6 +20,95 @@ LEDGER_ENTRY_DOCTYPES = frozenset( ) ) +DELETION_CACHE_TTL = 4 * 60 * 60 # 4 hours in seconds + +PROTECTED_CORE_DOCTYPES = frozenset( + ( + # Core Meta + "DocType", + "DocField", + "Custom Field", + "Property Setter", + "DocPerm", + "Custom DocPerm", + # User & Permissions + "User", + "Role", + "Has Role", + "User Permission", + "User Type", + # System Configuration + "Module Def", + "Workflow", + "Workflow State", + "System Settings", + # Critical System DocTypes + "File", + "Version", + "Activity Log", + "Error Log", + "Scheduled Job Type", + "Scheduled Job Log", + "Server Script", + "Client Script", + "Data Import", + "Data Export", + "Report", + "Print Format", + "Email Template", + "Assignment Rule", + "Workspace", + "Dashboard", + "Access Log", + # Transaction Deletion + "Transaction Deletion Record", + "Company", + ) +) + + +@frappe.whitelist() +def get_protected_doctypes(): + """Get list of protected DocTypes that cannot be deleted (whitelisted for frontend)""" + frappe.only_for("System Manager") + return _get_protected_doctypes_internal() + + +@frappe.whitelist() +def get_company_link_fields(doctype_name): + """Get all Company Link field names for a DocType (whitelisted for frontend autocomplete) + + Args: + doctype_name: The DocType to check + + Returns: + list: List of field names that link to Company DocType, ordered by field index + """ + frappe.only_for("System Manager") + if not doctype_name or not frappe.db.exists("DocType", doctype_name): + return [] + + return frappe.get_all( + "DocField", + filters={"parent": doctype_name, "fieldtype": "Link", "options": "Company"}, + pluck="fieldname", + order_by="idx", + ) + + +def _get_protected_doctypes_internal(): + """Internal method to get protected doctypes""" + protected = [] + + for doctype in PROTECTED_CORE_DOCTYPES: + if frappe.db.exists("DocType", doctype): + protected.append(doctype) + + singles = frappe.get_all("DocType", filters={"issingle": 1}, pluck="name") + protected.extend(singles) + + return protected + class TransactionDeletionRecord(Document): # begin: auto-generated types @@ -35,19 +125,23 @@ class TransactionDeletionRecord(Document): from erpnext.setup.doctype.transaction_deletion_record_item.transaction_deletion_record_item import ( TransactionDeletionRecordItem, ) + from erpnext.setup.doctype.transaction_deletion_record_to_delete.transaction_deletion_record_to_delete import ( + TransactionDeletionRecordToDelete, + ) amended_from: DF.Link | None - clear_notifications: DF.Check + clear_notifications_status: DF.Literal["Pending", "Completed", "Skipped"] company: DF.Link - delete_bin_data: DF.Check - delete_leads_and_addresses: DF.Check - delete_transactions: DF.Check + delete_bin_data_status: DF.Literal["Pending", "Completed", "Skipped"] + delete_leads_and_addresses_status: DF.Literal["Pending", "Completed", "Skipped"] + delete_transactions_status: DF.Literal["Pending", "Completed", "Skipped"] doctypes: DF.Table[TransactionDeletionRecordDetails] doctypes_to_be_ignored: DF.Table[TransactionDeletionRecordItem] + doctypes_to_delete: DF.Table[TransactionDeletionRecordToDelete] error_log: DF.LongText | None - initialize_doctypes_table: DF.Check + initialize_doctypes_table_status: DF.Literal["Pending", "Completed", "Skipped"] process_in_single_transaction: DF.Check - reset_company_default_values: DF.Check + reset_company_default_values_status: DF.Literal["Pending", "Completed", "Skipped"] status: DF.Literal["Queued", "Running", "Failed", "Completed", "Cancelled"] # end: auto-generated types @@ -71,33 +165,90 @@ class TransactionDeletionRecord(Document): def validate(self): frappe.only_for("System Manager") - self.validate_doctypes_to_be_ignored() + self.validate_to_delete_list() - def validate_doctypes_to_be_ignored(self): - doctypes_to_be_ignored_list = get_doctypes_to_be_ignored() - for doctype in self.doctypes_to_be_ignored: - if doctype.doctype_name not in doctypes_to_be_ignored_list: + def validate_to_delete_list(self): + """Validate To Delete list: existence, protection status, child table exclusion, duplicates""" + if not self.doctypes_to_delete: + return + + protected = _get_protected_doctypes_internal() + seen_combinations = set() + + for item in self.doctypes_to_delete: + if not frappe.db.exists("DocType", item.doctype_name): + frappe.throw(_("DocType {0} does not exist").format(item.doctype_name)) + + # Check for duplicates using composite key + composite_key = (item.doctype_name, item.company_field or None) + if composite_key in seen_combinations: + field_desc = f" with company field '{item.company_field}'" if item.company_field else "" frappe.throw( - _( - "DocTypes should not be added manually to the 'Excluded DocTypes' table. You are only allowed to remove entries from it." - ), - title=_("Not Allowed"), + _("Duplicate entry: {0}{1}").format(item.doctype_name, field_desc), + title=_("Duplicate DocType"), + ) + seen_combinations.add(composite_key) + + # Validate protected DocTypes + if item.doctype_name in protected: + frappe.throw( + _("Cannot delete protected core DocType: {0}").format(item.doctype_name), + title=_("Protected DocType"), ) + is_child_table = frappe.db.get_value("DocType", item.doctype_name, "istable") + if is_child_table: + frappe.throw( + _( + "Cannot add child table {0} to deletion list. Child tables are automatically deleted with their parent DocTypes." + ).format(item.doctype_name), + title=_("Child Table Not Allowed"), + ) + + is_virtual = frappe.db.get_value("DocType", item.doctype_name, "is_virtual") + if is_virtual: + frappe.throw( + _( + "Cannot delete virtual DocType: {0}. Virtual DocTypes do not have database tables." + ).format(item.doctype_name), + title=_("Virtual DocType"), + ) + + # Validate company_field if specified + if item.company_field: + valid_company_fields = self._get_company_link_fields(item.doctype_name) + if item.company_field not in valid_company_fields: + frappe.throw( + _("Field '{0}' is not a valid Company link field for DocType {1}").format( + item.company_field, item.doctype_name + ), + title=_("Invalid Company Field"), + ) + + def _is_any_doctype_in_deletion_list(self, doctypes_list): + """Check if any DocType from the list is in the To Delete list""" + if not self.doctypes_to_delete: + return False + + deletion_doctypes = {d.doctype_name for d in self.doctypes_to_delete} + return any(doctype in deletion_doctypes for doctype in doctypes_list) + def generate_job_name_for_task(self, task=None): + """Generate unique job name for a specific task""" method = self.task_to_internal_method_map[task] return f"{self.name}_{method}" def generate_job_name_for_next_tasks(self, task=None): + """Generate job names for all tasks following the specified task""" job_names = [] current_task_idx = list(self.task_to_internal_method_map).index(task) for idx, task in enumerate(self.task_to_internal_method_map.keys(), 0): - # generate job_name for next tasks if idx > current_task_idx: job_names.append(self.generate_job_name_for_task(task)) return job_names def generate_job_name_for_all_tasks(self): + """Generate job names for all tasks in the deletion workflow""" job_names = [] for task in self.task_to_internal_method_map.keys(): job_names.append(self.generate_job_name_for_task(task)) @@ -106,28 +257,28 @@ class TransactionDeletionRecord(Document): def before_submit(self): if queued_docs := frappe.db.get_all( "Transaction Deletion Record", - filters={"company": self.company, "status": ("in", ["Running", "Queued"]), "docstatus": 1}, + filters={"status": ("in", ["Running", "Queued"]), "docstatus": 1}, pluck="name", ): frappe.throw( _( - "Cannot enqueue multi docs for one company. {0} is already queued/running for company: {1}" - ).format( - comma_and([get_link_to_form("Transaction Deletion Record", x) for x in queued_docs]), - frappe.bold(self.company), - ) + "Cannot start deletion. Another deletion {0} is already queued/running. Please wait for it to complete." + ).format(comma_and([get_link_to_form("Transaction Deletion Record", x) for x in queued_docs])) ) + if not self.doctypes_to_delete and not self.doctypes_to_be_ignored: + frappe.throw(_("Please generate To Delete list before submitting")) + if not self.doctypes_to_be_ignored: self.populate_doctypes_to_be_ignored_table() def reset_task_flags(self): - self.clear_notifications = 0 - self.delete_bin_data = 0 - self.delete_leads_and_addresses = 0 - self.delete_transactions = 0 - self.initialize_doctypes_table = 0 - self.reset_company_default_values = 0 + self.clear_notifications_status = "Pending" + self.delete_bin_data_status = "Pending" + self.delete_leads_and_addresses_status = "Pending" + self.delete_transactions_status = "Pending" + self.initialize_doctypes_table_status = "Pending" + self.reset_company_default_values_status = "Pending" def before_save(self): self.status = "" @@ -136,17 +287,288 @@ class TransactionDeletionRecord(Document): def on_submit(self): self.db_set("status", "Queued") + self.start_deletion_tasks() def on_cancel(self): self.db_set("status", "Cancelled") + self._clear_deletion_cache() + + def _set_deletion_cache(self): + """Set Redis cache flags for per-doctype validation""" + for item in self.doctypes_to_delete: + frappe.cache.set_value( + f"deletion_running_doctype:{item.doctype_name}", + self.name, + expires_in_sec=DELETION_CACHE_TTL, + ) + + def _clear_deletion_cache(self): + """Clear Redis cache flags""" + for item in self.doctypes_to_delete: + frappe.cache.delete_value(f"deletion_running_doctype:{item.doctype_name}") + + def _get_child_tables(self, doctype_name): + """Get list of child table DocType names for a given DocType + + Args: + doctype_name: The parent DocType to check + + Returns: + list: List of child table DocType names (Table field options) + """ + return frappe.get_all( + "DocField", filters={"parent": doctype_name, "fieldtype": "Table"}, pluck="options" + ) + + def _get_to_delete_row_infos(self, doctype_name, company_field=None, company=None): + """Get child tables and document count for a To Delete list row + + Args: + doctype_name: The DocType to get information for + company_field: Optional company field name to filter by + company: Optional company value (defaults to self.company) + + Returns: + dict: {"child_doctypes": str, "document_count": int} + """ + company = company or self.company + + child_tables = self._get_child_tables(doctype_name) + child_doctypes_str = ", ".join(child_tables) if child_tables else "" + + if company_field and company: + doc_count = frappe.db.count(doctype_name, filters={company_field: company}) + else: + doc_count = frappe.db.count(doctype_name) + + return { + "child_doctypes": child_doctypes_str, + "document_count": doc_count, + } + + def _has_company_field(self, doctype_name): + """Check if DocType has a field specifically named 'company' linking to Company""" + return frappe.db.exists( + "DocField", + {"parent": doctype_name, "fieldname": "company", "fieldtype": "Link", "options": "Company"}, + ) + + def _get_company_link_fields(self, doctype_name): + """Get all Company Link field names for a DocType + + Args: + doctype_name: The DocType to check + + Returns: + list: List of field names that link to Company DocType, ordered by field index + """ + company_fields = frappe.get_all( + "DocField", + filters={"parent": doctype_name, "fieldtype": "Link", "options": "Company"}, + pluck="fieldname", + order_by="idx", + ) + return company_fields or [] + + @frappe.whitelist() + def generate_to_delete_list(self): + """Generate To Delete list with one row per company field""" + self.doctypes_to_delete = [] + + excluded = [d.doctype_name for d in self.doctypes_to_be_ignored] + excluded.extend(_get_protected_doctypes_internal()) + excluded.append(self.doctype) # Exclude self + + # Get all DocTypes that have Company link fields + doctypes_with_company_field = frappe.get_all( + "DocField", + filters={"fieldtype": "Link", "options": "Company"}, + pluck="parent", + distinct=True, + ) + + # Filter to get only valid DocTypes (not child tables, not virtual, not excluded) + doctypes_with_company = [] + for doctype_name in doctypes_with_company_field: + if doctype_name in excluded: + continue + + # Check if doctype exists and is not a child table or virtual + if frappe.db.exists("DocType", doctype_name): + meta = frappe.get_meta(doctype_name) + if not meta.istable and not meta.is_virtual: + doctypes_with_company.append(doctype_name) + + for doctype_name in doctypes_with_company: + # Get ALL company fields for this DocType + company_fields = self._get_company_link_fields(doctype_name) + + # Get child tables once (same for all company fields of this DocType) + child_tables = self._get_child_tables(doctype_name) + child_doctypes_str = ", ".join(child_tables) if child_tables else "" + + for company_field in company_fields: + doc_count = frappe.db.count(doctype_name, {company_field: self.company}) + + self.append( + "doctypes_to_delete", + { + "doctype_name": doctype_name, + "company_field": company_field, + "document_count": doc_count, + "child_doctypes": child_doctypes_str, + }, + ) + + self.save() + return {"count": len(self.doctypes_to_delete)} + + @frappe.whitelist() + def populate_doctype_details(self, doctype_name, company=None, company_field=None): + """Get child DocTypes and document count for specified DocType + + Args: + doctype_name: The DocType to get details for + company: Optional company value for filtering (defaults to self.company) + company_field: Optional company field name to use for filtering + """ + frappe.only_for("System Manager") + + if not doctype_name: + return {} + + if not frappe.db.exists("DocType", doctype_name): + frappe.throw(_("DocType {0} does not exist").format(doctype_name)) + + is_child_table = frappe.db.get_value("DocType", doctype_name, "istable") + if is_child_table: + return { + "child_doctypes": "", + "document_count": 0, + "error": _("{0} is a child table and will be deleted automatically with its parent").format( + doctype_name + ), + } + + try: + return self._get_to_delete_row_infos(doctype_name, company_field=company_field, company=company) + except Exception as e: + frappe.log_error( + f"Error in populate_doctype_details for {doctype_name}: {e!s}", "Transaction Deletion Record" + ) + return { + "child_doctypes": "", + "document_count": 0, + "error": _("Unable to fetch DocType details. Please contact system administrator."), + } + + def export_to_delete_template_method(self): + """Export To Delete list as CSV template""" + if not self.doctypes_to_delete: + frappe.throw(_("Generate To Delete list first")) + + import csv + from io import StringIO + + output = StringIO() + writer = csv.writer(output) + writer.writerow(["doctype_name", "company_field", "child_doctypes"]) + + for item in self.doctypes_to_delete: + writer.writerow([item.doctype_name, item.company_field or "", item.child_doctypes or ""]) + + frappe.response["result"] = output.getvalue() + frappe.response["type"] = "csv" + frappe.response[ + "doctype" + ] = f"deletion_template_{self.company}_{frappe.utils.now_datetime().strftime('%Y%m%d')}" + + def import_to_delete_template_method(self, csv_content): + """Import CSV template and regenerate counts""" + import csv + from io import StringIO + + reader = csv.DictReader(StringIO(csv_content)) + + if "doctype_name" not in (reader.fieldnames or []): + frappe.throw(_("Invalid CSV format. Expected column: doctype_name")) + + self.doctypes_to_delete = [] + protected = _get_protected_doctypes_internal() + + imported_count = 0 + skipped = [] + + for row in reader: + doctype_name = row.get("doctype_name", "").strip() + company_field = row.get("company_field", "").strip() or None + + if not doctype_name: + continue + + if doctype_name in protected: + skipped.append(_("{0}: Protected DocType").format(doctype_name)) + continue + + if not frappe.db.exists("DocType", doctype_name): + skipped.append(_("{0}: Not found").format(doctype_name)) + continue + + is_child = frappe.db.get_value("DocType", doctype_name, "istable") + if is_child: + skipped.append(_("{0}: Child table (auto-deleted with parent)").format(doctype_name)) + continue + + is_virtual = frappe.db.get_value("DocType", doctype_name, "is_virtual") + if is_virtual: + skipped.append(_("{0}: Virtual DocType (no database table)").format(doctype_name)) + continue + + db_company_fields = self._get_company_link_fields(doctype_name) + import_company_field = "" + if not db_company_fields: # Case no company field exists + details = self._get_to_delete_row_infos(doctype_name) + elif ( + company_field and company_field in db_company_fields + ): # Case it is provided by export and valid + details = self._get_to_delete_row_infos(doctype_name, company_field) + import_company_field = company_field + else: # Company field exists but not provided by export or invalid + if "company" in db_company_fields: # Check if 'company' is a valid field + details = self._get_to_delete_row_infos(doctype_name, "company") + import_company_field = "company" + else: # Fallback to first valid company field + details = self._get_to_delete_row_infos(doctype_name, db_company_fields[0]) + import_company_field = db_company_fields[0] + + self.append( + "doctypes_to_delete", + { + "doctype_name": doctype_name, + "company_field": import_company_field, + "document_count": details["document_count"], + "child_doctypes": details["child_doctypes"], + }, + ) + imported_count += 1 + + self.save() + + if skipped: + frappe.msgprint( + _("Skipped {0} DocType(s):
{1}").format(len(skipped), "
".join(skipped)), + title=_("Import Summary"), + indicator="orange", + ) + + return {"imported": imported_count, "skipped": len(skipped)} def enqueue_task(self, task: str | None = None): + """Enqueue a deletion task for background execution""" if task and task in self.task_to_internal_method_map: - # make sure that none of next tasks are already running job_names = self.generate_job_name_for_next_tasks(task=task) self.validate_running_task_for_doc(job_names=job_names) - # Generate Job Id to uniquely identify each task for this document job_id = self.generate_job_name_for_task(task) if self.process_in_single_transaction: @@ -176,12 +598,13 @@ class TransactionDeletionRecord(Document): message = "Traceback:
" + traceback frappe.db.set_value(self.doctype, self.name, "error_log", message) frappe.db.set_value(self.doctype, self.name, "status", "Failed") + self._clear_deletion_cache() def delete_notifications(self): self.validate_doc_status() - if not self.clear_notifications: + if self.clear_notifications_status == "Pending": clear_notifications() - self.db_set("clear_notifications", 1) + self.db_set("clear_notifications_status", "Completed") self.enqueue_task(task="Initialize Summary Table") def populate_doctypes_to_be_ignored_table(self): @@ -215,23 +638,46 @@ class TransactionDeletionRecord(Document): def start_deletion_tasks(self): # This method is the entry point for the chain of events that follow self.db_set("status", "Running") + self._set_deletion_cache() self.enqueue_task(task="Delete Bins") def delete_bins(self): self.validate_doc_status() - if not self.delete_bin_data: + if self.delete_bin_data_status == "Pending": + stock_related_doctypes = [ + "Item", + "Warehouse", + "Stock Entry", + "Delivery Note", + "Purchase Receipt", + "Stock Reconciliation", + "Material Request", + "Purchase Invoice", + "Sales Invoice", + ] + + if not self._is_any_doctype_in_deletion_list(stock_related_doctypes): + self.db_set("delete_bin_data_status", "Skipped") + self.enqueue_task(task="Delete Leads and Addresses") + return + frappe.db.sql( """delete from `tabBin` where warehouse in (select name from tabWarehouse where company=%s)""", self.company, ) - self.db_set("delete_bin_data", 1) + self.db_set("delete_bin_data_status", "Completed") self.enqueue_task(task="Delete Leads and Addresses") def delete_lead_addresses(self): """Delete addresses to which leads are linked""" self.validate_doc_status() - if not self.delete_leads_and_addresses: + if self.delete_leads_and_addresses_status == "Pending": + if not self._is_any_doctype_in_deletion_list(["Lead"]): + self.db_set("delete_leads_and_addresses_status", "Skipped") + self.enqueue_task(task="Reset Company Values") + return + leads = frappe.db.get_all("Lead", filters={"company": self.company}, pluck="name") addresses = [] if leads: @@ -268,54 +714,94 @@ class TransactionDeletionRecord(Document): customer = qb.DocType("Customer") qb.update(customer).set(customer.lead_name, None).where(customer.lead_name.isin(leads)).run() - self.db_set("delete_leads_and_addresses", 1) + self.db_set("delete_leads_and_addresses_status", "Completed") self.enqueue_task(task="Reset Company Values") def reset_company_values(self): self.validate_doc_status() - if not self.reset_company_default_values: + if self.reset_company_default_values_status == "Pending": + sales_related_doctypes = [ + "Sales Order", + "Sales Invoice", + "Quotation", + "Delivery Note", + ] + + if not self._is_any_doctype_in_deletion_list(sales_related_doctypes): + self.db_set("reset_company_default_values_status", "Skipped") + self.enqueue_task(task="Clear Notifications") + return + company_obj = frappe.get_doc("Company", self.company) company_obj.total_monthly_sales = 0 company_obj.sales_monthly_history = None company_obj.save() - self.db_set("reset_company_default_values", 1) + self.db_set("reset_company_default_values_status", "Completed") self.enqueue_task(task="Clear Notifications") def initialize_doctypes_to_be_deleted_table(self): + """Initialize deletion table from To Delete list or fall back to original logic""" self.validate_doc_status() - if not self.initialize_doctypes_table: - doctypes_to_be_ignored_list = self.get_doctypes_to_be_ignored_list() - docfields = self.get_doctypes_with_company_field(doctypes_to_be_ignored_list) + if self.initialize_doctypes_table_status == "Pending": + # Use To Delete list if available (new behavior) + if not self.doctypes_to_delete: + frappe.throw( + _("No DocTypes in To Delete list. Please generate or import the list before submitting."), + title=_("Empty To Delete List"), + ) tables = self.get_all_child_doctypes() - for docfield in docfields: - if docfield["parent"] != self.doctype: - no_of_docs = self.get_number_of_docs_linked_with_specified_company( - docfield["parent"], docfield["fieldname"] + + for to_delete_item in self.doctypes_to_delete: + if to_delete_item.document_count > 0: + # Add parent DocType only - child tables are handled automatically + # by delete_child_tables() when the parent is deleted + # Use company_field directly from To Delete item + self.populate_doctypes_table( + tables, to_delete_item.doctype_name, to_delete_item.company_field, 0 ) - if no_of_docs > 0: - # Initialize - self.populate_doctypes_table(tables, docfield["parent"], docfield["fieldname"], 0) - self.db_set("initialize_doctypes_table", 1) + self.db_set("initialize_doctypes_table_status", "Completed") self.enqueue_task(task="Delete Transactions") def delete_company_transactions(self): self.validate_doc_status() - if not self.delete_transactions: - doctypes_to_be_ignored_list = self.get_doctypes_to_be_ignored_list() - self.get_doctypes_with_company_field(doctypes_to_be_ignored_list) + if self.delete_transactions_status == "Pending": + protected_doctypes = _get_protected_doctypes_internal() - self.get_all_child_doctypes() for docfield in self.doctypes: if docfield.doctype_name != self.doctype and not docfield.done: - no_of_docs = self.get_number_of_docs_linked_with_specified_company( - docfield.doctype_name, docfield.docfield_name - ) - if no_of_docs > 0: - reference_docs = frappe.get_all( - docfield.doctype_name, - filters={docfield.docfield_name: self.company}, - limit=self.batch_size, + if docfield.doctype_name in protected_doctypes: + error_msg = ( + f"CRITICAL: Attempted to delete protected DocType: {docfield.doctype_name}" ) + frappe.log_error(error_msg, "Transaction Deletion Security") + frappe.throw( + _("Cannot delete protected core DocType: {0}").format(docfield.doctype_name), + title=_("Protected DocType"), + ) + + # Get company_field from stored value (could be any Company link field) + company_field = docfield.docfield_name + + if company_field: + no_of_docs = self.get_number_of_docs_linked_with_specified_company( + docfield.doctype_name, company_field + ) + else: + no_of_docs = frappe.db.count(docfield.doctype_name) + + if no_of_docs > 0: + if company_field: + reference_docs = frappe.get_all( + docfield.doctype_name, + filters={company_field: self.company}, + fields=["name"], + limit=self.batch_size, + ) + else: + reference_docs = frappe.get_all( + docfield.doctype_name, fields=["name"], limit=self.batch_size + ) + reference_doc_names = [r.name for r in reference_docs] self.delete_version_log(docfield.doctype_name, reference_doc_names) @@ -329,26 +815,38 @@ class TransactionDeletionRecord(Document): processed = int(docfield.no_of_docs) + len(reference_doc_names) frappe.db.set_value(docfield.doctype, docfield.name, "no_of_docs", processed) else: - # reset naming series naming_series = frappe.db.get_value("DocType", docfield.doctype_name, "autoname") if naming_series: if "#" in naming_series: self.update_naming_series(naming_series, docfield.doctype_name) frappe.db.set_value(docfield.doctype, docfield.name, "done", 1) + to_delete_row = frappe.db.get_value( + "Transaction Deletion Record To Delete", + { + "parent": self.name, + "doctype_name": docfield.doctype_name, + "company_field": company_field, + }, + "name", + ) + if to_delete_row: + frappe.db.set_value( + "Transaction Deletion Record To Delete", to_delete_row, "deleted", 1 + ) + pending_doctypes = frappe.db.get_all( "Transaction Deletion Record Details", filters={"parent": self.name, "done": 0}, pluck="doctype_name", ) if pending_doctypes: - # as method is enqueued after commit, calling itself will not make validate_doc_status to throw - # recursively call this task to delete all transactions self.enqueue_task(task="Delete Transactions") else: self.db_set("status", "Completed") - self.db_set("delete_transactions", 1) + self.db_set("delete_transactions_status", "Completed") self.db_set("error_log", None) + self._clear_deletion_cache() def get_doctypes_to_be_ignored_list(self): doctypes_to_be_ignored_list = frappe.get_all( @@ -378,18 +876,33 @@ class TransactionDeletionRecord(Document): def get_number_of_docs_linked_with_specified_company(self, doctype, company_fieldname): return frappe.db.count(doctype, {company_fieldname: self.company}) - def populate_doctypes_table(self, tables, doctype, fieldname, no_of_docs): + def get_company_field(self, doctype_name): + """Get company field name for a DocType""" + return frappe.db.get_value( + "DocField", + {"parent": doctype_name, "fieldtype": "Link", "options": "Company"}, + "fieldname", + ) + + def populate_doctypes_table(self, tables, doctype, company_field, no_of_docs): + """Add doctype to processing tracker + + Args: + tables: List of child table DocType names (to exclude) + doctype: DocType name to track + company_field: Company link field name (or None) + no_of_docs: Initial count + """ self.flags.ignore_validate_update_after_submit = True if doctype not in tables: self.append( - "doctypes", {"doctype_name": doctype, "docfield_name": fieldname, "no_of_docs": no_of_docs} + "doctypes", + {"doctype_name": doctype, "docfield_name": company_field, "no_of_docs": no_of_docs}, ) self.save(ignore_permissions=True) def delete_child_tables(self, doctype, reference_doc_names): - child_tables = frappe.get_all( - "DocField", filters={"fieldtype": "Table", "parent": doctype}, pluck="options" - ) + child_tables = self._get_child_tables(doctype) for table in child_tables: frappe.db.delete(table, {"parent": ["in", reference_doc_names]}) @@ -397,22 +910,52 @@ class TransactionDeletionRecord(Document): def delete_docs_linked_with_specified_company(self, doctype, reference_doc_names): frappe.db.delete(doctype, {"name": ("in", reference_doc_names)}) - def update_naming_series(self, naming_series, doctype_name): + @staticmethod + def get_naming_series_prefix(naming_series: str, doctype_name: str) -> str: + """Extract the static prefix from an autoname pattern. + + Args: + naming_series: The autoname pattern (e.g., "PREFIX.####", "format:PRE-{####}") + doctype_name: DocType name for error logging + + Returns: + The static prefix before the counter placeholders + """ if "." in naming_series: - prefix, hashes = naming_series.rsplit(".", 1) + prefix = naming_series.rsplit(".", 1)[0] + elif "{" in naming_series: + prefix = naming_series.rsplit("{", 1)[0] else: - prefix, hashes = naming_series.rsplit("{", 1) - last = frappe.db.sql( - f"""select max(name) from `tab{doctype_name}` - where name like %s""", - prefix + "%", + # Fallback for unexpected patterns (shouldn't happen with valid Frappe naming series) + frappe.log_error( + title=_("Unexpected Naming Series Pattern"), + message=_( + "Naming series '{0}' for DocType '{1}' does not contain standard '.' or '{{' separator. Using fallback extraction." + ).format(naming_series, doctype_name), + ) + prefix = naming_series.split("#", 1)[0] if "#" in naming_series else naming_series + + return prefix + + def update_naming_series(self, naming_series, doctype_name): + # Derive a static prefix from the autoname pattern + prefix = self.get_naming_series_prefix(naming_series, doctype_name) + + # Find the highest number used in the naming series to reset the counter + doctype_table = qb.DocType(doctype_name) + result = ( + qb.from_(doctype_table) + .select(Max(doctype_table.name)) + .where(doctype_table.name.like(prefix + "%")) + .run() ) - if last and last[0][0]: - last = cint(last[0][0].replace(prefix, "")) + + if result and result[0][0]: + last = cint(result[0][0].replace(prefix, "")) else: last = 0 - frappe.db.sql("""update `tabSeries` set current = %s where name=%s""", (last, prefix)) + frappe.db.set_value("Series", prefix, "current", last, update_modified=False) def delete_version_log(self, doctype, docnames): versions = qb.DocType("Version") @@ -487,15 +1030,61 @@ def get_doctypes_to_be_ignored(): return doctypes_to_be_ignored +@frappe.whitelist() +def export_to_delete_template(name): + """Export To Delete list as CSV via URL access""" + frappe.only_for("System Manager") + doc = frappe.get_doc("Transaction Deletion Record", name) + doc.check_permission("read") + return doc.export_to_delete_template_method() + + +@frappe.whitelist() +def process_import_template(transaction_deletion_record_name, file_url): + """Import CSV template and populate To Delete list""" + import os + + doc = frappe.get_doc("Transaction Deletion Record", transaction_deletion_record_name) + doc.check_permission("write") + + if not file_url or ".." in file_url: + frappe.throw(_("Invalid file URL")) + + try: + file_doc = frappe.get_doc("File", {"file_url": file_url}) + except frappe.DoesNotExistError: + frappe.throw(_("File not found")) + + if ( + file_doc.attached_to_doctype != "Transaction Deletion Record" + or file_doc.attached_to_name != transaction_deletion_record_name + ): + frappe.throw(_("File does not belong to this Transaction Deletion Record")) + + if not file_doc.file_name or not file_doc.file_name.lower().endswith(".csv"): + frappe.throw(_("Only CSV files are allowed")) + + file_path = file_doc.get_full_path() + + if not os.path.isfile(file_path): + frappe.throw(_("File not found on server")) + + with open(file_path, encoding="utf-8") as f: + csv_content = f.read() + + return doc.import_to_delete_template_method(csv_content) + + @frappe.whitelist() @request_cache def is_deletion_doc_running(company: str | None = None, err_msg: str | None = None): - if not company: - return + """Check if any deletion is running globally + The company parameter is kept for backwards compatibility but is now ignored. + """ running_deletion_job = frappe.db.get_value( "Transaction Deletion Record", - {"docstatus": 1, "company": company, "status": "Running"}, + {"docstatus": 1, "status": ("in", ["Running", "Queued"])}, "name", ) @@ -504,17 +1093,28 @@ def is_deletion_doc_running(company: str | None = None, err_msg: str | None = No frappe.throw( title=_("Deletion in Progress!"), - msg=_("Transaction Deletion Document: {0} is running for this Company. {1}").format( + msg=_("Transaction Deletion Record {0} is already running. {1}").format( get_link_to_form("Transaction Deletion Record", running_deletion_job), err_msg or "" ), ) def check_for_running_deletion_job(doc, method=None): - # Check if DocType has 'company' field - if doc.doctype in LEDGER_ENTRY_DOCTYPES or not doc.meta.has_field("company"): + """Hook function called on document validate - checks Redis cache for running deletions""" + if doc.doctype in LEDGER_ENTRY_DOCTYPES: return - is_deletion_doc_running( - doc.company, _("Cannot make any transactions until the deletion job is completed") - ) + if doc.doctype in PROTECTED_CORE_DOCTYPES: + return + + deletion_name = frappe.cache.get_value(f"deletion_running_doctype:{doc.doctype}") + + if deletion_name: + frappe.throw( + title=_("Deletion in Progress!"), + msg=_( + "Transaction Deletion Record {0} is currently deleting {1}. Cannot save documents until deletion completes." + ).format( + get_link_to_form("Transaction Deletion Record", deletion_name), frappe.bold(doc.doctype) + ), + ) diff --git a/erpnext/setup/doctype/transaction_deletion_record_item/transaction_deletion_record_item.json b/erpnext/setup/doctype/transaction_deletion_record_item/transaction_deletion_record_item.json index ee9cc968c17..70688b7a860 100644 --- a/erpnext/setup/doctype/transaction_deletion_record_item/transaction_deletion_record_item.json +++ b/erpnext/setup/doctype/transaction_deletion_record_item/transaction_deletion_record_item.json @@ -17,17 +17,19 @@ "reqd": 1 } ], + "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-03-27 13:10:55.128861", + "modified": "2025-11-14 16:17:47.755531", "modified_by": "Administrator", "module": "Setup", "name": "Transaction Deletion Record Item", "owner": "Administrator", "permissions": [], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/setup/doctype/transaction_deletion_record_to_delete/__init__.py b/erpnext/setup/doctype/transaction_deletion_record_to_delete/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/setup/doctype/transaction_deletion_record_to_delete/transaction_deletion_record_to_delete.json b/erpnext/setup/doctype/transaction_deletion_record_to_delete/transaction_deletion_record_to_delete.json new file mode 100644 index 00000000000..2cc94b3ee7d --- /dev/null +++ b/erpnext/setup/doctype/transaction_deletion_record_to_delete/transaction_deletion_record_to_delete.json @@ -0,0 +1,67 @@ +{ + "actions": [], + "creation": "2025-11-14 00:00:00", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "doctype_name", + "company_field", + "document_count", + "child_doctypes", + "deleted" + ], + "fields": [ + { + "fieldname": "doctype_name", + "fieldtype": "Link", + "in_list_view": 1, + "label": "DocType", + "options": "DocType" + }, + { + "description": "Company link field name used for filtering (optional - leave empty to delete all records)", + "fieldname": "company_field", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Company Field" + }, + { + "fieldname": "document_count", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Document Count", + "read_only": 1 + }, + { + "description": "Child tables that will also be deleted", + "fieldname": "child_doctypes", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Child DocTypes", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "deleted", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Deleted", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-11-14 16:17:04.494126", + "modified_by": "Administrator", + "module": "Setup", + "name": "Transaction Deletion Record To Delete", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/erpnext/setup/doctype/transaction_deletion_record_to_delete/transaction_deletion_record_to_delete.py b/erpnext/setup/doctype/transaction_deletion_record_to_delete/transaction_deletion_record_to_delete.py new file mode 100644 index 00000000000..e7883eaa0d0 --- /dev/null +++ b/erpnext/setup/doctype/transaction_deletion_record_to_delete/transaction_deletion_record_to_delete.py @@ -0,0 +1,27 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class TransactionDeletionRecordToDelete(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + child_doctypes: DF.SmallText | None + company_field: DF.Data | None + deleted: DF.Check + doctype_name: DF.Link | None + document_count: DF.Int + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + # end: auto-generated types + + pass diff --git a/transaction-deletion-import-logic-summary.md b/transaction-deletion-import-logic-summary.md new file mode 100644 index 00000000000..85ec35ef198 --- /dev/null +++ b/transaction-deletion-import-logic-summary.md @@ -0,0 +1,230 @@ +# Transaction Deletion CSV Import Logic - Updated Behavior + +## Auto-Detection of Company Field + +When importing a CSV without a `company_field` column or with empty values, the system uses smart auto-detection: + +### Priority Order: + +1. **"company" field** (most common convention) + - Check if a field named `company` exists that links to Company DocType + - ✅ Use "company" if found + +2. **First Company link field** (custom fields) + - If no "company" field, get all fields linking to Company DocType + - ✅ Use the first one (sorted by field index) + +3. **No company field** (DocTypes without company filtering) + - If no Company link fields exist at all + - ✅ Leave `company_field` as None/empty + - ✅ Delete ALL records (no company filtering) + +## Import CSV Format + +### Minimal Format (Auto-Detection) +```csv +doctype_name,child_doctypes +Sales Order,Sales Order Item +Note, +Task, +``` + +**Result:** +- `Sales Order`: Auto-detects "company" field → Filters by company +- `Note`: No company field → Deletes all Note records +- `Task`: Has "company" field → Filters by company + +### Explicit Format (Recommended) +```csv +doctype_name,company_field,child_doctypes +Sales Order,company,Sales Order Item +Sales Contract,primary_company,Sales Contract Item +Sales Contract,billing_company,Sales Contract Item +Note,, +``` + +**Result:** +- `Sales Order`: Uses "company" field explicitly +- `Sales Contract` (row 1): Uses "primary_company" field +- `Sales Contract` (row 2): Uses "billing_company" field (separate row!) +- `Note`: No company field, deletes all records + +### Multiple Company Fields Example +```csv +doctype_name,company_field,child_doctypes +Customer Invoice,head_office,Customer Invoice Item +Customer Invoice,billing_company,Customer Invoice Item +``` + +**Deletion Process:** +1. Row 1 deletes: `WHERE head_office = 'ABC Company'` +2. Row 2 deletes: `WHERE billing_company = 'ABC Company'` +3. Documents with both fields = ABC get deleted in first pass +4. Documents with only billing_company = ABC get deleted in second pass + +## Validation Rules + +### ✅ Accepted Cases + +1. **DocType with "company" field** - Auto-detected +2. **DocType with custom Company link field** - Auto-detected (first field used) +3. **DocType with multiple Company fields** - Auto-detected (first field used), but user can add multiple rows +4. **DocType with NO Company fields** - Accepted! Deletes ALL records +5. **Explicit company_field provided** - Validated and used + +### ❌ Rejected Cases + +1. **Protected DocTypes** - User, Role, DocType, etc. +2. **Child tables** - Auto-deleted with parent +3. **Virtual DocTypes** - No database table +4. **Invalid company_field** - Field doesn't exist or isn't a Company link +5. **DocType doesn't exist** - Not found in system + +## Code Flow + +```python +# 1. Read company_field from CSV (may be empty) +company_field = row.get("company_field", "").strip() + +# 2. Auto-detect if not provided +if not company_field: + # Try "company" first + if exists("company" field linking to Company): + company_field = "company" + else: + # Check for other Company link fields + company_fields = get_all_company_link_fields() + if company_fields: + company_field = company_fields[0] # Use first + # else: company_field stays empty + +# 3. Validate if company_field was provided/detected +if company_field: + if not is_valid_company_link_field(company_field): + skip_with_error() + +# 4. Count documents +if company_field: + count = count(WHERE company_field = self.company) +else: + count = count(all records) + +# 5. Store in To Delete list +append({ + "doctype_name": doctype_name, + "company_field": company_field or None, # Store None if empty + "document_count": count +}) +``` + +## Examples + +### Example 1: Standard DocType with "company" Field + +**CSV:** +```csv +doctype_name,company_field,child_doctypes +Sales Order,, +``` + +**Auto-Detection:** +- Finds "company" field linking to Company +- Sets `company_field = "company"` +- Counts: `WHERE company = 'Test Company'` +- Result: Deletes only Test Company's Sales Orders + +### Example 2: Custom Company Field + +**CSV:** +```csv +doctype_name,company_field,child_doctypes +Project Contract,, +``` + +**Auto-Detection:** +- No "company" field found +- Finds "contracting_company" field linking to Company +- Sets `company_field = "contracting_company"` +- Counts: `WHERE contracting_company = 'Test Company'` +- Result: Deletes only Test Company's Project Contracts + +### Example 3: No Company Field (Global DocType) + +**CSV:** +```csv +doctype_name,company_field,child_doctypes +Note,, +Global Settings,, +``` + +**Auto-Detection:** +- No Company link fields found +- Sets `company_field = None` +- Counts: All records +- Result: Deletes ALL Note and Global Settings records + +### Example 4: Multiple Company Fields (Explicit) + +**CSV:** +```csv +doctype_name,company_field,child_doctypes +Sales Contract,primary_company,Sales Contract Item +Sales Contract,billing_company,Sales Contract Item +``` + +**No Auto-Detection:** +- Row 1: Uses "primary_company" explicitly +- Row 2: Uses "billing_company" explicitly +- Both rows validated as valid Company link fields +- Result: Two separate deletion passes + +### Example 5: Mixed Approaches + +**CSV:** +```csv +doctype_name,company_field,child_doctypes +Sales Order,,Sales Order Item +Sales Contract,billing_company,Sales Contract Item +Note,, +``` + +**Result:** +- Row 1: Auto-detects "company" field +- Row 2: Uses "billing_company" explicitly +- Row 3: No company field (deletes all) + +## User Benefits + +✅ **Flexible**: Supports auto-detection and explicit specification +✅ **Safe**: Validates all fields before processing +✅ **Clear**: Empty company_field means "delete all" +✅ **Powerful**: Can target specific company fields in multi-company setups +✅ **Backward Compatible**: Old CSVs (without company_field column) still work + +## Migration from Old Format + +**Old CSV (without company_field):** +```csv +doctype_name,child_doctypes +Sales Order,Sales Order Item +``` + +**New System Behavior:** +- Auto-detects "company" field +- Works identically to before +- ✅ Backward compatible + +**New CSV (with company_field):** +```csv +doctype_name,company_field,child_doctypes +Sales Order,company,Sales Order Item +``` + +**Benefits:** +- Explicit and clear +- Supports multiple rows per DocType +- Can specify custom company fields + +--- + +*Generated for Transaction Deletion Record enhancement*