From 0f7a86ff2b5b9380750a82cd01cf43776d194ca2 Mon Sep 17 00:00:00 2001 From: Ali <010109ali@gmail.com> Date: Wed, 10 Jun 2026 17:52:03 +0000 Subject: [PATCH] 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) --- .gitignore | 1 + jey_erp/bank_integration/cascade_delete.py | 12 +- jey_erp/bank_integration/create_reconcile.py | 2 +- jey_erp/bank_integration/creation.py | 6 +- jey_erp/bank_integration/excel_parser.py | 2 +- jey_erp/bank_integration/import_api.py | 27 +- jey_erp/bank_integration/matching.py | 4 +- jey_erp/bank_integration/migrations.py | 10 +- jey_erp/custom_fields.py | 2 +- jey_erp/hooks.py | 2 + .../bank_integration_customer.json | 4 +- .../bank_integration_customer_mapping.json | 4 +- .../__init__.py | 0 .../bank_integration_profile.js} | 342 ++++++++++- .../bank_integration_profile.json} | 10 +- .../bank_integration_profile.py | 98 ++++ .../bank_integration_purpose.json | 4 +- .../bank_integration_supplier.json | 4 +- .../bank_integration_supplier_mapping.json | 4 +- .../bank_statement_importer.py | 55 -- jey_erp/patches.txt | 3 +- .../drop_bank_integration_account_mapping.py | 22 - jey_erp/patches/drop_orphan_bank_doctypes.py | 42 ++ jey_erp/patches/rename_importer_to_profile.py | 56 ++ jey_erp/public/js/bank_excel_import.bundle.js | 551 ++++++++++++++++++ jey_erp/public/js/bank_reconciliation_tool.js | 51 +- jey_erp/public/js/bank_transaction_list.js | 390 +------------ jey_erp/public/js/payment_entry_list.js | 32 + 28 files changed, 1237 insertions(+), 503 deletions(-) rename jey_erp/jey_erp/doctype/{bank_statement_importer => bank_integration_profile}/__init__.py (100%) rename jey_erp/jey_erp/doctype/{bank_statement_importer/bank_statement_importer.js => bank_integration_profile/bank_integration_profile.js} (63%) rename jey_erp/jey_erp/doctype/{bank_statement_importer/bank_statement_importer.json => bank_integration_profile/bank_integration_profile.json} (94%) create mode 100644 jey_erp/jey_erp/doctype/bank_integration_profile/bank_integration_profile.py delete mode 100644 jey_erp/jey_erp/doctype/bank_statement_importer/bank_statement_importer.py delete mode 100644 jey_erp/patches/drop_bank_integration_account_mapping.py create mode 100644 jey_erp/patches/drop_orphan_bank_doctypes.py create mode 100644 jey_erp/patches/rename_importer_to_profile.py create mode 100644 jey_erp/public/js/bank_excel_import.bundle.js create mode 100644 jey_erp/public/js/payment_entry_list.js diff --git a/.gitignore b/.gitignore index 8841ca6..f183993 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ tags node_modules __pycache__ Screenshot*.png +jey_erp/public/dist/ diff --git a/jey_erp/bank_integration/cascade_delete.py b/jey_erp/bank_integration/cascade_delete.py index 24cbae6..3ced502 100644 --- a/jey_erp/bank_integration/cascade_delete.py +++ b/jey_erp/bank_integration/cascade_delete.py @@ -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")) diff --git a/jey_erp/bank_integration/create_reconcile.py b/jey_erp/bank_integration/create_reconcile.py index 3ab79a9..39f81ec 100644 --- a/jey_erp/bank_integration/create_reconcile.py +++ b/jey_erp/bank_integration/create_reconcile.py @@ -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 diff --git a/jey_erp/bank_integration/creation.py b/jey_erp/bank_integration/creation.py index ff58f18..cbfbf00 100644 --- a/jey_erp/bank_integration/creation.py +++ b/jey_erp/bank_integration/creation.py @@ -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} diff --git a/jey_erp/bank_integration/excel_parser.py b/jey_erp/bank_integration/excel_parser.py index efdc1b6..e261135 100644 --- a/jey_erp/bank_integration/excel_parser.py +++ b/jey_erp/bank_integration/excel_parser.py @@ -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 = [] diff --git a/jey_erp/bank_integration/import_api.py b/jey_erp/bank_integration/import_api.py index 5ab2fa1..42c5362 100644 --- a/jey_erp/bank_integration/import_api.py +++ b/jey_erp/bank_integration/import_api.py @@ -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, { diff --git a/jey_erp/bank_integration/matching.py b/jey_erp/bank_integration/matching.py index 53b6cbe..8ae6f09 100644 --- a/jey_erp/bank_integration/matching.py +++ b/jey_erp/bank_integration/matching.py @@ -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) diff --git a/jey_erp/bank_integration/migrations.py b/jey_erp/bank_integration/migrations.py index 64e5419..d66a308 100644 --- a/jey_erp/bank_integration/migrations.py +++ b/jey_erp/bank_integration/migrations.py @@ -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 diff --git a/jey_erp/custom_fields.py b/jey_erp/custom_fields.py index 280d467..894b979 100644 --- a/jey_erp/custom_fields.py +++ b/jey_erp/custom_fields.py @@ -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, diff --git a/jey_erp/hooks.py b/jey_erp/hooks.py index 258450c..252ee45 100644 --- a/jey_erp/hooks.py +++ b/jey_erp/hooks.py @@ -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 = { diff --git a/jey_erp/jey_erp/doctype/bank_integration_customer/bank_integration_customer.json b/jey_erp/jey_erp/doctype/bank_integration_customer/bank_integration_customer.json index 97a00b5..7b146d3 100644 --- a/jey_erp/jey_erp/doctype/bank_integration_customer/bank_integration_customer.json +++ b/jey_erp/jey_erp/doctype/bank_integration_customer/bank_integration_customer.json @@ -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 }, { diff --git a/jey_erp/jey_erp/doctype/bank_integration_customer_mapping/bank_integration_customer_mapping.json b/jey_erp/jey_erp/doctype/bank_integration_customer_mapping/bank_integration_customer_mapping.json index 60ac6ba..29d77d7 100644 --- a/jey_erp/jey_erp/doctype/bank_integration_customer_mapping/bank_integration_customer_mapping.json +++ b/jey_erp/jey_erp/doctype/bank_integration_customer_mapping/bank_integration_customer_mapping.json @@ -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", diff --git a/jey_erp/jey_erp/doctype/bank_statement_importer/__init__.py b/jey_erp/jey_erp/doctype/bank_integration_profile/__init__.py similarity index 100% rename from jey_erp/jey_erp/doctype/bank_statement_importer/__init__.py rename to jey_erp/jey_erp/doctype/bank_integration_profile/__init__.py diff --git a/jey_erp/jey_erp/doctype/bank_statement_importer/bank_statement_importer.js b/jey_erp/jey_erp/doctype/bank_integration_profile/bank_integration_profile.js similarity index 63% rename from jey_erp/jey_erp/doctype/bank_statement_importer/bank_statement_importer.js rename to jey_erp/jey_erp/doctype/bank_integration_profile/bank_integration_profile.js index 48a024a..26b8baa 100644 --- a/jey_erp/jey_erp/doctype/bank_statement_importer/bank_statement_importer.js +++ b/jey_erp/jey_erp/doctype/bank_integration_profile/bank_integration_profile.js @@ -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 += ''; } 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 = { ? `

${__('No dependent records — deleting this importer is safe.')}

` : `

${__('Deleting {0} will also affect:', [frappe.utils.escape_html(frm.doc.name)])}

-

${__('Registry rows will be deleted. Bank Accounts will only have their Bank Statement Importer link cleared — the accounts themselves stay.')}

+

${__('Registry rows will be deleted. Bank Accounts will only have their Bank Integration Profile link cleared — the accounts themselves stay.')}

${__('This cannot be undone.')}

`; 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 = ` ${__('Skipped')}`; + } else if (s.done) { + badge = '✓'; cls = 'done'; tag = ''; + } else if (isCur) { + badge = '➜'; cls = 'cur'; + tag = ` ${__('Current step')}`; + } else { + badge = (i + 1); cls = 'todo'; tag = ''; + } + + const skipCtl = s.off ? '' : + `${ + s.skipped ? __('Unskip') : __('Skip')}`; + + return ` +
+
+ ${badge} +
+
${esc(s.title)}${tag}
+
${esc(s.hint)}
+
+
+ ${skipCtl} +
`; + }).join(''); + + const html = ` +
+
+ + ${__('Bank Import')} · ${esc(cur.title)} + ${__('Steps')} +
+
${rows}
+
`; + + _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 += '

' + + __("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]) + + ''; + } + if (droppedDirection > 0) { + const sample = unknownDirections.map(frappe.utils.escape_html).join(', ') || '?'; + msg += '

' + + __('Also skipped {0} row(s) with unknown direction values: {1}. Add them to Debit/Credit Values in the File Format tab.', + [droppedDirection, sample]) + + ''; + } + if (droppedAmount > 0) { + msg += '
' + + __('Also skipped {0} row(s) with missing/zero amount.', [droppedAmount]) + + ''; + } + frappe.msgprint({ + title: __('Data Loaded'), + indicator: (droppedDate > 0 || droppedDirection > 0) ? 'orange' : 'green', + message: msg, + }); +} diff --git a/jey_erp/jey_erp/doctype/bank_statement_importer/bank_statement_importer.json b/jey_erp/jey_erp/doctype/bank_integration_profile/bank_integration_profile.json similarity index 94% rename from jey_erp/jey_erp/doctype/bank_statement_importer/bank_statement_importer.json rename to jey_erp/jey_erp/doctype/bank_integration_profile/bank_integration_profile.json index 584d42f..abda6bc 100644 --- a/jey_erp/jey_erp/doctype/bank_statement_importer/bank_statement_importer.json +++ b/jey_erp/jey_erp/doctype/bank_integration_profile/bank_integration_profile.json @@ -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": [ diff --git a/jey_erp/jey_erp/doctype/bank_integration_profile/bank_integration_profile.py b/jey_erp/jey_erp/doctype/bank_integration_profile/bank_integration_profile.py new file mode 100644 index 0000000..854162d --- /dev/null +++ b/jey_erp/jey_erp/doctype/bank_integration_profile/bank_integration_profile.py @@ -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) diff --git a/jey_erp/jey_erp/doctype/bank_integration_purpose/bank_integration_purpose.json b/jey_erp/jey_erp/doctype/bank_integration_purpose/bank_integration_purpose.json index a68fc81..026124f 100644 --- a/jey_erp/jey_erp/doctype/bank_integration_purpose/bank_integration_purpose.json +++ b/jey_erp/jey_erp/doctype/bank_integration_purpose/bank_integration_purpose.json @@ -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 }, { diff --git a/jey_erp/jey_erp/doctype/bank_integration_supplier/bank_integration_supplier.json b/jey_erp/jey_erp/doctype/bank_integration_supplier/bank_integration_supplier.json index 40f3144..fc837d4 100644 --- a/jey_erp/jey_erp/doctype/bank_integration_supplier/bank_integration_supplier.json +++ b/jey_erp/jey_erp/doctype/bank_integration_supplier/bank_integration_supplier.json @@ -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 }, { diff --git a/jey_erp/jey_erp/doctype/bank_integration_supplier_mapping/bank_integration_supplier_mapping.json b/jey_erp/jey_erp/doctype/bank_integration_supplier_mapping/bank_integration_supplier_mapping.json index 664165b..e23193c 100644 --- a/jey_erp/jey_erp/doctype/bank_integration_supplier_mapping/bank_integration_supplier_mapping.json +++ b/jey_erp/jey_erp/doctype/bank_integration_supplier_mapping/bank_integration_supplier_mapping.json @@ -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", diff --git a/jey_erp/jey_erp/doctype/bank_statement_importer/bank_statement_importer.py b/jey_erp/jey_erp/doctype/bank_statement_importer/bank_statement_importer.py deleted file mode 100644 index f6f2f30..0000000 --- a/jey_erp/jey_erp/doctype/bank_statement_importer/bank_statement_importer.py +++ /dev/null @@ -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) diff --git a/jey_erp/patches.txt b/jey_erp/patches.txt index c27500a..e14350f 100644 --- a/jey_erp/patches.txt +++ b/jey_erp/patches.txt @@ -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 \ No newline at end of file diff --git a/jey_erp/patches/drop_bank_integration_account_mapping.py b/jey_erp/patches/drop_bank_integration_account_mapping.py deleted file mode 100644 index c82c327..0000000 --- a/jey_erp/patches/drop_bank_integration_account_mapping.py +++ /dev/null @@ -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 -IBAN→Bank 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` table. - frappe.delete_doc("DocType", dt, ignore_permissions=True, force=True) - frappe.db.commit() - print(f"BI: dropped DocType '{dt}'") diff --git a/jey_erp/patches/drop_orphan_bank_doctypes.py b/jey_erp/patches/drop_orphan_bank_doctypes.py new file mode 100644 index 0000000..5199e6f --- /dev/null +++ b/jey_erp/patches/drop_orphan_bank_doctypes.py @@ -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 IBAN→Bank 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() diff --git a/jey_erp/patches/rename_importer_to_profile.py b/jey_erp/patches/rename_importer_to_profile.py new file mode 100644 index 0000000..f7189e6 --- /dev/null +++ b/jey_erp/patches/rename_importer_to_profile.py @@ -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` 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}'") diff --git a/jey_erp/public/js/bank_excel_import.bundle.js b/jey_erp/public/js/bank_excel_import.bundle.js new file mode 100644 index 0000000..1c2af6a --- /dev/null +++ b/jey_erp/public/js/bank_excel_import.bundle.js @@ -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: "", // optional pre-fill +// bankAccount: "", // 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 = '
'; + if (skipped > 0) { + table += '
' + + __('Skipped {0} duplicates already imported. {1} new transactions ready.', [skipped, txns.length]) + + '
'; + } + if (droppedDate > 0) { + table += '
' + + __('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.") + + '
'; + } + if (droppedDirection > 0) { + const sample = (unknownDirections || []).map(frappe.utils.escape_html).join(', '); + table += '
' + + __('Dropped {0} row(s) — unknown direction value(s):', [droppedDirection]) + + ' ' + (sample || '?') + '. ' + + __('Open the File Format tab and add these to Debit Values or Credit Values.') + + '
'; + } + if (droppedAmount > 0) { + table += '
' + + __('Dropped {0} row(s) — missing or zero amount.', [droppedAmount]) + + '
'; + } + if (emptyRefCount > 0) { + table += '
' + + __('{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]) + + '
'; + } + table += ''; + table += ''; + table += ''; + table += ''; + table += ''; + table += ''; + table += ''; + table += ''; + table += ''; + table += ''; + + 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' + ? '' + __('Pay') + '' + : '' + __('Receive') + ''; + const text = t.purpose || t.description || ''; + table += '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + }); + table += '
' + __('Ref No') + '' + __('Date') + '' + __('Counterparty') + '' + __('Amount') + '' + __('Type') + '' + __('Purpose / Description') + '
' + esc(t.ref_no || '') + '' + dateStr + '' + esc(t.counterparty || '') + '' + amountStr + '' + drcrLabel + '' + esc(text) + '
'; + + 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 = '
' + __('Imported: {0}', [ok]) + '
'; + msg += '
' + __('Errors: {0}', [errCount]) + '
'; + + 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( + '
' + + '
' + ); + $('#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: + '
' + + __('All {0} row(s) were dropped because the date column could not be parsed.', [droppedCount]) + + '
' + + '

