feat(bank-statement-importer): cascade delete with confirmation
Stock delete on Bank Statement Importer hits Frappe's link-check (registry rows + Bank Account dynamic-link) and refuses to proceed, leaving users unable to remove an importer they no longer need without hand-cleaning every dependent row. New flow: - cascade_delete.py adds two whitelisted endpoints: get_dependents (counts) and cascade_delete (unlink Bank Accounts → delete Customer/ Supplier/Purpose registry rows → delete the importer) - bank_statement_importer.js overrides the Menu → Delete action: shows a confirm dialog listing exactly how many records will be deleted vs just unlinked, with a red primary button to drive the choice home. After success the user is sent to the list view. Bank Accounts are intentionally unlinked rather than deleted — they hold business data (Bank Transactions, reconciliations) that has nothing to do with the importer being removed.
This commit is contained in:
parent
5bff95501f
commit
31d93fb887
|
|
@ -0,0 +1,86 @@
|
|||
"""Cascade-delete endpoint for Bank Statement Importer.
|
||||
|
||||
By default Frappe refuses to delete a Bank Statement Importer that still has
|
||||
dependent registry rows (Customer/Supplier/Purpose) or Bank Accounts pointing
|
||||
at it via the bank_integration Dynamic Link. This module exposes:
|
||||
|
||||
- `get_dependents(name)` — preview counts so the UI can warn the user
|
||||
- `cascade_delete(name)` — unlinks Bank Accounts and deletes registry rows,
|
||||
then deletes the importer itself. Caller is expected to have shown a
|
||||
confirm dialog with the counts from get_dependents.
|
||||
"""
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
||||
DOCTYPE = "Bank Statement Importer"
|
||||
_REGISTRY_DOCTYPES = (
|
||||
("Bank Integration Customer", "Customers"),
|
||||
("Bank Integration Supplier", "Suppliers"),
|
||||
("Bank Integration Purpose", "Purposes"),
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_dependents(name):
|
||||
"""Return counts of records that would be touched by a cascade delete."""
|
||||
if not name or not frappe.db.exists(DOCTYPE, name):
|
||||
frappe.throw(_("Bank Statement Importer '{0}' not found").format(name))
|
||||
|
||||
counts = {}
|
||||
for dt, _label in _REGISTRY_DOCTYPES:
|
||||
counts[dt] = frappe.db.count(dt, {"parent_bank_integration": name})
|
||||
|
||||
counts["Bank Account"] = frappe.db.count("Bank Account", {
|
||||
"bank_integration_type": DOCTYPE,
|
||||
"bank_integration": name,
|
||||
})
|
||||
return counts
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def cascade_delete(name):
|
||||
"""Delete a Bank Statement Importer and everything that depends on it.
|
||||
|
||||
Steps (executed in this order so Frappe's link-check doesn't block the
|
||||
final delete_doc on the importer):
|
||||
|
||||
1. Unlink Bank Accounts: clear bank_integration_type / bank_integration
|
||||
2. Delete registry rows (Customer / Supplier / Purpose) by parent_bank_integration
|
||||
3. Delete the importer itself (its mapping child tables go with it)
|
||||
"""
|
||||
if not name or not frappe.db.exists(DOCTYPE, name):
|
||||
frappe.throw(_("Bank Statement Importer '{0}' not found").format(name))
|
||||
|
||||
frappe.only_for(("System Manager", "Accounts Manager"))
|
||||
|
||||
# 1. Unlink Bank Accounts
|
||||
unlinked_accounts = 0
|
||||
ba_names = frappe.get_all("Bank Account", filters={
|
||||
"bank_integration_type": DOCTYPE,
|
||||
"bank_integration": name,
|
||||
}, pluck="name")
|
||||
for ba in ba_names:
|
||||
frappe.db.set_value("Bank Account", ba, {
|
||||
"bank_integration_type": None,
|
||||
"bank_integration": None,
|
||||
}, update_modified=False)
|
||||
unlinked_accounts += 1
|
||||
|
||||
# 2. Delete registries
|
||||
deleted = {}
|
||||
for dt, _label in _REGISTRY_DOCTYPES:
|
||||
rows = frappe.get_all(dt, filters={"parent_bank_integration": name}, pluck="name")
|
||||
for r in rows:
|
||||
frappe.delete_doc(dt, r, ignore_permissions=True, force=True, delete_permanently=True)
|
||||
deleted[dt] = len(rows)
|
||||
|
||||
# 3. Delete the importer (child mapping tables cascade automatically)
|
||||
frappe.delete_doc(DOCTYPE, name, ignore_permissions=True, force=True, delete_permanently=True)
|
||||
|
||||
frappe.db.commit()
|
||||
return {
|
||||
"success": True,
|
||||
"deleted": deleted,
|
||||
"unlinked_bank_accounts": unlinked_accounts,
|
||||
}
|
||||
|
|
@ -20,6 +20,11 @@ frappe.ui.form.on('Bank Statement Importer', {
|
|||
|
||||
if (frm.is_new()) return;
|
||||
|
||||
// Replace the standard Menu → Delete with a cascading version. The
|
||||
// stock action would just error out on the link-check (registry rows
|
||||
// + Bank Account dynamic-link) and leave the user stuck.
|
||||
BSICascadeDelete.installMenuOverride(frm);
|
||||
|
||||
// === Load Data (file-based, registry-only) ===
|
||||
frm.add_custom_button(__('Load Data'), () => {
|
||||
_show_load_data_dialog(frm);
|
||||
|
|
@ -629,3 +634,113 @@ function _bi_list_header(label, total, refresh_id) {
|
|||
<i class="octicon octicon-sync"></i> ${__('Refresh')}</button></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// CASCADE DELETE — replace Menu → Delete with a confirm dialog that explains
|
||||
// what registry rows and Bank Account links will be cleaned up along with
|
||||
// this importer.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const BSICascadeDelete = {
|
||||
installMenuOverride(frm) {
|
||||
// frappe.PageMenu renders the items right after refresh; defer slightly.
|
||||
setTimeout(() => this._wireMenuItem(frm), 50);
|
||||
},
|
||||
|
||||
_wireMenuItem(frm) {
|
||||
// Each Standard menu item is an <a class="dropdown-item"> in the
|
||||
// page menu. We replace the "Delete" one's click handler.
|
||||
const $menu = frm.page.menu;
|
||||
if (!$menu || !$menu.length) return;
|
||||
const $delete = $menu.find('a.grey-link').filter(function () {
|
||||
const label = ($(this).find('.menu-item-label').text() || $(this).text() || '').trim();
|
||||
return label === __('Delete');
|
||||
});
|
||||
if (!$delete.length) return;
|
||||
if ($delete.data('bsiCascadeWired')) return;
|
||||
$delete.data('bsiCascadeWired', true);
|
||||
$delete.off('click.bsiCascade').on('click.bsiCascade', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
this.run(frm);
|
||||
});
|
||||
},
|
||||
|
||||
run(frm) {
|
||||
if (frm.is_new()) return;
|
||||
frappe.call({
|
||||
method: 'jey_erp.bank_integration.cascade_delete.get_dependents',
|
||||
args: { name: frm.doc.name },
|
||||
callback: (r) => {
|
||||
if (!r.message) return;
|
||||
this._showConfirm(frm, r.message);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
_showConfirm(frm, counts) {
|
||||
const items = [
|
||||
[__('Customers (registry)'), counts['Bank Integration Customer'] || 0],
|
||||
[__('Suppliers (registry)'), counts['Bank Integration Supplier'] || 0],
|
||||
[__('Purposes (registry)'), counts['Bank Integration Purpose'] || 0],
|
||||
[__('Bank Accounts (will be unlinked, not deleted)'), counts['Bank Account'] || 0],
|
||||
];
|
||||
const total = items.reduce((s, [, n]) => s + n, 0);
|
||||
const rows = items
|
||||
.filter(([, n]) => n > 0)
|
||||
.map(([label, n]) => `<li>${frappe.utils.escape_html(label)}: <b>${n}</b></li>`)
|
||||
.join('');
|
||||
|
||||
const body = total === 0
|
||||
? `<p>${__('No dependent records — deleting this importer is safe.')}</p>`
|
||||
: `<p>${__('Deleting <b>{0}</b> will also affect:', [frappe.utils.escape_html(frm.doc.name)])}</p>
|
||||
<ul style="margin-left:18px;">${rows}</ul>
|
||||
<p>${__('Registry rows will be <b>deleted</b>. Bank Accounts will only have their Bank Statement Importer link cleared — the accounts themselves stay.')}</p>
|
||||
<p class="text-danger"><b>${__('This cannot be undone.')}</b></p>`;
|
||||
|
||||
const d = new frappe.ui.Dialog({
|
||||
title: __('Delete Bank Statement Importer?'),
|
||||
fields: [{ fieldname: 'body', fieldtype: 'HTML', options: body }],
|
||||
primary_action_label: __('Delete Everything'),
|
||||
primary_action: () => {
|
||||
d.disable_primary_action();
|
||||
this._doDelete(frm.doc.name).then(() => {
|
||||
d.hide();
|
||||
frappe.set_route('List', 'Bank Statement Importer');
|
||||
}).catch(() => {
|
||||
d.enable_primary_action();
|
||||
});
|
||||
},
|
||||
secondary_action_label: __('Cancel'),
|
||||
secondary_action: () => d.hide(),
|
||||
});
|
||||
// Primary button red since this is destructive.
|
||||
d.show();
|
||||
d.get_primary_btn().removeClass('btn-primary').addClass('btn-danger');
|
||||
},
|
||||
|
||||
_doDelete(name) {
|
||||
return new Promise((resolve, reject) => {
|
||||
frappe.call({
|
||||
method: 'jey_erp.bank_integration.cascade_delete.cascade_delete',
|
||||
args: { name },
|
||||
freeze: true,
|
||||
freeze_message: __('Deleting…'),
|
||||
callback: (r) => {
|
||||
if (!r.message || r.message.success === false) {
|
||||
frappe.msgprint({
|
||||
title: __('Delete Failed'),
|
||||
indicator: 'red',
|
||||
message: (r.message && r.message.message) || __('Unknown error'),
|
||||
});
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
frappe.show_alert({ message: __('Deleted.'), indicator: 'green' }, 4);
|
||||
resolve();
|
||||
},
|
||||
error: reject,
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue