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:
parent
751154efa8
commit
0672441be3
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue