feat(bank-integration): Excel Preset rework — sample-driven columns, auto date format

- Sample File on Preset: upload once, headers populate the Excel Column
  Autocomplete in the mapping table; Clear Sample button reverts to free text.
- Date format: hidden behind "Use Custom Date Format" checkbox; parser tries
  24 common formats (ISO/EU/US × -./\\\\ × with/without time) automatically.
  Surfaces a clear "open the preset and enable custom format" dialog on
  unparseable dates, instead of silently dropping rows.
- Standard Field options renamed from machine codes (date, reference_number…)
  to readable labels (Date, Reference Number…); parser maps labels → codes.
  Idempotent migration converts existing rows.
- Header matching is now case- and whitespace-insensitive.
- Direction-column aliases extended with Azerbaijani terms (Mədaxil/Məxaric/
  Daxil/Çıxıb/Gəlir/Xərc).
- cost_center in transaction_mappings filtered to is_group:0 in the form.
- Load Data result surfaces dropped_date / dropped_amount counts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ali 2026-05-13 10:44:15 +00:00
parent 751154efa8
commit 0672441be3
10 changed files with 441 additions and 62 deletions

View File

@ -6,22 +6,73 @@ import frappe
from frappe.utils import flt from frappe.utils import flt
# Display labels (stored in DB on Bank Integration Excel Column Mapping.standard_field)
# mapped to the short internal codes the parser logic uses.
STANDARD_FIELD_TO_CODE = {
"Date": "date",
"Reference Number": "reference_number",
"Counterparty": "counterparty",
"Counterparty Tax ID (VOEN)": "counterparty_tax_id",
"Counterparty IBAN": "counterparty_iban",
"Amount": "amount",
"Debit": "debit",
"Credit": "credit",
"Direction": "direction",
"Currency": "currency",
"Purpose": "purpose",
"Description": "description",
}
# Tried in order until one parses the value. Covers ISO-style, day-first (EU/AZ),
# and month-first (US) on the four common separators, with and without time.
# Day-first variants are listed BEFORE month-first to bias towards the AZ region;
# users with US-style ambiguous dates can enable "Use Custom Date Format".
_COMMON_DATE_FORMATS = (
"%Y-%m-%d", "%Y.%m.%d", "%Y/%m/%d",
"%d-%m-%Y", "%d.%m.%Y", "%d/%m/%Y", "%d\\%m\\%Y",
"%m-%d-%Y", "%m.%d.%Y", "%m/%d/%Y",
"%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M",
"%d.%m.%Y %H:%M:%S", "%d.%m.%Y %H:%M",
"%d/%m/%Y %H:%M:%S", "%d/%m/%Y %H:%M",
"%m/%d/%Y %H:%M:%S", "%m/%d/%Y %H:%M",
"%d-%m-%y", "%d.%m.%y", "%d/%m/%y",
"%m-%d-%y", "%m.%d.%y", "%m/%d/%y",
)
def parse_excel(file_url, preset_name): def parse_excel(file_url, preset_name):
"""Parse an Excel bank statement using a saved preset. """Parse an Excel bank statement using a saved preset.
Returns: list of dicts with keys: Returns a dict:
ref_no, date (ISO string), counterparty, contr_voen, contr_iban, {
amount (positive float), currency, drcr (D/C), purpose, description "transactions": [ {ref_no, date, counterparty, contr_voen, contr_iban,
amount, currency, drcr, purpose, description}, ... ],
"dropped_date": int, # rows skipped because date couldn't be parsed
"dropped_amount": int, # rows skipped because amount was zero/missing
}
""" """
from openpyxl import load_workbook from openpyxl import load_workbook
preset = frappe.get_doc("Bank Integration Excel Preset", preset_name) preset = frappe.get_doc("Bank Integration Excel Preset", preset_name)
col_to_standard = {} col_to_standard = {}
unknown_labels = []
for row in preset.column_mappings: for row in preset.column_mappings:
if row.excel_column and row.standard_field: if not row.excel_column or not row.standard_field:
col_to_standard[row.excel_column.strip()] = row.standard_field continue
code = STANDARD_FIELD_TO_CODE.get(row.standard_field)
if not code:
unknown_labels.append(row.standard_field)
continue
col_to_standard[row.excel_column.strip()] = code
if unknown_labels:
frappe.throw(
"Preset '{}' has unknown standard field(s): {}. Edit the preset and pick a value from the dropdown.".format(
preset_name, ", ".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"Preset '{preset_name}' has no column mappings")
@ -40,38 +91,57 @@ def parse_excel(file_url, preset_name):
if header_idx >= len(all_rows): if header_idx >= len(all_rows):
frappe.throw(f"Header row {header_row} is out of range (file has {len(all_rows)} rows)") frappe.throw(f"Header row {header_row} is out of range (file has {len(all_rows)} rows)")
headers = [str(c).strip() if c is not None else "" for c in all_rows[header_idx]] headers = [_normalize_header(c) for c in all_rows[header_idx]]
normalized_lookup = {_normalize_header(k): v for k, v in col_to_standard.items()}
idx_to_standard = {} idx_to_standard = {}
for i, h in enumerate(headers): for i, h in enumerate(headers):
if h in col_to_standard: if h in normalized_lookup:
idx_to_standard[i] = col_to_standard[h] idx_to_standard[i] = normalized_lookup[h]
if not idx_to_standard: if not idx_to_standard:
frappe.throw( frappe.throw(
f"None of the configured columns ({sorted(col_to_standard.keys())}) " f"None of the configured columns ({sorted(col_to_standard.keys())}) "
f"found in Excel headers ({headers})" f"found in Excel headers ({[h for h in headers if h]})"
) )
date_format = preset.date_format or "%Y-%m-%d" use_custom = bool(preset.get("use_custom_date_format"))
custom_format = (preset.date_format or "").strip() if use_custom else ""
amount_mode = preset.amount_mode or "Separate debit/credit columns" amount_mode = preset.amount_mode or "Separate debit/credit columns"
data_start = header_idx + 1 + skip_after data_start = header_idx + 1 + skip_after
transactions = [] transactions = []
dropped_date = 0
dropped_amount = 0
for raw_row in all_rows[data_start:]: for raw_row in all_rows[data_start:]:
if not raw_row or all(c is None or str(c).strip() == "" for c in raw_row): if not raw_row or all(c is None or str(c).strip() == "" for c in raw_row):
continue continue
parsed = _parse_row(raw_row, idx_to_standard, date_format, amount_mode) parsed, reason = _parse_row(raw_row, idx_to_standard, custom_format, amount_mode)
if parsed: if parsed:
transactions.append(parsed) transactions.append(parsed)
elif reason == "date":
dropped_date += 1
elif reason == "amount":
dropped_amount += 1
return transactions return {
"transactions": transactions,
"dropped_date": dropped_date,
"dropped_amount": dropped_amount,
}
finally: finally:
wb.close() wb.close()
def _parse_row(raw_row, idx_to_standard, date_format, amount_mode): def _normalize_header(value):
if value is None:
return ""
return " ".join(str(value).strip().lower().split())
def _parse_row(raw_row, idx_to_standard, custom_format, amount_mode):
"""Returns (record_dict_or_None, reason). reason in {"", "date", "amount"}."""
record = { record = {
"ref_no": "", "ref_no": "",
"date": None, "date": None,
@ -89,6 +159,7 @@ def _parse_row(raw_row, idx_to_standard, date_format, amount_mode):
credit_val = None credit_val = None
amount_val = None amount_val = None
direction_val = None direction_val = None
saw_date_input = False
for i, std in idx_to_standard.items(): for i, std in idx_to_standard.items():
if i >= len(raw_row): if i >= len(raw_row):
@ -98,7 +169,8 @@ def _parse_row(raw_row, idx_to_standard, date_format, amount_mode):
continue continue
if std == "date": if std == "date":
record["date"] = _parse_date(cell, date_format) saw_date_input = saw_date_input or (str(cell).strip() != "")
record["date"] = _parse_date(cell, custom_format)
elif std == "reference_number": elif std == "reference_number":
record["ref_no"] = str(cell).strip() record["ref_no"] = str(cell).strip()
elif std == "counterparty": elif std == "counterparty":
@ -130,30 +202,36 @@ def _parse_row(raw_row, idx_to_standard, date_format, amount_mode):
record["amount"] = abs(flt(credit_val)) record["amount"] = abs(flt(credit_val))
record["drcr"] = "C" record["drcr"] = "C"
else: else:
return None return None, "amount"
elif amount_mode == "Single column with sign": elif amount_mode == "Single column with sign":
if amount_val is None: if amount_val is None:
return None return None, "amount"
record["amount"] = abs(flt(amount_val)) record["amount"] = abs(flt(amount_val))
record["drcr"] = "D" if amount_val < 0 else "C" record["drcr"] = "D" if amount_val < 0 else "C"
elif amount_mode == "Single column + direction column": elif amount_mode == "Single column + direction column":
if amount_val is None or direction_val is None: if amount_val is None or direction_val is None:
return None return None, "amount"
record["amount"] = abs(flt(amount_val)) record["amount"] = abs(flt(amount_val))
if direction_val in ("D", "DR", "DEBIT", "DT", "OUT", "PAY", "WITHDRAWAL", "-"): if direction_val in ("D", "DR", "DEBIT", "DT", "OUT", "PAY", "WITHDRAWAL", "-",
"MƏXARIC", "MEXARIC", "ÇIXIB", "CIXIB", "XƏRC", "XERC"):
record["drcr"] = "D" record["drcr"] = "D"
elif direction_val in ("C", "CR", "CREDIT", "CT", "IN", "RECEIVE", "DEPOSIT", "+"): elif direction_val in ("C", "CR", "CREDIT", "CT", "IN", "RECEIVE", "DEPOSIT", "+",
"MƏDAXIL", "MEDAXIL", "DAXIL", "DAXIL OLUB", "GƏLIR", "GELIR"):
record["drcr"] = "C" record["drcr"] = "C"
else: else:
record["drcr"] = "D" record["drcr"] = "D"
if not record["date"] or record["amount"] == 0: if not record["date"]:
return None # Distinguish "row had a date input we couldn't parse" from "row had no date
# input at all" — only the former is a user-actionable error.
return None, "date" if saw_date_input else "amount"
if record["amount"] == 0:
return None, "amount"
return record return record, ""
def _parse_date(value, date_format): def _parse_date(value, custom_format):
if isinstance(value, datetime): if isinstance(value, datetime):
return value.date().isoformat() return value.date().isoformat()
if isinstance(value, date): if isinstance(value, date):
@ -167,9 +245,62 @@ def _parse_date(value, date_format):
s = str(value).strip() s = str(value).strip()
if not s: if not s:
return None return None
for fmt in (date_format, "%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y", "%m/%d/%Y", "%Y/%m/%d"):
if custom_format:
try:
return datetime.strptime(s, custom_format).date().isoformat()
except ValueError:
return None
for fmt in _COMMON_DATE_FORMATS:
try: try:
return datetime.strptime(s, fmt).date().isoformat() return datetime.strptime(s, fmt).date().isoformat()
except ValueError: except ValueError:
continue continue
return None return None
@frappe.whitelist()
def parse_sample_headers(file_url, header_row=1):
"""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
dropdown without parsing the whole file.
"""
from openpyxl import load_workbook
from frappe.utils import cint
header_row = max(cint(header_row) or 1, 1)
if not file_url:
frappe.throw("file_url is required")
file_doc = frappe.get_doc("File", {"file_url": file_url})
# Restrict to private files owned by the caller or files they otherwise have
# read access to — public attachments are always allowed.
if file_doc.is_private and file_doc.owner != frappe.session.user:
if not frappe.has_permission("File", "read", file_doc.name):
frappe.throw("Not permitted to read this file")
file_path = file_doc.get_full_path()
wb = load_workbook(file_path, data_only=True, read_only=True)
try:
ws = wb.active
all_rows = list(ws.iter_rows(min_row=header_row, max_row=header_row, values_only=True))
if not all_rows:
return {"success": True, "headers": []}
headers = []
seen = set()
for cell in all_rows[0]:
if cell is None:
continue
h = str(cell).strip()
if not h:
continue
if h in seen:
continue
seen.add(h)
headers.append(h)
return {"success": True, "headers": headers}
finally:
wb.close()

View File

@ -12,10 +12,20 @@ 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, preset_name, bank_integration, bank_account):
"""Parse Excel and return preview transactions, deduped against existing BTs.""" """Parse Excel and return preview transactions, deduped against existing BTs."""
transactions = parse_excel(file_url, preset_name) result = parse_excel(file_url, preset_name)
transactions = result["transactions"]
dropped_date = result.get("dropped_date", 0)
dropped_amount = result.get("dropped_amount", 0)
if not transactions: if not transactions:
return {"success": True, "transactions": [], "skipped_duplicates": 0, "total_parsed": 0} return {
"success": True,
"transactions": [],
"skipped_duplicates": 0,
"total_parsed": 0,
"dropped_date": dropped_date,
"dropped_amount": dropped_amount,
}
ref_nos = [t["ref_no"] for t in transactions if t.get("ref_no")] ref_nos = [t["ref_no"] for t in transactions if t.get("ref_no")]
existing = set() existing = set()
@ -44,6 +54,8 @@ def parse_excel_for_preview(file_url, preset_name, bank_integration, bank_accoun
"transactions": fresh, "transactions": fresh,
"skipped_duplicates": skipped, "skipped_duplicates": skipped,
"total_parsed": len(transactions), "total_parsed": len(transactions),
"dropped_date": dropped_date,
"dropped_amount": dropped_amount,
} }
@ -313,7 +325,10 @@ 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.")}
transactions = parse_excel(file_url, preset_name) result = parse_excel(file_url, preset_name)
transactions = result["transactions"]
dropped_date = result.get("dropped_date", 0)
dropped_amount = result.get("dropped_amount", 0)
new_c = new_s = new_p = 0 new_c = new_s = new_p = 0
for txn in transactions: for txn in transactions:
@ -342,4 +357,6 @@ def load_registries_from_excel(file_url, preset_name, bank_integration,
"new_customers": new_c, "new_customers": new_c,
"new_suppliers": new_s, "new_suppliers": new_s,
"new_purposes": new_p, "new_purposes": new_p,
"dropped_date": dropped_date,
"dropped_amount": dropped_amount,
} }

