refactor(bank-integration): cleanup + robust amount parsing

- Delete mapping_resolver.py: dead code (nothing imports it; create_reconcile
  builds the indexes it needs directly).
- Simplify _create_journal_entry_for_brt: the if is_pay / else branches were
  identical because the matching transaction mapping is scoped by payment_type,
  so paid_from / paid_to already encode direction (from = credit side, to =
  debit side for both Pay and Receive). Removed the dead conditional, kept a
  comment explaining why no swap is needed.
- Add _parse_amount helper in excel_parser: handles currency text in the cell
  ('AZN 250', '250 AZN'), comma decimals ('250,00'), mixed thousands+decimal
  ('1,200.50' / '1.200,50'), accounting-style brackets ('(45.50)' → -45.50),
  and trailing minus ('250-'). Used for amount/debit/credit cells.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ali 2026-05-13 16:40:50 +00:00
parent e8e9359cb5
commit 5dd1296221
3 changed files with 82 additions and 223 deletions

View File

@ -645,32 +645,22 @@ def _create_journal_entry_for_brt(txn, paid_from, paid_to, bank_txn, multi_curre
if is_multi: if is_multi:
je.multi_currency = 1 je.multi_currency = 1
if is_pay: # paid_from / paid_to come from the matching transaction mapping (which is
from_row = { # scoped by payment_type), so direction is already baked in: paid_from is
"account": paid_from, # always the credit side, paid_to is always the debit side. No swap needed
"exchange_rate": from_rate, # between Pay and Receive at this layer.
"credit_in_account_currency": from_amount, from_row = {
"debit_in_account_currency": 0, "account": paid_from,
} "exchange_rate": from_rate,
to_row = { "credit_in_account_currency": from_amount,
"account": paid_to, "debit_in_account_currency": 0,
"exchange_rate": to_rate, }
"debit_in_account_currency": to_amount, to_row = {
"credit_in_account_currency": 0, "account": paid_to,
} "exchange_rate": to_rate,
else: "debit_in_account_currency": to_amount,
from_row = { "credit_in_account_currency": 0,
"account": paid_from, }
"exchange_rate": from_rate,
"credit_in_account_currency": from_amount,
"debit_in_account_currency": 0,
}
to_row = {
"account": paid_to,
"exchange_rate": to_rate,
"debit_in_account_currency": to_amount,
"credit_in_account_currency": 0,
}
if party_type and party: if party_type and party:
if paid_from_type in ("Receivable", "Payable"): if paid_from_type in ("Receivable", "Payable"):

View File

@ -1,5 +1,6 @@
"""Parse Excel bank statements using Bank Integration Excel Preset configuration.""" """Parse Excel bank statements using Bank Integration Excel Preset configuration."""
import re
from datetime import datetime, date from datetime import datetime, date
import frappe import frappe
@ -160,6 +161,68 @@ def _split_aliases(raw):
return {part.strip().upper() for part in str(raw).split(",") if part.strip()} return {part.strip().upper() for part in str(raw).split(",") if part.strip()}
_AMOUNT_STRIP_RE = re.compile(r"[^\d,.\-]")
def _parse_amount(cell):
"""Convert a money-like cell value into a float.
Handles:
- numeric cells (int/float) directly
- currency text prefix/suffix: 'AZN 250.00', '250.00 AZN', 'USD 1,200.50'
- comma decimal separator: '250,00' 250.00
- thousands + decimal: '1,200.50' / '1.200,50'
- accounting-style negative in brackets: '(45.50)' -45.50
Returns float, or None if the cell is empty / unparseable.
"""
if cell is None:
return None
if isinstance(cell, bool):
return None
if isinstance(cell, (int, float)):
return float(cell)
s = str(cell).strip()
if not s:
return None
negate = False
if s.startswith("(") and s.endswith(")"):
negate = True
s = s[1:-1].strip()
if s.endswith("-"):
negate = not negate
s = s[:-1].strip()
# Drop currency text and stray symbols, keep digits, separators, minus.
s = _AMOUNT_STRIP_RE.sub("", s)
if s.count("-") > 1:
s = s.replace("-", "")
negate = not negate
elif s.startswith("-"):
negate = not negate
s = s[1:]
# Decimal separator detection. If both ',' and '.' appear, the rightmost is
# the decimal one and the other is a thousands grouping. If only ',' appears,
# assume it's the decimal separator.
if "," in s and "." in s:
if s.rfind(",") > s.rfind("."):
s = s.replace(".", "").replace(",", ".")
else:
s = s.replace(",", "")
elif "," in s:
s = s.replace(",", ".")
if not s or s in (".", "-"):
return None
try:
v = float(s)
except ValueError:
return None
return -v if negate else v
def _normalize_header(value): def _normalize_header(value):
if value is None: if value is None:
return "" return ""
@ -212,11 +275,11 @@ def _parse_row(raw_row, idx_to_standard, custom_format, amount_mode,
elif std == "counterparty_iban": elif std == "counterparty_iban":
record["contr_iban"] = str(cell).strip() record["contr_iban"] = str(cell).strip()
elif std == "amount": elif std == "amount":
amount_val = flt(cell) amount_val = _parse_amount(cell)
elif std == "debit": elif std == "debit":
debit_val = flt(cell) debit_val = _parse_amount(cell)
elif std == "credit": elif std == "credit":
credit_val = flt(cell) credit_val = _parse_amount(cell)
elif std == "direction": elif std == "direction":
direction_val = str(cell).strip().upper() direction_val = str(cell).strip().upper()
elif std == "currency": elif std == "currency":

View File

@ -1,194 +0,0 @@
"""Resolve a unified mappings dict for a given Bank Account.
Reads either a Bank Integration record or Kapital Bank Settings (Single)
based on the BA's hidden bank_integration_type / bank_integration fields,
and returns a uniform shape consumable by create_reconcile.
"""
import frappe
from frappe.utils import flt
def resolve_mappings_for_bank_account(bank_account_name):
"""Returns the unified mappings dict, or None if BA is not configured."""
ba = frappe.get_doc("Bank Account", bank_account_name)
bi_type = ba.get("bank_integration_type")
bi_name = ba.get("bank_integration")
if bi_type == "Bank Integration" and bi_name:
if not frappe.db.exists("Bank Integration", bi_name):
return None
bi = frappe.get_doc("Bank Integration", bi_name)
return _resolve_bank_integration(bi)
if bi_type == "Kapital Bank Settings":
if not frappe.db.exists("DocType", "Kapital Bank Settings"):
return None
try:
kb = frappe.get_single("Kapital Bank Settings")
except Exception:
return None
return _resolve_kb_settings(kb)
return None
def _resolve_bank_integration(bi):
purpose_rules = []
for row in bi.transaction_mappings:
if not row.paid_from or not row.paid_to:
continue
keyword_text = ""
if row.purpose_keyword:
keyword_text = frappe.db.get_value(
"Bank Integration Purpose", row.purpose_keyword, "purpose_keyword"
) or ""
cp_name = ""
cp_voen = ""
if row.counterparty and row.counterparty_type:
source_doctype = (
"Bank Integration Customer" if row.counterparty_type == "Customer"
else "Bank Integration Supplier"
)
name_field = "customer_name" if row.counterparty_type == "Customer" else "supplier_name"
cp_data = frappe.db.get_value(source_doctype, row.counterparty, [name_field, "tax_id"], as_dict=True)
if cp_data:
cp_name = (cp_data.get(name_field) or "").strip()
cp_voen = (cp_data.get("tax_id") or "").strip()
purpose_rules.append({
"purpose_keyword": keyword_text.lower().strip(),
"payment_type": row.payment_type or "",
"counterparty_name": cp_name,
"counterparty_voen": cp_voen,
"counterparty_type": row.counterparty_type or "",
"paid_from": row.paid_from,
"paid_to": row.paid_to,
"cost_center": row.cost_center or "",
"multi_currency": bool(row.multi_currency),
"currency": (row.currency or "").upper(),
"document_type": row.document_type or "Payment Entry",
})
voen_to_party, name_to_party = _build_party_indexes_bi(bi)
return {
"source": "Bank Integration",
"name": bi.name,
"company": bi.default_company,
"purpose_rules": purpose_rules,
"voen_to_party": voen_to_party,
"name_to_party": name_to_party,
"similarity_threshold_purpose": flt(bi.similarity_threshold_purpose or 70) / 100.0,
"consider_azeri_chars": bool(bi.consider_azeri_chars),
}
def _resolve_kb_settings(kb):
purpose_rules = []
for row in kb.transaction_mappings:
if not row.paid_from or not row.paid_to:
continue
keyword_text = ""
if row.purpose_keyword:
keyword_text = frappe.db.get_value(
"Kapital Bank Purpose", row.purpose_keyword, "purpose_keyword"
) or ""
cp_name = ""
cp_voen = ""
if row.counterparty and row.counterparty_type:
source_doctype = row.counterparty_type
name_field = "customer_name" if "Customer" in source_doctype else "supplier_name"
cp_data = frappe.db.get_value(source_doctype, row.counterparty, [name_field, "tax_id"], as_dict=True)
if cp_data:
cp_name = (cp_data.get(name_field) or "").strip()
cp_voen = (cp_data.get("tax_id") or "").strip()
cp_type_normalized = ""
if row.counterparty_type:
cp_type_normalized = "Customer" if "Customer" in row.counterparty_type else "Supplier"
purpose_rules.append({
"purpose_keyword": keyword_text.lower().strip(),
"payment_type": row.payment_type or "",
"counterparty_name": cp_name,
"counterparty_voen": cp_voen,
"counterparty_type": cp_type_normalized,
"paid_from": row.paid_from,
"paid_to": row.paid_to,
"cost_center": row.cost_center or "",
"multi_currency": bool(getattr(row, "multi_currency", 0)),
"currency": (getattr(row, "currency", None) or "").upper(),
"document_type": getattr(row, "document_type", None) or "Payment Entry",
})
voen_to_party, name_to_party = _build_party_indexes_kb(kb)
return {
"source": "Kapital Bank Settings",
"name": "Kapital Bank Settings",
"company": kb.default_company,
"purpose_rules": purpose_rules,
"voen_to_party": voen_to_party,
"name_to_party": name_to_party,
"similarity_threshold_purpose": flt(kb.similarity_threshold_purpose or 70) / 100.0,
"consider_azeri_chars": bool(kb.consider_azeri_chars),
}
def _build_party_indexes_bi(bi):
voen_to_party = {}
name_to_party = {}
for row in bi.customer_mappings:
if not row.erp_customer:
continue
entry = {"party_type": "Customer", "erp_party": row.erp_customer}
if row.tax_id:
voen_to_party.setdefault(row.tax_id.strip(), {})["Customer"] = entry
if row.bi_customer_name:
cust_name = frappe.db.get_value("Bank Integration Customer", row.bi_customer_name, "customer_name")
if cust_name:
name_to_party.setdefault(cust_name.strip(), {})["Customer"] = entry
for row in bi.supplier_mappings:
if not row.erp_supplier:
continue
entry = {"party_type": "Supplier", "erp_party": row.erp_supplier}
if row.tax_id:
voen_to_party.setdefault(row.tax_id.strip(), {})["Supplier"] = entry
if row.bi_supplier_name:
supp_name = frappe.db.get_value("Bank Integration Supplier", row.bi_supplier_name, "supplier_name")
if supp_name:
name_to_party.setdefault(supp_name.strip(), {})["Supplier"] = entry
return voen_to_party, name_to_party
def _build_party_indexes_kb(kb):
voen_to_party = {}
name_to_party = {}
for row in kb.customer_mappings:
if not row.erp_customer:
continue
entry = {"party_type": "Customer", "erp_party": row.erp_customer}
if row.tax_id:
voen_to_party.setdefault(row.tax_id.strip(), {})["Customer"] = entry
if row.kb_customer_name:
cust_name = frappe.db.get_value("Kapital Bank Customer", row.kb_customer_name, "customer_name")
if cust_name:
name_to_party.setdefault(cust_name.strip(), {})["Customer"] = entry
for row in kb.supplier_mappings:
if not row.erp_supplier:
continue
entry = {"party_type": "Supplier", "erp_party": row.erp_supplier}
if row.tax_id:
voen_to_party.setdefault(row.tax_id.strip(), {})["Supplier"] = entry
if row.kb_supplier_name:
supp_name = frappe.db.get_value("Kapital Bank Supplier", row.kb_supplier_name, "supplier_name")
if supp_name:
name_to_party.setdefault(supp_name.strip(), {})["Supplier"] = entry
return voen_to_party, name_to_party