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(