diff --git a/jey_erp/bank_integration/create_reconcile.py b/jey_erp/bank_integration/create_reconcile.py index b09ad10..8102550 100644 --- a/jey_erp/bank_integration/create_reconcile.py +++ b/jey_erp/bank_integration/create_reconcile.py @@ -645,32 +645,22 @@ def _create_journal_entry_for_brt(txn, paid_from, paid_to, bank_txn, multi_curre if is_multi: je.multi_currency = 1 - if is_pay: - from_row = { - "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, - } - else: - from_row = { - "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, - } + # paid_from / paid_to come from the matching transaction mapping (which is + # scoped by payment_type), so direction is already baked in: paid_from is + # always the credit side, paid_to is always the debit side. No swap needed + # between Pay and Receive at this layer. + from_row = { + "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 paid_from_type in ("Receivable", "Payable"): diff --git a/jey_erp/bank_integration/excel_parser.py b/jey_erp/bank_integration/excel_parser.py index 65b0d99..e10fca2 100644 --- a/jey_erp/bank_integration/excel_parser.py +++ b/jey_erp/bank_integration/excel_parser.py @@ -1,5 +1,6 @@ """Parse Excel bank statements using Bank Integration Excel Preset configuration.""" +import re from datetime import datetime, date import frappe @@ -160,6 +161,68 @@ def _split_aliases(raw): 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): if value is None: return "" @@ -212,11 +275,11 @@ def _parse_row(raw_row, idx_to_standard, custom_format, amount_mode, elif std == "counterparty_iban": record["contr_iban"] = str(cell).strip() elif std == "amount": - amount_val = flt(cell) + amount_val = _parse_amount(cell) elif std == "debit": - debit_val = flt(cell) + debit_val = _parse_amount(cell) elif std == "credit": - credit_val = flt(cell) + credit_val = _parse_amount(cell) elif std == "direction": direction_val = str(cell).strip().upper() elif std == "currency": diff --git a/jey_erp/bank_integration/mapping_resolver.py b/jey_erp/bank_integration/mapping_resolver.py deleted file mode 100644 index b6c5c63..0000000 --- a/jey_erp/bank_integration/mapping_resolver.py +++ /dev/null @@ -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