Added create & reconcile to brt

This commit is contained in:
Ali 2026-03-11 15:00:39 +04:00
parent bced451359
commit 1c002a5e87
3 changed files with 318 additions and 96 deletions

View File

@ -1,27 +1,31 @@
// Kapital Bank — Bank Reconciliation Tool integration // 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", { frappe.ui.form.on("Bank Reconciliation Tool", {
render: function (frm) { render: function (frm) {
// DTM is created synchronously but its datatable is built after an async API call. // Only activate if at least one Kapital Bank Login exists
// Poll until datatable is ready, then enhance once. frappe.db.count("Kapital Bank Login").then(function (count) {
if (frm._kb_check_interval) clearInterval(frm._kb_check_interval); if (!count) return;
frm._kb_check_interval = setInterval(function () { if (frm._kb_check_interval) clearInterval(frm._kb_check_interval);
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); 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 = { const KBBRTMapping = {
enhance: function (frm, dtm) { enhance: function (frm, dtm) {
dtm._kb_enhanced = true; dtm._kb_enhanced = true;
dtm._kb_frm = frm;
// Rebuild DataTable with checkboxColumn: true // Rebuild DataTable with checkboxColumn: true
dtm.datatable.destroy(); dtm.datatable.destroy();
@ -52,7 +56,7 @@ const KBBRTMapping = {
border:1px solid var(--border-color); border-radius:6px;"> border:1px solid var(--border-color); border-radius:6px;">
<span class="kb-brt-selected-count text-muted"></span> <span class="kb-brt-selected-count text-muted"></span>
<button class="btn btn-primary btn-sm kb-brt-create-mapping-btn"> <button class="btn btn-primary btn-sm kb-brt-create-mapping-btn">
${__("Create Purpose Mapping")} ${__("Create & Reconcile")}
</button> </button>
</div> </div>
`); `);
@ -90,7 +94,7 @@ const KBBRTMapping = {
return; 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 = []; const txns = [];
let skippedCount = 0; let skippedCount = 0;
checkedIndices.forEach(function (rowIndex) { checkedIndices.forEach(function (rowIndex) {
@ -100,16 +104,22 @@ const KBBRTMapping = {
if (!description) { skippedCount++; return; } if (!description) { skippedCount++; return; }
const partyType = (row[1] || "").trim(); const partyType = (row[1] || "").trim();
const partyName = $("<div>").html(row[2] || "").text().trim(); const partyName = $("<div>").html(row[2] || "").text().trim();
const deposit = parseFloat(row[4]) || 0;
const withdrawal = parseFloat(row[5]) || 0; const withdrawal = parseFloat(row[5]) || 0;
const bankTransactionName = $(row[8] || "").data("name") || "";
txns.push({ txns.push({
purpose: description, purpose: description,
drcr: withdrawal > 0 ? "D" : "C", drcr: withdrawal > 0 ? "D" : "C",
party_type: partyType, party_type: partyType,
party: partyName, 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(); const purposeMap = new Map();
txns.forEach(function (txn) { txns.forEach(function (txn) {
const key = txn.purpose + "|" + txn.drcr; const key = txn.purpose + "|" + txn.drcr;
@ -157,9 +167,17 @@ const KBBRTMapping = {
} }
const d = new frappe.ui.Dialog({ const d = new frappe.ui.Dialog({
title: __("Create Purpose Mappings") + " (" + purposeMap.size + ")", title: __("Create & Reconcile") + " (" + purposeMap.size + ")",
size: "large", size: "large",
fields: [ 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: "preview_html", fieldtype: "HTML", options: previewHtml },
{ fieldname: "sec_options", fieldtype: "Section Break", label: __("Options") }, { fieldname: "sec_options", fieldtype: "Section Break", label: __("Options") },
{ {
@ -199,29 +217,22 @@ const KBBRTMapping = {
label: __("Paid To (Account)"), reqd: 1, label: __("Paid To (Account)"), reqd: 1,
}, },
], ],
primary_action_label: __("Create Mappings"), primary_action_label: __("Create & Reconcile"),
primary_action: function () { primary_action: function () {
const values = d.get_values(); const values = d.get_values();
if (!values) return; if (!values) return;
let finalTxns = txns; let finalTxns = txns.slice();
if (values.use_custom_purpose && (values.custom_purpose || "").trim()) { if (values.use_custom_purpose && (values.custom_purpose || "").trim()) {
const cp = values.custom_purpose.trim(); const cp = values.custom_purpose.trim();
const seen = new Set(); finalTxns = txns.map(t => Object.assign({}, t, { purpose: cp }));
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); KBBRTMapping.submitMappings(d, finalTxns, values.paid_from, values.paid_to, values.document_type, values.mode);
}, },
}); });
d._kb_frm = dtm._kb_frm;
d.show(); d.show();
// Pre-fill custom purpose from the Description inline filter if active // 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(); dialog.disable_primary_action();
frappe.call({ frappe.call({
method: "kapital_bank.mapping.create_purpose_mappings", method: "kapital_bank.mapping.create_purpose_mappings",
@ -245,6 +256,7 @@ const KBBRTMapping = {
paid_from: paid_from, paid_from: paid_from,
paid_to: paid_to, paid_to: paid_to,
document_type: document_type || "Payment Entry", document_type: document_type || "Payment Entry",
mode: mode || "Both",
}, },
callback: function (r) { callback: function (r) {
dialog.enable_primary_action(); dialog.enable_primary_action();
@ -256,13 +268,20 @@ const KBBRTMapping = {
dialog.hide(); dialog.hide();
const res = r.message; const res = r.message;
const parts = []; 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.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.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({ frappe.show_alert({
message: parts.join(" &bull; ") || __("No changes"), message: parts.join(" &bull; ") || __("No changes"),
indicator: res.created > 0 ? "green" : "blue", indicator: (res.created_mappings > 0 || res.created_docs > 0) ? "green" : "blue",
}, 5); }, 5);
if (dialog._kb_frm) dialog._kb_frm.refresh();
if (res.errors && res.errors.length) {
KBBRTMapping.showErrors(res.errors);
}
}, },
error: function () { error: function () {
dialog.enable_primary_action(); dialog.enable_primary_action();
@ -271,4 +290,28 @@ const KBBRTMapping = {
}, },
}); });
}, },
showErrors: function (errors) {
let html = '<div style="max-height:500px; overflow-y:auto;">';
errors.forEach(function (err) {
const ref = err.reference_number || __("Unknown");
html += '<div style="margin-bottom:14px; padding:12px; border:1px solid var(--border-color); border-radius:6px; background:var(--bg-color);">';
html += '<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px; padding-bottom:6px; border-bottom:1px solid var(--border-color);">';
html += '<strong>' + frappe.utils.escape_html(ref) + '</strong>';
html += '<span class="label label-danger">' + __("Error") + '</span>';
html += '</div>';
html += '<div style="color:var(--text-color);">' + frappe.utils.escape_html(err.message || __("Unknown error")) + '</div>';
html += '</div>';
});
html += '</div>';
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();
},
}; };

View File

@ -672,10 +672,10 @@ KBImport.import = {
}; };
// ======= LIST VIEW SETUP ======= // ======= LIST VIEW SETUP =======
frappe.listview_settings['Payment Entry'] = { // frappe.listview_settings['Payment Entry'] = {
onload(listview) { // onload(listview) {
listview.page.add_inner_button(__('Import'), function() { // listview.page.add_inner_button(__('Import'), function() {
KBImport.import.showDialog(); // KBImport.import.showDialog();
}, __('Kapital Bank')); // }, __('Kapital Bank'));
} // }
}; // };

View File

@ -4,77 +4,113 @@ import frappe
@frappe.whitelist() @frappe.whitelist()
def create_purpose_mappings(transactions, paid_from, paid_to, document_type="Payment Entry"): def create_purpose_mappings(transactions, paid_from, paid_to, document_type="Payment Entry", mode="Both"):
"""Bulk-create transaction mappings from selected BRT transactions.""" """Bulk-create transaction mappings and/or documents from selected BRT transactions."""
try: try:
txn_list = json.loads(transactions) if isinstance(transactions, str) else transactions txn_list = json.loads(transactions) if isinstance(transactions, str) else transactions
settings = frappe.get_single("Kapital Bank Settings") do_mappings = mode in ("Both", "Mappings Only")
existing = { do_documents = mode in ("Both", "Documents & Reconcile")
(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 created_mappings = 0
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 already_mapped = 0
for (purpose_text, payment_type), data in unique_purposes.items(): skipped_no_data = 0
purpose_name = _ensure_purpose(purpose_text) created_docs = 0
reconciled = 0
errors = []
if (purpose_name, payment_type) in existing: if do_mappings:
already_mapped += 1 settings = frappe.get_single("Kapital Bank Settings")
continue 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", { # Deduplicate within batch; first occurrence wins for party info
"purpose_keyword": purpose_name, unique_purposes = {}
"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: for txn in txn_list:
settings.save(ignore_permissions=True) purpose = (txn.get("purpose") or "").strip()
for (purpose_text, _payment_type) in unique_purposes: if not purpose:
purpose_name = frappe.db.get_value( skipped_no_data += 1
"Kapital Bank Purpose", {"purpose_keyword": purpose_text}, "name" continue
) payment_type = "Pay" if txn.get("drcr") == "D" else "Receive"
if purpose_name: key = (purpose, payment_type)
frappe.db.set_value( if key not in unique_purposes:
"Kapital Bank Purpose", purpose_name, "status", "Mapped", unique_purposes[key] = {
update_modified=False, "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 { return {
"success": True, "success": True,
"created": created, "created_mappings": created_mappings,
"skipped_no_data": skipped_no_data,
"already_mapped": already_mapped, "already_mapped": already_mapped,
"skipped_no_data": skipped_no_data,
"created_docs": created_docs,
"reconciled": reconciled,
"errors": errors,
} }
except Exception as e: 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)} 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): def _ensure_purpose(purpose_text):
"""Return the name of an existing or newly created Kapital Bank Purpose.""" """Return the name of an existing or newly created Kapital Bank Purpose."""
existing = frappe.db.get_value( existing = frappe.db.get_value(