diff --git a/jey_erp/bank_integration/excel_parser.py b/jey_erp/bank_integration/excel_parser.py index 2cc764f..3753e38 100644 --- a/jey_erp/bank_integration/excel_parser.py +++ b/jey_erp/bank_integration/excel_parser.py @@ -6,22 +6,73 @@ import frappe 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): """Parse an Excel bank statement using a saved preset. - Returns: list of dicts with keys: - ref_no, date (ISO string), counterparty, contr_voen, contr_iban, - amount (positive float), currency, drcr (D/C), purpose, description + Returns a dict: + { + "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 preset = frappe.get_doc("Bank Integration Excel Preset", preset_name) col_to_standard = {} + unknown_labels = [] for row in preset.column_mappings: - if row.excel_column and row.standard_field: - col_to_standard[row.excel_column.strip()] = row.standard_field + if not row.excel_column or not 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: 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): 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 = {} for i, h in enumerate(headers): - if h in col_to_standard: - idx_to_standard[i] = col_to_standard[h] + if h in normalized_lookup: + idx_to_standard[i] = normalized_lookup[h] if not idx_to_standard: frappe.throw( 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" data_start = header_idx + 1 + skip_after transactions = [] + dropped_date = 0 + dropped_amount = 0 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): 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: 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: 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 = { "ref_no": "", "date": None, @@ -89,6 +159,7 @@ def _parse_row(raw_row, idx_to_standard, date_format, amount_mode): credit_val = None amount_val = None direction_val = None + saw_date_input = False for i, std in idx_to_standard.items(): if i >= len(raw_row): @@ -98,7 +169,8 @@ def _parse_row(raw_row, idx_to_standard, date_format, amount_mode): continue 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": record["ref_no"] = str(cell).strip() 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["drcr"] = "C" else: - return None + return None, "amount" elif amount_mode == "Single column with sign": if amount_val is None: - return None + return None, "amount" record["amount"] = abs(flt(amount_val)) record["drcr"] = "D" if amount_val < 0 else "C" elif amount_mode == "Single column + direction column": if amount_val is None or direction_val is None: - return None + return None, "amount" 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" - 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" else: record["drcr"] = "D" - if not record["date"] or record["amount"] == 0: - return None + if not record["date"]: + # 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): return value.date().isoformat() if isinstance(value, date): @@ -167,9 +245,62 @@ def _parse_date(value, date_format): s = str(value).strip() if not s: 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: return datetime.strptime(s, fmt).date().isoformat() except ValueError: continue 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() diff --git a/jey_erp/bank_integration/import_api.py b/jey_erp/bank_integration/import_api.py index a37d5fc..91cf8fa 100644 --- a/jey_erp/bank_integration/import_api.py +++ b/jey_erp/bank_integration/import_api.py @@ -12,10 +12,20 @@ from jey_erp.bank_integration.excel_parser import parse_excel @frappe.whitelist() def parse_excel_for_preview(file_url, preset_name, bank_integration, bank_account): """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: - 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")] existing = set() @@ -44,6 +54,8 @@ def parse_excel_for_preview(file_url, preset_name, bank_integration, bank_accoun "transactions": fresh, "skipped_duplicates": skipped, "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): 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 for txn in transactions: @@ -342,4 +357,6 @@ def load_registries_from_excel(file_url, preset_name, bank_integration, "new_customers": new_c, "new_suppliers": new_s, "new_purposes": new_p, + "dropped_date": dropped_date, + "dropped_amount": dropped_amount, } diff --git a/jey_erp/bank_integration/migrations.py b/jey_erp/bank_integration/migrations.py index 095d44a..cb96acd 100644 --- a/jey_erp/bank_integration/migrations.py +++ b/jey_erp/bank_integration/migrations.py @@ -83,3 +83,45 @@ def rename_bip_purposes(): _rename_to_readable("Bank Integration Purpose", "BIP", "purpose_keyword") _rename_to_readable("Bank Integration Customer", "BIC", "customer_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") diff --git a/jey_erp/fixtures/bank_integration_excel_preset.json b/jey_erp/fixtures/bank_integration_excel_preset.json index 604050a..e01437e 100644 --- a/jey_erp/fixtures/bank_integration_excel_preset.json +++ b/jey_erp/fixtures/bank_integration_excel_preset.json @@ -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.", "header_row": 1, "skip_rows_after_header": 0, - "date_format": "%Y-%m-%d", + "use_custom_date_format": 0, + "date_format": "", "amount_mode": "Separate debit/credit columns", "column_mappings": [ { "doctype": "Bank Integration Excel Column Mapping", "excel_column": "Date", - "standard_field": "date" + "standard_field": "Date" }, { "doctype": "Bank Integration Excel Column Mapping", "excel_column": "Reference", - "standard_field": "reference_number" + "standard_field": "Reference Number" }, { "doctype": "Bank Integration Excel Column Mapping", "excel_column": "Counterparty", - "standard_field": "counterparty" + "standard_field": "Counterparty" }, { "doctype": "Bank Integration Excel Column Mapping", "excel_column": "Tax ID", - "standard_field": "counterparty_tax_id" + "standard_field": "Counterparty Tax ID (VOEN)" }, { "doctype": "Bank Integration Excel Column Mapping", "excel_column": "IBAN", - "standard_field": "counterparty_iban" + "standard_field": "Counterparty IBAN" }, { "doctype": "Bank Integration Excel Column Mapping", "excel_column": "Debit", - "standard_field": "debit" + "standard_field": "Debit" }, { "doctype": "Bank Integration Excel Column Mapping", "excel_column": "Credit", - "standard_field": "credit" + "standard_field": "Credit" }, { "doctype": "Bank Integration Excel Column Mapping", "excel_column": "Currency", - "standard_field": "currency" + "standard_field": "Currency" }, { "doctype": "Bank Integration Excel Column Mapping", "excel_column": "Purpose", - "standard_field": "purpose" + "standard_field": "Purpose" }, { "doctype": "Bank Integration Excel Column Mapping", "excel_column": "Description", - "standard_field": "description" + "standard_field": "Description" } ] } diff --git a/jey_erp/hooks.py b/jey_erp/hooks.py index cd58aff..1aea2f6 100644 --- a/jey_erp/hooks.py +++ b/jey_erp/hooks.py @@ -143,9 +143,14 @@ def after_migrate_combined(): # Link kb-mapped Bank Accounts to Kapital Bank Settings via the # Dynamic Link fields used by the universal Create & Reconcile resolver. 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() rename_bip_purposes() + migrate_standard_field_codes_to_labels() except Exception as e: print(f"BI: post-migrate sync failed: {e}") # REMOVED: Asset categories creation moved to setup_wizard_complete hook diff --git a/jey_erp/jey_erp/doctype/bank_integration/bank_integration.js b/jey_erp/jey_erp/doctype/bank_integration/bank_integration.js index 2601382..cd08bb5 100644 --- a/jey_erp/jey_erp/doctype/bank_integration/bank_integration.js +++ b/jey_erp/jey_erp/doctype/bank_integration/bank_integration.js @@ -12,6 +12,7 @@ frappe.ui.form.on('Bank Integration', { frm.set_query('customer_group', 'customer_mappings', leaf); frm.set_query('territory', 'customer_mappings', leaf); frm.set_query('supplier_group', 'supplier_mappings', leaf); + frm.set_query('cost_center', 'transaction_mappings', leaf); }, refresh(frm) { @@ -224,11 +225,25 @@ function _show_load_data_dialog(frm) { } d.hide(); 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 += '

