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:
parent
e4d63cdc74
commit
0f7a86ff2b
|
|
@ -6,3 +6,4 @@ tags
|
||||||
node_modules
|
node_modules
|
||||||
__pycache__
|
__pycache__
|
||||||
Screenshot*.png
|
Screenshot*.png
|
||||||
|
jey_erp/public/dist/
|
||||||
|
|
|
||||||
|
|
@ -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
|
dependent registry rows (Customer/Supplier/Purpose) or Bank Accounts pointing
|
||||||
at it via the bank_integration Dynamic Link. This module exposes:
|
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
|
import frappe
|
||||||
from frappe import _
|
from frappe import _
|
||||||
|
|
||||||
DOCTYPE = "Bank Statement Importer"
|
DOCTYPE = "Bank Integration Profile"
|
||||||
_REGISTRY_DOCTYPES = (
|
_REGISTRY_DOCTYPES = (
|
||||||
("Bank Integration Customer", "Customers"),
|
("Bank Integration Customer", "Customers"),
|
||||||
("Bank Integration Supplier", "Suppliers"),
|
("Bank Integration Supplier", "Suppliers"),
|
||||||
|
|
@ -25,7 +25,7 @@ _REGISTRY_DOCTYPES = (
|
||||||
def get_dependents(name):
|
def get_dependents(name):
|
||||||
"""Return counts of records that would be touched by a cascade delete."""
|
"""Return counts of records that would be touched by a cascade delete."""
|
||||||
if not name or not frappe.db.exists(DOCTYPE, name):
|
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 = {}
|
counts = {}
|
||||||
for dt, _label in _REGISTRY_DOCTYPES:
|
for dt, _label in _REGISTRY_DOCTYPES:
|
||||||
|
|
@ -40,7 +40,7 @@ def get_dependents(name):
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def cascade_delete(name):
|
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
|
Steps (executed in this order so Frappe's link-check doesn't block the
|
||||||
final delete_doc on the importer):
|
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)
|
3. Delete the importer itself (its mapping child tables go with it)
|
||||||
"""
|
"""
|
||||||
if not name or not frappe.db.exists(DOCTYPE, name):
|
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"))
|
frappe.only_for(("System Manager", "Accounts Manager"))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -173,7 +173,7 @@ def create_purpose_mappings(transactions, bank_integration, paid_from=None, paid
|
||||||
reconciled = 0
|
reconciled = 0
|
||||||
errors = []
|
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
|
# 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
|
# us who they are). Resolve it now, from the customer/supplier mappings, so
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ def _pick_leaf(doctype, configured, label):
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def create_unmapped_customers(bank_integration):
|
def create_unmapped_customers(bank_integration):
|
||||||
"""For each Bank Integration Customer Mapping row without erp_customer, create an ERPNext Customer."""
|
"""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 = []
|
unmapped = []
|
||||||
for mapping in bi.customer_mappings:
|
for mapping in bi.customer_mappings:
|
||||||
|
|
@ -130,7 +130,7 @@ def create_unmapped_customers(bank_integration):
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def create_unmapped_suppliers(bank_integration):
|
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 = []
|
unmapped = []
|
||||||
for mapping in bi.supplier_mappings:
|
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}.
|
`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":
|
if table == "customer_mappings":
|
||||||
existing = {row.bi_customer_name for row in bi.customer_mappings if row.bi_customer_name}
|
existing = {row.bi_customer_name for row in bi.customer_mappings if row.bi_customer_name}
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ def parse_excel(file_url, bank_integration):
|
||||||
"""
|
"""
|
||||||
from openpyxl import load_workbook
|
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 = {}
|
col_to_standard = {}
|
||||||
unknown_labels = []
|
unknown_labels = []
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ def _process_bulk_bt_import(txn_list, bank_integration, bank_account, user):
|
||||||
|
|
||||||
frappe.set_user(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()
|
company = erpnext.get_default_company()
|
||||||
|
|
||||||
ba_doc = frappe.get_doc("Bank Account", bank_account)
|
ba_doc = frappe.get_doc("Bank Account", bank_account)
|
||||||
|
|
@ -254,7 +254,7 @@ def _upsert_purpose(purpose, drcr, bank_integration):
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_bi_reference_data_list(bank_integration, data_type, limit=100, offset=0):
|
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}.
|
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)}
|
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()
|
@frappe.whitelist()
|
||||||
def update_bank_account_bi_default(bank_account, bi_type, bi_name):
|
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."""
|
"""Save the user's BRT-time choice to the BA's hidden fields."""
|
||||||
if not bank_account:
|
if not bank_account:
|
||||||
frappe.throw(_("Bank Account required"))
|
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.throw(_("Invalid Bank Integration Type"))
|
||||||
|
|
||||||
frappe.db.set_value("Bank Account", bank_account, {
|
frappe.db.set_value("Bank Account", bank_account, {
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ def _normalize(text, consider_azeri=True):
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def match_similar_customers(bank_integration):
|
def match_similar_customers(bank_integration):
|
||||||
"""Auto-match BI Customers to ERPNext Customers by VOEN or name similarity."""
|
"""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
|
threshold = flt(bi.similarity_threshold_customers or 90) / 100.0
|
||||||
consider_azeri = bool(bi.consider_azeri_chars)
|
consider_azeri = bool(bi.consider_azeri_chars)
|
||||||
|
|
||||||
|
|
@ -80,7 +80,7 @@ def match_similar_customers(bank_integration):
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def match_similar_suppliers(bank_integration):
|
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
|
threshold = flt(bi.similarity_threshold_suppliers or 80) / 100.0
|
||||||
consider_azeri = bool(bi.consider_azeri_chars)
|
consider_azeri = bool(bi.consider_azeri_chars)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import frappe
|
||||||
|
|
||||||
|
|
||||||
def add_importer_link_to_invoicing_workspace():
|
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.
|
on the ERPNext 'Invoicing' workspace.
|
||||||
|
|
||||||
This intentionally edits an ERPNext-owned workspace at runtime rather than
|
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.
|
Idempotent — runs every migrate but only inserts when the link is missing.
|
||||||
"""
|
"""
|
||||||
ws_name = "Invoicing"
|
ws_name = "Invoicing"
|
||||||
link_to = "Bank Statement Importer"
|
link_to = "Bank Integration Profile"
|
||||||
card_label = "Banking"
|
card_label = "Banking"
|
||||||
|
|
||||||
if not frappe.db.exists("Workspace", ws_name):
|
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
|
Idempotent: once a Bank Integration has any column mappings or an
|
||||||
explicit amount_mode, it's left alone.
|
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
|
return
|
||||||
if not frappe.db.exists("DocType", "Bank Integration Excel Preset"):
|
if not frappe.db.exists("DocType", "Bank Integration Excel Preset"):
|
||||||
return
|
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:
|
if not bi_names:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -202,7 +202,7 @@ def copy_excel_preset_into_bank_integration():
|
||||||
|
|
||||||
copied = 0
|
copied = 0
|
||||||
for bi_name in bi_names:
|
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.
|
# Skip if File Format already configured.
|
||||||
if bi.column_mappings or (bi.amount_mode and bi.amount_mode != "Separate debit/credit columns"):
|
if bi.column_mappings or (bi.amount_mode and bi.amount_mode != "Separate debit/credit columns"):
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -1081,7 +1081,7 @@ def create_custom_fields():
|
||||||
fieldname='bank_integration_type',
|
fieldname='bank_integration_type',
|
||||||
label='Bank Integration Type',
|
label='Bank Integration Type',
|
||||||
fieldtype='Select',
|
fieldtype='Select',
|
||||||
options='\nBank Statement Importer\nKapital Bank Settings',
|
options='\nBank Integration Profile\nKapital Bank Settings',
|
||||||
insert_after='correspondent_account',
|
insert_after='correspondent_account',
|
||||||
hidden=1,
|
hidden=1,
|
||||||
read_only=1,
|
read_only=1,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ app_include_js = [
|
||||||
"/assets/jey_erp/js/sales_order_vat.js",
|
"/assets/jey_erp/js/sales_order_vat.js",
|
||||||
"/assets/jey_erp/js/asset.js",
|
"/assets/jey_erp/js/asset.js",
|
||||||
"/assets/jey_erp/js/link_field_fix.js",
|
"/assets/jey_erp/js/link_field_fix.js",
|
||||||
|
"bank_excel_import.bundle.js",
|
||||||
]
|
]
|
||||||
|
|
||||||
app_include_css = [
|
app_include_css = [
|
||||||
|
|
@ -39,6 +40,7 @@ doctype_list_js = {
|
||||||
"Account": "public/js/account_list.js",
|
"Account": "public/js/account_list.js",
|
||||||
"Leave Type": "public/js/leave_type_list.js",
|
"Leave Type": "public/js/leave_type_list.js",
|
||||||
"Bank Transaction": "public/js/bank_transaction_list.js",
|
"Bank Transaction": "public/js/bank_transaction_list.js",
|
||||||
|
"Payment Entry": "public/js/payment_entry_list.js",
|
||||||
}
|
}
|
||||||
|
|
||||||
extend_doctype_class = {
|
extend_doctype_class = {
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,8 @@
|
||||||
"fieldname": "parent_bank_integration",
|
"fieldname": "parent_bank_integration",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "Bank Statement Importer",
|
"label": "Bank Integration Profile",
|
||||||
"options": "Bank Statement Importer",
|
"options": "Bank Integration Profile",
|
||||||
"reqd": 1
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,12 @@
|
||||||
"reqd": 1
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"fetch_from": "bi_customer_name.tax_id",
|
||||||
"fieldname": "tax_id",
|
"fieldname": "tax_id",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "Tax ID"
|
"label": "Tax ID",
|
||||||
|
"read_only": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "erp_customer",
|
"fieldname": "erp_customer",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// 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) {
|
setup(frm) {
|
||||||
// Customer/Supplier Group and Territory must be leaf (non-group) nodes —
|
// 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.
|
// 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() is what the Menu Delete item ultimately calls.
|
||||||
frm.savetrash = () => BSICascadeDelete.run(frm);
|
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) ===
|
// === Load Data (file-based, registry-only) ===
|
||||||
frm.add_custom_button(__('Load Data'), () => {
|
frm.add_custom_button(__('Load Data'), () => {
|
||||||
_show_load_data_dialog(frm);
|
_show_load_data_dialog(frm);
|
||||||
|
|
@ -104,6 +110,9 @@ frappe.ui.form.on('Bank Statement Importer', {
|
||||||
));
|
));
|
||||||
}, __('Purposes'));
|
}, __('Purposes'));
|
||||||
|
|
||||||
|
// === Workflow wizard (current step + expandable step list) ===
|
||||||
|
_bi_render_form_wizard(frm);
|
||||||
|
|
||||||
// === Data tab rendering ===
|
// === Data tab rendering ===
|
||||||
_bi_load_data_tabs(frm);
|
_bi_load_data_tabs(frm);
|
||||||
},
|
},
|
||||||
|
|
@ -119,11 +128,15 @@ frappe.ui.form.on('Bank Statement Importer', {
|
||||||
preview_sample_btn(frm) {
|
preview_sample_btn(frm) {
|
||||||
BIFileFormat.showPreview(frm);
|
BIFileFormat.showPreview(frm);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
load_data_from_file_btn(frm) {
|
||||||
|
_bi_load_from_sample(frm);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── File Format helpers ─────────────────────────────────────────────────────
|
// ─── File Format helpers ─────────────────────────────────────────────────────
|
||||||
// Mirrors what bank_integration_excel_preset.js used to do, but on the merged
|
// 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
|
// the Excel Column field in the column_mappings grid into a dropdown of real
|
||||||
// header strings, so users don't have to type them.
|
// header strings, so users don't have to type them.
|
||||||
|
|
||||||
|
|
@ -376,7 +389,7 @@ function _show_load_data_dialog(frm) {
|
||||||
fieldtype: 'Attach',
|
fieldtype: 'Attach',
|
||||||
label: __('Excel File'),
|
label: __('Excel File'),
|
||||||
reqd: 1,
|
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') },
|
{ fieldname: 'sec_what', fieldtype: 'Section Break', label: __('What to load') },
|
||||||
{
|
{
|
||||||
|
|
@ -518,7 +531,7 @@ function _bi_render_customers(rows, total, reload) {
|
||||||
});
|
});
|
||||||
html += '</tbody></table>';
|
html += '</tbody></table>';
|
||||||
} else {
|
} 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-customers-container').html(html);
|
||||||
$('#bi-refresh-customers').on('click', reload);
|
$('#bi-refresh-customers').on('click', reload);
|
||||||
|
|
@ -628,18 +641,18 @@ const BSICascadeDelete = {
|
||||||
? `<p>${__('No dependent records — deleting this importer is safe.')}</p>`
|
? `<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>
|
: `<p>${__('Deleting <b>{0}</b> will also affect:', [frappe.utils.escape_html(frm.doc.name)])}</p>
|
||||||
<ul style="margin-left:18px;">${rows}</ul>
|
<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>`;
|
<p class="text-danger"><b>${__('This cannot be undone.')}</b></p>`;
|
||||||
|
|
||||||
const d = new frappe.ui.Dialog({
|
const d = new frappe.ui.Dialog({
|
||||||
title: __('Delete Bank Statement Importer?'),
|
title: __('Delete Bank Integration Profile?'),
|
||||||
fields: [{ fieldname: 'body', fieldtype: 'HTML', options: body }],
|
fields: [{ fieldname: 'body', fieldtype: 'HTML', options: body }],
|
||||||
primary_action_label: __('Delete Everything'),
|
primary_action_label: __('Delete Everything'),
|
||||||
primary_action: () => {
|
primary_action: () => {
|
||||||
d.disable_primary_action();
|
d.disable_primary_action();
|
||||||
this._doDelete(frm.doc.name).then(() => {
|
this._doDelete(frm.doc.name).then(() => {
|
||||||
d.hide();
|
d.hide();
|
||||||
frappe.set_route('List', 'Bank Statement Importer');
|
frappe.set_route('List', 'Bank Integration Profile');
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
d.enable_primary_action();
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
"sample_file",
|
"sample_file",
|
||||||
"ff_sample_actions_section",
|
"ff_sample_actions_section",
|
||||||
"preview_sample_btn",
|
"preview_sample_btn",
|
||||||
|
"load_data_from_file_btn",
|
||||||
"ff_format_section",
|
"ff_format_section",
|
||||||
"header_row",
|
"header_row",
|
||||||
"amount_mode",
|
"amount_mode",
|
||||||
|
|
@ -171,6 +172,13 @@
|
||||||
"fieldtype": "Button",
|
"fieldtype": "Button",
|
||||||
"label": "Preview Sample"
|
"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",
|
"fieldname": "ff_format_section",
|
||||||
"fieldtype": "Section Break",
|
"fieldtype": "Section Break",
|
||||||
|
|
@ -355,7 +363,7 @@
|
||||||
"modified": "2026-05-18 00:00:00.000000",
|
"modified": "2026-05-18 00:00:00.000000",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Jey Erp",
|
"module": "Jey Erp",
|
||||||
"name": "Bank Statement Importer",
|
"name": "Bank Integration Profile",
|
||||||
"naming_rule": "By fieldname",
|
"naming_rule": "By fieldname",
|
||||||
"owner": "Administrator",
|
"owner": "Administrator",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -23,8 +23,8 @@
|
||||||
"fieldname": "parent_bank_integration",
|
"fieldname": "parent_bank_integration",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "Bank Statement Importer",
|
"label": "Bank Integration Profile",
|
||||||
"options": "Bank Statement Importer",
|
"options": "Bank Integration Profile",
|
||||||
"reqd": 1
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,8 @@
|
||||||
"fieldname": "parent_bank_integration",
|
"fieldname": "parent_bank_integration",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "Bank Statement Importer",
|
"label": "Bank Integration Profile",
|
||||||
"options": "Bank Statement Importer",
|
"options": "Bank Integration Profile",
|
||||||
"reqd": 1
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,12 @@
|
||||||
"reqd": 1
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"fetch_from": "bi_supplier_name.tax_id",
|
||||||
"fieldname": "tax_id",
|
"fieldname": "tax_id",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "Tax ID"
|
"label": "Tax ID",
|
||||||
|
"read_only": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "erp_supplier",
|
"fieldname": "erp_supplier",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
jey_erp.patches.fix_employee_row_format
|
jey_erp.patches.fix_employee_row_format
|
||||||
jey_erp.patches.fix_employee_columns_as_text
|
jey_erp.patches.fix_employee_columns_as_text
|
||||||
jey_erp.patches.rename_bank_integration_to_importer
|
jey_erp.patches.rename_bank_integration_to_importer
|
||||||
|
jey_erp.patches.rename_importer_to_profile
|
||||||
|
|
||||||
[post_model_sync]
|
[post_model_sync]
|
||||||
# Patches added in this section will be executed after doctypes are migrated
|
# Patches added in this section will be executed after doctypes are migrated
|
||||||
|
|
@ -11,7 +12,7 @@ jey_erp.patches.create_asset_categories_post_migration
|
||||||
jey_erp.patches.remove_old_won_lost_fields
|
jey_erp.patches.remove_old_won_lost_fields
|
||||||
jey_erp.patches.remove_won_lost_breaks
|
jey_erp.patches.remove_won_lost_breaks
|
||||||
jey_erp.patches.remove_won_lost_checkboxes
|
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_si_tax_article_row_keys
|
||||||
jey_erp.patches.backfill_sales_invoice_tax_articles_history
|
jey_erp.patches.backfill_sales_invoice_tax_articles_history
|
||||||
jey_erp.patches.add_tax_articles_to_si_grid_views
|
jey_erp.patches.add_tax_articles_to_si_grid_views
|
||||||
|
|
@ -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<Name>` table.
|
|
||||||
frappe.delete_doc("DocType", dt, ignore_permissions=True, force=True)
|
|
||||||
frappe.db.commit()
|
|
||||||
print(f"BI: dropped DocType '{dt}'")
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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}'")
|
||||||
|
|
@ -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');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
// (done here so there is no destroy/rebuild flicker)
|
// (done here so there is no destroy/rebuild flicker)
|
||||||
//
|
//
|
||||||
// "Create & Reconcile" maps the selected Bank Transactions to Payment Entry /
|
// "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
|
// (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
|
// 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.
|
// 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) {
|
refresh(frm) {
|
||||||
frm.$wrapper.closest(".page-container").css("--page-max-width", "none");
|
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 () {
|
frappe.require("bank-reconciliation-tool.bundle.js", function () {
|
||||||
const DTM = erpnext.accounts.bank_reconciliation.DataTableManager;
|
const DTM = erpnext.accounts.bank_reconciliation.DataTableManager;
|
||||||
if (!DTM || DTM.prototype._jey_erp_patched) return;
|
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 = {
|
const BIBRT = {
|
||||||
injectToolbar: function (dtm) {
|
injectToolbar: function (dtm) {
|
||||||
$("#bi-brt-toolbar").remove();
|
$("#bi-brt-toolbar").remove();
|
||||||
|
|
@ -174,7 +215,7 @@ const BIBRT = {
|
||||||
}
|
}
|
||||||
|
|
||||||
Promise.all([
|
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_value("Bank Account", bankAccount, ["bank_integration_type", "bank_integration"]),
|
||||||
frappe.db.get_list("DocType", { filters: { name: "Kapital Bank Settings" }, fields: ["name"], limit: 1 }),
|
frappe.db.get_list("DocType", { filters: { name: "Kapital Bank Settings" }, fields: ["name"], limit: 1 }),
|
||||||
]).then(([integrations, baResp, kbList]) => {
|
]).then(([integrations, baResp, kbList]) => {
|
||||||
|
|
@ -183,7 +224,7 @@ const BIBRT = {
|
||||||
|
|
||||||
const sourceMap = {};
|
const sourceMap = {};
|
||||||
(integrations || []).forEach(i => {
|
(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) {
|
if (kbInstalled) {
|
||||||
sourceMap["Kapital Bank Settings::Kapital Bank Settings"] = __("Kapital Bank Settings");
|
sourceMap["Kapital Bank Settings::Kapital Bank Settings"] = __("Kapital Bank Settings");
|
||||||
|
|
@ -192,7 +233,7 @@ const BIBRT = {
|
||||||
const sourceValues = Object.keys(sourceMap);
|
const sourceValues = Object.keys(sourceMap);
|
||||||
if (sourceValues.length === 0) {
|
if (sourceValues.length === 0) {
|
||||||
frappe.msgprint({ title: __("No Mapping Source"), indicator: "orange",
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -257,7 +298,7 @@ const BIBRT = {
|
||||||
options: sourceValues.join("\n"),
|
options: sourceValues.join("\n"),
|
||||||
default: defaultSource || sourceValues[0],
|
default: defaultSource || sourceValues[0],
|
||||||
reqd: 1,
|
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",
|
fieldname: "mode",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
// Bank Transaction list view: "Import From..." dropdown with "Load from Excel"
|
// Bank Transaction list view: the shared "Bank Integrations" menu.
|
||||||
// Other apps (e.g. kapital_bank) add their own items into the same group.
|
//
|
||||||
|
// 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 () {
|
(function () {
|
||||||
const existing = frappe.listview_settings['Bank Transaction'] || {};
|
const existing = frappe.listview_settings['Bank Transaction'] || {};
|
||||||
|
|
@ -11,385 +15,9 @@
|
||||||
try { prevOnload(listview); } catch (e) { console.error(e); }
|
try { prevOnload(listview); } catch (e) { console.error(e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
listview.page.add_inner_button(__('Load from Excel'), function () {
|
jey_erp.bankImportMenu.attach(listview.page, {
|
||||||
BIExcelImport.showDialog(listview);
|
importContext: () => ({ onComplete: () => listview.refresh() }),
|
||||||
}, __('Import From...'));
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
|
||||||
|
|
@ -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'); },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
Loading…
Reference in New Issue