diff --git a/kapital_bank/client/bank_reconciliation_tool.js b/kapital_bank/client/bank_reconciliation_tool.js new file mode 100644 index 0000000..dda7a40 --- /dev/null +++ b/kapital_bank/client/bank_reconciliation_tool.js @@ -0,0 +1,274 @@ +// Kapital Bank — Bank Reconciliation Tool integration +// Adds checkbox column + "Create Purpose Mapping" toolbar + +frappe.ui.form.on("Bank Reconciliation Tool", { + render: function (frm) { + // DTM is created synchronously but its datatable is built after an async API call. + // Poll until datatable is ready, then enhance once. + if (frm._kb_check_interval) clearInterval(frm._kb_check_interval); + + frm._kb_check_interval = setInterval(function () { + const dtm = frm.bank_reconciliation_data_table_manager; + if (dtm && dtm.datatable && !dtm._kb_enhanced) { + clearInterval(frm._kb_check_interval); + KBBRTMapping.enhance(frm, dtm); + } + }, 200); + + setTimeout(function () { clearInterval(frm._kb_check_interval); }, 15000); + }, +}); + +const KBBRTMapping = { + enhance: function (frm, dtm) { + dtm._kb_enhanced = true; + + // Rebuild DataTable with checkboxColumn: true + dtm.datatable.destroy(); + dtm.datatable = new frappe.DataTable(dtm.$reconciliation_tool_dt.get(0), { + columns: dtm.columns, + data: dtm.transactions, + dynamicRowHeight: true, + checkboxColumn: true, + inlineFilters: true, + }); + $(`.${dtm.datatable.style.scopeClass} .dt-scrollable`).css("max-height", "calc(100vh - 400px)"); + + // Restore ERPNext's Action-button listeners for the new datatable + dtm.set_listeners(); + + dtm._kb_scopeClass = dtm.datatable.style.scopeClass; + + KBBRTMapping.injectToolbar(dtm); + }, + + injectToolbar: function (dtm) { + $("#kb-brt-mapping-toolbar").remove(); + + const $toolbar = $(` + + `); + + dtm.$reconciliation_tool_dt.before($toolbar); + + const scopeClass = dtm.datatable.style.scopeClass; + $(document).off("click.kb_brt_mapping").on( + "click.kb_brt_mapping", + `.${scopeClass} input[type="checkbox"]`, + function () { + setTimeout(() => KBBRTMapping.updateToolbar(dtm, $toolbar), 30); + } + ); + + $toolbar.find(".kb-brt-create-mapping-btn").on("click", function () { + KBBRTMapping.showMappingDialog(dtm); + }); + }, + + updateToolbar: function (dtm, $toolbar) { + const checked = dtm.datatable.rowmanager.getCheckedRows(); + if (checked && checked.length > 0) { + $toolbar.css("display", "flex"); + $toolbar.find(".kb-brt-selected-count").text(__("{0} row(s) selected", [checked.length])); + } else { + $toolbar.hide(); + } + }, + + showMappingDialog: function (dtm) { + const checkedIndices = dtm.datatable.rowmanager.getCheckedRows(); + if (!checkedIndices || checkedIndices.length === 0) { + frappe.msgprint({ title: __("Warning"), indicator: "orange", message: __("No rows selected.") }); + return; + } + + // dtm.transactions row: [date, party_type, party(HTML), description, deposit, withdrawal, ...] + const txns = []; + let skippedCount = 0; + checkedIndices.forEach(function (rowIndex) { + const row = dtm.transactions[parseInt(rowIndex, 10)]; + if (!row) return; + const description = (row[3] || "").trim(); + if (!description) { skippedCount++; return; } + const partyType = (row[1] || "").trim(); + const partyName = $("
").html(row[2] || "").text().trim(); + const withdrawal = parseFloat(row[5]) || 0; + txns.push({ + purpose: description, + drcr: withdrawal > 0 ? "D" : "C", + party_type: partyType, + party: partyName, + }); + }); + + // Deduplicate for preview + const purposeMap = new Map(); + txns.forEach(function (txn) { + const key = txn.purpose + "|" + txn.drcr; + if (purposeMap.has(key)) { + purposeMap.get(key).count++; + } else { + purposeMap.set(key, { + purpose: txn.purpose, + payment_type: txn.drcr === "D" ? "Pay" : "Receive", + party: txn.party, + count: 1, + }); + } + }); + + if (purposeMap.size === 0) { + frappe.msgprint({ title: __("No Purposes"), indicator: "orange", + message: __("All selected rows have no description text.") }); + return; + } + + // Preview table + let previewHtml = '
' + + '' + + "" + + "" + + '" + + '" + + '" + + ""; + purposeMap.forEach(function (e) { + const badge = e.payment_type === "Pay" + ? 'Pay' + : 'Receive'; + previewHtml += "" + + '" + + "" + + "" + + '"; + }); + previewHtml += "
" + __("Description") + "' + __("Type") + "' + __("Party") + "' + __("Rows") + "
' + frappe.utils.escape_html(e.purpose) + "" + badge + "" + (e.party ? frappe.utils.escape_html(e.party) : '') + "' + e.count + "
"; + if (skippedCount > 0) { + previewHtml += '
' + + __("{0} row(s) skipped — no description text.", [skippedCount]) + "
"; + } + + const d = new frappe.ui.Dialog({ + title: __("Create Purpose Mappings") + " (" + purposeMap.size + ")", + size: "large", + fields: [ + { fieldname: "preview_html", fieldtype: "HTML", options: previewHtml }, + { fieldname: "sec_options", fieldtype: "Section Break", label: __("Options") }, + { + fieldname: "document_type", + fieldtype: "Select", + label: __("Document Type"), + options: "Payment Entry\nJournal Entry", + default: "Payment Entry", + reqd: 1, + }, + { + fieldname: "use_custom_purpose", + fieldtype: "Check", + label: __("Use custom purpose keyword"), + onchange: function () { + const on = d.get_value("use_custom_purpose"); + d.set_df_property("custom_purpose", "hidden", on ? 0 : 1); + d.set_df_property("custom_purpose", "reqd", on ? 1 : 0); + d.get_field("custom_purpose").refresh(); + }, + }, + { + fieldname: "custom_purpose", + fieldtype: "Data", + label: __("Custom Purpose Keyword"), + hidden: 1, + description: __("All selected rows will be mapped with this purpose instead of their own description"), + }, + { fieldname: "sec_accounts", fieldtype: "Section Break", label: __("GL Accounts") }, + { + fieldname: "paid_from", fieldtype: "Link", options: "Account", + label: __("Paid From (Account)"), reqd: 1, + }, + { fieldname: "col_break", fieldtype: "Column Break" }, + { + fieldname: "paid_to", fieldtype: "Link", options: "Account", + label: __("Paid To (Account)"), reqd: 1, + }, + ], + primary_action_label: __("Create Mappings"), + primary_action: function () { + const values = d.get_values(); + if (!values) return; + + let finalTxns = txns; + if (values.use_custom_purpose && (values.custom_purpose || "").trim()) { + const cp = values.custom_purpose.trim(); + const seen = new Set(); + finalTxns = []; + txns.forEach(function (t) { + const key = cp + "|" + t.drcr; + if (!seen.has(key)) { + seen.add(key); + finalTxns.push(Object.assign({}, t, { purpose: cp })); + } + }); + } + + KBBRTMapping.submitMappings(d, finalTxns, values.paid_from, values.paid_to, values.document_type); + }, + }); + + d.show(); + + // Pre-fill custom purpose from the Description inline filter if active + const $fi = $(`.${dtm._kb_scopeClass} .dt-row-filter input.dt-filter[data-name="Description"]`); + const filterVal = ($fi.val() || "").trim(); + if (filterVal) { + d.set_df_property("custom_purpose", "hidden", 0); + d.set_df_property("custom_purpose", "reqd", 1); + d.set_value("use_custom_purpose", 1); + d.set_value("custom_purpose", filterVal); + d.get_field("custom_purpose").refresh(); + } + }, + + submitMappings: function (dialog, txns, paid_from, paid_to, document_type) { + dialog.disable_primary_action(); + frappe.call({ + method: "kapital_bank.mapping.create_purpose_mappings", + args: { + transactions: JSON.stringify(txns), + paid_from: paid_from, + paid_to: paid_to, + document_type: document_type || "Payment Entry", + }, + callback: function (r) { + dialog.enable_primary_action(); + if (!r.message || !r.message.success) { + frappe.msgprint({ title: __("Error"), indicator: "red", + message: r.message ? r.message.message : __("Unknown error") }); + return; + } + dialog.hide(); + const res = r.message; + const parts = []; + if (res.created) parts.push(__("Created: {0}", [res.created])); + if (res.already_mapped) parts.push(__("Already mapped: {0}", [res.already_mapped])); + if (res.skipped_no_data) parts.push(__("Skipped (no purpose): {0}", [res.skipped_no_data])); + frappe.show_alert({ + message: parts.join(" • ") || __("No changes"), + indicator: res.created > 0 ? "green" : "blue", + }, 5); + }, + error: function () { + dialog.enable_primary_action(); + frappe.msgprint({ title: __("Error"), indicator: "red", + message: __("Network error creating mappings") }); + }, + }); + }, +}; diff --git a/kapital_bank/client/mapping.js b/kapital_bank/client/mapping.js new file mode 100644 index 0000000..65911c2 --- /dev/null +++ b/kapital_bank/client/mapping.js @@ -0,0 +1,196 @@ +const KBMapping = {}; + +KBMapping.init = function(dialog, txns) { + const $container = dialog.$wrapper.find('.kb-bt-txn-table').closest('div'); + if ($container.length === 0) return; + + // Search input + button row + const $toolbar = $(` +
+ + +
+ `); + $container.prepend($toolbar); + + const $searchInput = $toolbar.find('.kb-mapping-search'); + const $mappingBtn = $toolbar.find('.kb-mapping-btn'); + + // Search: filter rows by purpose column (8th td, index 7) + $searchInput.on('input', function() { + const query = $(this).val().toLowerCase(); + dialog.$wrapper.find('.kb-bt-txn-table tbody tr').each(function() { + const purposeText = $(this).find('td').eq(7).text().toLowerCase(); + $(this).toggle(purposeText.indexOf(query) !== -1); + }); + }); + + // Toggle mapping button visibility based on checked rows + function toggleMappingBtn() { + const anyChecked = dialog.$wrapper.find('.kb-bt-select-txn:checked').length > 0; + $mappingBtn.toggle(anyChecked); + } + + dialog.$wrapper.on('change', '.kb-bt-select-txn, .kb-bt-select-all-txns', toggleMappingBtn); + + // Button click -> show mapping dialog + $mappingBtn.on('click', function() { + KBMapping.showMappingDialog(dialog, txns); + }); +}; + +KBMapping.showMappingDialog = function(parentDialog, txns) { + // Collect checked transactions + const selectedTxns = []; + parentDialog.$wrapper.find('.kb-bt-select-txn:checked').each(function() { + const idx = $(this).data('idx'); + selectedTxns.push(txns[idx]); + }); + + if (selectedTxns.length === 0) { + frappe.msgprint({ title: __('Warning'), indicator: 'orange', message: __('No transactions selected') }); + return; + } + + // Build unique purposes: key = "purpose|payment_type" + const purposeMap = new Map(); + let skippedCount = 0; + + selectedTxns.forEach(function(txn) { + const purpose = (txn.purpose || '').trim(); + if (!purpose) { + skippedCount++; + return; + } + const paymentType = txn.drcr === 'D' ? 'Pay' : 'Receive'; + const key = purpose + '|' + paymentType; + if (purposeMap.has(key)) { + purposeMap.get(key).count++; + } else { + purposeMap.set(key, { purpose: purpose, payment_type: paymentType, count: 1 }); + } + }); + + if (purposeMap.size === 0 && skippedCount > 0) { + frappe.msgprint({ + title: __('No Purposes'), + indicator: 'orange', + message: __('All selected transactions have no purpose text.'), + }); + return; + } + + // Preview table + let previewHtml = '
'; + previewHtml += ''; + previewHtml += '' + + '' + + '' + + '' + + ''; + + purposeMap.forEach(function(entry) { + const typeLabel = entry.payment_type === 'Pay' + ? 'Pay' + : 'Receive'; + previewHtml += '' + + '' + + '' + + '' + + ''; + }); + + previewHtml += '
' + __('Purpose') + '' + __('Type') + '' + __('Txns') + '
' + entry.purpose + '' + typeLabel + '' + entry.count + '
'; + + if (skippedCount > 0) { + previewHtml += '
' + + __('{0} transaction(s) skipped — no purpose text.', [skippedCount]) + + '
'; + } + + const d = new frappe.ui.Dialog({ + title: __('Create Purpose Mappings') + ' (' + purposeMap.size + ')', + size: 'large', + fields: [ + { + fieldname: 'preview_html', + fieldtype: 'HTML', + options: previewHtml, + }, + { fieldname: 'sec_accounts', fieldtype: 'Section Break', label: __('GL Accounts') }, + { + fieldname: 'paid_from', + fieldtype: 'Link', + options: 'Account', + label: __('Paid From (Account)'), + reqd: 1, + }, + { fieldname: 'col_break', fieldtype: 'Column Break' }, + { + fieldname: 'paid_to', + fieldtype: 'Link', + options: 'Account', + label: __('Paid To (Account)'), + reqd: 1, + }, + ], + primary_action_label: __('Create Mappings'), + primary_action: function() { + const values = d.get_values(); + if (!values) return; + KBMapping._submitMappings(d, selectedTxns, values.paid_from, values.paid_to); + }, + }); + + d.show(); +}; + +KBMapping._submitMappings = function(dialog, txns, paid_from, paid_to) { + dialog.disable_primary_action(); + + frappe.call({ + method: 'kapital_bank.mapping.create_purpose_mappings', + args: { + transactions: JSON.stringify(txns), + paid_from: paid_from, + paid_to: paid_to, + }, + callback: function(r) { + dialog.enable_primary_action(); + + if (!r.message || !r.message.success) { + frappe.msgprint({ + title: __('Error'), + indicator: 'red', + message: r.message ? r.message.message : __('Unknown error'), + }); + return; + } + + dialog.hide(); + + const res = r.message; + let parts = []; + if (res.created) parts.push(__('Created: {0}', [res.created])); + if (res.already_mapped) parts.push(__('Already mapped: {0}', [res.already_mapped])); + if (res.skipped_no_data) parts.push(__('Skipped (no purpose): {0}', [res.skipped_no_data])); + + frappe.show_alert({ + message: parts.join(' • ') || __('No changes'), + indicator: res.created > 0 ? 'green' : 'blue', + }, 5); + }, + error: function() { + dialog.enable_primary_action(); + frappe.msgprint({ + title: __('Error'), + indicator: 'red', + message: __('Network error creating mappings'), + }); + }, + }); +}; diff --git a/kapital_bank/hooks.py b/kapital_bank/hooks.py index 1b0b9ca..e0946f2 100644 --- a/kapital_bank/hooks.py +++ b/kapital_bank/hooks.py @@ -13,6 +13,7 @@ after_migrate = "kapital_bank.setup.after_migrate" doctype_js = { "Payment Entry": "client/payment_entry.js", "Bank Transaction": "client/bank_transaction.js", + "Bank Reconciliation Tool": "client/bank_reconciliation_tool.js", } doctype_list_js = { diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_transaction_mapping/kapital_bank_transaction_mapping.json b/kapital_bank/kapital_bank/doctype/kapital_bank_transaction_mapping/kapital_bank_transaction_mapping.json index b14480b..d32916a 100644 --- a/kapital_bank/kapital_bank/doctype/kapital_bank_transaction_mapping/kapital_bank_transaction_mapping.json +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_transaction_mapping/kapital_bank_transaction_mapping.json @@ -1,7 +1,8 @@ { "actions": [], - "creation": "2026-02-26 00:00:00.000000", + "creation": "2026-02-26 00:00:00", "doctype": "DocType", + "editable_grid": 1, "engine": "InnoDB", "field_order": [ "document_type", @@ -43,7 +44,7 @@ "fieldtype": "Select", "in_list_view": 1, "label": "Counterparty Type", - "options": "\nKapital Bank Customer\nKapital Bank Supplier" + "options": "\nCustomer\nSupplier" }, { "fieldname": "counterparty", @@ -83,17 +84,15 @@ "label": "Notes" } ], - "index_web_pages_for_search": 0, - "allow_bulk_edit": 1, - "editable_grid": 1, "istable": 1, "links": [], - "modified": "2026-03-05 00:00:00.000000", + "modified": "2026-03-10 17:54:47.738883", "modified_by": "Administrator", "module": "Kapital Bank", "name": "Kapital Bank Transaction Mapping", "owner": "Administrator", "permissions": [], + "row_format": "Dynamic", "sort_field": "modified", "sort_order": "DESC", "states": [] diff --git a/kapital_bank/mapping.py b/kapital_bank/mapping.py new file mode 100644 index 0000000..6496619 --- /dev/null +++ b/kapital_bank/mapping.py @@ -0,0 +1,112 @@ +import json + +import frappe + + +@frappe.whitelist() +def create_purpose_mappings(transactions, paid_from, paid_to, document_type="Payment Entry"): + """Bulk-create transaction mappings from selected BRT transactions.""" + try: + txn_list = json.loads(transactions) if isinstance(transactions, str) else transactions + + settings = frappe.get_single("Kapital Bank Settings") + existing = { + (row.purpose_keyword, row.payment_type) + for row in settings.transaction_mappings + if row.purpose_keyword and row.payment_type + } + + # Deduplicate within batch; first occurrence wins for party info + unique_purposes = {} + skipped_no_data = 0 + + for txn in txn_list: + purpose = (txn.get("purpose") or "").strip() + if not purpose: + skipped_no_data += 1 + continue + payment_type = "Pay" if txn.get("drcr") == "D" else "Receive" + key = (purpose, payment_type) + if key not in unique_purposes: + unique_purposes[key] = { + "count": 0, + # party_type is already "Customer"/"Supplier" from ERPNext BRT + "counterparty_type": (txn.get("party_type") or "").strip(), + "counterparty": (txn.get("party") or "").strip(), + } + unique_purposes[key]["count"] += 1 + + created = 0 + already_mapped = 0 + for (purpose_text, payment_type), data in unique_purposes.items(): + purpose_name = _ensure_purpose(purpose_text) + + if (purpose_name, payment_type) in existing: + already_mapped += 1 + continue + + settings.append("transaction_mappings", { + "purpose_keyword": purpose_name, + "payment_type": payment_type, + "paid_from": paid_from, + "paid_to": paid_to, + "document_type": document_type, + "counterparty_type": data["counterparty_type"] or None, + "counterparty": data["counterparty"] or None, + }) + existing.add((purpose_name, payment_type)) + created += 1 + + if created > 0: + settings.save(ignore_permissions=True) + for (purpose_text, _payment_type) in unique_purposes: + purpose_name = frappe.db.get_value( + "Kapital Bank Purpose", {"purpose_keyword": purpose_text}, "name" + ) + if purpose_name: + frappe.db.set_value( + "Kapital Bank Purpose", purpose_name, "status", "Mapped", + update_modified=False, + ) + frappe.db.commit() + + return { + "success": True, + "created": created, + "skipped_no_data": skipped_no_data, + "already_mapped": already_mapped, + } + + except Exception as e: + frappe.log_error( + f"create_purpose_mappings: {e}\n{frappe.get_traceback()}", + "Kapital Bank Mapping", + ) + return {"success": False, "message": str(e)} + + +def _ensure_purpose(purpose_text): + """Return the name of an existing or newly created Kapital Bank Purpose.""" + existing = frappe.db.get_value( + "Kapital Bank Purpose", {"purpose_keyword": purpose_text}, "name" + ) + if existing: + return existing + + try: + doc = frappe.new_doc("Kapital Bank Purpose") + doc.purpose_keyword = purpose_text + doc.direction = "Both" + doc.status = "New" + doc.insert(ignore_permissions=True) + return doc.name + except frappe.DuplicateEntryError: + return frappe.db.get_value( + "Kapital Bank Purpose", {"purpose_keyword": purpose_text}, "name" + ) + except Exception as e: + frappe.log_error( + f"Insert purpose failed for {purpose_text}: {e}", + "Kapital Bank Purpose Loading", + ) + raise diff --git a/kapital_bank/patches.txt b/kapital_bank/patches.txt index 9a71ca0..753f784 100644 --- a/kapital_bank/patches.txt +++ b/kapital_bank/patches.txt @@ -4,4 +4,5 @@ kapital_bank.patches.rename_purpose_mapping_to_transaction_mapping [post_model_sync] -# Patches added in this section will be executed after doctypes are migrated \ No newline at end of file +# Patches added in this section will be executed after doctypes are migrated +kapital_bank.patches.migrate_counterparty_type \ No newline at end of file diff --git a/kapital_bank/patches/migrate_counterparty_type.py b/kapital_bank/patches/migrate_counterparty_type.py new file mode 100644 index 0000000..393faca --- /dev/null +++ b/kapital_bank/patches/migrate_counterparty_type.py @@ -0,0 +1,18 @@ +import frappe + + +def execute(): + """Rename counterparty_type values from 'Kapital Bank Customer/Supplier' to 'Customer/Supplier'. + The counterparty values (KB registry names) are no longer valid for the new Dynamic Link target, + so they are cleared.""" + frappe.db.sql(""" + UPDATE `tabKapital Bank Transaction Mapping` + SET counterparty_type = 'Customer', counterparty = NULL + WHERE counterparty_type = 'Kapital Bank Customer' + """) + frappe.db.sql(""" + UPDATE `tabKapital Bank Transaction Mapping` + SET counterparty_type = 'Supplier', counterparty = NULL + WHERE counterparty_type = 'Kapital Bank Supplier' + """) + frappe.db.commit()