' + __("The parser tries common formats like 2026-01-31, 31.01.2026, 01/31/2026 automatically.") + '

' + + '

' + __("If your bank uses a different format, open the Bank Integration Profile's File Format tab and enable Use Custom Date Format, then enter the exact Python strftime format (e.g. %d-%b-%Y for 31-Jan-2026).") + '

', + }], + 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: + '
' + + __('All {0} row(s) were dropped because the amount could not be read.', [droppedCount]) + + '
' + + '

' + __('Most likely the Amount column (or Debit/Credit, depending on Amount Mode) is not mapped to an Excel header in the File Format tab.') + '

' + + '

' + __('Open the Bank Integration Profile and check the Column Mappings table — Standard Field values must include all columns required by the chosen Amount Mode.') + '

', + }], + 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: + '
' + + __('All {0} row(s) were dropped because the Direction column contained values not configured in the File Format tab.', [droppedCount]) + + '
' + + '

' + __('Unrecognised values:') + ' ' + sample + '

' + + '

' + __('Open the Bank Integration Profile and add these to Debit Values (for outflows) or Credit Values (for inflows), comma-separated.') + '

', + }], + 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 = '
'; + errs.forEach(e => { + html += '
'; + html += '' + esc(e.ref_no || 'No ref') + ''; + html += ' ' + esc(e.error_type || 'error') + ''; + html += '
' + + esc(e.counterparty || '') + ' — ' + parseFloat(e.amount || 0).toFixed(2) + '
'; + html += '
' + esc(e.message || '') + '
'; + html += '
'; + }); + html += '
'; + 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 = + '
' + + choices.map((c, i) => + '' + ).join('') + + '
'; + 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'); + }, +}); diff --git a/jey_erp/public/js/bank_reconciliation_tool.js b/jey_erp/public/js/bank_reconciliation_tool.js index dc76fa0..3e7d9c1 100644 --- a/jey_erp/public/js/bank_reconciliation_tool.js +++ b/jey_erp/public/js/bank_reconciliation_tool.js @@ -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", diff --git a/jey_erp/public/js/bank_transaction_list.js b/jey_erp/public/js/bank_transaction_list.js index e0a9703..1d35be6 100644 --- a/jey_erp/public/js/bank_transaction_list.js +++ b/jey_erp/public/js/bank_transaction_list.js @@ -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 = '
'; - if (skipped > 0) { - table += '
' + - __('Skipped {0} duplicates already imported. {1} new transactions ready.', [skipped, txns.length]) + - '
'; - } - if (droppedDate > 0) { - table += '
' + - __('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.") + - '
'; - } - if (droppedDirection > 0) { - const sample = (unknownDirections || []).map(frappe.utils.escape_html).join(', '); - table += '
' + - __('Dropped {0} row(s) — unknown direction value(s):', [droppedDirection]) + - ' ' + (sample || '?') + '. ' + - __('Open the File Format tab and add these to Debit Values or Credit Values.') + - '
'; - } - if (droppedAmount > 0) { - table += '
' + - __('Dropped {0} row(s) — missing or zero amount.', [droppedAmount]) + - '
'; - } - if (emptyRefCount > 0) { - table += '
' + - __('{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]) + - '
'; - } - table += ''; - table += ''; - table += ''; - table += ''; - table += ''; - table += ''; - table += ''; - table += ''; - table += ''; - table += ''; - - 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' - ? '' + __('Pay') + '' - : '' + __('Receive') + ''; - const text = t.purpose || t.description || ''; - table += '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - ''; - }); - table += '
' + __('Ref No') + '' + __('Date') + '' + __('Counterparty') + '' + __('Amount') + '' + __('Type') + '' + __('Purpose / Description') + '
' + esc(t.ref_no || '') + '' + dateStr + '' + esc(t.counterparty || '') + '' + amountStr + '' + drcrLabel + '' + esc(text) + '
'; - - 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 = '
' + __('Imported: {0}', [ok]) + '
'; - msg += '
' + __('Errors: {0}', [errCount]) + '
'; - - 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( - '
' + - '
' - ); - $('#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: - '
' + - __('All {0} row(s) were dropped because the date column could not be parsed.', [droppedCount]) + - '
' + - '

