mirror of
https://github.com/frappe/erpnext.git
synced 2026-02-12 17:23:38 +00:00
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
This commit is contained in:
committed by
GitHub
parent
88069779b2
commit
0fb37ad792
@@ -43,15 +43,17 @@
|
||||
"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": []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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"]
|
||||
"""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["fieldtype"] == "Link" and doctype_field["options"] == "Company":
|
||||
contains_company = True
|
||||
if (
|
||||
doctype_field["fieldname"] == doctype_row.company_field
|
||||
and doctype_field["fieldtype"] == "Link"
|
||||
and doctype_field["options"] == "Company"
|
||||
):
|
||||
field_found = True
|
||||
break
|
||||
self.assertTrue(contains_company)
|
||||
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()
|
||||
|
||||
@@ -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 =
|
||||
`<div style='margin-bottom: 15px;'><b style='color: #d73939;'>⚠ ${__(
|
||||
"Warning: This action cannot be undone!"
|
||||
)}</b></div>` +
|
||||
`<div style='margin-bottom: 10px;'>${__(
|
||||
"You are about to permanently delete data for {0} entries for company {1}.",
|
||||
[`<b>${frm.doc.doctypes_to_delete.length}</b>`, `<b>${frm.doc.company}</b>`]
|
||||
)}</div>` +
|
||||
`<div style='margin-bottom: 10px;'><b>${__("What will be deleted:")}</b></div>` +
|
||||
`<ul style='margin-left: 20px; margin-bottom: 10px;'>` +
|
||||
`<li><b>${__("DocTypes with a company field:")}</b> ${__(
|
||||
"Only records belonging to {0} will be deleted",
|
||||
[`<b>${frm.doc.company}</b>`]
|
||||
)}</li>` +
|
||||
`<li><b>${__("DocTypes without a company field:")}</b> ${__(
|
||||
"ALL records will be deleted (entire DocType cleared)"
|
||||
)}</li>` +
|
||||
`</ul>` +
|
||||
`<div style='margin-bottom: 10px; padding: 10px; background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px;'>` +
|
||||
`<b style='color: #856404;'>📦 ${__(
|
||||
"IMPORTANT: Create a backup before proceeding!"
|
||||
)}</b>` +
|
||||
`</div>` +
|
||||
`<div style='margin-top: 10px;'>${__(
|
||||
"Deletion will start automatically after submission."
|
||||
)}</div>`;
|
||||
|
||||
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;
|
||||
|
||||
@@ -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,6 +190,7 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
|
||||
@@ -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):<br>{1}").format(len(skipped), "<br>".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: <br>" + 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)
|
||||
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"]
|
||||
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"),
|
||||
)
|
||||
if no_of_docs > 0:
|
||||
# Initialize
|
||||
self.populate_doctypes_table(tables, docfield["parent"], docfield["fieldname"], 0)
|
||||
self.db_set("initialize_doctypes_table", 1)
|
||||
tables = self.get_all_child_doctypes()
|
||||
|
||||
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
|
||||
)
|
||||
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 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={docfield.docfield_name: self.company},
|
||||
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),
|
||||
)
|
||||
if last and last[0][0]:
|
||||
last = cint(last[0][0].replace(prefix, ""))
|
||||
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 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)
|
||||
),
|
||||
)
|
||||
|
||||
@@ -17,15 +17,17 @@
|
||||
"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": [],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
230
transaction-deletion-import-logic-summary.md
Normal file
230
transaction-deletion-import-logic-summary.md
Normal file
@@ -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*
|
||||
Reference in New Issue
Block a user