View File

@ -83,3 +83,45 @@ def rename_bip_purposes():
_rename_to_readable("Bank Integration Purpose", "BIP", "purpose_keyword") _rename_to_readable("Bank Integration Purpose", "BIP", "purpose_keyword")
_rename_to_readable("Bank Integration Customer", "BIC", "customer_name") _rename_to_readable("Bank Integration Customer", "BIC", "customer_name")
_rename_to_readable("Bank Integration Supplier", "BIS", "supplier_name") _rename_to_readable("Bank Integration Supplier", "BIS", "supplier_name")
_STANDARD_FIELD_CODE_TO_LABEL = {
"date": "Date",
"reference_number": "Reference Number",
"counterparty": "Counterparty",
"counterparty_tax_id": "Counterparty Tax ID (VOEN)",
"counterparty_iban": "Counterparty IBAN",
"amount": "Amount",
"debit": "Debit",
"credit": "Credit",
"direction": "Direction",
"currency": "Currency",
"purpose": "Purpose",
"description": "Description",
}
def migrate_standard_field_codes_to_labels():
"""Rewrite Bank Integration Excel Column Mapping.standard_field from the old
machine codes ('date', 'reference_number', ...) to the new human-readable
labels stored by the doctype select options. Idempotent: rows already on a
label value are left alone."""
if not frappe.db.exists("DocType", "Bank Integration Excel Column Mapping"):
return
rows = frappe.get_all(
"Bank Integration Excel Column Mapping",
filters={"standard_field": ["in", list(_STANDARD_FIELD_CODE_TO_LABEL.keys())]},
fields=["name", "standard_field"],
)
if not rows:
return
for r in rows:
label = _STANDARD_FIELD_CODE_TO_LABEL.get(r.standard_field)
if not label:
continue
frappe.db.set_value(
"Bank Integration Excel Column Mapping", r.name,
"standard_field", label, update_modified=False,
)
frappe.db.commit()
print(f"BI: migrated {len(rows)} Excel column mapping(s) to human-readable labels")

