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,10 +1,12 @@
// 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.
// Only activate if at least one Kapital Bank Login exists
frappe.db.count("Kapital Bank Login").then(function (count) {
if (!count) return;
if (frm._kb_check_interval) clearInterval(frm._kb_check_interval);
frm._kb_check_interval = setInterval(function () {
@ -16,12 +18,14 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
}, 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;">
<span class="kb-brt-selected-count text-muted"></span>
<button class="btn btn-primary btn-sm kb-brt-create-mapping-btn">
${__("Create Purpose Mapping")}
${__("Create & Reconcile")}
</button>
</div>
`);
@ -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 = $("<div>").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(" &bull; ") || __("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 = '<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 =======
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'));
// }
// };

View File

@ -4,11 +4,22 @@ 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
do_mappings = mode in ("Both", "Mappings Only")
do_documents = mode in ("Both", "Documents & Reconcile")
created_mappings = 0
already_mapped = 0
skipped_no_data = 0
created_docs = 0
reconciled = 0
errors = []
if do_mappings:
settings = frappe.get_single("Kapital Bank Settings")
existing = {
(row.purpose_keyword, row.payment_type)
@ -18,7 +29,6 @@ def create_purpose_mappings(transactions, paid_from, paid_to, document_type="Pay
# 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()
@ -36,8 +46,6 @@ def create_purpose_mappings(transactions, paid_from, paid_to, document_type="Pay
}
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)
@ -55,9 +63,9 @@ def create_purpose_mappings(transactions, paid_from, paid_to, document_type="Pay
"counterparty": data["counterparty"] or None,
})
existing.add((purpose_name, payment_type))
created += 1
created_mappings += 1
if created > 0:
if created_mappings > 0:
settings.save(ignore_permissions=True)
for (purpose_text, _payment_type) in unique_purposes:
purpose_name = frappe.db.get_value(
@ -70,11 +78,39 @@ def create_purpose_mappings(transactions, paid_from, paid_to, document_type="Pay
)
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(