' + + __("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]) + + ''; + } + if (droppedAmount > 0) { + msg += '
' + + __('Also skipped {0} row(s) with missing/zero amount.', [droppedAmount]) + + ''; + } frappe.msgprint({ title: __('Data Loaded'), - indicator: 'green', - message: __('From {0} rows — new customers: {1}, suppliers: {2}, purposes: {3}', - [res.total_rows, res.new_customers, res.new_suppliers, res.new_purposes]), + indicator: droppedDate > 0 ? 'orange' : 'green', + message: msg, }); frm.reload_doc(); }, diff --git a/jey_erp/jey_erp/doctype/bank_integration_excel_column_mapping/bank_integration_excel_column_mapping.json b/jey_erp/jey_erp/doctype/bank_integration_excel_column_mapping/bank_integration_excel_column_mapping.json index 398145f..29812e5 100644 --- a/jey_erp/jey_erp/doctype/bank_integration_excel_column_mapping/bank_integration_excel_column_mapping.json +++ b/jey_erp/jey_erp/doctype/bank_integration_excel_column_mapping/bank_integration_excel_column_mapping.json @@ -12,17 +12,18 @@ "fields": [ { "fieldname": "excel_column", - "fieldtype": "Data", + "fieldtype": "Autocomplete", "in_list_view": 1, "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", "fieldtype": "Select", "in_list_view": 1, "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 }, { diff --git a/jey_erp/jey_erp/doctype/bank_integration_excel_preset/bank_integration_excel_preset.js b/jey_erp/jey_erp/doctype/bank_integration_excel_preset/bank_integration_excel_preset.js new file mode 100644 index 0000000..9d6bf3f --- /dev/null +++ b/jey_erp/jey_erp/doctype/bank_integration_excel_preset/bank_integration_excel_preset.js @@ -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(); + }, +}; diff --git a/jey_erp/jey_erp/doctype/bank_integration_excel_preset/bank_integration_excel_preset.json b/jey_erp/jey_erp/doctype/bank_integration_excel_preset/bank_integration_excel_preset.json index 6a4585d..2b646ca 100644 --- a/jey_erp/jey_erp/doctype/bank_integration_excel_preset/bank_integration_excel_preset.json +++ b/jey_erp/jey_erp/doctype/bank_integration_excel_preset/bank_integration_excel_preset.json @@ -10,9 +10,13 @@ "format_section", "header_row", "skip_rows_after_header", - "date_format", - "column_break_1", "amount_mode", + "column_break_1", + "use_custom_date_format", + "date_format", + "sample_section", + "sample_file", + "sample_section_help", "columns_section", "column_mappings" ], @@ -50,18 +54,6 @@ "fieldtype": "Int", "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", "fieldname": "amount_mode", @@ -70,6 +62,41 @@ "label": "Amount Mode", "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": "
After uploading, headers from the file become the options for Excel Column in the table below. Use the Clear Sample button to remove the file and go back to free text.
" + }, { "fieldname": "columns_section", "fieldtype": "Section Break", @@ -84,7 +111,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2026-05-08 00:00:00.000000", + "modified": "2026-05-13 00:00:00.000000", "modified_by": "Administrator", "module": "Jey Erp", "name": "Bank Integration Excel Preset", diff --git a/jey_erp/public/js/bank_transaction_list.js b/jey_erp/public/js/bank_transaction_list.js index 3481be1..5e161f4 100644 --- a/jey_erp/public/js/bank_transaction_list.js +++ b/jey_erp/public/js/bank_transaction_list.js @@ -115,6 +115,13 @@ const BIExcelImport = { } 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; + + if (droppedDate > 0 && !txns.length) { + BIExcelImport._showDateParseHelp(droppedDate, values.preset); + return; + } if (!txns.length) { frappe.msgprint({ title: __('Nothing to Import'), @@ -125,18 +132,29 @@ const BIExcelImport = { }); 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 = '
'; if (skipped > 0) { table += '
' + __('Skipped {0} duplicates already imported. {1} new transactions ready.', [skipped, txns.length]) + '
'; } + if (droppedDate > 0) { + table += '
' + + __('Dropped {0} row(s) — the date could not be parsed.', [droppedDate]) + ' ' + + __("Open the Bank Integration Excel Preset, enable 'Use Custom Date Format', and set the exact format your bank uses.") + + '
'; + } + if (droppedAmount > 0) { + table += '
' + + __('Dropped {0} row(s) — missing or zero amount.', [droppedAmount]) + + '
'; + } table += ''; table += ''; table += ''; @@ -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: + '
' + + __('All {0} row(s) were dropped because the date column could not be parsed.', [droppedCount]) + + '
' + + '

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

' + + '

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

', + }], + primary_action_label: __('Open Preset'), + primary_action() { d.hide(); openPreset(); }, + secondary_action_label: __('Close'), + secondary_action() { d.hide(); }, + }); + d.show(); + }, + _closeProgress() { try { if (frappe.cur_progress) {