View File

@ -6,58 +6,59 @@
"description": "Generic preset showing the supported standard fields. Copy this preset and adjust the Excel column names to match your bank's export format.", "description": "Generic preset showing the supported standard fields. Copy this preset and adjust the Excel column names to match your bank's export format.",
"header_row": 1, "header_row": 1,
"skip_rows_after_header": 0, "skip_rows_after_header": 0,
"date_format": "%Y-%m-%d", "use_custom_date_format": 0,
"date_format": "",
"amount_mode": "Separate debit/credit columns", "amount_mode": "Separate debit/credit columns",
"column_mappings": [ "column_mappings": [
{ {
"doctype": "Bank Integration Excel Column Mapping", "doctype": "Bank Integration Excel Column Mapping",
"excel_column": "Date", "excel_column": "Date",
"standard_field": "date" "standard_field": "Date"
}, },
{ {
"doctype": "Bank Integration Excel Column Mapping", "doctype": "Bank Integration Excel Column Mapping",
"excel_column": "Reference", "excel_column": "Reference",
"standard_field": "reference_number" "standard_field": "Reference Number"
}, },
{ {
"doctype": "Bank Integration Excel Column Mapping", "doctype": "Bank Integration Excel Column Mapping",
"excel_column": "Counterparty", "excel_column": "Counterparty",
"standard_field": "counterparty" "standard_field": "Counterparty"
}, },
{ {
"doctype": "Bank Integration Excel Column Mapping", "doctype": "Bank Integration Excel Column Mapping",
"excel_column": "Tax ID", "excel_column": "Tax ID",
"standard_field": "counterparty_tax_id" "standard_field": "Counterparty Tax ID (VOEN)"
}, },
{ {
"doctype": "Bank Integration Excel Column Mapping", "doctype": "Bank Integration Excel Column Mapping",
"excel_column": "IBAN", "excel_column": "IBAN",
"standard_field": "counterparty_iban" "standard_field": "Counterparty IBAN"
}, },
{ {
"doctype": "Bank Integration Excel Column Mapping", "doctype": "Bank Integration Excel Column Mapping",
"excel_column": "Debit", "excel_column": "Debit",
"standard_field": "debit" "standard_field": "Debit"
}, },
{ {
"doctype": "Bank Integration Excel Column Mapping", "doctype": "Bank Integration Excel Column Mapping",
"excel_column": "Credit", "excel_column": "Credit",
"standard_field": "credit" "standard_field": "Credit"
}, },
{ {
"doctype": "Bank Integration Excel Column Mapping", "doctype": "Bank Integration Excel Column Mapping",
"excel_column": "Currency", "excel_column": "Currency",
"standard_field": "currency" "standard_field": "Currency"
}, },
{ {
"doctype": "Bank Integration Excel Column Mapping", "doctype": "Bank Integration Excel Column Mapping",
"excel_column": "Purpose", "excel_column": "Purpose",
"standard_field": "purpose" "standard_field": "Purpose"
}, },
{ {
"doctype": "Bank Integration Excel Column Mapping", "doctype": "Bank Integration Excel Column Mapping",
"excel_column": "Description", "excel_column": "Description",
"standard_field": "description" "standard_field": "Description"
} }
] ]
} }

