reconsiliation tool modified

This commit is contained in:
Ali 2026-03-10 19:47:52 +04:00
parent 7be2112e2a
commit bced451359
7 changed files with 608 additions and 7 deletions

View File

@ -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 = $(`
<div id="kb-brt-mapping-toolbar"
style="display:none; align-items:center; gap:10px; margin-bottom:8px;
padding:8px 12px; background:var(--fg-color);
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")}
</button>
</div>
`);
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 = $("<div>").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 = '<div style="max-height:260px; overflow-y:auto; margin-bottom:12px;">' +
'<table class="table table-bordered" style="width:100%;">' +
"<thead><tr>" +
"<th>" + __("Description") + "</th>" +
'<th style="width:90px;">' + __("Type") + "</th>" +
'<th style="width:140px;">' + __("Party") + "</th>" +
'<th style="width:60px; text-align:center;">' + __("Rows") + "</th>" +
"</tr></thead><tbody>";
purposeMap.forEach(function (e) {
const badge = e.payment_type === "Pay"
? '<span class="label label-danger">Pay</span>'
: '<span class="label label-success">Receive</span>';
previewHtml += "<tr>" +
'<td style="word-break:break-word;font-size:0.9em;">' + frappe.utils.escape_html(e.purpose) + "</td>" +
"<td>" + badge + "</td>" +
"<td>" + (e.party ? frappe.utils.escape_html(e.party) : '<span class="text-muted">—</span>') + "</td>" +
'<td style="text-align:center;">' + e.count + "</td></tr>";
});
previewHtml += "</tbody></table></div>";
if (skippedCount > 0) {
previewHtml += '<div class="alert alert-warning">' +
__("{0} row(s) skipped — no description text.", [skippedCount]) + "</div>";
}
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(" &bull; ") || __("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") });
},
});
},
};

View File

@ -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 = $(`
<div class="kb-mapping-toolbar" style="display: flex; gap: 10px; align-items: center; margin-bottom: 10px;">
<input type="text" class="form-control kb-mapping-search"
placeholder="${__('Search by purpose...')}"
style="flex: 1; max-width: 300px;">
<button class="btn btn-primary btn-sm kb-mapping-btn" style="display: none; white-space: nowrap;">
${__('Create Purpose Mapping')}
</button>
</div>
`);
$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 = '<div style="max-height: 300px; overflow-y: auto; margin-bottom: 15px;">';
previewHtml += '<table class="table table-bordered" style="width: 100%;">';
previewHtml += '<thead><tr>' +
'<th>' + __('Purpose') + '</th>' +
'<th style="width: 100px;">' + __('Type') + '</th>' +
'<th style="width: 80px; text-align: center;">' + __('Txns') + '</th>' +
'</tr></thead><tbody>';
purposeMap.forEach(function(entry) {
const typeLabel = entry.payment_type === 'Pay'
? '<span class="label label-danger">Pay</span>'
: '<span class="label label-success">Receive</span>';
previewHtml += '<tr>' +
'<td style="word-break: break-word; font-size: 0.9em;">' + entry.purpose + '</td>' +
'<td>' + typeLabel + '</td>' +
'<td style="text-align: center;">' + entry.count + '</td>' +
'</tr>';
});
previewHtml += '</tbody></table></div>';
if (skippedCount > 0) {
previewHtml += '<div class="alert alert-warning">' +
__('{0} transaction(s) skipped — no purpose text.', [skippedCount]) +
'</div>';
}
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(' &bull; ') || __('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'),
});
},
});
};

View File

@ -13,6 +13,7 @@ after_migrate = "kapital_bank.setup.after_migrate"
doctype_js = { doctype_js = {
"Payment Entry": "client/payment_entry.js", "Payment Entry": "client/payment_entry.js",
"Bank Transaction": "client/bank_transaction.js", "Bank Transaction": "client/bank_transaction.js",
"Bank Reconciliation Tool": "client/bank_reconciliation_tool.js",
} }
doctype_list_js = { doctype_list_js = {

View File

@ -1,7 +1,8 @@
{ {
"actions": [], "actions": [],
"creation": "2026-02-26 00:00:00.000000", "creation": "2026-02-26 00:00:00",
"doctype": "DocType", "doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"document_type", "document_type",
@ -43,7 +44,7 @@
"fieldtype": "Select", "fieldtype": "Select",
"in_list_view": 1, "in_list_view": 1,
"label": "Counterparty Type", "label": "Counterparty Type",
"options": "\nKapital Bank Customer\nKapital Bank Supplier" "options": "\nCustomer\nSupplier"
}, },
{ {
"fieldname": "counterparty", "fieldname": "counterparty",
@ -83,17 +84,15 @@
"label": "Notes" "label": "Notes"
} }
], ],
"index_web_pages_for_search": 0,
"allow_bulk_edit": 1,
"editable_grid": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2026-03-05 00:00:00.000000", "modified": "2026-03-10 17:54:47.738883",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Kapital Bank", "module": "Kapital Bank",
"name": "Kapital Bank Transaction Mapping", "name": "Kapital Bank Transaction Mapping",
"owner": "Administrator", "owner": "Administrator",
"permissions": [], "permissions": [],
"row_format": "Dynamic",
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [] "states": []

112
kapital_bank/mapping.py Normal file
View File

@ -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

View File

@ -5,3 +5,4 @@ kapital_bank.patches.rename_purpose_mapping_to_transaction_mapping
[post_model_sync] [post_model_sync]
# Patches added in this section will be executed after doctypes are migrated # Patches added in this section will be executed after doctypes are migrated
kapital_bank.patches.migrate_counterparty_type

View File

@ -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()