From 1c002a5e875920f9c0f8ce7a82c06049724722f4 Mon Sep 17 00:00:00 2001 From: Ali <010109ali@gmail.com> Date: Wed, 11 Mar 2026 15:00:39 +0400 Subject: [PATCH] Added create & reconcile to brt --- .../client/bank_reconciliation_tool.js | 105 +++++-- kapital_bank/client/payment_entry.js | 14 +- kapital_bank/mapping.py | 295 ++++++++++++++---- 3 files changed, 318 insertions(+), 96 deletions(-) diff --git a/kapital_bank/client/bank_reconciliation_tool.js b/kapital_bank/client/bank_reconciliation_tool.js index dda7a40..3fffe16 100644 --- a/kapital_bank/client/bank_reconciliation_tool.js +++ b/kapital_bank/client/bank_reconciliation_tool.js @@ -1,27 +1,31 @@ // Kapital Bank — Bank Reconciliation Tool integration -// Adds checkbox column + "Create Purpose Mapping" toolbar +// Adds checkbox column + "Create & Reconcile" 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); + // Only activate if at least one Kapital Bank Login exists + frappe.db.count("Kapital Bank Login").then(function (count) { + if (!count) return; - 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); + if (frm._kb_check_interval) clearInterval(frm._kb_check_interval); - setTimeout(function () { clearInterval(frm._kb_check_interval); }, 15000); + 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; + dtm._kb_frm = frm; // Rebuild DataTable with checkboxColumn: true dtm.datatable.destroy(); @@ -52,7 +56,7 @@ const KBBRTMapping = { border:1px solid var(--border-color); border-radius:6px;"> `); @@ -90,7 +94,7 @@ const KBBRTMapping = { return; } - // dtm.transactions row: [date, party_type, party(HTML), description, deposit, withdrawal, ...] + // dtm.transactions row: [date, party_type, party(HTML), description, deposit, withdrawal, ..., reference_number, bank_transaction] const txns = []; let skippedCount = 0; checkedIndices.forEach(function (rowIndex) { @@ -100,16 +104,22 @@ const KBBRTMapping = { if (!description) { skippedCount++; return; } const partyType = (row[1] || "").trim(); const partyName = $("
").html(row[2] || "").text().trim(); + const deposit = parseFloat(row[4]) || 0; const withdrawal = parseFloat(row[5]) || 0; + const bankTransactionName = $(row[8] || "").data("name") || ""; txns.push({ purpose: description, drcr: withdrawal > 0 ? "D" : "C", party_type: partyType, party: partyName, + amount: deposit || withdrawal, + date: (row[0] || "").trim(), + reference_number: (row[7] || "").trim(), + bank_transaction_name: bankTransactionName, }); }); - // Deduplicate for preview + // Deduplicate for preview only const purposeMap = new Map(); txns.forEach(function (txn) { const key = txn.purpose + "|" + txn.drcr; @@ -157,9 +167,17 @@ const KBBRTMapping = { } const d = new frappe.ui.Dialog({ - title: __("Create Purpose Mappings") + " (" + purposeMap.size + ")", + title: __("Create & Reconcile") + " (" + purposeMap.size + ")", size: "large", fields: [ + { + fieldname: "mode", + fieldtype: "Select", + label: __("Action"), + options: "Both\nMappings Only\nDocuments & Reconcile", + default: "Both", + reqd: 1, + }, { fieldname: "preview_html", fieldtype: "HTML", options: previewHtml }, { fieldname: "sec_options", fieldtype: "Section Break", label: __("Options") }, { @@ -199,29 +217,22 @@ const KBBRTMapping = { label: __("Paid To (Account)"), reqd: 1, }, ], - primary_action_label: __("Create Mappings"), + primary_action_label: __("Create & Reconcile"), primary_action: function () { const values = d.get_values(); if (!values) return; - let finalTxns = txns; + let finalTxns = txns.slice(); 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 })); - } - }); + finalTxns = txns.map(t => Object.assign({}, t, { purpose: cp })); } - KBBRTMapping.submitMappings(d, finalTxns, values.paid_from, values.paid_to, values.document_type); + KBBRTMapping.submitMappings(d, finalTxns, values.paid_from, values.paid_to, values.document_type, values.mode); }, }); + d._kb_frm = dtm._kb_frm; d.show(); // Pre-fill custom purpose from the Description inline filter if active @@ -236,7 +247,7 @@ const KBBRTMapping = { } }, - submitMappings: function (dialog, txns, paid_from, paid_to, document_type) { + submitMappings: function (dialog, txns, paid_from, paid_to, document_type, mode) { dialog.disable_primary_action(); frappe.call({ method: "kapital_bank.mapping.create_purpose_mappings", @@ -245,6 +256,7 @@ const KBBRTMapping = { paid_from: paid_from, paid_to: paid_to, document_type: document_type || "Payment Entry", + mode: mode || "Both", }, callback: function (r) { dialog.enable_primary_action(); @@ -256,13 +268,20 @@ const KBBRTMapping = { dialog.hide(); const res = r.message; const parts = []; - if (res.created) parts.push(__("Created: {0}", [res.created])); + if (res.created_mappings) parts.push(__("Mappings created: {0}", [res.created_mappings])); 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])); + if (res.created_docs) parts.push(__("Documents created: {0}", [res.created_docs])); + if (res.reconciled) parts.push(__("Reconciled: {0}", [res.reconciled])); + if (res.errors && res.errors.length) parts.push(__("Errors: {0}", [res.errors.length])); frappe.show_alert({ message: parts.join(" • ") || __("No changes"), - indicator: res.created > 0 ? "green" : "blue", + indicator: (res.created_mappings > 0 || res.created_docs > 0) ? "green" : "blue", }, 5); + if (dialog._kb_frm) dialog._kb_frm.refresh(); + if (res.errors && res.errors.length) { + KBBRTMapping.showErrors(res.errors); + } }, error: function () { dialog.enable_primary_action(); @@ -271,4 +290,28 @@ const KBBRTMapping = { }, }); }, + + showErrors: function (errors) { + let html = '
'; + errors.forEach(function (err) { + const ref = err.reference_number || __("Unknown"); + html += '
'; + html += '
'; + html += '' + frappe.utils.escape_html(ref) + ''; + html += '' + __("Error") + ''; + html += '
'; + html += '
' + frappe.utils.escape_html(err.message || __("Unknown error")) + '
'; + html += '
'; + }); + html += '
'; + + const d = new frappe.ui.Dialog({ + title: __("Errors ({0})", [errors.length]), + size: "large", + fields: [{ fieldname: "errors_html", fieldtype: "HTML", options: html }], + primary_action_label: __("Close"), + primary_action: function () { d.hide(); }, + }); + d.show(); + }, }; diff --git a/kapital_bank/client/payment_entry.js b/kapital_bank/client/payment_entry.js index 6cc7bfa..c7594ff 100644 --- a/kapital_bank/client/payment_entry.js +++ b/kapital_bank/client/payment_entry.js @@ -672,10 +672,10 @@ KBImport.import = { }; // ======= LIST VIEW SETUP ======= -frappe.listview_settings['Payment Entry'] = { - onload(listview) { - listview.page.add_inner_button(__('Import'), function() { - KBImport.import.showDialog(); - }, __('Kapital Bank')); - } -}; +// frappe.listview_settings['Payment Entry'] = { +// onload(listview) { +// listview.page.add_inner_button(__('Import'), function() { +// KBImport.import.showDialog(); +// }, __('Kapital Bank')); +// } +// }; diff --git a/kapital_bank/mapping.py b/kapital_bank/mapping.py index 6496619..fdfdbb0 100644 --- a/kapital_bank/mapping.py +++ b/kapital_bank/mapping.py @@ -4,77 +4,113 @@ 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.""" +def create_purpose_mappings(transactions, paid_from, paid_to, document_type="Payment Entry", mode="Both"): + """Bulk-create transaction mappings and/or documents 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 - } + do_mappings = mode in ("Both", "Mappings Only") + do_documents = mode in ("Both", "Documents & Reconcile") - # 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 + created_mappings = 0 already_mapped = 0 - for (purpose_text, payment_type), data in unique_purposes.items(): - purpose_name = _ensure_purpose(purpose_text) + skipped_no_data = 0 + created_docs = 0 + reconciled = 0 + errors = [] - if (purpose_name, payment_type) in existing: - already_mapped += 1 - continue + if do_mappings: + 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 + } - 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 + # Deduplicate within batch; first occurrence wins for party info + unique_purposes = {} - 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, + 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 + + 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_mappings += 1 + + if created_mappings > 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" ) - frappe.db.commit() + if purpose_name: + frappe.db.set_value( + "Kapital Bank Purpose", purpose_name, "status", "Mapped", + update_modified=False, + ) + frappe.db.commit() + + if do_documents: + for txn in txn_list: + if not txn.get("bank_transaction_name"): + errors.append({ + "reference_number": txn.get("reference_number") or "", + "message": "No bank transaction name", + }) + continue + + result = _create_and_reconcile_doc(txn, paid_from, paid_to, document_type) + if result.get("success"): + created_docs += 1 + if result.get("reconciled"): + reconciled += 1 + elif result.get("error"): + errors.append({ + "reference_number": txn.get("reference_number") or "", + "message": result.get("error"), + }) + else: + errors.append({ + "reference_number": txn.get("reference_number") or "", + "message": result.get("error") or "Unknown error", + }) return { "success": True, - "created": created, - "skipped_no_data": skipped_no_data, + "created_mappings": created_mappings, "already_mapped": already_mapped, + "skipped_no_data": skipped_no_data, + "created_docs": created_docs, + "reconciled": reconciled, + "errors": errors, } except Exception as e: @@ -85,6 +121,149 @@ def create_purpose_mappings(transactions, paid_from, paid_to, document_type="Pay return {"success": False, "message": str(e)} +def _create_and_reconcile_doc(txn, paid_from, paid_to, document_type): + """Create a Payment Entry or Journal Entry and reconcile it with the bank transaction.""" + bank_transaction_name = txn.get("bank_transaction_name") + try: + bank_txn = frappe.get_doc("Bank Transaction", bank_transaction_name) + + if document_type == "Journal Entry": + result = _create_journal_entry_for_brt(txn, paid_from, paid_to, bank_txn) + else: + result = _create_payment_entry_for_brt(txn, paid_from, paid_to, bank_txn) + + if not result.get("success"): + return result + + doc_name = result["doc_name"] + + try: + from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import ( + reconcile_vouchers, + ) + + reconcile_vouchers( + bank_transaction_name, + json.dumps([{ + "payment_doctype": document_type, + "payment_name": doc_name, + "amount": abs(float(txn.get("amount") or 0)), + }]), + ) + return {"success": True, "doc_name": doc_name, "reconciled": True} + except Exception as re: + frappe.log_error( + f"reconcile_vouchers failed for {doc_name}: {re}\n{frappe.get_traceback()}", + "Kapital Bank Mapping", + ) + return {"success": True, "doc_name": doc_name, "reconciled": False, "error": str(re)} + + except Exception as e: + frappe.log_error( + f"_create_and_reconcile_doc failed for {bank_transaction_name}: {e}\n{frappe.get_traceback()}", + "Kapital Bank Mapping", + ) + return {"success": False, "error": str(e)} + + +def _create_payment_entry_for_brt(txn, paid_from, paid_to, bank_txn): + """Create and submit a Payment Entry for a BRT transaction.""" + try: + amount = abs(float(txn.get("amount") or 0)) + party_type = (txn.get("party_type") or "").strip() + party = (txn.get("party") or "").strip() + posting_date = bank_txn.date or txn.get("date") + + pe = frappe.new_doc("Payment Entry") + pe.payment_type = "Pay" if txn.get("drcr") == "D" else "Receive" + pe.company = bank_txn.company + pe.posting_date = posting_date + pe.paid_from = paid_from + pe.paid_to = paid_to + pe.paid_amount = amount + pe.received_amount = amount + pe.reference_no = bank_txn.reference_number or txn.get("reference_number") or "" + pe.reference_date = posting_date + pe.remarks = txn.get("purpose") or "" + if party_type and party: + pe.party_type = party_type + pe.party = party + pe.insert(ignore_permissions=True) + pe.submit() + return {"success": True, "doc_name": pe.name} + + except Exception as e: + frappe.log_error( + f"_create_payment_entry_for_brt failed: {e}\n{frappe.get_traceback()}", + "Kapital Bank Mapping", + ) + return {"success": False, "error": str(e)} + + +def _create_journal_entry_for_brt(txn, paid_from, paid_to, bank_txn): + """Create and submit a Journal Entry for a BRT transaction.""" + try: + amount = abs(float(txn.get("amount") or 0)) + party_type = (txn.get("party_type") or "").strip() + party = (txn.get("party") or "").strip() + is_pay = txn.get("drcr") == "D" + posting_date = bank_txn.date or txn.get("date") + ref_no = bank_txn.reference_number or txn.get("reference_number") or "" + + # Determine account types to assign party to the right row + paid_from_type = frappe.db.get_value("Account", paid_from, "account_type") or "" + paid_to_type = frappe.db.get_value("Account", paid_to, "account_type") or "" + + je = frappe.new_doc("Journal Entry") + je.voucher_type = "Bank Entry" + je.posting_date = posting_date + je.company = bank_txn.company + je.cheque_no = ref_no + je.cheque_date = posting_date + je.user_remark = txn.get("purpose") or "" + + # Pay: paid_from credit, paid_to debit + # Receive: paid_from debit, paid_to credit + if is_pay: + from_credit, from_debit = amount, 0 + to_credit, to_debit = 0, amount + else: + from_credit, from_debit = 0, amount + to_credit, to_debit = amount, 0 + + from_row = { + "account": paid_from, + "credit_in_account_currency": from_credit, + "debit_in_account_currency": from_debit, + } + to_row = { + "account": paid_to, + "credit_in_account_currency": to_credit, + "debit_in_account_currency": to_debit, + } + + if party_type and party: + if paid_from_type in ("Receivable", "Payable"): + from_row["party_type"] = party_type + from_row["party"] = party + if paid_to_type in ("Receivable", "Payable"): + to_row["party_type"] = party_type + to_row["party"] = party + + je.append("accounts", from_row) + je.append("accounts", to_row) + je.insert(ignore_permissions=True) + je.submit() + return {"success": True, "doc_name": je.name} + + except Exception as e: + frappe.log_error( + f"_create_journal_entry_for_brt failed: {e}\n{frappe.get_traceback()}", + "Kapital Bank Mapping", + ) + return {"success": False, "error": str(e)} + + def _ensure_purpose(purpose_text): """Return the name of an existing or newly created Kapital Bank Purpose.""" existing = frappe.db.get_value(