View File

@ -143,9 +143,14 @@ def after_migrate_combined():
# Link kb-mapped Bank Accounts to Kapital Bank Settings via the # Link kb-mapped Bank Accounts to Kapital Bank Settings via the
# Dynamic Link fields used by the universal Create & Reconcile resolver. # Dynamic Link fields used by the universal Create & Reconcile resolver.
try: try:
from jey_erp.bank_integration.migrations import link_kb_bank_accounts_to_bi, rename_bip_purposes from jey_erp.bank_integration.migrations import (
link_kb_bank_accounts_to_bi,
rename_bip_purposes,
migrate_standard_field_codes_to_labels,
)
link_kb_bank_accounts_to_bi() link_kb_bank_accounts_to_bi()
rename_bip_purposes() rename_bip_purposes()
migrate_standard_field_codes_to_labels()
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

View File

@ -12,6 +12,7 @@ frappe.ui.form.on('Bank Integration', {
frm.set_query('customer_group', 'customer_mappings', leaf); frm.set_query('customer_group', 'customer_mappings', leaf);
frm.set_query('territory', 'customer_mappings', leaf); frm.set_query('territory', 'customer_mappings', leaf);
frm.set_query('supplier_group', 'supplier_mappings', leaf); frm.set_query('supplier_group', 'supplier_mappings', leaf);
frm.set_query('cost_center', 'transaction_mappings', leaf);
}, },
refresh(frm) { refresh(frm) {
@ -224,11 +225,25 @@ function _show_load_data_dialog(frm) {
} }
d.hide(); d.hide();
const res = r.message; const res = r.message;
const droppedDate = res.dropped_date || 0;
const droppedAmount = res.dropped_amount || 0;
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' on the preset if your bank uses an unusual date format.",
[droppedDate]) +
'</span>';
}
if (droppedAmount > 0) {
msg += '<br><span class="text-muted">' +
__('Also skipped {0} row(s) with missing/zero amount.', [droppedAmount]) +
'</span>';
}
frappe.msgprint({ frappe.msgprint({
title: __('Data Loaded'), title: __('Data Loaded'),
indicator: 'green', indicator: droppedDate > 0 ? 'orange' : 'green',
message: __('From {0} rows — new customers: {1}, suppliers: {2}, purposes: {3}', message: msg,
[res.total_rows, res.new_customers, res.new_suppliers, res.new_purposes]),
}); });
frm.reload_doc(); frm.reload_doc();
}, },

