refactor(bank-integration): merge Excel Preset into File Format tab

File format settings (header row, amount mode, column mappings, sample
file, direction values, custom date format, currency-from-file) now live
on the Bank Integration record itself under a new "File Format" tab,
removing the extra hop through Bank Integration Excel Preset.

- Bank Integration JSON gains the File Format tab and column_mappings child
- Excel parser and import API read format from Bank Integration directly,
  drop the preset_name argument from public endpoints
- Load Data dialog (on Bank Integration) and Load From Excel dialog (on
  Bank Transaction list) drop the preset Link field; the chosen Bank
  Integration provides everything needed
- after_migrate patch copies fields and column mappings from an existing
  preset into each Bank Integration whose File Format is still empty
- Bank Integration Excel Preset doctype kept around for rollback but
  marked deprecated (System Manager only, in-form warning), removed from
  the fixtures list so fresh installs don't ship the Generic preset
This commit is contained in:
Ali 2026-05-18 11:45:56 +00:00
parent 4f0c26208b
commit 04b16a6eea
10 changed files with 373 additions and 121 deletions

View File

@ -1,4 +1,4 @@
"""Parse Excel bank statements using Bank Integration Excel Preset configuration.""" """Parse Excel bank statements using a Bank Integration's File Format configuration."""
import re import re
from datetime import datetime, date from datetime import datetime, date
@ -26,7 +26,7 @@ STANDARD_FIELD_TO_CODE = {
# Internal codes that must be present in the headers of any imported file, by # Internal codes that must be present in the headers of any imported file, by
# amount_mode. Kept in sync with bank_integration_excel_preset.py:_REQUIRED_BY_MODE. # amount_mode. Kept in sync with bank_integration.py:_REQUIRED_BY_MODE.
_REQUIRED_CODES_BY_MODE = { _REQUIRED_CODES_BY_MODE = {
"Single column with sign": ("date", "reference_number", "amount"), "Single column with sign": ("date", "reference_number", "amount"),
"Separate debit/credit columns": ("date", "reference_number", "debit", "credit"), "Separate debit/credit columns": ("date", "reference_number", "debit", "credit"),
@ -51,8 +51,8 @@ _COMMON_DATE_FORMATS = (
) )
def parse_excel(file_url, preset_name): def parse_excel(file_url, bank_integration):
"""Parse an Excel bank statement using a saved preset. """Parse an Excel bank statement using a Bank Integration's File Format.
Returns a dict: Returns a dict:
{ {
@ -64,11 +64,11 @@ def parse_excel(file_url, preset_name):
""" """
from openpyxl import load_workbook from openpyxl import load_workbook
preset = frappe.get_doc("Bank Integration Excel Preset", preset_name) bi = frappe.get_doc("Bank Integration", bank_integration)
col_to_standard = {} col_to_standard = {}
unknown_labels = [] unknown_labels = []
for row in preset.column_mappings: for row in bi.column_mappings:
if not row.excel_column or not row.standard_field: if not row.excel_column or not row.standard_field:
continue continue
code = STANDARD_FIELD_TO_CODE.get(row.standard_field) code = STANDARD_FIELD_TO_CODE.get(row.standard_field)
@ -79,12 +79,15 @@ def parse_excel(file_url, preset_name):
if unknown_labels: if unknown_labels:
frappe.throw( frappe.throw(
"Preset '{}' has unknown standard field(s): {}. Edit the preset and pick a value from the dropdown.".format( "Bank Integration '{}' has unknown standard field(s): {}. Open the File Format tab and pick a value from the dropdown.".format(
preset_name, ", ".join(sorted(set(unknown_labels))) bank_integration, ", ".join(sorted(set(unknown_labels)))
) )
) )
if not col_to_standard: if not col_to_standard:
frappe.throw(f"Preset '{preset_name}' has no column mappings") frappe.throw(
f"Bank Integration '{bank_integration}' has no column mappings. "
"Open the File Format tab and map Excel columns to Standard Fields."
)
file_doc = frappe.get_doc("File", {"file_url": file_url}) file_doc = frappe.get_doc("File", {"file_url": file_url})
file_path = file_doc.get_full_path() file_path = file_doc.get_full_path()
@ -93,7 +96,7 @@ def parse_excel(file_url, preset_name):
try: try:
ws = wb.active ws = wb.active
header_row = preset.header_row or 1 header_row = bi.header_row or 1
all_rows = list(ws.iter_rows(values_only=True)) all_rows = list(ws.iter_rows(values_only=True))
header_idx = header_row - 1 header_idx = header_row - 1
@ -114,7 +117,7 @@ def parse_excel(file_url, preset_name):
f"found in Excel headers ({[h for h in headers if h]})" f"found in Excel headers ({[h for h in headers if h]})"
) )
amount_mode = preset.amount_mode or "Separate debit/credit columns" amount_mode = bi.amount_mode or "Separate debit/credit columns"
found_codes = set(idx_to_standard.values()) found_codes = set(idx_to_standard.values())
required = _REQUIRED_CODES_BY_MODE.get(amount_mode, ()) required = _REQUIRED_CODES_BY_MODE.get(amount_mode, ())
missing_codes = [c for c in required if c not in found_codes] missing_codes = [c for c in required if c not in found_codes]
@ -124,21 +127,21 @@ def parse_excel(file_url, preset_name):
missing_labels = [code_to_label.get(c, c) for c in missing_codes] missing_labels = [code_to_label.get(c, c) for c in missing_codes]
frappe.throw( frappe.throw(
"Required column(s) for Amount Mode '{}' not found in the file headers: {}. " "Required column(s) for Amount Mode '{}' not found in the file headers: {}. "
"Either the Header Row on the preset points at the wrong line, or the Excel " "Either the Header Row points at the wrong line, or the Excel column "
"column names in the preset don't match the file. Headers seen: {}".format( "names in the File Format tab don't match the file. Headers seen: {}".format(
amount_mode, ", ".join(missing_labels), [h for h in headers if h] amount_mode, ", ".join(missing_labels), [h for h in headers if h]
) )
) )
use_custom = bool(preset.get("use_custom_date_format")) use_custom = bool(bi.get("use_custom_date_format"))
custom_format = (preset.date_format or "").strip() if use_custom else "" custom_format = (bi.date_format or "").strip() if use_custom else ""
debit_aliases = _split_aliases(preset.get("direction_debit_values")) debit_aliases = _split_aliases(bi.get("direction_debit_values"))
credit_aliases = _split_aliases(preset.get("direction_credit_values")) credit_aliases = _split_aliases(bi.get("direction_credit_values"))
if amount_mode == "Single column + direction column" and not (debit_aliases or credit_aliases): if amount_mode == "Single column + direction column" and not (debit_aliases or credit_aliases):
frappe.throw( frappe.throw(
"Preset '{}' uses 'Single column + direction column' but has no Debit/Credit values configured. " "Bank Integration '{}' uses 'Single column + direction column' but has no Debit/Credit values configured. "
"Open the preset and fill in at least one of 'Debit Values' / 'Credit Values'.".format(preset_name) "Open the File Format tab and fill in at least one of 'Debit Values' / 'Credit Values'.".format(bank_integration)
) )
data_start = header_idx + 1 data_start = header_idx + 1
@ -383,7 +386,7 @@ def _parse_date(value, custom_format):
def parse_sample_headers(file_url, header_row=1): def parse_sample_headers(file_url, header_row=1):
"""Read just the header row of a sample file and return cleaned header strings. """Read just the header row of a sample file and return cleaned header strings.
Used by the Bank Integration Excel Preset form to populate the Excel Column Used by the Bank Integration File Format tab to populate the Excel Column
dropdown without parsing the whole file. dropdown without parsing the whole file.
""" """
from openpyxl import load_workbook from openpyxl import load_workbook

View File

@ -10,9 +10,9 @@ from jey_erp.bank_integration.excel_parser import parse_excel
@frappe.whitelist() @frappe.whitelist()
def parse_excel_for_preview(file_url, preset_name, bank_integration, bank_account): def parse_excel_for_preview(file_url, bank_integration, bank_account):
"""Parse Excel and return preview transactions, deduped against existing BTs.""" """Parse Excel and return preview transactions, deduped against existing BTs."""
result = parse_excel(file_url, preset_name) result = parse_excel(file_url, bank_integration)
transactions = result["transactions"] transactions = result["transactions"]
dropped_date = result.get("dropped_date", 0) dropped_date = result.get("dropped_date", 0)
dropped_amount = result.get("dropped_amount", 0) dropped_amount = result.get("dropped_amount", 0)
@ -73,7 +73,7 @@ def parse_excel_for_preview(file_url, preset_name, bank_integration, bank_accoun
@frappe.whitelist() @frappe.whitelist()
def import_bulk_bt(txn_list, bank_integration, bank_account, preset_name=None): def import_bulk_bt(txn_list, bank_integration, bank_account):
"""Enqueue bulk Bank Transaction creation as a background job. """Enqueue bulk Bank Transaction creation as a background job.
Creates Bank Transactions only registries (Bank Integration Customer / Creates Bank Transactions only registries (Bank Integration Customer /
@ -91,7 +91,6 @@ def import_bulk_bt(txn_list, bank_integration, bank_account, preset_name=None):
txn_list=txn_list, txn_list=txn_list,
bank_integration=bank_integration, bank_integration=bank_integration,
bank_account=bank_account, bank_account=bank_account,
preset_name=preset_name,
user=user, user=user,
queue="default", queue="default",
timeout=600, timeout=600,
@ -100,7 +99,7 @@ def import_bulk_bt(txn_list, bank_integration, bank_account, preset_name=None):
return {"success": True, "enqueued": True, "total": len(txn_list)} return {"success": True, "enqueued": True, "total": len(txn_list)}
def _process_bulk_bt_import(txn_list, bank_integration, bank_account, user, preset_name=None): def _process_bulk_bt_import(txn_list, bank_integration, bank_account, user):
"""Background job: create one Bank Transaction per row.""" """Background job: create one Bank Transaction per row."""
frappe.set_user(user) frappe.set_user(user)
@ -113,11 +112,7 @@ def _process_bulk_bt_import(txn_list, bank_integration, bank_account, user, pres
frappe.db.get_value("Account", ba_account, "account_currency") if ba_account else None frappe.db.get_value("Account", ba_account, "account_currency") if ba_account else None
) or "AZN" ) or "AZN"
use_currency_from_file = False use_currency_from_file = bool(bi.get("use_currency_from_file"))
if preset_name:
use_currency_from_file = bool(
frappe.db.get_value("Bank Integration Excel Preset", preset_name, "use_currency_from_file")
)
total = len(txn_list) total = len(txn_list)
imported_count = 0 imported_count = 0
@ -335,7 +330,7 @@ def update_bank_account_bi_default(bank_account, bi_type, bi_name):
@frappe.whitelist() @frappe.whitelist()
def load_registries_from_excel(file_url, preset_name, bank_integration, def load_registries_from_excel(file_url, bank_integration,
load_customers=1, load_suppliers=1, load_purposes=1): load_customers=1, load_suppliers=1, load_purposes=1):
"""Parse an Excel statement and upsert ONLY the selected reference registries """Parse an Excel statement and upsert ONLY the selected reference registries
(Bank Integration Customer / Supplier / Purpose) no Bank Transactions. (Bank Integration Customer / Supplier / Purpose) no Bank Transactions.
@ -347,7 +342,7 @@ def load_registries_from_excel(file_url, preset_name, bank_integration,
if not (load_customers or load_suppliers or load_purposes): if not (load_customers or load_suppliers or load_purposes):
return {"success": False, "message": _("Select at least one type to load.")} return {"success": False, "message": _("Select at least one type to load.")}
result = parse_excel(file_url, preset_name) result = parse_excel(file_url, bank_integration)
transactions = result["transactions"] transactions = result["transactions"]
dropped_date = result.get("dropped_date", 0) dropped_date = result.get("dropped_date", 0)
dropped_amount = result.get("dropped_amount", 0) dropped_amount = result.get("dropped_amount", 0)

View File

@ -101,6 +101,83 @@ _STANDARD_FIELD_CODE_TO_LABEL = {
} }
_PRESET_FIELDS_TO_COPY = (
"header_row",
"amount_mode",
"direction_debit_values",
"direction_credit_values",
"use_currency_from_file",
"use_custom_date_format",
"date_format",
"sample_file",
)
def copy_excel_preset_into_bank_integration():
"""Iteration: merging Bank Integration Excel Preset into Bank Integration.
For each Bank Integration whose File Format is still empty (no column
mappings, no format chosen), copy the format and column mappings from
a Bank Integration Excel Preset. Strategy: pick the only user-created
preset if there's exactly one (excluding the 'Generic Bank Statement'
fixture); otherwise pick the fixture as a starting template.
Idempotent: once a Bank Integration has any column mappings or an
explicit amount_mode, it's left alone.
"""
if not frappe.db.exists("DocType", "Bank Integration"):
return
if not frappe.db.exists("DocType", "Bank Integration Excel Preset"):
return
bi_names = frappe.get_all("Bank Integration", pluck="name")
if not bi_names:
return
preset_names = frappe.get_all(
"Bank Integration Excel Preset",
fields=["name"],
order_by="modified desc",
pluck="name",
)
if not preset_names:
return
non_fixture = [p for p in preset_names if p != "Generic Bank Statement"]
chosen_preset_name = non_fixture[0] if len(non_fixture) == 1 else (
non_fixture[0] if non_fixture else preset_names[0]
)
preset = frappe.get_doc("Bank Integration Excel Preset", chosen_preset_name)
copied = 0
for bi_name in bi_names:
bi = frappe.get_doc("Bank Integration", bi_name)
# Skip if File Format already configured.
if bi.column_mappings or (bi.amount_mode and bi.amount_mode != "Separate debit/credit columns"):
continue
if bi.column_mappings:
continue
for f in _PRESET_FIELDS_TO_COPY:
value = preset.get(f)
if value is None:
continue
bi.set(f, value)
for row in preset.column_mappings or []:
bi.append("column_mappings", {
"excel_column": row.excel_column,
"standard_field": row.standard_field,
"notes": row.notes,
})
bi.flags.ignore_validate = True
bi.flags.ignore_mandatory = True
bi.save(ignore_permissions=True)
copied += 1
if copied:
frappe.db.commit()
print(f"BI: copied File Format from preset '{chosen_preset_name}' into {copied} Bank Integration(s)")
def migrate_standard_field_codes_to_labels(): def migrate_standard_field_codes_to_labels():
"""Rewrite Bank Integration Excel Column Mapping.standard_field from the old """Rewrite Bank Integration Excel Column Mapping.standard_field from the old
machine codes ('date', 'reference_number', ...) to the new human-readable machine codes ('date', 'reference_number', ...) to the new human-readable

View File

@ -143,10 +143,12 @@ def after_migrate_combined():
link_kb_bank_accounts_to_bi, link_kb_bank_accounts_to_bi,
rename_bip_purposes, rename_bip_purposes,
migrate_standard_field_codes_to_labels, migrate_standard_field_codes_to_labels,
copy_excel_preset_into_bank_integration,
) )
link_kb_bank_accounts_to_bi() link_kb_bank_accounts_to_bi()
rename_bip_purposes() rename_bip_purposes()
migrate_standard_field_codes_to_labels() migrate_standard_field_codes_to_labels()
copy_excel_preset_into_bank_integration()
except Exception as e: except Exception as e:
print(f"BI: post-migrate sync failed: {e}") print(f"BI: post-migrate sync failed: {e}")
# REMOVED: Asset categories creation moved to setup_wizard_complete hook # REMOVED: Asset categories creation moved to setup_wizard_complete hook
@ -160,12 +162,6 @@ fixtures = [
{ {
"doctype": "Print Format" "doctype": "Print Format"
}, },
{
"doctype": "Bank Integration Excel Preset"
},
{
"doctype": "Bank Integration Excel Column Mapping"
},
] ]
# Includes in <head> # Includes in <head>

View File

@ -16,6 +16,8 @@ frappe.ui.form.on('Bank Integration', {
}, },
refresh(frm) { refresh(frm) {
BIFileFormat.applySampleHeaders(frm);
if (frm.is_new()) return; if (frm.is_new()) return;
// === Load Data (file-based, registry-only) === // === Load Data (file-based, registry-only) ===
@ -120,8 +122,83 @@ frappe.ui.form.on('Bank Integration', {
// === Data tab rendering === // === Data tab rendering ===
_bi_load_data_tabs(frm); _bi_load_data_tabs(frm);
}, },
sample_file(frm) {
BIFileFormat.applySampleHeaders(frm);
},
header_row(frm) {
if (frm.doc.sample_file) BIFileFormat.applySampleHeaders(frm, /*force*/ true);
},
}); });
// ─── File Format helpers ─────────────────────────────────────────────────────
// Mirrors what bank_integration_excel_preset.js used to do, but on the merged
// File Format tab of the Bank Integration form: a Sample File attachment turns
// the Excel Column field in the column_mappings grid into a dropdown of real
// header strings, so users don't have to type them.
const BIFileFormat = {
_cachedHeaders: [],
_cachedFor: null,
applySampleHeaders(frm, force) {
if (!frm.doc.sample_file) {
this._cachedHeaders = [];
this._cachedFor = null;
this.setColumnOptions(frm, []);
return;
}
const key = frm.doc.sample_file + '::' + (frm.doc.header_row || 1);
if (!force && this._cachedFor === key && this._cachedHeaders.length) {
this.setColumnOptions(frm, this._cachedHeaders);
return;
}
frappe.call({
method: 'jey_erp.bank_integration.excel_parser.parse_sample_headers',
args: {
file_url: frm.doc.sample_file,
header_row: frm.doc.header_row || 1,
},
callback: (r) => {
if (!r.message || !r.message.success) {
frappe.msgprint({
title: __('Sample File Error'),
indicator: 'red',
message: (r.message && r.message.message) || __('Could not read sample file.'),
});
return;
}
const headers = r.message.headers || [];
this._cachedHeaders = headers;
this._cachedFor = key;
this.setColumnOptions(frm, headers);
if (!headers.length) {
frappe.show_alert({
message: __("No headers found in row {0}. Adjust 'Header Row'.", [frm.doc.header_row || 1]),
indicator: 'orange',
}, 5);
} else {
frappe.show_alert({
message: __('{0} column header(s) loaded.', [headers.length]),
indicator: 'green',
}, 3);
}
},
});
},
setColumnOptions(frm, headers) {
const grid = frm.fields_dict.column_mappings && frm.fields_dict.column_mappings.grid;
if (!grid) return;
grid.update_docfield_property('excel_column', 'options', (headers || []).join('\n'));
grid.refresh();
},
};
function _safe(frm, action) { function _safe(frm, action) {
if (frm.is_dirty()) { if (frm.is_dirty()) {
frappe.confirm( frappe.confirm(
@ -156,21 +233,23 @@ function _call(frm, method, args, onSuccess) {
} }
function _show_load_data_dialog(frm) { function _show_load_data_dialog(frm) {
if (!frm.doc.column_mappings || !frm.doc.column_mappings.length) {
frappe.msgprint({
title: __('No Column Mappings'),
indicator: 'orange',
message: __('Open the <b>File Format</b> tab first and map Excel columns to Standard Fields.'),
});
return;
}
const d = new frappe.ui.Dialog({ const d = new frappe.ui.Dialog({
title: __('Load Data from Excel'), title: __('Load Data from Excel'),
fields: [ fields: [
{
fieldname: 'preset',
fieldtype: 'Link',
options: 'Bank Integration Excel Preset',
label: __('Excel Preset'),
reqd: 1,
},
{ {
fieldname: 'file_url', fieldname: 'file_url',
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 Integration.'),
}, },
{ fieldname: 'sec_what', fieldtype: 'Section Break', label: __('What to load') }, { fieldname: 'sec_what', fieldtype: 'Section Break', label: __('What to load') },
{ {
@ -207,7 +286,6 @@ function _show_load_data_dialog(frm) {
method: 'jey_erp.bank_integration.import_api.load_registries_from_excel', method: 'jey_erp.bank_integration.import_api.load_registries_from_excel',
args: { args: {
file_url: values.file_url, file_url: values.file_url,
preset_name: values.preset,
bank_integration: frm.doc.name, bank_integration: frm.doc.name,
load_customers: values.load_customers ? 1 : 0, load_customers: values.load_customers ? 1 : 0,
load_suppliers: values.load_suppliers ? 1 : 0, load_suppliers: values.load_suppliers ? 1 : 0,
@ -233,14 +311,14 @@ function _show_load_data_dialog(frm) {
[res.total_rows, res.new_customers, res.new_suppliers, res.new_purposes]); [res.total_rows, res.new_customers, res.new_suppliers, res.new_purposes]);
if (droppedDate > 0) { if (droppedDate > 0) {
msg += '<br><br><span class="text-warning">' + msg += '<br><br><span class="text-warning">' +
__("Note: {0} row(s) had unparseable dates and were skipped. Enable 'Use Custom Date Format' on the preset if your bank uses an unusual date format.", __("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]) + [droppedDate]) +
'</span>'; '</span>';
} }
if (droppedDirection > 0) { if (droppedDirection > 0) {
const sample = unknownDirections.map(frappe.utils.escape_html).join(', ') || '?'; const sample = unknownDirections.map(frappe.utils.escape_html).join(', ') || '?';
msg += '<br><br><span class="text-warning">' + 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 on the preset.', __('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]) + [droppedDirection, sample]) +
'</span>'; '</span>';
} }

View File

@ -21,6 +21,19 @@
"default_payment_terms", "default_payment_terms",
"col_break_party", "col_break_party",
"default_territory", "default_territory",
"file_format_tab",
"sample_file",
"ff_format_section",
"header_row",
"amount_mode",
"direction_debit_values",
"direction_credit_values",
"use_currency_from_file",
"ff_column_break",
"use_custom_date_format",
"date_format",
"ff_columns_section",
"column_mappings",
"data_tab", "data_tab",
"data_tab_html", "data_tab_html",
"accounts_tab", "accounts_tab",
@ -145,6 +158,87 @@
"label": "Default Territory", "label": "Default Territory",
"options": "Territory" "options": "Territory"
}, },
{
"fieldname": "file_format_tab",
"fieldtype": "Tab Break",
"label": "File Format"
},
{
"description": "Upload a sample bank statement so the column names below can be picked from a dropdown. Only used at design time.",
"fieldname": "sample_file",
"fieldtype": "Attach",
"label": "Sample Excel File"
},
{
"fieldname": "ff_format_section",
"fieldtype": "Section Break",
"label": "Format"
},
{
"default": "1",
"description": "1-indexed row number containing column headers",
"fieldname": "header_row",
"fieldtype": "Int",
"label": "Header Row"
},
{
"default": "Separate debit/credit columns",
"fieldname": "amount_mode",
"fieldtype": "Select",
"label": "Amount Mode",
"options": "Single column with sign\nSeparate debit/credit columns\nSingle column + direction column"
},
{
"depends_on": "eval:doc.amount_mode == 'Single column + direction column'",
"description": "Comma-separated values from the Direction column that mean DEBIT (outflow). Case-insensitive. Example: DR, Debit, Məxaric, -",
"fieldname": "direction_debit_values",
"fieldtype": "Small Text",
"label": "Debit Values"
},
{
"depends_on": "eval:doc.amount_mode == 'Single column + direction column'",
"description": "Comma-separated values from the Direction column that mean CREDIT (inflow). Case-insensitive. Example: CR, Credit, Mədaxil, +",
"fieldname": "direction_credit_values",
"fieldtype": "Small Text",
"label": "Credit Values"
},
{
"default": "0",
"description": "Off: every imported Bank Transaction gets its Bank Account's currency.\nOn: the parsed Currency column on each row is used, falling back to the Bank Account's currency when empty. Enable only for multi-currency Bank Accounts where the statement actually carries per-row currency.",
"fieldname": "use_currency_from_file",
"fieldtype": "Check",
"label": "Use Currency From File"
},
{
"fieldname": "ff_column_break",
"fieldtype": "Column Break"
},
{
"default": "0",
"description": "Off: the parser auto-detects common date formats (2026-01-31, 31.01.2026, 01/31/2026, etc.).\nOn: only the format below is used — set this if your bank uses an unusual format the auto-detection misses.",
"fieldname": "use_custom_date_format",
"fieldtype": "Check",
"label": "Use Custom Date Format"
},
{
"depends_on": "eval:doc.use_custom_date_format",
"mandatory_depends_on": "eval:doc.use_custom_date_format",
"description": "Python strftime format. Examples: %Y-%m-%d, %d.%m.%Y, %d/%m/%Y %H:%M:%S",
"fieldname": "date_format",
"fieldtype": "Data",
"label": "Custom Date Format"
},
{
"fieldname": "ff_columns_section",
"fieldtype": "Section Break",
"label": "Column Mappings"
},
{
"fieldname": "column_mappings",
"fieldtype": "Table",
"label": "Column Mappings",
"options": "Bank Integration Excel Column Mapping"
},
{ {
"fieldname": "data_tab", "fieldname": "data_tab",
"fieldtype": "Tab Break", "fieldtype": "Tab Break",
@ -285,7 +379,7 @@
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2026-05-08 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 Integration", "name": "Bank Integration",

View File

@ -2,10 +2,23 @@
# For license information, please see license.txt # For license information, please see license.txt
import frappe import frappe
from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
# Standard fields the parser needs depending on the chosen amount_mode.
# Date + Reference Number are always required.
_REQUIRED_BY_MODE = {
"Single column with sign": ("Date", "Reference Number", "Amount"),
"Separate debit/credit columns": ("Date", "Reference Number", "Debit", "Credit"),
"Single column + direction column": ("Date", "Reference Number", "Amount", "Direction"),
}
class BankIntegration(Document): class BankIntegration(Document):
def validate(self):
self._validate_file_format()
def on_update(self): def on_update(self):
# Keep the registry records in sync with the mapping child tables: # Keep the registry records in sync with the mapping child tables:
# removing a row OR clearing its erp_customer / erp_supplier flips the # removing a row OR clearing its erp_customer / erp_supplier flips the
@ -13,6 +26,33 @@ class BankIntegration(Document):
self._sync_customer_statuses() self._sync_customer_statuses()
self._sync_supplier_statuses() self._sync_supplier_statuses()
def _validate_file_format(self):
# Only validate when the user has started filling in the File Format tab.
# An empty Bank Integration is allowed (e.g. just created, before any setup).
if not (self.column_mappings or self.amount_mode or self.header_row):
return
mode = self.amount_mode or "Separate debit/credit columns"
mapped = {
(row.standard_field or "").strip()
for row in (self.column_mappings or [])
if row.excel_column and row.standard_field
}
required = _REQUIRED_BY_MODE.get(mode, ())
missing = [f for f in required if f not in mapped]
if mapped and missing:
frappe.throw(_(
"Amount Mode '{0}' requires these Standard Fields in the Column Mappings table: {1}. "
"Add a row for each missing field."
).format(mode, ", ".join(missing)))
if mode == "Single column + direction column":
if not (self.direction_debit_values or "").strip() and not (self.direction_credit_values or "").strip():
frappe.throw(_(
"Mode 'Single column + direction column' requires at least one of "
"'Debit Values' or 'Credit Values' to be filled (comma-separated)."
))
def _sync_customer_statuses(self): def _sync_customer_statuses(self):
mapped_names = { mapped_names = {
row.bi_customer_name row.bi_customer_name

View File

@ -9,6 +9,12 @@
frappe.ui.form.on("Bank Integration Excel Preset", { frappe.ui.form.on("Bank Integration Excel Preset", {
refresh(frm) { refresh(frm) {
frm.dashboard.add_comment(
__("This doctype is deprecated. File format configuration now lives directly on the Bank Integration record, under the <b>File Format</b> tab."),
"orange",
true
);
BIPreset.applySampleHeaders(frm); BIPreset.applySampleHeaders(frm);
if (frm.doc.sample_file) { if (frm.doc.sample_file) {

View File

@ -127,7 +127,7 @@
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2026-05-13 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 Integration Excel Preset", "name": "Bank Integration Excel Preset",
@ -135,25 +135,12 @@
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
{ {
"create": 1,
"delete": 1, "delete": 1,
"email": 1,
"export": 1, "export": 1,
"print": 1, "print": 1,
"read": 1, "read": 1,
"report": 1, "report": 1,
"role": "System Manager", "role": "System Manager"
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts User",
"share": 1
} }
], ],
"sort_field": "modified", "sort_field": "modified",

View File

@ -20,24 +20,10 @@
const BIExcelImport = { const BIExcelImport = {
showDialog(listview) { showDialog(listview) {
Promise.all([ frappe.db.get_list('Bank Integration', {
frappe.db.get_list('Bank Integration Excel Preset', { fields: ['name', 'bank_name'],
fields: ['name', 'preset_name', 'description'], limit: 100,
limit: 200, }).then((integrations) => {
}),
frappe.db.get_list('Bank Integration', {
fields: ['name', 'bank_name'],
limit: 100,
}),
]).then(([presets, integrations]) => {
if (!presets || !presets.length) {
frappe.msgprint({
title: __('No Presets'),
indicator: 'orange',
message: __('Create at least one Bank Integration Excel Preset first.'),
});
return;
}
if (!integrations || !integrations.length) { if (!integrations || !integrations.length) {
frappe.msgprint({ frappe.msgprint({
title: __('No Bank Integration'), title: __('No Bank Integration'),
@ -46,29 +32,21 @@ const BIExcelImport = {
}); });
return; return;
} }
BIExcelImport._showSetupDialog(presets, integrations, listview); BIExcelImport._showSetupDialog(integrations, listview);
}); });
}, },
_showSetupDialog(presets, integrations, listview) { _showSetupDialog(integrations, listview) {
const presetOptions = ['', ...presets.map(p => p.name)].join('\n');
const d = new frappe.ui.Dialog({ const d = new frappe.ui.Dialog({
title: __('Load Bank Transactions from Excel'), title: __('Load Bank Transactions from Excel'),
fields: [ fields: [
{
fieldname: 'preset',
fieldtype: 'Select',
label: __('Excel Preset'),
options: presetOptions,
reqd: 1,
},
{ {
fieldname: 'bank_integration', fieldname: 'bank_integration',
fieldtype: 'Link', fieldtype: 'Link',
label: __('Bank Integration'), label: __('Bank Integration'),
options: 'Bank Integration', options: 'Bank Integration',
reqd: 1, reqd: 1,
description: __('Format and column mappings come from the chosen Bank Integration.'),
}, },
{ {
fieldname: 'bank_account', fieldname: 'bank_account',
@ -100,7 +78,6 @@ const BIExcelImport = {
method: 'jey_erp.bank_integration.import_api.parse_excel_for_preview', method: 'jey_erp.bank_integration.import_api.parse_excel_for_preview',
args: { args: {
file_url: values.file_url, file_url: values.file_url,
preset_name: values.preset,
bank_integration: values.bank_integration, bank_integration: values.bank_integration,
bank_account: values.bank_account, bank_account: values.bank_account,
}, },
@ -122,15 +99,15 @@ const BIExcelImport = {
const emptyRefCount = r.message.empty_ref_count || 0; const emptyRefCount = r.message.empty_ref_count || 0;
if (droppedDate > 0 && !txns.length) { if (droppedDate > 0 && !txns.length) {
BIExcelImport._showDateParseHelp(droppedDate, values.preset); BIExcelImport._showDateParseHelp(droppedDate, values.bank_integration);
return; return;
} }
if (droppedDirection > 0 && !txns.length) { if (droppedDirection > 0 && !txns.length) {
BIExcelImport._showDirectionHelp(droppedDirection, unknownDirections, values.preset); BIExcelImport._showDirectionHelp(droppedDirection, unknownDirections, values.bank_integration);
return; return;
} }
if (droppedAmount > 0 && !txns.length) { if (droppedAmount > 0 && !txns.length) {
BIExcelImport._showAmountHelp(droppedAmount, values.preset); BIExcelImport._showAmountHelp(droppedAmount, values.bank_integration);
return; return;
} }
if (!txns.length) { if (!txns.length) {
@ -139,7 +116,7 @@ const BIExcelImport = {
indicator: 'blue', indicator: 'blue',
message: skipped > 0 message: skipped > 0
? __('All {0} parsed rows are duplicates.', [skipped]) ? __('All {0} parsed rows are duplicates.', [skipped])
: __('No transactions found in the file. Check that the Header Row on the preset points at the actual header line.'), : __('No transactions found in the file. Check that the Header Row in the File Format tab points at the actual header line.'),
}); });
return; return;
} }
@ -159,7 +136,7 @@ const BIExcelImport = {
if (droppedDate > 0) { if (droppedDate > 0) {
table += '<div class="alert alert-warning">' + table += '<div class="alert alert-warning">' +
__('Dropped {0} row(s) — the date could not be parsed.', [droppedDate]) + ' ' + __('Dropped {0} row(s) — the date could not be parsed.', [droppedDate]) + ' ' +
__("Open the Bank Integration Excel Preset, enable 'Use Custom Date Format', and set the exact format your bank uses.") + __("Open the Bank Integration's File Format tab, enable 'Use Custom Date Format', and set the exact format your bank uses.") +
'</div>'; '</div>';
} }
if (droppedDirection > 0) { if (droppedDirection > 0) {
@ -167,7 +144,7 @@ const BIExcelImport = {
table += '<div class="alert alert-warning">' + table += '<div class="alert alert-warning">' +
__('Dropped {0} row(s) — unknown direction value(s):', [droppedDirection]) + __('Dropped {0} row(s) — unknown direction value(s):', [droppedDirection]) +
' <code>' + (sample || '?') + '</code>. ' + ' <code>' + (sample || '?') + '</code>. ' +
__('Open the preset and add these to Debit Values or Credit Values.') + __('Open the File Format tab and add these to Debit Values or Credit Values.') +
'</div>'; '</div>';
} }
if (droppedAmount > 0) { if (droppedAmount > 0) {
@ -292,7 +269,6 @@ const BIExcelImport = {
txn_list: JSON.stringify(selected), txn_list: JSON.stringify(selected),
bank_integration: values.bank_integration, bank_integration: values.bank_integration,
bank_account: values.bank_account, bank_account: values.bank_account,
preset_name: values.preset,
}, },
error() { error() {
frappe.realtime.off('bi_bt_import_progress'); frappe.realtime.off('bi_bt_import_progress');
@ -306,9 +282,9 @@ const BIExcelImport = {
}); });
}, },
_showDateParseHelp(droppedCount, presetName) { _showDateParseHelp(droppedCount, bankIntegrationName) {
const openPreset = function () { const openForm = function () {
frappe.set_route('Form', 'Bank Integration Excel Preset', presetName); frappe.set_route('Form', 'Bank Integration', bankIntegrationName);
}; };
const d = new frappe.ui.Dialog({ const d = new frappe.ui.Dialog({
title: __('Date Format Not Recognized'), title: __('Date Format Not Recognized'),
@ -319,19 +295,19 @@ const BIExcelImport = {
__('All {0} row(s) were dropped because the date column could not be parsed.', [droppedCount]) + __('All {0} row(s) were dropped because the date column could not be parsed.', [droppedCount]) +
'</div>' + '</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>' + __("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 preset 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>', '<p>' + __("If your bank uses a different format, open the Bank Integration'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 Preset'), primary_action_label: __('Open Bank Integration'),
primary_action() { d.hide(); openPreset(); }, primary_action() { d.hide(); openForm(); },
secondary_action_label: __('Close'), secondary_action_label: __('Close'),
secondary_action() { d.hide(); }, secondary_action() { d.hide(); },
}); });
d.show(); d.show();
}, },
_showAmountHelp(droppedCount, presetName) { _showAmountHelp(droppedCount, bankIntegrationName) {
const openPreset = function () { const openForm = function () {
frappe.set_route('Form', 'Bank Integration Excel Preset', presetName); frappe.set_route('Form', 'Bank Integration', bankIntegrationName);
}; };
const d = new frappe.ui.Dialog({ const d = new frappe.ui.Dialog({
title: __('Amount Column Missing or Empty'), title: __('Amount Column Missing or Empty'),
@ -341,20 +317,20 @@ const BIExcelImport = {
'<div class="alert alert-danger">' + '<div class="alert alert-danger">' +
__('All {0} row(s) were dropped because the amount could not be read.', [droppedCount]) + __('All {0} row(s) were dropped because the amount could not be read.', [droppedCount]) +
'</div>' + '</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 preset.') + '</p>' + '<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 preset and check the Column Mappings table — Standard Field values must include all columns required by the chosen Amount Mode.') + '</p>', '<p>' + __('Open the Bank Integration and check the Column Mappings table — Standard Field values must include all columns required by the chosen Amount Mode.') + '</p>',
}], }],
primary_action_label: __('Open Preset'), primary_action_label: __('Open Bank Integration'),
primary_action() { d.hide(); openPreset(); }, primary_action() { d.hide(); openForm(); },
secondary_action_label: __('Close'), secondary_action_label: __('Close'),
secondary_action() { d.hide(); }, secondary_action() { d.hide(); },
}); });
d.show(); d.show();
}, },
_showDirectionHelp(droppedCount, unknownDirections, presetName) { _showDirectionHelp(droppedCount, unknownDirections, bankIntegrationName) {
const openPreset = function () { const openForm = function () {
frappe.set_route('Form', 'Bank Integration Excel Preset', presetName); frappe.set_route('Form', 'Bank Integration', bankIntegrationName);
}; };
const sample = (unknownDirections || []).map(frappe.utils.escape_html).join(', ') || '?'; const sample = (unknownDirections || []).map(frappe.utils.escape_html).join(', ') || '?';
const d = new frappe.ui.Dialog({ const d = new frappe.ui.Dialog({
@ -363,13 +339,13 @@ const BIExcelImport = {
fieldtype: 'HTML', fieldtype: 'HTML',
options: options:
'<div class="alert alert-danger">' + '<div class="alert alert-danger">' +
__('All {0} row(s) were dropped because the Direction column contained values not configured on the preset.', [droppedCount]) + __('All {0} row(s) were dropped because the Direction column contained values not configured in the File Format tab.', [droppedCount]) +
'</div>' + '</div>' +
'<p>' + __('Unrecognised values:') + ' <code>' + sample + '</code></p>' + '<p>' + __('Unrecognised values:') + ' <code>' + sample + '</code></p>' +
'<p>' + __('Open the preset and add these to <b>Debit Values</b> (for outflows) or <b>Credit Values</b> (for inflows), comma-separated.') + '</p>', '<p>' + __('Open the Bank Integration and add these to <b>Debit Values</b> (for outflows) or <b>Credit Values</b> (for inflows), comma-separated.') + '</p>',
}], }],
primary_action_label: __('Open Preset'), primary_action_label: __('Open Bank Integration'),
primary_action() { d.hide(); openPreset(); }, primary_action() { d.hide(); openForm(); },
secondary_action_label: __('Close'), secondary_action_label: __('Close'),
secondary_action() { d.hide(); }, secondary_action() { d.hide(); },
}); });