feat(bank-integration): rename Bank Statement Importer to Bank Integration Profile; excel import, list views, cleanup patches

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ali 2026-06-10 17:52:03 +00:00
parent e4d63cdc74
commit 0f7a86ff2b
28 changed files with 1237 additions and 503 deletions

1
.gitignore vendored
View File

@ -6,3 +6,4 @@ tags
node_modules
__pycache__
Screenshot*.png
jey_erp/public/dist/

View File

@ -1,6 +1,6 @@
"""Cascade-delete endpoint for Bank Statement Importer.
"""Cascade-delete endpoint for Bank Integration Profile.
By default Frappe refuses to delete a Bank Statement Importer that still has
By default Frappe refuses to delete a Bank Integration Profile that still has
dependent registry rows (Customer/Supplier/Purpose) or Bank Accounts pointing
at it via the bank_integration Dynamic Link. This module exposes:
@ -13,7 +13,7 @@ at it via the bank_integration Dynamic Link. This module exposes:
import frappe
from frappe import _
DOCTYPE = "Bank Statement Importer"
DOCTYPE = "Bank Integration Profile"
_REGISTRY_DOCTYPES = (
("Bank Integration Customer", "Customers"),
("Bank Integration Supplier", "Suppliers"),
@ -25,7 +25,7 @@ _REGISTRY_DOCTYPES = (
def get_dependents(name):
"""Return counts of records that would be touched by a cascade delete."""
if not name or not frappe.db.exists(DOCTYPE, name):
frappe.throw(_("Bank Statement Importer '{0}' not found").format(name))
frappe.throw(_("Bank Integration Profile '{0}' not found").format(name))
counts = {}
for dt, _label in _REGISTRY_DOCTYPES:
@ -40,7 +40,7 @@ def get_dependents(name):
@frappe.whitelist()
def cascade_delete(name):
"""Delete a Bank Statement Importer and everything that depends on it.
"""Delete a Bank Integration Profile and everything that depends on it.
Steps (executed in this order so Frappe's link-check doesn't block the
final delete_doc on the importer):
@ -50,7 +50,7 @@ def cascade_delete(name):
3. Delete the importer itself (its mapping child tables go with it)
"""
if not name or not frappe.db.exists(DOCTYPE, name):
frappe.throw(_("Bank Statement Importer '{0}' not found").format(name))
frappe.throw(_("Bank Integration Profile '{0}' not found").format(name))
frappe.only_for(("System Manager", "Accounts Manager"))

View File

@ -173,7 +173,7 @@ def create_purpose_mappings(transactions, bank_integration, paid_from=None, paid
reconciled = 0
errors = []
settings = frappe.get_doc("Bank Statement Importer", bank_integration)
settings = frappe.get_doc("Bank Integration Profile", bank_integration)
# Bank Transactions are imported without a party (the mapping is what tells
# us who they are). Resolve it now, from the customer/supplier mappings, so

View File

@ -42,7 +42,7 @@ def _pick_leaf(doctype, configured, label):
@frappe.whitelist()
def create_unmapped_customers(bank_integration):
"""For each Bank Integration Customer Mapping row without erp_customer, create an ERPNext Customer."""
bi = frappe.get_doc("Bank Statement Importer", bank_integration)
bi = frappe.get_doc("Bank Integration Profile", bank_integration)
unmapped = []
for mapping in bi.customer_mappings:
@ -130,7 +130,7 @@ def create_unmapped_customers(bank_integration):
@frappe.whitelist()
def create_unmapped_suppliers(bank_integration):
bi = frappe.get_doc("Bank Statement Importer", bank_integration)
bi = frappe.get_doc("Bank Integration Profile", bank_integration)
unmapped = []
for mapping in bi.supplier_mappings:
@ -210,7 +210,7 @@ def add_unmapped_to_table(bank_integration, table):
`table` {customer_mappings, supplier_mappings, transaction_mappings}.
"""
bi = frappe.get_doc("Bank Statement Importer", bank_integration)
bi = frappe.get_doc("Bank Integration Profile", bank_integration)
if table == "customer_mappings":
existing = {row.bi_customer_name for row in bi.customer_mappings if row.bi_customer_name}

View File

@ -64,7 +64,7 @@ def parse_excel(file_url, bank_integration):
"""
from openpyxl import load_workbook
bi = frappe.get_doc("Bank Statement Importer", bank_integration)
bi = frappe.get_doc("Bank Integration Profile", bank_integration)
col_to_standard = {}
unknown_labels = []

View File

@ -105,7 +105,7 @@ def _process_bulk_bt_import(txn_list, bank_integration, bank_account, user):
frappe.set_user(user)
bi = frappe.get_doc("Bank Statement Importer", bank_integration)
bi = frappe.get_doc("Bank Integration Profile", bank_integration)
company = erpnext.get_default_company()
ba_doc = frappe.get_doc("Bank Account", bank_account)
@ -254,7 +254,7 @@ def _upsert_purpose(purpose, drcr, bank_integration):
@frappe.whitelist()
def get_bi_reference_data_list(bank_integration, data_type, limit=100, offset=0):
"""Paginated rows for the Bank Statement Importer "Data" tab subtabs.
"""Paginated rows for the Bank Integration Profile "Data" tab subtabs.
data_type {customers, suppliers, purposes}.
"""
@ -304,12 +304,33 @@ def get_bi_reference_data_list(bank_integration, data_type, limit=100, offset=0)
return {"success": False, "message": str(e)}
@frappe.whitelist()
def get_workflow_state(bank_integration):
"""Lightweight registry counts that drive the workflow guide on the
Bank Integration Profile form. Used to figure out the user's next step."""
def counts(dt):
total = frappe.db.count(dt, {"parent_bank_integration": bank_integration})
new = frappe.db.count(dt, {"parent_bank_integration": bank_integration, "status": "New"})
return total, new
c_total, c_new = counts("Bank Integration Customer")
s_total, s_new = counts("Bank Integration Supplier")
p_total, p_new = counts("Bank Integration Purpose")
return {
"registry_total": c_total + s_total + p_total,
"customers_total": c_total, "customers_new": c_new,
"suppliers_total": s_total, "suppliers_new": s_new,
"purposes_total": p_total, "purposes_new": p_new,
}
@frappe.whitelist()
def update_bank_account_bi_default(bank_account, bi_type, bi_name):
"""Save the user's BRT-time choice to the BA's hidden fields."""
if not bank_account:
frappe.throw(_("Bank Account required"))
if bi_type and bi_type not in ("Bank Statement Importer", "Kapital Bank Settings"):
if bi_type and bi_type not in ("Bank Integration Profile", "Kapital Bank Settings"):
frappe.throw(_("Invalid Bank Integration Type"))
frappe.db.set_value("Bank Account", bank_account, {

View File

@ -21,7 +21,7 @@ def _normalize(text, consider_azeri=True):
@frappe.whitelist()
def match_similar_customers(bank_integration):
"""Auto-match BI Customers to ERPNext Customers by VOEN or name similarity."""
bi = frappe.get_doc("Bank Statement Importer", bank_integration)
bi = frappe.get_doc("Bank Integration Profile", bank_integration)
threshold = flt(bi.similarity_threshold_customers or 90) / 100.0
consider_azeri = bool(bi.consider_azeri_chars)
@ -80,7 +80,7 @@ def match_similar_customers(bank_integration):
@frappe.whitelist()
def match_similar_suppliers(bank_integration):
bi = frappe.get_doc("Bank Statement Importer", bank_integration)
bi = frappe.get_doc("Bank Integration Profile", bank_integration)
threshold = flt(bi.similarity_threshold_suppliers or 80) / 100.0
consider_azeri = bool(bi.consider_azeri_chars)

View File

@ -4,7 +4,7 @@ import frappe
def add_importer_link_to_invoicing_workspace():
"""Insert a 'Bank Statement Importer' link into the existing 'Banking' card
"""Insert a 'Bank Integration Profile' link into the existing 'Banking' card
on the ERPNext 'Invoicing' workspace.
This intentionally edits an ERPNext-owned workspace at runtime rather than
@ -15,7 +15,7 @@ def add_importer_link_to_invoicing_workspace():
Idempotent runs every migrate but only inserts when the link is missing.
"""
ws_name = "Invoicing"
link_to = "Bank Statement Importer"
link_to = "Bank Integration Profile"
card_label = "Banking"
if not frappe.db.exists("Workspace", ws_name):
@ -176,12 +176,12 @@ def copy_excel_preset_into_bank_integration():
Idempotent: once a Bank Integration has any column mappings or an
explicit amount_mode, it's left alone.
"""
if not frappe.db.exists("DocType", "Bank Statement Importer"):
if not frappe.db.exists("DocType", "Bank Integration Profile"):
return
if not frappe.db.exists("DocType", "Bank Integration Excel Preset"):
return
bi_names = frappe.get_all("Bank Statement Importer", pluck="name")
bi_names = frappe.get_all("Bank Integration Profile", pluck="name")
if not bi_names:
return
@ -202,7 +202,7 @@ def copy_excel_preset_into_bank_integration():
copied = 0
for bi_name in bi_names:
bi = frappe.get_doc("Bank Statement Importer", bi_name)
bi = frappe.get_doc("Bank Integration Profile", bi_name)
# Skip if File Format already configured.
if bi.column_mappings or (bi.amount_mode and bi.amount_mode != "Separate debit/credit columns"):
continue

View File

@ -1081,7 +1081,7 @@ def create_custom_fields():
fieldname='bank_integration_type',
label='Bank Integration Type',
fieldtype='Select',
options='\nBank Statement Importer\nKapital Bank Settings',
options='\nBank Integration Profile\nKapital Bank Settings',
insert_after='correspondent_account',
hidden=1,
read_only=1,

View File

@ -16,6 +16,7 @@ app_include_js = [
"/assets/jey_erp/js/sales_order_vat.js",
"/assets/jey_erp/js/asset.js",
"/assets/jey_erp/js/link_field_fix.js",
"bank_excel_import.bundle.js",
]
app_include_css = [
@ -39,6 +40,7 @@ doctype_list_js = {
"Account": "public/js/account_list.js",
"Leave Type": "public/js/leave_type_list.js",
"Bank Transaction": "public/js/bank_transaction_list.js",
"Payment Entry": "public/js/payment_entry_list.js",
}
extend_doctype_class = {

View File

@ -46,8 +46,8 @@
"fieldname": "parent_bank_integration",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Bank Statement Importer",
"options": "Bank Statement Importer",
"label": "Bank Integration Profile",
"options": "Bank Integration Profile",
"reqd": 1
},
{

View File

@ -24,10 +24,12 @@
"reqd": 1
},
{
"fetch_from": "bi_customer_name.tax_id",
"fieldname": "tax_id",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Tax ID"
"label": "Tax ID",
"read_only": 1
},
{
"fieldname": "erp_customer",

View File

@ -1,7 +1,7 @@
// Buttons on Bank Statement Importer form: matching, creation, add unmapped
// Buttons on Bank Integration Profile form: matching, creation, add unmapped
// Mirrors the kapital_bank_settings.js layout for familiarity.
frappe.ui.form.on('Bank Statement Importer', {
frappe.ui.form.on('Bank Integration Profile', {
setup(frm) {
// Customer/Supplier Group and Territory must be leaf (non-group) nodes —
// don't let group nodes be picked here or in the mapping child tables.
@ -26,6 +26,12 @@ frappe.ui.form.on('Bank Statement Importer', {
// frm.savetrash() is what the Menu Delete item ultimately calls.
frm.savetrash = () => BSICascadeDelete.run(frm);
// === Import Bank Statement (creates Bank Transactions for this profile) ===
frm.add_custom_button(__('Import Bank Statement'), () => {
window.BIExcelImport.showDialog({ bankIntegration: frm.doc.name });
});
frm.change_custom_button_type(__('Import Bank Statement'), null, 'primary');
// === Load Data (file-based, registry-only) ===
frm.add_custom_button(__('Load Data'), () => {
_show_load_data_dialog(frm);
@ -104,6 +110,9 @@ frappe.ui.form.on('Bank Statement Importer', {
));
}, __('Purposes'));
// === Workflow wizard (current step + expandable step list) ===
_bi_render_form_wizard(frm);
// === Data tab rendering ===
_bi_load_data_tabs(frm);
},
@ -119,11 +128,15 @@ frappe.ui.form.on('Bank Statement Importer', {
preview_sample_btn(frm) {
BIFileFormat.showPreview(frm);
},
load_data_from_file_btn(frm) {
_bi_load_from_sample(frm);
},
});
// ─── File Format helpers ─────────────────────────────────────────────────────
// Mirrors what bank_integration_excel_preset.js used to do, but on the merged
// File Format tab of the Bank Statement Importer form: a Sample File attachment turns
// File Format tab of the Bank Integration Profile form: a Sample File attachment turns
// the Excel Column field in the column_mappings grid into a dropdown of real
// header strings, so users don't have to type them.
@ -376,7 +389,7 @@ function _show_load_data_dialog(frm) {
fieldtype: 'Attach',
label: __('Excel File'),
reqd: 1,
description: __('Format is read from the File Format tab on this Bank Statement Importer.'),
description: __('Format is read from the File Format tab on this Bank Integration Profile.'),
},
{ fieldname: 'sec_what', fieldtype: 'Section Break', label: __('What to load') },
{
@ -518,7 +531,7 @@ function _bi_render_customers(rows, total, reload) {
});
html += '</tbody></table>';
} else {
html += _bi_empty_state(__('No customers registered yet. Import a statement (with "Also load counterparties & purposes") or use "Load Data".'));
html += _bi_empty_state(__('No customers registered yet.'));
}
$('#bi-customers-container').html(html);
$('#bi-refresh-customers').on('click', reload);
@ -628,18 +641,18 @@ const BSICascadeDelete = {
? `<p>${__('No dependent records — deleting this importer is safe.')}</p>`
: `<p>${__('Deleting <b>{0}</b> will also affect:', [frappe.utils.escape_html(frm.doc.name)])}</p>
<ul style="margin-left:18px;">${rows}</ul>
<p>${__('Registry rows will be <b>deleted</b>. Bank Accounts will only have their Bank Statement Importer link cleared — the accounts themselves stay.')}</p>
<p>${__('Registry rows will be <b>deleted</b>. Bank Accounts will only have their Bank Integration Profile link cleared — the accounts themselves stay.')}</p>
<p class="text-danger"><b>${__('This cannot be undone.')}</b></p>`;
const d = new frappe.ui.Dialog({
title: __('Delete Bank Statement Importer?'),
title: __('Delete Bank Integration Profile?'),
fields: [{ fieldname: 'body', fieldtype: 'HTML', options: body }],
primary_action_label: __('Delete Everything'),
primary_action: () => {
d.disable_primary_action();
this._doDelete(frm.doc.name).then(() => {
d.hide();
frappe.set_route('List', 'Bank Statement Importer');
frappe.set_route('List', 'Bank Integration Profile');
}).catch(() => {
d.enable_primary_action();
});
@ -677,3 +690,316 @@ const BSICascadeDelete = {
});
},
};
// ═══════════════════════════════════════════════════════════════════════════════
// WORKFLOW WIZARD — compact bar at the top of the form showing the CURRENT step.
// Clicking it smoothly expands the full list of steps (current one highlighted).
// Each step row jumps to where that step is performed; steps can be skipped.
// ═══════════════════════════════════════════════════════════════════════════════
function _bi_inject_wizard_styles() {
if (document.getElementById('bi-wizard-form-styles')) return;
const css = `
.bi-wizard-form { margin: 0 0 12px; border: 1px solid var(--border-color, #e2e6e9);
border-radius: 8px; background: var(--card-bg, #fff); overflow: hidden; }
.bi-wizard-bar { display: flex; align-items: center; gap: 9px; padding: 9px 12px;
cursor: pointer; user-select: none; }
.bi-wizard-bar:hover { background: var(--bg-light-gray, #f4f5f6); }
.bi-wizard-dot { width: 9px; height: 9px; border-radius: 50%;
background: var(--blue-500, #2490ef); flex: 0 0 auto; }
.bi-wizard-cur { flex: 1 1 auto; }
.bi-wizard-cur b { margin-right: 4px; }
.bi-wizard-toggle { font-size: 12px; color: var(--text-muted); white-space: nowrap;
display: inline-flex; align-items: center; gap: 4px; }
.bi-wizard-chev { display: inline-block;
transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1); }
.bi-wizard-form.open .bi-wizard-chev { transform: rotate(180deg); }
/* Smooth expand/collapse — matches jey_theme (max-height + padding transition). */
.bi-wizard-list { max-height: 0; overflow: hidden;
padding: 0 6px; border-top: 0 solid var(--border-color, #eef0f2);
transition: max-height 0.45s ease-in-out, padding 0.45s ease-in-out,
border-width 0.45s ease-in-out; }
.bi-wizard-form.open .bi-wizard-list { max-height: 600px; padding: 6px;
border-top-width: 1px; }
.bi-wizard-step { display: flex; align-items: flex-start; gap: 10px;
padding: 8px; border-radius: 6px; }
.bi-wizard-step-main { display: flex; align-items: flex-start; gap: 10px;
flex: 1 1 auto; cursor: pointer; min-width: 0; }
.bi-wizard-step:hover { background: var(--bg-light-gray, #f4f5f6); }
.bi-wizard-step.cur { background: var(--highlight-color, #f0f4ff); }
.bi-wizard-step.done { opacity: 0.65; }
.bi-wizard-step.skipped { opacity: 0.5; }
.bi-wizard-badge { flex: 0 0 22px; width: 22px; height: 22px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
font-weight: 600; font-size: 12px; margin-top: 1px;
background: var(--control-bg, #f4f5f6); }
.bi-wizard-step.done .bi-wizard-badge { background: var(--green-100, #d4f3e0);
color: var(--green-600, #1f9d55); }
.bi-wizard-step.cur .bi-wizard-badge { background: var(--blue-500, #2490ef);
color: #fff; }
.bi-wizard-step-title { font-weight: 600; }
.bi-wizard-step-hint { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.bi-wizard-tag { font-size: 11px; font-weight: 600; margin-left: 6px; }
.bi-wizard-tag.cur { color: var(--blue-600, #1373cc); }
.bi-wizard-tag.skip { color: var(--text-muted); }
.bi-wizard-skip { flex: 0 0 auto; font-size: 11px; color: var(--text-muted);
cursor: pointer; white-space: nowrap; padding: 2px 4px; align-self: center; }
.bi-wizard-skip:hover { color: var(--text-color, #1f272e); text-decoration: underline; }`;
const style = document.createElement('style');
style.id = 'bi-wizard-form-styles';
style.textContent = css;
document.head.appendChild(style);
}
// Skip state is per-user / per-importer (kept in localStorage — a skipped step is
// "my view", not a property of the importer shared across users).
function _bi_skip_key(frm) { return 'bi_wizard_skipped::' + frm.doc.name; }
function _bi_get_skipped(frm) {
try { return JSON.parse(localStorage.getItem(_bi_skip_key(frm)) || '[]'); }
catch (e) { return []; }
}
function _bi_set_skipped(frm, arr) {
try { localStorage.setItem(_bi_skip_key(frm), JSON.stringify(arr)); } catch (e) { /* ignore */ }
}
// Focus a field that may live inside a custom_subtabs subtab. scroll_to_field only
// activates the parent tab and leaves the subtab content hidden, so for subtab
// fields we click the subtab button (its handler reveals the content + parent chain).
function _bi_focus_field(frm, fieldname) {
const wrapper = (frm.layout && frm.layout.wrapper) || frm.$wrapper;
const field = frm.get_field(fieldname);
const $pane = field && field.$wrapper ? field.$wrapper.closest('.tab-pane') : $();
const paneId = $pane.attr('id');
if (paneId) {
const $btn = wrapper.find(
`.sub-tab button.nav-link[data-target-id="${paneId}"], ` +
`.sub-tab a.nav-link[data-target-id="${paneId}"]`
);
if ($btn.length) {
$btn.first().trigger('click');
setTimeout(() => {
if (field.$wrapper && field.$wrapper[0]) {
field.$wrapper[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 80);
return;
}
}
frm.scroll_to_field(fieldname);
}
function _bi_render_form_wizard(frm, keepOpen) {
frappe.call({
method: 'jey_erp.bank_integration.import_api.get_workflow_state',
args: { bank_integration: frm.doc.name },
callback(r) {
const st = r.message || {};
const skipped = _bi_get_skipped(frm);
const hasFormat = (frm.doc.column_mappings || []).length > 0;
const hasRegistry = (st.registry_total || 0) > 0;
const hasParties = (st.customers_total || 0) + (st.suppliers_total || 0) > 0;
const partiesMapped = hasParties &&
((st.customers_new || 0) + (st.suppliers_new || 0) === 0);
const hasTxnRules = (frm.doc.transaction_mappings || []).length > 0;
// Steps 5-6 live on other doctypes — always navigation, not skippable.
const steps = [
{
key: 'format', done: hasFormat,
title: __('Configure file format'),
hint: __('Open the File Format tab and map the Excel columns to standard fields.'),
action: () => _bi_focus_field(frm, 'column_mappings'),
},
{
key: 'load', done: hasRegistry,
title: __('Load data'),
hint: __('Use "Load Data" to pull counterparties and purposes from a statement.'),
action: () => _show_load_data_dialog(frm),
},
{
key: 'map', done: partiesMapped,
title: __('Map counterparties'),
hint: __('Match or create customers and suppliers (Customers / Suppliers menus above).'),
action: () => _bi_focus_field(frm, 'customer_mappings'),
},
{
key: 'rules', done: hasTxnRules,
title: __('Configure transaction rules'),
hint: __('Define how payments map to Payment Entries / Journal Entries.'),
action: () => _bi_focus_field(frm, 'transaction_mappings'),
},
{
key: 'import', off: true,
title: __('Import transactions'),
hint: __('Use the "Import Bank Statement" button to load the statement into Bank Transactions.'),
action: () => window.BIExcelImport.showDialog({ bankIntegration: frm.doc.name }),
},
{
key: 'reconcile', off: true,
title: __('Create documents & reconcile'),
hint: __('In the Bank Reconciliation Tool: pick the account and Create & Reconcile.'),
action: () => frappe.set_route('Form', 'Bank Reconciliation Tool'),
},
];
steps.forEach((s) => { s.skipped = !s.off && skipped.indexOf(s.key) !== -1; });
let currentIdx = steps.findIndex((s) => !s.off && !s.done && !s.skipped);
if (currentIdx === -1) currentIdx = 4; // all on-form steps done/skipped → "Import"
const cur = steps[currentIdx];
const esc = frappe.utils.escape_html;
const rows = steps.map((s, i) => {
const isCur = i === currentIdx;
let badge;
let cls;
let tag;
if (s.skipped) {
badge = ''; cls = 'skipped';
tag = ` <span class="bi-wizard-tag skip">${__('Skipped')}</span>`;
} else if (s.done) {
badge = '✓'; cls = 'done'; tag = '';
} else if (isCur) {
badge = '➜'; cls = 'cur';
tag = ` <span class="bi-wizard-tag cur">${__('Current step')}</span>`;
} else {
badge = (i + 1); cls = 'todo'; tag = '';
}
const skipCtl = s.off ? '' :
`<span class="bi-wizard-skip" data-bi-skip="${s.key}">${
s.skipped ? __('Unskip') : __('Skip')}</span>`;
return `
<div class="bi-wizard-step ${cls}">
<div class="bi-wizard-step-main" data-bi-step="${i}">
<span class="bi-wizard-badge">${badge}</span>
<div class="bi-wizard-step-body">
<div class="bi-wizard-step-title">${esc(s.title)}${tag}</div>
<div class="bi-wizard-step-hint">${esc(s.hint)}</div>
</div>
</div>
${skipCtl}
</div>`;
}).join('');
const html = `
<div class="bi-wizard-form${keepOpen ? ' open' : ''}">
<div class="bi-wizard-bar">
<span class="bi-wizard-dot"></span>
<span class="bi-wizard-cur"><b>${__('Bank Import')}</b> · ${esc(cur.title)}</span>
<span class="bi-wizard-toggle">${__('Steps')}<span class="bi-wizard-chev"></span></span>
</div>
<div class="bi-wizard-list">${rows}</div>
</div>`;
_bi_inject_wizard_styles();
// Mount at the top of the form body; re-render replaces any prior instance.
const $host = frm.$wrapper.find('.form-layout').first();
frm.$wrapper.find('.bi-wizard-form').remove();
const $w = $(html);
$host.prepend($w);
$w.find('.bi-wizard-bar').on('click', () => $w.toggleClass('open'));
$w.find('.bi-wizard-step-main').on('click', function () {
steps[$(this).data('bi-step')].action();
});
$w.find('.bi-wizard-skip').on('click', function (e) {
e.stopPropagation();
const key = $(this).data('bi-skip');
const set = _bi_get_skipped(frm);
const idx = set.indexOf(key);
if (idx === -1) set.push(key); else set.splice(idx, 1);
_bi_set_skipped(frm, set);
_bi_render_form_wizard(frm, /* keepOpen */ true);
});
},
});
}
// ─── Load Data from the attached File Format sample file ─────────────────────
// The File Format sample file is normally a design-time sample (used only to
// detect columns). But when it actually holds real statement rows, this lets the
// user load its counterparties / purposes into the registries straight from the
// File Format tab — no separate dialog, no re-attaching the file.
function _bi_load_from_sample(frm) {
if (!frm.doc.sample_file) {
frappe.msgprint({
title: __('No File'),
indicator: 'orange',
message: __('Attach a Sample Excel File first.'),
});
return;
}
if (!frm.doc.column_mappings || !frm.doc.column_mappings.length) {
frappe.msgprint({
title: __('No Column Mappings'),
indicator: 'orange',
message: __('Map the Excel columns to standard fields first.'),
});
return;
}
frappe.confirm(
__('Load counterparties and purposes from the attached file into the registries? Use this only if the file contains real statement data, not just a column sample.'),
() => {
frappe.call({
method: 'jey_erp.bank_integration.import_api.load_registries_from_excel',
args: {
file_url: frm.doc.sample_file,
bank_integration: frm.doc.name,
load_customers: 1,
load_suppliers: 1,
load_purposes: 1,
},
freeze: true,
freeze_message: __('Loading data from file…'),
callback(r) {
if (!r.message || r.message.success === false) {
frappe.msgprint({
title: __('Error'),
indicator: 'red',
message: (r.message && r.message.message) || __('Unknown error'),
});
return;
}
_bi_show_load_result(r.message);
frm.reload_doc();
},
});
}
);
}
function _bi_show_load_result(res) {
const droppedDate = res.dropped_date || 0;
const droppedAmount = res.dropped_amount || 0;
const droppedDirection = res.dropped_direction || 0;
const unknownDirections = res.unknown_directions || [];
let msg = __('From {0} rows — new customers: {1}, suppliers: {2}, purposes: {3}',
[res.total_rows, res.new_customers, res.new_suppliers, res.new_purposes]);
if (droppedDate > 0) {
msg += '<br><br><span class="text-warning">' +
__("Note: {0} row(s) had unparseable dates and were skipped. Enable 'Use Custom Date Format' in the File Format tab if your bank uses an unusual date format.",
[droppedDate]) +
'</span>';
}
if (droppedDirection > 0) {
const sample = unknownDirections.map(frappe.utils.escape_html).join(', ') || '?';
msg += '<br><br><span class="text-warning">' +
__('Also skipped {0} row(s) with unknown direction values: <code>{1}</code>. Add them to Debit/Credit Values in the File Format tab.',
[droppedDirection, sample]) +
'</span>';
}
if (droppedAmount > 0) {
msg += '<br><span class="text-muted">' +
__('Also skipped {0} row(s) with missing/zero amount.', [droppedAmount]) +
'</span>';
}
frappe.msgprint({
title: __('Data Loaded'),
indicator: (droppedDate > 0 || droppedDirection > 0) ? 'orange' : 'green',
message: msg,
});
}

View File

@ -24,6 +24,7 @@
"sample_file",
"ff_sample_actions_section",
"preview_sample_btn",
"load_data_from_file_btn",
"ff_format_section",
"header_row",
"amount_mode",
@ -171,6 +172,13 @@
"fieldtype": "Button",
"label": "Preview Sample"
},
{
"depends_on": "eval:doc.sample_file",
"description": "Requires the Excel columns to be mapped to standard fields first. If this attached file contains REAL statement data (not just a sample used to detect columns), you can load its counterparties and purposes into the registries right now, ready for mapping. If it is only a design-time sample, you can ignore this and use the file just for detecting the columns.",
"fieldname": "load_data_from_file_btn",
"fieldtype": "Button",
"label": "Load Data from File"
},
{
"fieldname": "ff_format_section",
"fieldtype": "Section Break",
@ -355,7 +363,7 @@
"modified": "2026-05-18 00:00:00.000000",
"modified_by": "Administrator",
"module": "Jey Erp",
"name": "Bank Statement Importer",
"name": "Bank Integration Profile",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [

View File

@ -0,0 +1,98 @@
# Copyright (c) 2026, JeyERP and contributors
# For license information, please see license.txt
import frappe
from frappe.model.document import Document
class BankIntegrationProfile(Document):
def on_update(self):
# Keep the registry records in sync with the mapping child tables, both ways:
# * a row with a linked ERP party -> registry record becomes Mapped
# (and inherits its group / territory / payment terms);
# * removing a row OR clearing its erp_customer / erp_supplier flips the
# corresponding registry record back to New.
self._sync_customer_statuses()
self._sync_supplier_statuses()
def _sync_customer_statuses(self):
# PROMOTE: every mapping row with a linked ERP customer becomes Mapped.
mapped_names = set()
for row in self.customer_mappings:
if not (row.bi_customer_name and row.erp_customer):
continue
mapped_names.add(row.bi_customer_name)
current = frappe.db.get_value(
"Bank Integration Customer", row.bi_customer_name,
["status", "mapped_customer", "customer_group", "territory", "payment_terms"],
as_dict=True,
)
if not current:
continue
desired = {
"status": "Mapped",
"mapped_customer": row.erp_customer,
"customer_group": row.customer_group or None,
"territory": row.territory or None,
"payment_terms": row.payment_terms or None,
}
if any(current.get(k) != v for k, v in desired.items()):
frappe.db.set_value(
"Bank Integration Customer", row.bi_customer_name, desired, update_modified=False
)
# DEMOTE: Mapped records no longer present in the table revert to New.
orphaned = frappe.get_all(
"Bank Integration Customer",
filters={"status": "Mapped", "parent_bank_integration": self.name},
fields=["name"],
)
for c in orphaned:
if c.name not in mapped_names:
frappe.db.set_value("Bank Integration Customer", c.name, {
"status": "New",
"mapped_customer": None,
"customer_group": None,
"territory": None,
"payment_terms": None,
}, update_modified=False)
def _sync_supplier_statuses(self):
# PROMOTE: every mapping row with a linked ERP supplier becomes Mapped.
mapped_names = set()
for row in self.supplier_mappings:
if not (row.bi_supplier_name and row.erp_supplier):
continue
mapped_names.add(row.bi_supplier_name)
current = frappe.db.get_value(
"Bank Integration Supplier", row.bi_supplier_name,
["status", "mapped_supplier", "supplier_group", "payment_terms"],
as_dict=True,
)
if not current:
continue
desired = {
"status": "Mapped",
"mapped_supplier": row.erp_supplier,
"supplier_group": row.supplier_group or None,
"payment_terms": row.payment_terms or None,
}
if any(current.get(k) != v for k, v in desired.items()):
frappe.db.set_value(
"Bank Integration Supplier", row.bi_supplier_name, desired, update_modified=False
)
# DEMOTE: Mapped records no longer present in the table revert to New.
orphaned = frappe.get_all(
"Bank Integration Supplier",
filters={"status": "Mapped", "parent_bank_integration": self.name},
fields=["name"],
)
for s in orphaned:
if s.name not in mapped_names:
frappe.db.set_value("Bank Integration Supplier", s.name, {
"status": "New",
"mapped_supplier": None,
"supplier_group": None,
"payment_terms": None,
}, update_modified=False)

View File

@ -23,8 +23,8 @@
"fieldname": "parent_bank_integration",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Bank Statement Importer",
"options": "Bank Statement Importer",
"label": "Bank Integration Profile",
"options": "Bank Integration Profile",
"reqd": 1
},
{

View File

@ -45,8 +45,8 @@
"fieldname": "parent_bank_integration",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Bank Statement Importer",
"options": "Bank Statement Importer",
"label": "Bank Integration Profile",
"options": "Bank Integration Profile",
"reqd": 1
},
{

View File

@ -23,10 +23,12 @@
"reqd": 1
},
{
"fetch_from": "bi_supplier_name.tax_id",
"fieldname": "tax_id",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Tax ID"
"label": "Tax ID",
"read_only": 1
},
{
"fieldname": "erp_supplier",

View File

@ -1,55 +0,0 @@
# Copyright (c) 2026, JeyERP and contributors
# For license information, please see license.txt
import frappe
from frappe.model.document import Document
class BankStatementImporter(Document):
def on_update(self):
# Keep the registry records in sync with the mapping child tables:
# removing a row OR clearing its erp_customer / erp_supplier flips the
# corresponding registry record back to status=New.
self._sync_customer_statuses()
self._sync_supplier_statuses()
def _sync_customer_statuses(self):
mapped_names = {
row.bi_customer_name
for row in self.customer_mappings
if row.bi_customer_name and row.erp_customer
}
orphaned = frappe.get_all(
"Bank Integration Customer",
filters={"status": "Mapped", "parent_bank_integration": self.name},
fields=["name"],
)
for c in orphaned:
if c.name not in mapped_names:
frappe.db.set_value("Bank Integration Customer", c.name, {
"status": "New",
"mapped_customer": None,
"customer_group": None,
"territory": None,
"payment_terms": None,
}, update_modified=False)
def _sync_supplier_statuses(self):
mapped_names = {
row.bi_supplier_name
for row in self.supplier_mappings
if row.bi_supplier_name and row.erp_supplier
}
orphaned = frappe.get_all(
"Bank Integration Supplier",
filters={"status": "Mapped", "parent_bank_integration": self.name},
fields=["name"],
)
for s in orphaned:
if s.name not in mapped_names:
frappe.db.set_value("Bank Integration Supplier", s.name, {
"status": "New",
"mapped_supplier": None,
"supplier_group": None,
"payment_terms": None,
}, update_modified=False)

View File

@ -4,6 +4,7 @@
jey_erp.patches.fix_employee_row_format
jey_erp.patches.fix_employee_columns_as_text
jey_erp.patches.rename_bank_integration_to_importer
jey_erp.patches.rename_importer_to_profile
[post_model_sync]
# Patches added in this section will be executed after doctypes are migrated
@ -11,7 +12,7 @@ jey_erp.patches.create_asset_categories_post_migration
jey_erp.patches.remove_old_won_lost_fields
jey_erp.patches.remove_won_lost_breaks
jey_erp.patches.remove_won_lost_checkboxes
jey_erp.patches.drop_bank_integration_account_mapping
jey_erp.patches.drop_orphan_bank_doctypes
jey_erp.patches.backfill_si_tax_article_row_keys
jey_erp.patches.backfill_sales_invoice_tax_articles_history
jey_erp.patches.add_tax_articles_to_si_grid_views

View File

@ -1,22 +0,0 @@
"""Drop the Bank Integration Account Mapping DocType.
The child table was unused in the Bank Statement Importer flow: Bank Account
is picked explicitly in the Import dialog, so there was no need for an
IBANBank Account lookup table. The kapital_bank app keeps its own
equivalent because that flow pulls transactions through an API without a
per-import Bank Account choice.
Idempotent runs only when the DocType is still around.
"""
import frappe
def execute():
dt = "Bank Integration Account Mapping"
if not frappe.db.exists("DocType", dt):
return
# Drops both the metadata row and the underlying `tab<Name>` table.
frappe.delete_doc("DocType", dt, ignore_permissions=True, force=True)
frappe.db.commit()
print(f"BI: dropped DocType '{dt}'")

View File

@ -0,0 +1,42 @@
"""Drop orphaned Bank Integration DocTypes left over from earlier iterations.
Two DocTypes are no longer part of the Bank Integration Profile flow and have
no references anywhere in the codebase:
* Bank Integration Account Mapping the IBANBank Account lookup child table.
Bank Account is now picked explicitly in the Import dialog, so the table is
unused. (Its metadata was already removed on some sites, but the underlying
`tab` table can linger; the old `drop_bank_integration_account_mapping`
patch only called delete_doc, which is a no-op once the meta is gone.)
* Bank Integration Standard Field a seed/config table holding the 12 standard
field labels (Date, Amount, Counterparty, ). These are now hard-coded in
`jey_erp.bank_integration.excel_parser.STANDARD_FIELD_TO_CODE`, so the table
is dead config data.
This patch removes any leftover DocType metadata AND drops the lingering tables
explicitly, so the database is fully cleaned regardless of how far the previous
(incomplete) cleanup got on a given site. Idempotent.
"""
import frappe
ORPHANS = [
"Bank Integration Account Mapping",
"Bank Integration Standard Field",
]
def execute():
for dt in ORPHANS:
# Remove DocType metadata if it is still registered.
if frappe.db.exists("DocType", dt):
frappe.delete_doc("DocType", dt, ignore_permissions=True, force=True)
# Drop the underlying table if it survived the meta removal.
table = f"tab{dt}"
if frappe.db.sql("SHOW TABLES LIKE %s", (table,)):
frappe.db.sql_ddl(f"DROP TABLE IF EXISTS `{table}`")
print(f"BI cleanup: dropped table `{table}`")
frappe.db.commit()

View File

@ -0,0 +1,56 @@
"""Rename the Bank Statement Importer DocType to Bank Integration Profile.
"Bank Statement Importer" collides with ERPNext's stock "Bank Statement Import"
DocType, which is confusing in the UI and in searches. "Bank Integration Profile"
also brings the main record name in line with the rest of the family (all the
registry / mapping doctypes are already "Bank Integration *").
Runs in [pre_model_sync], AFTER `rename_bank_integration_to_importer`, so the
chain is: Bank Integration -> Bank Statement Importer -> Bank Integration Profile.
Idempotent: only runs while the old name still exists and the new one doesn't.
"""
import frappe
def execute():
old_name = "Bank Statement Importer"
new_name = "Bank Integration Profile"
if frappe.db.exists("DocType", new_name):
# Already renamed (or freshly created under the new name).
return
if not frappe.db.exists("DocType", old_name):
# Fresh install / nothing to rename yet.
return
# frappe.rename_doc on a DocType:
# - renames the underlying `tab<Name>` table
# - updates Link/Dynamic Link references stored in DocField.options
# - updates `parenttype` rows in linked child tables
frappe.rename_doc("DocType", old_name, new_name, force=True, show_alert=False)
# Dynamic Link VALUES are user data, not metadata — rename_doc leaves them be.
# Re-point the Bank Account.bank_integration_type column.
frappe.db.sql(
"""UPDATE `tabBank Account`
SET bank_integration_type = %s
WHERE bank_integration_type = %s""",
(new_name, old_name),
)
# Keep the Select options on the bank_integration_type Custom Field consistent
# within this same migrate run (custom_fields.py also rewrites it afterwards).
custom_field = frappe.db.exists("Custom Field", {
"dt": "Bank Account", "fieldname": "bank_integration_type"
})
if custom_field:
frappe.db.set_value(
"Custom Field", custom_field, "options",
"\n" + new_name + "\nKapital Bank Settings",
update_modified=False,
)
frappe.db.commit()
print(f"BI: renamed DocType '{old_name}' to '{new_name}'")

View File

@ -0,0 +1,551 @@
// Shared Excel bank-statement importer dialog.
//
// Loaded globally (app_include_js) so it can be launched from BOTH the Bank
// Transaction list view AND the Bank Reconciliation Tool form.
//
// Usage:
// BIExcelImport.showDialog({
// bankIntegration: "<profile name>", // optional pre-fill
// bankAccount: "<bank account>", // optional pre-fill
// onComplete: function (res) { ... }, // optional; res = { imported, errors, transactions }
// });
//
// `res.transactions` is the list of rows that were sent to import (each with a
// `date`), so the caller can e.g. derive the imported statement's date range.
const BIExcelImport = {
showDialog(opts) {
opts = opts || {};
frappe.db.get_list('Bank Integration Profile', {
fields: ['name', 'bank_name'],
limit: 100,
}).then((integrations) => {
if (!integrations || !integrations.length) {
frappe.msgprint({
title: __('No Bank Integration Profile'),
indicator: 'orange',
message: __('Create at least one Bank Integration Profile record first.'),
});
return;
}
BIExcelImport._showSetupDialog(integrations, opts);
});
},
_showSetupDialog(integrations, opts) {
const d = new frappe.ui.Dialog({
title: __('Load Bank Transactions from Excel'),
fields: [
{
fieldname: 'bank_integration',
fieldtype: 'Link',
label: __('Bank Integration Profile'),
options: 'Bank Integration Profile',
reqd: 1,
default: opts.bankIntegration || undefined,
description: __('Format and column mappings come from the chosen Bank Integration Profile.'),
},
{
fieldname: 'bank_account',
fieldtype: 'Link',
label: __('Bank Account'),
options: 'Bank Account',
reqd: 1,
default: opts.bankAccount || undefined,
description: __('The Bank Account these transactions will be assigned to.'),
},
{
fieldname: 'file_url',
fieldtype: 'Attach',
label: __('Excel File'),
reqd: 1,
},
],
primary_action_label: __('Parse'),
primary_action(values) {
d.hide();
BIExcelImport._parseAndPreview(values, opts);
},
});
d.show();
},
_parseAndPreview(values, opts) {
frappe.show_alert({ message: __('Parsing file...'), indicator: 'blue' });
frappe.call({
method: 'jey_erp.bank_integration.import_api.parse_excel_for_preview',
args: {
file_url: values.file_url,
bank_integration: values.bank_integration,
bank_account: values.bank_account,
},
callback(r) {
if (!r.message || !r.message.success) {
frappe.msgprint({
title: __('Parse Error'),
indicator: 'red',
message: (r.message && r.message.message) || __('Unknown error'),
});
return;
}
const txns = r.message.transactions || [];
const skipped = r.message.skipped_duplicates || 0;
const droppedDate = r.message.dropped_date || 0;
const droppedAmount = r.message.dropped_amount || 0;
const droppedDirection = r.message.dropped_direction || 0;
const unknownDirections = r.message.unknown_directions || [];
const emptyRefCount = r.message.empty_ref_count || 0;
if (droppedDate > 0 && !txns.length) {
BIExcelImport._showDateParseHelp(droppedDate, values.bank_integration);
return;
}
if (droppedDirection > 0 && !txns.length) {
BIExcelImport._showDirectionHelp(droppedDirection, unknownDirections, values.bank_integration);
return;
}
if (droppedAmount > 0 && !txns.length) {
BIExcelImport._showAmountHelp(droppedAmount, values.bank_integration);
return;
}
if (!txns.length) {
frappe.msgprint({
title: __('Nothing to Import'),
indicator: 'blue',
message: skipped > 0
? __('All {0} parsed rows are duplicates.', [skipped])
: __('No transactions found in the file. Check that the Header Row in the File Format tab points at the actual header line.'),
});
return;
}
BIExcelImport._showPreview(txns, skipped, values, opts,
droppedDate, droppedAmount, droppedDirection, unknownDirections, emptyRefCount);
},
});
},
_showPreview(txns, skipped, values, opts, droppedDate, droppedAmount, droppedDirection, unknownDirections, emptyRefCount) {
let table = '<div style="max-height: 500px; overflow-y: auto;">';
if (skipped > 0) {
table += '<div class="alert alert-info">' +
__('Skipped {0} duplicates already imported. {1} new transactions ready.', [skipped, txns.length]) +
'</div>';
}
if (droppedDate > 0) {
table += '<div class="alert alert-warning">' +
__('Dropped {0} row(s) — the date could not be parsed.', [droppedDate]) + ' ' +
__("Open the Bank Integration Profile's File Format tab, enable 'Use Custom Date Format', and set the exact format your bank uses.") +
'</div>';
}
if (droppedDirection > 0) {
const sample = (unknownDirections || []).map(frappe.utils.escape_html).join(', ');
table += '<div class="alert alert-warning">' +
__('Dropped {0} row(s) — unknown direction value(s):', [droppedDirection]) +
' <code>' + (sample || '?') + '</code>. ' +
__('Open the File Format tab and add these to Debit Values or Credit Values.') +
'</div>';
}
if (droppedAmount > 0) {
table += '<div class="alert alert-warning">' +
__('Dropped {0} row(s) — missing or zero amount.', [droppedAmount]) +
'</div>';
}
if (emptyRefCount > 0) {
table += '<div class="alert alert-warning">' +
__('{0} of {1} row(s) have no Reference Number — re-importing the same file will create duplicates because deduplication uses the Reference Number.',
[emptyRefCount, txns.length]) +
'</div>';
}
table += '<table class="table table-bordered" style="width:100%; table-layout:fixed;">';
table += '<thead style="position:sticky; top:0; z-index:1; background:var(--bg-color);"><tr>';
table += '<th style="width:4%;"><input type="checkbox" class="bi-select-all-txns"></th>';
table += '<th style="width:14%;">' + __('Ref No') + '</th>';
table += '<th style="width:10%;">' + __('Date') + '</th>';
table += '<th style="width:24%;">' + __('Counterparty') + '</th>';
table += '<th style="width:12%; text-align:right;">' + __('Amount') + '</th>';
table += '<th style="width:8%; text-align:center;">' + __('Type') + '</th>';
table += '<th style="width:28%;">' + __('Purpose / Description') + '</th>';
table += '</tr></thead><tbody>';
const esc = frappe.utils.escape_html;
txns.forEach((t, i) => {
const dateStr = t.date ? moment(t.date).format('DD.MM.YYYY') : '';
const amountStr = parseFloat(t.amount || 0).toFixed(2);
const drcrLabel = t.drcr === 'D'
? '<span class="label label-danger">' + __('Pay') + '</span>'
: '<span class="label label-success">' + __('Receive') + '</span>';
const text = t.purpose || t.description || '';
table += '<tr>' +
'<td><input type="checkbox" class="bi-select-txn" data-idx="' + i + '"></td>' +
'<td style="word-break:break-word;">' + esc(t.ref_no || '') + '</td>' +
'<td>' + dateStr + '</td>' +
'<td style="word-break:break-word;">' + esc(t.counterparty || '') + '</td>' +
'<td style="text-align:right;">' + amountStr + '</td>' +
'<td style="text-align:center;">' + drcrLabel + '</td>' +
'<td style="word-break:break-word; font-size:0.9em;">' + esc(text) + '</td>' +
'</tr>';
});
table += '</tbody></table></div>';
const d = new frappe.ui.Dialog({
title: __('Select Transactions to Import') + ' (' + txns.length + ')',
size: 'large',
fields: [{ fieldname: 'preview_html', fieldtype: 'HTML', options: table }],
primary_action_label: __('Import Selected'),
primary_action() {
const selected = [];
d.$wrapper.find('.bi-select-txn:checked').each(function () {
const i = parseInt($(this).data('idx'), 10);
selected.push(txns[i]);
});
if (!selected.length) {
frappe.msgprint({
title: __('No Selection'), indicator: 'orange',
message: __('Please select at least one transaction.'),
});
return;
}
d.hide();
BIExcelImport._runImport(selected, values, opts);
},
});
d.$wrapper.find('.modal-dialog').css({ 'max-width': '90%', 'width': '90%' });
d.show();
d.$wrapper.find('.bi-select-all-txns').on('change', function () {
d.$wrapper.find('.bi-select-txn').prop('checked', $(this).prop('checked'));
});
},
_runImport(selected, values, opts) {
const total = selected.length;
// Freeze the page instead of using show_progress. show_progress relies
// on a Bootstrap modal whose backdrop reliably gets stuck when we then
// open a msgprint on completion; freeze is a simple full-page overlay
// that unfreeze() always cleans up.
frappe.dom.freeze(__('Starting import…'));
frappe.realtime.off('bi_bt_import_progress');
frappe.realtime.on('bi_bt_import_progress', function (d) {
BIExcelImport._setFreezeMessage(__('Importing {0} of {1}…', [d.current, d.total]));
});
frappe.realtime.off('bi_bt_import_complete');
frappe.realtime.on('bi_bt_import_complete', function (d) {
frappe.realtime.off('bi_bt_import_progress');
frappe.realtime.off('bi_bt_import_complete');
frappe.dom.unfreeze();
const ok = d.imported || 0;
const errCount = (d.errors || []).length;
let msg = '<div>' + __('Imported: <b>{0}</b>', [ok]) + '</div>';
msg += '<div>' + __('Errors: <b>{0}</b>', [errCount]) + '</div>';
const indicator = errCount === 0 ? 'green' : (ok > 0 ? 'orange' : 'red');
const title = errCount === 0
? __('Import Successful')
: (ok === 0 ? __('Import Failed') : __('Import Completed with Errors'));
const dlg = frappe.msgprint({ title, indicator, message: msg });
if (errCount > 0 && d.errors) {
BIExcelImport._lastErrors = d.errors;
setTimeout(() => {
if (!dlg) return;
dlg.body.append(
'<div style="text-align:center; margin-top:12px;">' +
'<button class="btn btn-default btn-sm" id="bi-show-errors-btn">' +
__('View Errors') + '</button></div>'
);
$('#bi-show-errors-btn').on('click', () => BIExcelImport._showErrorDetails());
}, 200);
}
if (opts && typeof opts.onComplete === 'function') {
opts.onComplete({ imported: ok, errors: d.errors || [], transactions: selected });
}
});
frappe.call({
method: 'jey_erp.bank_integration.import_api.import_bulk_bt',
args: {
txn_list: JSON.stringify(selected),
bank_integration: values.bank_integration,
bank_account: values.bank_account,
},
error() {
frappe.realtime.off('bi_bt_import_progress');
frappe.realtime.off('bi_bt_import_complete');
frappe.dom.unfreeze();
frappe.msgprint({
title: __('Error'), indicator: 'red',
message: __('Network error starting import'),
});
},
});
},
// Replace the message on the active freeze overlay without re-freezing
// (re-freezing stacks counter and breaks unfreeze pairing).
_setFreezeMessage(msg) {
const $msg = $('#freeze .freeze-message');
if ($msg.length) {
$msg.text(msg);
}
},
_showDateParseHelp(droppedCount, bankIntegrationName) {
const openForm = function () {
frappe.set_route('Form', 'Bank Integration Profile', bankIntegrationName);
};
const d = new frappe.ui.Dialog({
title: __('Date Format Not Recognized'),
fields: [{
fieldtype: 'HTML',
options:
'<div class="alert alert-danger">' +
__('All {0} row(s) were dropped because the date column could not be parsed.', [droppedCount]) +
'</div>' +
'<p>' + __("The parser tries common formats like <code>2026-01-31</code>, <code>31.01.2026</code>, <code>01/31/2026</code> automatically.") + '</p>' +
'<p>' + __("If your bank uses a different format, open the Bank Integration Profile's File Format tab and enable <b>Use Custom Date Format</b>, then enter the exact Python strftime format (e.g. <code>%d-%b-%Y</code> for <code>31-Jan-2026</code>).") + '</p>',
}],
primary_action_label: __('Open Bank Integration Profile'),
primary_action() { d.hide(); openForm(); },
secondary_action_label: __('Close'),
secondary_action() { d.hide(); },
});
d.show();
},
_showAmountHelp(droppedCount, bankIntegrationName) {
const openForm = function () {
frappe.set_route('Form', 'Bank Integration Profile', bankIntegrationName);
};
const d = new frappe.ui.Dialog({
title: __('Amount Column Missing or Empty'),
fields: [{
fieldtype: 'HTML',
options:
'<div class="alert alert-danger">' +
__('All {0} row(s) were dropped because the amount could not be read.', [droppedCount]) +
'</div>' +
'<p>' + __('Most likely the <b>Amount</b> column (or <b>Debit</b>/<b>Credit</b>, depending on Amount Mode) is not mapped to an Excel header in the File Format tab.') + '</p>' +
'<p>' + __('Open the Bank Integration Profile and check the Column Mappings table — Standard Field values must include all columns required by the chosen Amount Mode.') + '</p>',
}],
primary_action_label: __('Open Bank Integration Profile'),
primary_action() { d.hide(); openForm(); },
secondary_action_label: __('Close'),
secondary_action() { d.hide(); },
});
d.show();
},
_showDirectionHelp(droppedCount, unknownDirections, bankIntegrationName) {
const openForm = function () {
frappe.set_route('Form', 'Bank Integration Profile', bankIntegrationName);
};
const sample = (unknownDirections || []).map(frappe.utils.escape_html).join(', ') || '?';
const d = new frappe.ui.Dialog({
title: __('Unknown Direction Values'),
fields: [{
fieldtype: 'HTML',
options:
'<div class="alert alert-danger">' +
__('All {0} row(s) were dropped because the Direction column contained values not configured in the File Format tab.', [droppedCount]) +
'</div>' +
'<p>' + __('Unrecognised values:') + ' <code>' + sample + '</code></p>' +
'<p>' + __('Open the Bank Integration Profile and add these to <b>Debit Values</b> (for outflows) or <b>Credit Values</b> (for inflows), comma-separated.') + '</p>',
}],
primary_action_label: __('Open Bank Integration Profile'),
primary_action() { d.hide(); openForm(); },
secondary_action_label: __('Close'),
secondary_action() { d.hide(); },
});
d.show();
},
_showErrorDetails() {
const errs = BIExcelImport._lastErrors || [];
if (!errs.length) {
frappe.msgprint(__('No error details'));
return;
}
const esc = frappe.utils.escape_html;
let html = '<div style="max-height:500px; overflow-y:auto;">';
errs.forEach(e => {
html += '<div style="margin-bottom:12px; padding:10px; border:1px solid var(--border-color); border-radius:6px;">';
html += '<strong>' + esc(e.ref_no || 'No ref') + '</strong>';
html += ' <span class="label label-danger" style="margin-left:8px;">' + esc(e.error_type || 'error') + '</span>';
html += '<div style="margin-top:6px; color:var(--text-muted);">' +
esc(e.counterparty || '') + ' — ' + parseFloat(e.amount || 0).toFixed(2) + '</div>';
html += '<div style="margin-top:6px;"><code>' + esc(e.message || '') + '</code></div>';
html += '</div>';
});
html += '</div>';
const d = new frappe.ui.Dialog({
title: __('Error Details') + ' (' + errs.length + ')',
size: 'large',
fields: [{ fieldname: 'errors_html', fieldtype: 'HTML', options: html }],
});
d.show();
},
};
window.BIExcelImport = BIExcelImport;
// ═══════════════════════════════════════════════════════════════════════════════
// BANK IMPORT SOURCE REGISTRY + shared "Bank Integrations" menu
// ═══════════════════════════════════════════════════════════════════════════════
//
// Each app registers a "source" describing how to import a statement and where
// its settings live. The shared menu shows exactly two items everywhere —
// "Import Bank Statement" and "Integration Settings" — and resolves across the
// registered sources: a single source runs directly, multiple sources prompt a
// small choice dialog (Frappe has no nested button-group / submenu).
//
// A source:
// {
// key: "excel",
// label: __("Excel file"),
// settingsLabel: __("Bank Integration Profiles"),
// runImport(ctx) { ... }, // optional; ctx = { bankAccount, bankIntegration, onComplete }
// goToSettings() { ... }, // optional
// }
frappe.provide('jey_erp');
jey_erp.bank_sources = jey_erp.bank_sources || [];
// Order-independent: whichever app's bundle loads first creates the array; both
// push into it. De-duped by key so a double-load is harmless.
jey_erp.registerBankSource = function (src) {
if (!src || !src.key) return;
if (!jey_erp.bank_sources.some((s) => s.key === src.key)) {
jey_erp.bank_sources.push(src);
}
};
jey_erp.bankImportMenu = {
GROUP: 'Bank Integrations',
// page: a Frappe Page (listview.page or frm.page).
// opts:
// importHandler - custom click handler for the Import item (overrides the
// source-choice logic; used by Payment Entry's redirect).
// importContext - () => ({bankAccount, bankIntegration, onComplete}) | null
// (return null to abort, e.g. when a Bank Account is required).
// allowImport - set false to omit the Import item entirely.
// prependGroup - move the whole group to the left of the toolbar.
attach(page, opts) {
opts = opts || {};
const grp = __(this.GROUP);
if (opts.importHandler) {
page.add_inner_button(__('Import Bank Statement'), opts.importHandler, grp);
} else if (opts.allowImport !== false) {
page.add_inner_button(__('Import Bank Statement'),
() => jey_erp.bankImportMenu._chooseImport(opts), grp);
}
page.add_inner_button(__('Integration Settings'),
() => jey_erp.bankImportMenu._chooseSettings(), grp);
if (opts.prependGroup && page.get_or_add_inner_group_button && page.inner_toolbar) {
const $grp = page.get_or_add_inner_group_button(grp);
if ($grp && $grp.length) $grp.prependTo(page.inner_toolbar);
}
},
_chooseImport(opts) {
const sources = (jey_erp.bank_sources || []).filter((s) => typeof s.runImport === 'function');
if (!sources.length) {
frappe.msgprint({
title: __('No Import Source'), indicator: 'orange',
message: __('No bank import source is configured.'),
});
return;
}
const ctx = (opts && opts.importContext) ? opts.importContext() : {};
if (ctx === null || ctx === false) return; // aborted (e.g. missing Bank Account)
if (sources.length === 1) { sources[0].runImport(ctx || {}); return; }
jey_erp.bankImportMenu._pick(
__('Import from which source?'),
sources.map((s) => ({ label: s.importLabel || s.label, action: () => s.runImport(ctx || {}) }))
);
},
_chooseSettings() {
const sources = (jey_erp.bank_sources || []).filter((s) => typeof s.goToSettings === 'function');
if (!sources.length) return;
if (sources.length === 1) { sources[0].goToSettings(); return; }
jey_erp.bankImportMenu._pick(
__('Open which settings?'),
sources.map((s) => ({ label: s.settingsLabel || s.label, action: () => s.goToSettings() }))
);
},
_pick(title, choices) {
// Only one chooser open at a time — rapid clicks must not stack dialogs
// (a stacked one would linger behind the action and look "stuck").
if (jey_erp.bankImportMenu._pickDialog) {
try { jey_erp.bankImportMenu._pickDialog.hide(); } catch (e) { /* ignore */ }
jey_erp.bankImportMenu._pickDialog = null;
}
const esc = frappe.utils.escape_html;
const html =
'<div style="display:flex; flex-direction:column; align-items:center; gap:10px; padding:10px 0 14px;">' +
choices.map((c, i) =>
'<button class="btn btn-default" data-pick="' + i + '" style="width:260px; max-width:100%;">' +
esc(c.label) + '</button>'
).join('') +
'</div>';
const d = new frappe.ui.Dialog({
title,
size: 'small',
fields: [{ fieldtype: 'HTML', fieldname: 'choices', options: html }],
});
jey_erp.bankImportMenu._pickDialog = d;
d.onhide = () => {
if (jey_erp.bankImportMenu._pickDialog === d) jey_erp.bankImportMenu._pickDialog = null;
};
d.show();
let chosen = false;
d.$wrapper.find('button[data-pick]').on('click', function () {
if (chosen) return; // guard against a fast double-click
chosen = true;
const i = parseInt($(this).attr('data-pick'), 10);
d.hide();
// Run the action only after the dialog has fully closed, so a route
// change or a follow-up dialog doesn't leave this modal's backdrop stuck.
setTimeout(() => choices[i].action(), 200);
});
},
};
// jey_erp's own source: Excel file import + Bank Integration Profile settings.
jey_erp.registerBankSource({
key: 'excel',
label: __('Excel file'),
importLabel: __('Excel file'),
settingsLabel: __('Bank Integration Profiles'),
runImport(ctx) {
ctx = ctx || {};
// If only the Bank Account is known (e.g. launched from the Bank
// Reconciliation Tool), resolve its default profile before opening.
if (ctx.bankAccount && !ctx.bankIntegration) {
frappe.db.get_value('Bank Account', ctx.bankAccount,
['bank_integration_type', 'bank_integration']).then((r) => {
const ba = (r && r.message) || {};
const profile = ba.bank_integration_type === 'Bank Integration Profile'
? ba.bank_integration : '';
window.BIExcelImport.showDialog(Object.assign({}, ctx, { bankIntegration: profile }));
});
} else {
window.BIExcelImport.showDialog(ctx);
}
},
goToSettings() {
frappe.set_route('List', 'Bank Integration Profile');
},
});

View File

@ -8,7 +8,7 @@
// (done here so there is no destroy/rebuild flicker)
//
// "Create & Reconcile" maps the selected Bank Transactions to Payment Entry /
// Journal Entry using a chosen mapping source: a Bank Statement Importer record
// Journal Entry using a chosen mapping source: a Bank Integration Profile record
// (jey_erp) or Kapital Bank Settings (kapital_bank). The default source comes
// from the Bank Account's hidden bank_integration field and is written back on
// submit. The dialog / mapping logic is a port of kapital_bank's BRT extension.
@ -17,6 +17,26 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
refresh(frm) {
frm.$wrapper.closest(".page-container").css("--page-max-width", "none");
// Shared "Bank Integrations" menu (Import Bank Statement / Integration
// Settings). ERPNext adds its own header buttons before this app's refresh
// runs, so the group lands last — prependGroup moves it left of those.
// Import resolves the statement's date range from the file and reloads the
// table afterwards.
jey_erp.bankImportMenu.attach(frm.page, {
prependGroup: true,
importContext: () => {
if (!frm.doc.bank_account) {
frappe.msgprint({ title: __("Bank Account Required"), indicator: "orange",
message: __("Select a Bank Account first.") });
return null;
}
return {
bankAccount: frm.doc.bank_account,
onComplete: (res) => _bi_apply_imported_range_and_reload(frm, res),
};
},
});
frappe.require("bank-reconciliation-tool.bundle.js", function () {
const DTM = erpnext.accounts.bank_reconciliation.DataTableManager;
if (!DTM || DTM.prototype._jey_erp_patched) return;
@ -74,6 +94,27 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
},
});
// After an Excel import launched from this form, set the statement date range to
// the imported rows' min/max date (so the new Bank Transactions fall inside the
// reconciliation filter) and reload the transaction table.
function _bi_apply_imported_range_and_reload(frm, res) {
const txns = (res && res.transactions) || [];
const moments = txns
.map((t) => (t.date ? moment(t.date) : null))
.filter((m) => m && m.isValid());
if (moments.length) {
const minD = moment.min(moments).format("YYYY-MM-DD");
const maxD = moment.max(moments).format("YYYY-MM-DD");
if (frm.doc.filter_by_reference_date) {
frm.set_value("filter_by_reference_date", 0);
}
frm.set_value("bank_statement_from_date", minD);
frm.set_value("bank_statement_to_date", maxD);
}
// Reload the unreconciled-transactions table (re-queries by account + range).
setTimeout(() => frm.trigger("make_reconciliation_tool"), 150);
}
const BIBRT = {
injectToolbar: function (dtm) {
$("#bi-brt-toolbar").remove();
@ -174,7 +215,7 @@ const BIBRT = {
}
Promise.all([
frappe.db.get_list("Bank Statement Importer", { fields: ["name", "bank_name"], limit: 200 }),
frappe.db.get_list("Bank Integration Profile", { fields: ["name", "bank_name"], limit: 200 }),
frappe.db.get_value("Bank Account", bankAccount, ["bank_integration_type", "bank_integration"]),
frappe.db.get_list("DocType", { filters: { name: "Kapital Bank Settings" }, fields: ["name"], limit: 1 }),
]).then(([integrations, baResp, kbList]) => {
@ -183,7 +224,7 @@ const BIBRT = {
const sourceMap = {};
(integrations || []).forEach(i => {
sourceMap["Bank Statement Importer::" + i.name] = __("Bank Statement Importer: {0}", [i.bank_name || i.name]);
sourceMap["Bank Integration Profile::" + i.name] = __("Bank Integration Profile: {0}", [i.bank_name || i.name]);
});
if (kbInstalled) {
sourceMap["Kapital Bank Settings::Kapital Bank Settings"] = __("Kapital Bank Settings");
@ -192,7 +233,7 @@ const BIBRT = {
const sourceValues = Object.keys(sourceMap);
if (sourceValues.length === 0) {
frappe.msgprint({ title: __("No Mapping Source"), indicator: "orange",
message: __("Create at least one Bank Statement Importer record first.") });
message: __("Create at least one Bank Integration Profile record first.") });
return;
}
@ -257,7 +298,7 @@ const BIBRT = {
options: sourceValues.join("\n"),
default: defaultSource || sourceValues[0],
reqd: 1,
description: __("Which Bank Statement Importer / Kapital Bank Settings provides the mappings."),
description: __("Which Bank Integration Profile / Kapital Bank Settings provides the mappings."),
},
{
fieldname: "mode",

View File

@ -1,5 +1,9 @@
// Bank Transaction list view: "Import From..." dropdown with "Load from Excel"
// Other apps (e.g. kapital_bank) add their own items into the same group.
// Bank Transaction list view: the shared "Bank Integrations" menu.
//
// The menu (two items: Import Bank Statement / Integration Settings) and the
// source registry live in bank_excel_import.bundle.js (loaded globally via
// app_include_js). Each app registers its own source; the menu resolves the
// choice at click time.
(function () {
const existing = frappe.listview_settings['Bank Transaction'] || {};
@ -11,385 +15,9 @@
try { prevOnload(listview); } catch (e) { console.error(e); }
}
listview.page.add_inner_button(__('Load from Excel'), function () {
BIExcelImport.showDialog(listview);
}, __('Import From...'));
jey_erp.bankImportMenu.attach(listview.page, {
importContext: () => ({ onComplete: () => listview.refresh() }),
});
}
});
})();
const BIExcelImport = {
showDialog(listview) {
frappe.db.get_list('Bank Statement Importer', {
fields: ['name', 'bank_name'],
limit: 100,
}).then((integrations) => {
if (!integrations || !integrations.length) {
frappe.msgprint({
title: __('No Bank Statement Importer'),
indicator: 'orange',
message: __('Create at least one Bank Statement Importer record first.'),
});
return;
}
BIExcelImport._showSetupDialog(integrations, listview);
});
},
_showSetupDialog(integrations, listview) {
const d = new frappe.ui.Dialog({
title: __('Load Bank Transactions from Excel'),
fields: [
{
fieldname: 'bank_integration',
fieldtype: 'Link',
label: __('Bank Statement Importer'),
options: 'Bank Statement Importer',
reqd: 1,
description: __('Format and column mappings come from the chosen Bank Statement Importer.'),
},
{
fieldname: 'bank_account',
fieldtype: 'Link',
label: __('Bank Account'),
options: 'Bank Account',
reqd: 1,
description: __('The Bank Account these transactions will be assigned to.'),
},
{
fieldname: 'file_url',
fieldtype: 'Attach',
label: __('Excel File'),
reqd: 1,
},
],
primary_action_label: __('Parse'),
primary_action(values) {
d.hide();
BIExcelImport._parseAndPreview(values, listview);
},
});
d.show();
},
_parseAndPreview(values, listview) {
frappe.show_alert({ message: __('Parsing file...'), indicator: 'blue' });
frappe.call({
method: 'jey_erp.bank_integration.import_api.parse_excel_for_preview',
args: {
file_url: values.file_url,
bank_integration: values.bank_integration,
bank_account: values.bank_account,
},
callback(r) {
if (!r.message || !r.message.success) {
frappe.msgprint({
title: __('Parse Error'),
indicator: 'red',
message: (r.message && r.message.message) || __('Unknown error'),
});
return;
}
const txns = r.message.transactions || [];
const skipped = r.message.skipped_duplicates || 0;
const droppedDate = r.message.dropped_date || 0;
const droppedAmount = r.message.dropped_amount || 0;
const droppedDirection = r.message.dropped_direction || 0;
const unknownDirections = r.message.unknown_directions || [];
const emptyRefCount = r.message.empty_ref_count || 0;
if (droppedDate > 0 && !txns.length) {
BIExcelImport._showDateParseHelp(droppedDate, values.bank_integration);
return;
}
if (droppedDirection > 0 && !txns.length) {
BIExcelImport._showDirectionHelp(droppedDirection, unknownDirections, values.bank_integration);
return;
}
if (droppedAmount > 0 && !txns.length) {
BIExcelImport._showAmountHelp(droppedAmount, values.bank_integration);
return;
}
if (!txns.length) {
frappe.msgprint({
title: __('Nothing to Import'),
indicator: 'blue',
message: skipped > 0
? __('All {0} parsed rows are duplicates.', [skipped])
: __('No transactions found in the file. Check that the Header Row in the File Format tab points at the actual header line.'),
});
return;
}
BIExcelImport._showPreview(txns, skipped, values, listview,
droppedDate, droppedAmount, droppedDirection, unknownDirections, emptyRefCount);
},
});
},
_showPreview(txns, skipped, values, listview, droppedDate, droppedAmount, droppedDirection, unknownDirections, emptyRefCount) {
let table = '<div style="max-height: 500px; overflow-y: auto;">';
if (skipped > 0) {
table += '<div class="alert alert-info">' +
__('Skipped {0} duplicates already imported. {1} new transactions ready.', [skipped, txns.length]) +
'</div>';
}
if (droppedDate > 0) {
table += '<div class="alert alert-warning">' +
__('Dropped {0} row(s) — the date could not be parsed.', [droppedDate]) + ' ' +
__("Open the Bank Statement Importer's File Format tab, enable 'Use Custom Date Format', and set the exact format your bank uses.") +
'</div>';
}
if (droppedDirection > 0) {
const sample = (unknownDirections || []).map(frappe.utils.escape_html).join(', ');
table += '<div class="alert alert-warning">' +
__('Dropped {0} row(s) — unknown direction value(s):', [droppedDirection]) +
' <code>' + (sample || '?') + '</code>. ' +
__('Open the File Format tab and add these to Debit Values or Credit Values.') +
'</div>';
}
if (droppedAmount > 0) {
table += '<div class="alert alert-warning">' +
__('Dropped {0} row(s) — missing or zero amount.', [droppedAmount]) +
'</div>';
}
if (emptyRefCount > 0) {
table += '<div class="alert alert-warning">' +
__('{0} of {1} row(s) have no Reference Number — re-importing the same file will create duplicates because deduplication uses the Reference Number.',
[emptyRefCount, txns.length]) +
'</div>';
}
table += '<table class="table table-bordered" style="width:100%; table-layout:fixed;">';
table += '<thead style="position:sticky; top:0; z-index:1; background:var(--bg-color);"><tr>';
table += '<th style="width:4%;"><input type="checkbox" class="bi-select-all-txns"></th>';
table += '<th style="width:14%;">' + __('Ref No') + '</th>';
table += '<th style="width:10%;">' + __('Date') + '</th>';
table += '<th style="width:24%;">' + __('Counterparty') + '</th>';
table += '<th style="width:12%; text-align:right;">' + __('Amount') + '</th>';
table += '<th style="width:8%; text-align:center;">' + __('Type') + '</th>';
table += '<th style="width:28%;">' + __('Purpose / Description') + '</th>';
table += '</tr></thead><tbody>';
const esc = frappe.utils.escape_html;
txns.forEach((t, i) => {
const dateStr = t.date ? moment(t.date).format('DD.MM.YYYY') : '';
const amountStr = parseFloat(t.amount || 0).toFixed(2);
const drcrLabel = t.drcr === 'D'
? '<span class="label label-danger">' + __('Pay') + '</span>'
: '<span class="label label-success">' + __('Receive') + '</span>';
const text = t.purpose || t.description || '';
table += '<tr>' +
'<td><input type="checkbox" class="bi-select-txn" data-idx="' + i + '"></td>' +
'<td style="word-break:break-word;">' + esc(t.ref_no || '') + '</td>' +
'<td>' + dateStr + '</td>' +
'<td style="word-break:break-word;">' + esc(t.counterparty || '') + '</td>' +
'<td style="text-align:right;">' + amountStr + '</td>' +
'<td style="text-align:center;">' + drcrLabel + '</td>' +
'<td style="word-break:break-word; font-size:0.9em;">' + esc(text) + '</td>' +
'</tr>';
});
table += '</tbody></table></div>';
const d = new frappe.ui.Dialog({
title: __('Select Transactions to Import') + ' (' + txns.length + ')',
size: 'large',
fields: [{ fieldname: 'preview_html', fieldtype: 'HTML', options: table }],
primary_action_label: __('Import Selected'),
primary_action() {
const selected = [];
d.$wrapper.find('.bi-select-txn:checked').each(function () {
const i = parseInt($(this).data('idx'), 10);
selected.push(txns[i]);
});
if (!selected.length) {
frappe.msgprint({
title: __('No Selection'), indicator: 'orange',
message: __('Please select at least one transaction.'),
});
return;
}
d.hide();
BIExcelImport._runImport(selected, values, listview);
},
});
d.$wrapper.find('.modal-dialog').css({ 'max-width': '90%', 'width': '90%' });
d.show();
d.$wrapper.find('.bi-select-all-txns').on('change', function () {
d.$wrapper.find('.bi-select-txn').prop('checked', $(this).prop('checked'));
});
},
_runImport(selected, values, listview) {
const total = selected.length;
// Freeze the page instead of using show_progress. show_progress relies
// on a Bootstrap modal whose backdrop reliably gets stuck when we then
// open a msgprint on completion; freeze is a simple full-page overlay
// that unfreeze() always cleans up.
frappe.dom.freeze(__('Starting import…'));
frappe.realtime.off('bi_bt_import_progress');
frappe.realtime.on('bi_bt_import_progress', function (d) {
BIExcelImport._setFreezeMessage(__('Importing {0} of {1}…', [d.current, d.total]));
});
frappe.realtime.off('bi_bt_import_complete');
frappe.realtime.on('bi_bt_import_complete', function (d) {
frappe.realtime.off('bi_bt_import_progress');
frappe.realtime.off('bi_bt_import_complete');
frappe.dom.unfreeze();
const ok = d.imported || 0;
const errCount = (d.errors || []).length;
let msg = '<div>' + __('Imported: <b>{0}</b>', [ok]) + '</div>';
msg += '<div>' + __('Errors: <b>{0}</b>', [errCount]) + '</div>';
const indicator = errCount === 0 ? 'green' : (ok > 0 ? 'orange' : 'red');
const title = errCount === 0
? __('Import Successful')
: (ok === 0 ? __('Import Failed') : __('Import Completed with Errors'));
const dlg = frappe.msgprint({ title, indicator, message: msg });
if (errCount > 0 && d.errors) {
BIExcelImport._lastErrors = d.errors;
setTimeout(() => {
if (!dlg) return;
dlg.body.append(
'<div style="text-align:center; margin-top:12px;">' +
'<button class="btn btn-default btn-sm" id="bi-show-errors-btn">' +
__('View Errors') + '</button></div>'
);
$('#bi-show-errors-btn').on('click', () => BIExcelImport._showErrorDetails());
}, 200);
}
if (listview) listview.refresh();
});
frappe.call({
method: 'jey_erp.bank_integration.import_api.import_bulk_bt',
args: {
txn_list: JSON.stringify(selected),
bank_integration: values.bank_integration,
bank_account: values.bank_account,
},
error() {
frappe.realtime.off('bi_bt_import_progress');
frappe.realtime.off('bi_bt_import_complete');
frappe.dom.unfreeze();
frappe.msgprint({
title: __('Error'), indicator: 'red',
message: __('Network error starting import'),
});
},
});
},
// Replace the message on the active freeze overlay without re-freezing
// (re-freezing stacks counter and breaks unfreeze pairing).
_setFreezeMessage(msg) {
const $msg = $('#freeze .freeze-message');
if ($msg.length) {
$msg.text(msg);
}
},
_showDateParseHelp(droppedCount, bankIntegrationName) {
const openForm = function () {
frappe.set_route('Form', 'Bank Statement Importer', bankIntegrationName);
};
const d = new frappe.ui.Dialog({
title: __('Date Format Not Recognized'),
fields: [{
fieldtype: 'HTML',
options:
'<div class="alert alert-danger">' +
__('All {0} row(s) were dropped because the date column could not be parsed.', [droppedCount]) +
'</div>' +
'<p>' + __("The parser tries common formats like <code>2026-01-31</code>, <code>31.01.2026</code>, <code>01/31/2026</code> automatically.") + '</p>' +
'<p>' + __("If your bank uses a different format, open the Bank Statement Importer's File Format tab and enable <b>Use Custom Date Format</b>, then enter the exact Python strftime format (e.g. <code>%d-%b-%Y</code> for <code>31-Jan-2026</code>).") + '</p>',
}],
primary_action_label: __('Open Bank Statement Importer'),
primary_action() { d.hide(); openForm(); },
secondary_action_label: __('Close'),
secondary_action() { d.hide(); },
});
d.show();
},
_showAmountHelp(droppedCount, bankIntegrationName) {
const openForm = function () {
frappe.set_route('Form', 'Bank Statement Importer', bankIntegrationName);
};
const d = new frappe.ui.Dialog({
title: __('Amount Column Missing or Empty'),
fields: [{
fieldtype: 'HTML',
options:
'<div class="alert alert-danger">' +
__('All {0} row(s) were dropped because the amount could not be read.', [droppedCount]) +
'</div>' +
'<p>' + __('Most likely the <b>Amount</b> column (or <b>Debit</b>/<b>Credit</b>, depending on Amount Mode) is not mapped to an Excel header in the File Format tab.') + '</p>' +
'<p>' + __('Open the Bank Statement Importer and check the Column Mappings table — Standard Field values must include all columns required by the chosen Amount Mode.') + '</p>',
}],
primary_action_label: __('Open Bank Statement Importer'),
primary_action() { d.hide(); openForm(); },
secondary_action_label: __('Close'),
secondary_action() { d.hide(); },
});
d.show();
},
_showDirectionHelp(droppedCount, unknownDirections, bankIntegrationName) {
const openForm = function () {
frappe.set_route('Form', 'Bank Statement Importer', bankIntegrationName);
};
const sample = (unknownDirections || []).map(frappe.utils.escape_html).join(', ') || '?';
const d = new frappe.ui.Dialog({
title: __('Unknown Direction Values'),
fields: [{
fieldtype: 'HTML',
options:
'<div class="alert alert-danger">' +
__('All {0} row(s) were dropped because the Direction column contained values not configured in the File Format tab.', [droppedCount]) +
'</div>' +
'<p>' + __('Unrecognised values:') + ' <code>' + sample + '</code></p>' +
'<p>' + __('Open the Bank Statement Importer and add these to <b>Debit Values</b> (for outflows) or <b>Credit Values</b> (for inflows), comma-separated.') + '</p>',
}],
primary_action_label: __('Open Bank Statement Importer'),
primary_action() { d.hide(); openForm(); },
secondary_action_label: __('Close'),
secondary_action() { d.hide(); },
});
d.show();
},
_showErrorDetails() {
const errs = BIExcelImport._lastErrors || [];
if (!errs.length) {
frappe.msgprint(__('No error details'));
return;
}
const esc = frappe.utils.escape_html;
let html = '<div style="max-height:500px; overflow-y:auto;">';
errs.forEach(e => {
html += '<div style="margin-bottom:12px; padding:10px; border:1px solid var(--border-color); border-radius:6px;">';
html += '<strong>' + esc(e.ref_no || 'No ref') + '</strong>';
html += ' <span class="label label-danger" style="margin-left:8px;">' + esc(e.error_type || 'error') + '</span>';
html += '<div style="margin-top:6px; color:var(--text-muted);">' +
esc(e.counterparty || '') + ' — ' + parseFloat(e.amount || 0).toFixed(2) + '</div>';
html += '<div style="margin-top:6px;"><code>' + esc(e.message || '') + '</code></div>';
html += '</div>';
});
html += '</div>';
const d = new frappe.ui.Dialog({
title: __('Error Details') + ' (' + errs.length + ')',
size: 'large',
fields: [{ fieldname: 'errors_html', fieldtype: 'HTML', options: html }],
});
d.show();
},
};
window.BIExcelImport = BIExcelImport;

View File

@ -0,0 +1,32 @@
// Payment Entry list view: the shared "Bank Integrations" menu.
//
// Payment Entries are NOT imported directly here — so the Import item explains
// the flow and redirects to Bank Transactions (where the real import + reconcile
// happens). The Integration Settings item is the shared source-choice.
(function () {
const existing = frappe.listview_settings['Payment Entry'] || {};
const prevOnload = existing.onload;
frappe.listview_settings['Payment Entry'] = Object.assign({}, existing, {
onload(listview) {
if (typeof prevOnload === 'function') {
try { prevOnload(listview); } catch (e) { console.error(e); }
}
jey_erp.bankImportMenu.attach(listview.page, {
importHandler() {
frappe.msgprint({
title: __('Import from a bank statement'),
indicator: 'blue',
message: __('Payment Entries are not imported directly. First import the bank statement into Bank Transactions, then create and reconcile the Payment Entries via the Bank Reconciliation Tool (Create & Reconcile).'),
primary_action: {
label: __('Go to Bank Transactions'),
action() { frappe.set_route('List', 'Bank Transaction'); },
},
});
},
});
}
});
})();