View File

@ -12,17 +12,18 @@
"fields": [ "fields": [
{ {
"fieldname": "excel_column", "fieldname": "excel_column",
"fieldtype": "Data", "fieldtype": "Autocomplete",
"in_list_view": 1, "in_list_view": 1,
"label": "Excel Column", "label": "Excel Column",
"reqd": 1 "reqd": 1,
"description": "Column header text in the Excel file. Upload a Sample File above to pick from a list."
}, },
{ {
"fieldname": "standard_field", "fieldname": "standard_field",
"fieldtype": "Select", "fieldtype": "Select",
"in_list_view": 1, "in_list_view": 1,
"label": "Standard Field", "label": "Standard Field",
"options": "\ndate\nreference_number\ncounterparty\ncounterparty_tax_id\ncounterparty_iban\namount\ndebit\ncredit\ndirection\ncurrency\npurpose\ndescription", "options": "\nDate\nReference Number\nCounterparty\nCounterparty Tax ID (VOEN)\nCounterparty IBAN\nAmount\nDebit\nCredit\nDirection\nCurrency\nPurpose\nDescription",
"reqd": 1 "reqd": 1
}, },
{ {

View File

@ -0,0 +1,99 @@
// Bank Integration Excel Preset form:
// - When a Sample File is attached, fetch its header row and use those as
// options for the Excel Column dropdown in the child table.
// - "Clear Sample" button removes the sample and reverts the column to free
// text (Autocomplete with no options).
//
// The Custom Date Format field is hidden by depends_on; the parser tries a
// list of common formats automatically when the checkbox is off.
frappe.ui.form.on("Bank Integration Excel Preset", {
refresh(frm) {
BIPreset.applySampleHeaders(frm);
if (frm.doc.sample_file) {
frm.add_custom_button(__("Clear Sample"), function () {
frappe.confirm(
__("Remove the sample file? The Excel Column dropdown will go back to free text."),
function () {
frm.set_value("sample_file", null);
BIPreset._cachedHeaders = [];
BIPreset.setColumnOptions(frm, []);
frm.save();
}
);
});
}
},
sample_file(frm) {
BIPreset.applySampleHeaders(frm);
},
header_row(frm) {
// Header row changed — if there's a sample, re-fetch headers.
if (frm.doc.sample_file) BIPreset.applySampleHeaders(frm, /*force*/ true);
},
});
const BIPreset = {
_cachedHeaders: [],
_cachedFor: null, // "file_url::header_row" so we know when to refetch
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;
// Autocomplete field accepts options as a newline-separated string.
grid.update_docfield_property("excel_column", "options", (headers || []).join("\n"));
grid.refresh();
},
};

View File

@ -10,9 +10,13 @@
"format_section", "format_section",
"header_row", "header_row",
"skip_rows_after_header", "skip_rows_after_header",
"date_format",
"column_break_1",
"amount_mode", "amount_mode",
"column_break_1",
"use_custom_date_format",
"date_format",
"sample_section",
"sample_file",
"sample_section_help",
"columns_section", "columns_section",
"column_mappings" "column_mappings"
], ],
@ -50,18 +54,6 @@
"fieldtype": "Int", "fieldtype": "Int",
"label": "Skip Rows After Header" "label": "Skip Rows After Header"
}, },
{
"default": "%Y-%m-%d",
"description": "Python strftime format. Examples: %Y-%m-%d, %d.%m.%Y, %d/%m/%Y",
"fieldname": "date_format",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Date Format"
},
{
"fieldname": "column_break_1",
"fieldtype": "Column Break"
},
{ {
"default": "Separate debit/credit columns", "default": "Separate debit/credit columns",
"fieldname": "amount_mode", "fieldname": "amount_mode",
@ -70,6 +62,41 @@
"label": "Amount Mode", "label": "Amount Mode",
"options": "Single column with sign\nSeparate debit/credit columns\nSingle column + direction column" "options": "Single column with sign\nSeparate debit/credit columns\nSingle column + direction column"
}, },
{
"fieldname": "column_break_1",
"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": "sample_section",
"fieldtype": "Section Break",
"label": "Sample File"
},
{
"description": "Upload a sample bank statement here 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": "sample_section_help",
"fieldtype": "HTML",
"options": "<div class=\"text-muted small\">After uploading, headers from the file become the options for <b>Excel Column</b> in the table below. Use the <b>Clear Sample</b> button to remove the file and go back to free text.</div>"
},
{ {
"fieldname": "columns_section", "fieldname": "columns_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
@ -84,7 +111,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-13 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",

View File

@ -115,6 +115,13 @@ const BIExcelImport = {
} }
const txns = r.message.transactions || []; const txns = r.message.transactions || [];
const skipped = r.message.skipped_duplicates || 0; const skipped = r.message.skipped_duplicates || 0;
const droppedDate = r.message.dropped_date || 0;
const droppedAmount = r.message.dropped_amount || 0;
if (droppedDate > 0 && !txns.length) {
BIExcelImport._showDateParseHelp(droppedDate, values.preset);
return;
}
if (!txns.length) { if (!txns.length) {
frappe.msgprint({ frappe.msgprint({
title: __('Nothing to Import'), title: __('Nothing to Import'),
@ -125,18 +132,29 @@ const BIExcelImport = {
}); });
return; return;
} }
BIExcelImport._showPreview(txns, skipped, values, listview); BIExcelImport._showPreview(txns, skipped, values, listview, droppedDate, droppedAmount);
}, },
}); });
}, },
_showPreview(txns, skipped, values, listview) { _showPreview(txns, skipped, values, listview, droppedDate, droppedAmount) {
let table = '<div style="max-height: 500px; overflow-y: auto;">'; let table = '<div style="max-height: 500px; overflow-y: auto;">';
if (skipped > 0) { if (skipped > 0) {
table += '<div class="alert alert-info">' + table += '<div class="alert alert-info">' +
__('Skipped {0} duplicates already imported. {1} new transactions ready.', [skipped, txns.length]) + __('Skipped {0} duplicates already imported. {1} new transactions ready.', [skipped, txns.length]) +
'</div>'; '</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 Excel Preset, enable 'Use Custom Date Format', and set the exact format your bank uses.") +
'</div>';
}
if (droppedAmount > 0) {
table += '<div class="alert alert-warning">' +
__('Dropped {0} row(s) — missing or zero amount.', [droppedAmount]) +
'</div>';
}
table += '<table class="table table-bordered" style="width:100%; table-layout:fixed;">'; 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 += '<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:4%;"><input type="checkbox" class="bi-select-all-txns"></th>';
@ -260,6 +278,29 @@ const BIExcelImport = {
}); });
}, },
_showDateParseHelp(droppedCount, presetName) {
const openPreset = function () {
frappe.set_route('Form', 'Bank Integration Excel Preset', presetName);
};
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 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>',
}],
primary_action_label: __('Open Preset'),
primary_action() { d.hide(); openPreset(); },
secondary_action_label: __('Close'),
secondary_action() { d.hide(); },
});
d.show();
},
_closeProgress() { _closeProgress() {
try { try {
if (frappe.cur_progress) { if (frappe.cur_progress) {