' + __("The parser tries common formats like 2026-01-31, 31.01.2026, 01/31/2026 automatically.") + '

' + - '

' + __("If your bank uses a different format, open the Bank Statement Importer's File Format tab and enable Use Custom Date Format, then enter the exact Python strftime format (e.g. %d-%b-%Y for 31-Jan-2026).") + '

', - }], - 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: - '
' + - __('All {0} row(s) were dropped because the amount could not be read.', [droppedCount]) + - '
' + - '

' + __('Most likely the Amount column (or Debit/Credit, depending on Amount Mode) is not mapped to an Excel header in the File Format tab.') + '

' + - '

' + __('Open the Bank Statement Importer and check the Column Mappings table — Standard Field values must include all columns required by the chosen Amount Mode.') + '

', - }], - 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: - '
' + - __('All {0} row(s) were dropped because the Direction column contained values not configured in the File Format tab.', [droppedCount]) + - '
' + - '

' + __('Unrecognised values:') + ' ' + sample + '

' + - '

' + __('Open the Bank Statement Importer and add these to Debit Values (for outflows) or Credit Values (for inflows), comma-separated.') + '

', - }], - 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 = '
'; - errs.forEach(e => { - html += '
'; - html += '' + esc(e.ref_no || 'No ref') + ''; - html += ' ' + esc(e.error_type || 'error') + ''; - html += '
' + - esc(e.counterparty || '') + ' — ' + parseFloat(e.amount || 0).toFixed(2) + '
'; - html += '
' + esc(e.message || '') + '
'; - html += '
'; - }); - html += '
'; - 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; diff --git a/jey_erp/public/js/payment_entry_list.js b/jey_erp/public/js/payment_entry_list.js new file mode 100644 index 0000000..8f9da0c --- /dev/null +++ b/jey_erp/public/js/payment_entry_list.js @@ -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'); }, + }, + }); + }, + }); + } + }); +})();