2444 lines
96 KiB
Python
2444 lines
96 KiB
Python
import json
|
|
import re
|
|
import erpnext
|
|
import frappe
|
|
from datetime import datetime
|
|
from frappe.utils import cint, flt
|
|
from difflib import SequenceMatcher
|
|
from kapital_bank.api import BIRBankClient, extract_error_message
|
|
|
|
|
|
def _mask(text):
|
|
"""Mask digit sequences longer than 6 chars: keep first 3 + *** + last 3."""
|
|
return re.sub(r'\d{7,}', lambda m: m.group()[:3] + '***' + m.group()[-3:], str(text))
|
|
|
|
|
|
|
|
def _api_error_message(resp):
|
|
"""Extract the bank's own error message (incl. field-level errors)."""
|
|
return extract_error_message(resp) or f"HTTP {resp.status_code}: {resp.text[:200]}"
|
|
|
|
|
|
def _resolve_leaf_node(doctype, configured, label):
|
|
"""Validate `configured` as a leaf node of an NSM tree doctype
|
|
(Customer Group / Supplier Group / Territory).
|
|
|
|
- If configured: it must exist and NOT be a group node.
|
|
- If not configured: returns (None, None) — the party is then created
|
|
without that field (ERPNext allows an empty group / territory).
|
|
|
|
Returns (value, error_message). On success error_message is None.
|
|
"""
|
|
if not configured:
|
|
return None, None
|
|
if not frappe.db.exists(doctype, configured):
|
|
return None, f"{label} '{configured}' does not exist."
|
|
if frappe.db.get_value(doctype, configured, "is_group"):
|
|
return None, f"{label} '{configured}' is a group node — pick a leaf (non-group) {label}."
|
|
return configured, None
|
|
|
|
|
|
def _find_existing_party(doctype, name_field, name, tax_id):
|
|
"""VOEN-first lookup: a matching tax_id wins over a name match.
|
|
|
|
Returns the existing party's name, or None.
|
|
"""
|
|
tid = (tax_id or "").strip()
|
|
if tid:
|
|
existing = frappe.db.get_value(doctype, {"tax_id": tid}, "name")
|
|
if existing:
|
|
return existing
|
|
clean_name = (name or "").strip()
|
|
if clean_name:
|
|
existing = frappe.db.get_value(doctype, {name_field: clean_name}, "name")
|
|
if existing:
|
|
return existing
|
|
return None
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# REGISTRY LOADERS (fetch from Kapital Bank → save to local DocTypes)
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
@frappe.whitelist()
|
|
def load_accounts_to_registry(login_name=None):
|
|
"""Fetch accounts from GET /b2b/accounts/portal/v1 and upsert into Kapital Bank Account registry."""
|
|
try:
|
|
client = BIRBankClient(login_name)
|
|
data = client.get_json("/b2b/accounts/portal/v1")
|
|
accounts = data.get("accounts", [])
|
|
|
|
created = updated = 0
|
|
for a in accounts:
|
|
iban = (a.get("iban") or "").strip()
|
|
if not iban:
|
|
continue
|
|
|
|
values = {
|
|
"cust_ac_no": a.get("accountNumber", ""),
|
|
"currency": a.get("currency", ""),
|
|
"account_desc": a.get("description", ""),
|
|
"account_status": a.get("status", ""),
|
|
"branch_code": a.get("branchCode", ""),
|
|
"current_balance": flt(a.get("balance", 0)),
|
|
"planned_balance": flt(a.get("plannedBalance", 0)),
|
|
"hold": 0,
|
|
}
|
|
|
|
if frappe.db.exists("Kapital Bank Account", iban):
|
|
frappe.db.set_value("Kapital Bank Account", iban, values, update_modified=False)
|
|
updated += 1
|
|
else:
|
|
doc = frappe.new_doc("Kapital Bank Account")
|
|
doc.iban = iban
|
|
doc.update(values)
|
|
doc.insert(ignore_permissions=True)
|
|
created += 1
|
|
|
|
frappe.db.commit()
|
|
return {"success": True, "created": created, "updated": updated, "total": len(accounts)}
|
|
|
|
except Exception as e:
|
|
frappe.log_error(f"load_accounts_to_registry failed: {e}\n{frappe.get_traceback()}", "Kapital Bank API")
|
|
return {"success": False, "message": str(e)}
|
|
|
|
|
|
@frappe.whitelist()
|
|
def load_cards_to_registry(login_name=None):
|
|
"""Fetch cards from GET /b2b/cards/portal/v1 and upsert into Kapital Bank Card registry."""
|
|
try:
|
|
client = BIRBankClient(login_name)
|
|
data = client.get_json("/b2b/cards/portal/v1")
|
|
cards = data.get("cards", [])
|
|
|
|
created = updated = 0
|
|
for c in cards:
|
|
account_number = str(c.get("accountNumber", "")).strip()
|
|
if not account_number:
|
|
continue
|
|
|
|
values = {
|
|
"card_type": c.get("cardProductName", ""),
|
|
"pan": c.get("maskedPan", ""),
|
|
"currency": c.get("currencyCode", ""),
|
|
"balance": flt(c.get("availBalance", 0)),
|
|
"expiry_date": str(c.get("expiryDate", "")),
|
|
}
|
|
|
|
if frappe.db.exists("Kapital Bank Card", account_number):
|
|
frappe.db.set_value("Kapital Bank Card", account_number, values, update_modified=False)
|
|
updated += 1
|
|
else:
|
|
doc = frappe.new_doc("Kapital Bank Card")
|
|
doc.account_number = account_number
|
|
doc.update(values)
|
|
doc.insert(ignore_permissions=True)
|
|
created += 1
|
|
|
|
frappe.db.commit()
|
|
return {"success": True, "created": created, "updated": updated, "total": len(cards)}
|
|
|
|
except Exception as e:
|
|
frappe.log_error(f"load_cards_to_registry failed: {e}\n{frappe.get_traceback()}", "Kapital Bank API")
|
|
return {"success": False, "message": str(e)}
|
|
|
|
|
|
@frappe.whitelist()
|
|
def load_counterparties_from_statements(from_date, to_date, login_name=None):
|
|
"""
|
|
Fetch account statements for all registered accounts and extract counterparties
|
|
into Kapital Bank Customer / Kapital Bank Supplier registries based on transaction direction.
|
|
D (debit, money out) → Supplier, C (credit, money in) → Customer.
|
|
"""
|
|
try:
|
|
client = BIRBankClient(login_name)
|
|
|
|
accounts = frappe.get_all("Kapital Bank Account", fields=["iban", "cust_ac_no"])
|
|
if not accounts:
|
|
return {"success": False, "message": "No accounts in registry. Load accounts first."}
|
|
|
|
customers_created = suppliers_created = skipped = 0
|
|
debug_log = []
|
|
|
|
for acc in accounts:
|
|
iban = acc.iban
|
|
account_number = acc.cust_ac_no or iban
|
|
debug_log.append(f"Account: {_mask(iban)} custAcNo={_mask(account_number)} ({from_date} → {to_date})")
|
|
|
|
try:
|
|
resp = client.get(
|
|
"/b2b/account/portal/v3/statements",
|
|
params={"accountNumber": account_number, "fromDate": _iso_date(from_date), "toDate": _iso_date(to_date)}
|
|
)
|
|
debug_log.append(f" HTTP {resp.status_code}")
|
|
if not resp.ok:
|
|
debug_log.append(f" Error: {_api_error_message(resp)}")
|
|
continue
|
|
data = resp.json()
|
|
except Exception as e:
|
|
debug_log.append(f" Error: {e}")
|
|
frappe.log_error(f"Statement fetch failed for {_mask(iban)}: {e}", "Kapital Bank Load Counterparties")
|
|
continue
|
|
|
|
statement_list = data.get("statementList", [])
|
|
debug_log.append(f" statementList entries: {len(statement_list)}")
|
|
|
|
for entry in statement_list:
|
|
contr_raw = (entry.get("contrAccount") or "").strip()
|
|
if not contr_raw:
|
|
continue
|
|
|
|
parts = [p.strip() for p in contr_raw.split(" / ")]
|
|
if len(parts) < 2:
|
|
continue
|
|
|
|
contr_iban = parts[0]
|
|
contr_name = parts[1]
|
|
contr_voen = parts[2] if len(parts) >= 3 else ""
|
|
|
|
if not contr_name:
|
|
continue
|
|
|
|
# Determine direction: D = money out = Supplier, C = money in = Customer
|
|
dr_cr = (entry.get("drcrInd") or "").upper()
|
|
|
|
if dr_cr == "D":
|
|
# Supplier
|
|
doctype = "Kapital Bank Supplier"
|
|
name_field = "supplier_name"
|
|
elif dr_cr == "C":
|
|
# Customer
|
|
doctype = "Kapital Bank Customer"
|
|
name_field = "customer_name"
|
|
else:
|
|
skipped += 1
|
|
continue
|
|
|
|
# Deduplicate: by VÖEN if present, else by name
|
|
if contr_voen and frappe.db.exists(doctype, {"tax_id": contr_voen}):
|
|
skipped += 1
|
|
continue
|
|
if not contr_voen and frappe.db.exists(doctype, {name_field: contr_name}):
|
|
skipped += 1
|
|
continue
|
|
|
|
doc = frappe.new_doc(doctype)
|
|
doc.update({
|
|
name_field: contr_name,
|
|
"tax_id": contr_voen or None,
|
|
"iban": contr_iban or None,
|
|
"status": "New",
|
|
})
|
|
try:
|
|
doc.insert(ignore_permissions=True)
|
|
if dr_cr == "D":
|
|
suppliers_created += 1
|
|
else:
|
|
customers_created += 1
|
|
except frappe.DuplicateEntryError:
|
|
skipped += 1
|
|
except Exception as ins_e:
|
|
frappe.log_error(f"Insert counterparty failed for {_mask(contr_raw)}: {ins_e}", "Kapital Bank Load Counterparties")
|
|
|
|
frappe.db.commit()
|
|
frappe.log_error("\n".join(debug_log), "Kapital Bank Load Counterparties Debug")
|
|
|
|
return {
|
|
"success": True,
|
|
"customers_created": customers_created,
|
|
"suppliers_created": suppliers_created,
|
|
"skipped": skipped,
|
|
"debug": debug_log,
|
|
}
|
|
|
|
except Exception as e:
|
|
frappe.log_error(f"load_counterparties_from_statements: {e}\n{frappe.get_traceback()}", "Kapital Bank API")
|
|
return {"success": False, "message": str(e)}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# REFERENCE DATA SUMMARY & LISTS (for Settings data tabs)
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
@frappe.whitelist()
|
|
def get_kb_reference_data_summary():
|
|
"""Return counts for the Load Data dialog summary."""
|
|
try:
|
|
def counts(doctype, mapped_filter):
|
|
total = frappe.db.count(doctype)
|
|
mapped = frappe.db.count(doctype, filters=mapped_filter)
|
|
return {"total": total, "mapped": mapped}
|
|
|
|
summary = {
|
|
"accounts": counts("Kapital Bank Account", {"status": "Mapped"}),
|
|
"cards": counts("Kapital Bank Card", {"status": "Mapped"}),
|
|
"customers": counts("Kapital Bank Customer", {"status": "Mapped"}),
|
|
"suppliers": counts("Kapital Bank Supplier", {"status": "Mapped"}),
|
|
"purposes": counts("Kapital Bank Purpose", {"status": "Mapped"}),
|
|
}
|
|
return {"success": True, "summary": summary}
|
|
except Exception as e:
|
|
frappe.log_error(str(e), "Kapital Bank Reference Data Summary")
|
|
return {"success": False, "message": str(e)}
|
|
|
|
|
|
@frappe.whitelist()
|
|
def get_kb_reference_data_list(data_type, limit=100, offset=0):
|
|
"""Return paginated list of registry records for HTML tab rendering."""
|
|
limit = cint(limit)
|
|
offset = cint(offset)
|
|
|
|
try:
|
|
if data_type == "accounts":
|
|
total = frappe.db.count("Kapital Bank Account")
|
|
data = frappe.get_all(
|
|
"Kapital Bank Account",
|
|
fields=["name", "iban", "currency", "current_balance", "planned_balance",
|
|
"account_status", "status", "bank_account"],
|
|
limit=limit, start=offset,
|
|
order_by="iban asc"
|
|
)
|
|
|
|
elif data_type == "cards":
|
|
total = frappe.db.count("Kapital Bank Card")
|
|
data = frappe.get_all(
|
|
"Kapital Bank Card",
|
|
fields=["name", "account_number", "card_type", "pan", "currency",
|
|
"balance", "expiry_date", "status", "bank_account"],
|
|
limit=limit, start=offset,
|
|
order_by="card_type asc"
|
|
)
|
|
|
|
elif data_type == "customers":
|
|
total = frappe.db.count("Kapital Bank Customer")
|
|
data = frappe.get_all(
|
|
"Kapital Bank Customer",
|
|
fields=["name", "customer_name", "tax_id", "iban", "bank_code",
|
|
"status", "mapped_customer", "creation"],
|
|
limit=limit, start=offset,
|
|
order_by="customer_name asc"
|
|
)
|
|
|
|
elif data_type == "suppliers":
|
|
total = frappe.db.count("Kapital Bank Supplier")
|
|
data = frappe.get_all(
|
|
"Kapital Bank Supplier",
|
|
fields=["name", "supplier_name", "tax_id", "iban", "bank_code",
|
|
"status", "mapped_supplier", "creation"],
|
|
limit=limit, start=offset,
|
|
order_by="supplier_name asc"
|
|
)
|
|
|
|
elif data_type == "purposes":
|
|
total = frappe.db.count("Kapital Bank Purpose")
|
|
data = frappe.get_all(
|
|
"Kapital Bank Purpose",
|
|
fields=["name", "purpose_keyword", "direction", "status"],
|
|
limit=limit, start=offset,
|
|
order_by="purpose_keyword asc"
|
|
)
|
|
|
|
else:
|
|
return {"success": False, "message": f"Unknown data_type: {data_type}"}
|
|
|
|
return {
|
|
"success": True,
|
|
"data": data,
|
|
"total_count": total,
|
|
"has_more": (offset + limit) < total
|
|
}
|
|
|
|
except Exception as e:
|
|
frappe.log_error(f"get_kb_reference_data_list({data_type}): {e}\n{frappe.get_traceback()}", "Kapital Bank API")
|
|
return {"success": False, "message": str(e)}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# UNMAPPED GETTERS (for "Add unmapped" buttons)
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
@frappe.whitelist()
|
|
def get_unmapped_accounts():
|
|
"""Return accounts not yet present in account_mappings child table."""
|
|
try:
|
|
settings = frappe.get_single("Kapital Bank Settings")
|
|
already_mapped = {row.iban for row in settings.account_mappings if row.iban}
|
|
|
|
all_accounts = frappe.get_all(
|
|
"Kapital Bank Account",
|
|
fields=["name", "iban", "account_desc", "currency", "current_balance", "account_status"]
|
|
)
|
|
unmapped = [a for a in all_accounts if a.iban not in already_mapped]
|
|
return {"success": True, "accounts": unmapped}
|
|
|
|
except Exception as e:
|
|
frappe.log_error(str(e), "Kapital Bank get_unmapped_accounts")
|
|
return {"success": False, "message": str(e)}
|
|
|
|
|
|
@frappe.whitelist()
|
|
def get_unmapped_cards():
|
|
"""Return cards not yet present in card_mappings child table."""
|
|
try:
|
|
settings = frappe.get_single("Kapital Bank Settings")
|
|
already_mapped = {row.account_number for row in settings.card_mappings if row.account_number}
|
|
|
|
all_cards = frappe.get_all(
|
|
"Kapital Bank Card",
|
|
fields=["name", "account_number", "pan", "card_type", "currency", "balance"]
|
|
)
|
|
unmapped = [c for c in all_cards if c.account_number not in already_mapped]
|
|
return {"success": True, "cards": unmapped}
|
|
|
|
except Exception as e:
|
|
frappe.log_error(str(e), "Kapital Bank get_unmapped_cards")
|
|
return {"success": False, "message": str(e)}
|
|
|
|
|
|
@frappe.whitelist()
|
|
def get_unmapped_customers():
|
|
"""Return Kapital Bank Customers with status='New' not yet in customer_mappings."""
|
|
try:
|
|
settings = frappe.get_single("Kapital Bank Settings")
|
|
already_mapped = {row.kb_customer_name for row in settings.customer_mappings if row.kb_customer_name}
|
|
|
|
customers = frappe.get_all(
|
|
"Kapital Bank Customer",
|
|
filters={"status": "New"},
|
|
fields=["name", "customer_name", "tax_id", "iban"]
|
|
)
|
|
unmapped = [c for c in customers if c.name not in already_mapped]
|
|
return {"success": True, "customers": unmapped}
|
|
|
|
except Exception as e:
|
|
frappe.log_error(str(e), "Kapital Bank get_unmapped_customers")
|
|
return {"success": False, "message": str(e)}
|
|
|
|
|
|
@frappe.whitelist()
|
|
def get_unmapped_suppliers():
|
|
"""Return Kapital Bank Suppliers with status='New' not yet in supplier_mappings."""
|
|
try:
|
|
settings = frappe.get_single("Kapital Bank Settings")
|
|
already_mapped = {row.kb_supplier_name for row in settings.supplier_mappings if row.kb_supplier_name}
|
|
|
|
suppliers = frappe.get_all(
|
|
"Kapital Bank Supplier",
|
|
filters={"status": "New"},
|
|
fields=["name", "supplier_name", "tax_id", "iban"]
|
|
)
|
|
unmapped = [s for s in suppliers if s.name not in already_mapped]
|
|
return {"success": True, "suppliers": unmapped}
|
|
|
|
except Exception as e:
|
|
frappe.log_error(str(e), "Kapital Bank get_unmapped_suppliers")
|
|
return {"success": False, "message": str(e)}
|
|
|
|
|
|
@frappe.whitelist()
|
|
def get_unmapped_purposes():
|
|
"""Return Kapital Bank Purposes with status='New' not yet in transaction_mappings."""
|
|
try:
|
|
settings = frappe.get_single("Kapital Bank Settings")
|
|
already_mapped = {row.purpose_keyword for row in settings.transaction_mappings if row.purpose_keyword}
|
|
|
|
purposes = frappe.get_all(
|
|
"Kapital Bank Purpose",
|
|
filters={"status": "New"},
|
|
fields=["name", "purpose_keyword", "direction"]
|
|
)
|
|
unmapped = [p for p in purposes if p.name not in already_mapped]
|
|
return {"success": True, "purposes": unmapped}
|
|
|
|
except Exception as e:
|
|
frappe.log_error(str(e), "Kapital Bank get_unmapped_purposes")
|
|
return {"success": False, "message": str(e)}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# CUSTOMER MATCHING & CREATION
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
@frappe.whitelist()
|
|
def match_similar_customers():
|
|
"""Auto-match Kapital Bank Customers to ERPNext Customers by name similarity."""
|
|
try:
|
|
doc = frappe.get_doc("Kapital Bank Settings")
|
|
threshold = flt(doc.similarity_threshold_customers or 90) / 100.0
|
|
consider_azeri = bool(doc.consider_azeri_chars)
|
|
|
|
new_customers = frappe.get_all(
|
|
"Kapital Bank Customer",
|
|
filters={"status": "New"},
|
|
fields=["name", "customer_name", "tax_id"]
|
|
)
|
|
if not new_customers:
|
|
return {"success": True, "matched_count": 0, "total_processed": 0}
|
|
|
|
erp_customers = frappe.get_all("Customer", fields=["name", "customer_name", "tax_id"])
|
|
|
|
# VÖEN lookup for exact matching
|
|
voen_index = {}
|
|
for c in erp_customers:
|
|
if c.tax_id:
|
|
voen_index[c.tax_id.strip()] = c.name
|
|
|
|
# Build dict of existing mappings: kb_customer_name → erp_customer
|
|
existing_mappings = {
|
|
row.kb_customer_name: row.erp_customer
|
|
for row in doc.customer_mappings if row.kb_customer_name
|
|
}
|
|
|
|
# Group 1: customers already in the table but with empty erp_customer
|
|
customers_with_empty_mapping = []
|
|
for kb_name, erp_customer in existing_mappings.items():
|
|
if not erp_customer:
|
|
for c in new_customers:
|
|
if c.name == kb_name:
|
|
customers_with_empty_mapping.append(c)
|
|
break
|
|
|
|
# Group 2: customers not in the table at all
|
|
customers_not_in_table = [c for c in new_customers if c.name not in existing_mappings]
|
|
|
|
customers_to_process = customers_not_in_table + customers_with_empty_mapping
|
|
|
|
matched = 0
|
|
for customer in customers_to_process:
|
|
best_party = None
|
|
|
|
# Priority 1: exact VÖEN match
|
|
if customer.tax_id and customer.tax_id.strip() in voen_index:
|
|
best_party = voen_index[customer.tax_id.strip()]
|
|
else:
|
|
# Priority 2: name similarity
|
|
kb_name = _normalize(customer.customer_name, consider_azeri)
|
|
best_score = 0
|
|
|
|
for c in erp_customers:
|
|
score = SequenceMatcher(None, kb_name, _normalize(c.customer_name, consider_azeri)).ratio()
|
|
if score > best_score:
|
|
best_score = score
|
|
best_party = c.name
|
|
|
|
if best_score < threshold:
|
|
best_party = None
|
|
|
|
if best_party:
|
|
# Update existing row if present, else append new row
|
|
existing_idx = None
|
|
for idx, row in enumerate(doc.customer_mappings):
|
|
if row.kb_customer_name == customer.name:
|
|
existing_idx = idx
|
|
break
|
|
|
|
if existing_idx is not None:
|
|
doc.customer_mappings[existing_idx].erp_customer = best_party
|
|
doc.customer_mappings[existing_idx].mapping_type = "Automatic"
|
|
else:
|
|
doc.append("customer_mappings", {
|
|
"kb_customer_name": customer.name,
|
|
"tax_id": customer.tax_id or None,
|
|
"erp_customer": best_party,
|
|
"mapping_type": "Automatic",
|
|
})
|
|
|
|
frappe.db.set_value("Kapital Bank Customer", customer.name, {
|
|
"mapped_customer": best_party,
|
|
"status": "Mapped",
|
|
}, update_modified=False)
|
|
matched += 1
|
|
|
|
if matched > 0:
|
|
doc.save(ignore_permissions=True)
|
|
|
|
frappe.db.commit()
|
|
return {"success": True, "matched_count": matched, "total_processed": len(customers_to_process)}
|
|
|
|
except Exception as e:
|
|
frappe.log_error(f"match_similar_customers: {e}\n{frappe.get_traceback()}", "Kapital Bank Customer Matching")
|
|
return {"success": False, "message": str(e)}
|
|
|
|
|
|
@frappe.whitelist()
|
|
def match_similar_suppliers():
|
|
"""Auto-match Kapital Bank Suppliers to ERPNext Suppliers by name similarity."""
|
|
try:
|
|
doc = frappe.get_doc("Kapital Bank Settings")
|
|
threshold = flt(doc.similarity_threshold_suppliers or 80) / 100.0
|
|
consider_azeri = bool(doc.consider_azeri_chars)
|
|
|
|
new_suppliers = frappe.get_all(
|
|
"Kapital Bank Supplier",
|
|
filters={"status": "New"},
|
|
fields=["name", "supplier_name", "tax_id"]
|
|
)
|
|
if not new_suppliers:
|
|
return {"success": True, "matched_count": 0, "total_processed": 0}
|
|
|
|
erp_suppliers = frappe.get_all("Supplier", fields=["name", "supplier_name", "tax_id"])
|
|
|
|
# VÖEN lookup for exact matching
|
|
voen_index = {}
|
|
for s in erp_suppliers:
|
|
if s.tax_id:
|
|
voen_index[s.tax_id.strip()] = s.name
|
|
|
|
# Build dict of existing mappings: kb_supplier_name → erp_supplier
|
|
existing_mappings = {
|
|
row.kb_supplier_name: row.erp_supplier
|
|
for row in doc.supplier_mappings if row.kb_supplier_name
|
|
}
|
|
|
|
# Group 1: suppliers already in the table but with empty erp_supplier
|
|
suppliers_with_empty_mapping = []
|
|
for kb_name, erp_supplier in existing_mappings.items():
|
|
if not erp_supplier:
|
|
for s in new_suppliers:
|
|
if s.name == kb_name:
|
|
suppliers_with_empty_mapping.append(s)
|
|
break
|
|
|
|
# Group 2: suppliers not in the table at all
|
|
suppliers_not_in_table = [s for s in new_suppliers if s.name not in existing_mappings]
|
|
|
|
suppliers_to_process = suppliers_not_in_table + suppliers_with_empty_mapping
|
|
|
|
matched = 0
|
|
for supplier in suppliers_to_process:
|
|
best_party = None
|
|
|
|
# Priority 1: exact VÖEN match
|
|
if supplier.tax_id and supplier.tax_id.strip() in voen_index:
|
|
best_party = voen_index[supplier.tax_id.strip()]
|
|
else:
|
|
# Priority 2: name similarity
|
|
kb_name = _normalize(supplier.supplier_name, consider_azeri)
|
|
best_score = 0
|
|
|
|
for s in erp_suppliers:
|
|
score = SequenceMatcher(None, kb_name, _normalize(s.supplier_name, consider_azeri)).ratio()
|
|
if score > best_score:
|
|
best_score = score
|
|
best_party = s.name
|
|
|
|
if best_score < threshold:
|
|
best_party = None
|
|
|
|
if best_party:
|
|
existing_idx = None
|
|
for idx, row in enumerate(doc.supplier_mappings):
|
|
if row.kb_supplier_name == supplier.name:
|
|
existing_idx = idx
|
|
break
|
|
|
|
if existing_idx is not None:
|
|
doc.supplier_mappings[existing_idx].erp_supplier = best_party
|
|
doc.supplier_mappings[existing_idx].mapping_type = "Automatic"
|
|
else:
|
|
doc.append("supplier_mappings", {
|
|
"kb_supplier_name": supplier.name,
|
|
"tax_id": supplier.tax_id or None,
|
|
"erp_supplier": best_party,
|
|
"mapping_type": "Automatic",
|
|
})
|
|
|
|
frappe.db.set_value("Kapital Bank Supplier", supplier.name, {
|
|
"mapped_supplier": best_party,
|
|
"status": "Mapped",
|
|
}, update_modified=False)
|
|
matched += 1
|
|
|
|
if matched > 0:
|
|
doc.save(ignore_permissions=True)
|
|
|
|
frappe.db.commit()
|
|
return {"success": True, "matched_count": matched, "total_processed": len(suppliers_to_process)}
|
|
|
|
except Exception as e:
|
|
frappe.log_error(f"match_similar_suppliers: {e}\n{frappe.get_traceback()}", "Kapital Bank Supplier Matching")
|
|
return {"success": False, "message": str(e)}
|
|
|
|
|
|
@frappe.whitelist()
|
|
def create_unmapped_customers(settings_name=None):
|
|
"""Create Customer records for unmapped customers from Settings.customer_mappings table."""
|
|
try:
|
|
doc = frappe.get_doc("Kapital Bank Settings")
|
|
|
|
unmapped = []
|
|
for mapping in doc.customer_mappings:
|
|
if not mapping.erp_customer and mapping.kb_customer_name:
|
|
if frappe.db.exists("Kapital Bank Customer", mapping.kb_customer_name):
|
|
kb_customer = frappe.get_doc("Kapital Bank Customer", mapping.kb_customer_name)
|
|
unmapped.append((mapping, kb_customer))
|
|
|
|
if not unmapped:
|
|
return {"success": True, "created_count": 0, "message": "No unmapped customers in table to create"}
|
|
|
|
# Resolve group / territory per row upfront — empty config falls back to
|
|
# the first leaf node, so creation works without a default set, and bad
|
|
# config surfaces a clear message instead of an ERPNext error mid-create.
|
|
resolved = []
|
|
for mapping, _kb_customer in unmapped:
|
|
cg, err = _resolve_leaf_node("Customer Group", mapping.customer_group or doc.default_customer_group, "Customer Group")
|
|
if err:
|
|
return {"success": False, "message": err}
|
|
terr, err = _resolve_leaf_node("Territory", mapping.territory or doc.default_territory, "Territory")
|
|
if err:
|
|
return {"success": False, "message": err}
|
|
resolved.append((cg, terr))
|
|
|
|
created = 0
|
|
linked = 0
|
|
for (mapping, kb_customer), (customer_group, territory) in zip(unmapped, resolved):
|
|
try:
|
|
customer_name = kb_customer.customer_name
|
|
payment_terms = mapping.payment_terms or doc.default_payment_terms
|
|
|
|
existing = _find_existing_party("Customer", "customer_name", customer_name, kb_customer.tax_id)
|
|
if existing:
|
|
mapping.erp_customer = existing
|
|
mapping.mapping_type = "Manual"
|
|
existing_group, existing_territory = frappe.db.get_value(
|
|
"Customer", existing, ["customer_group", "territory"]
|
|
) or (None, None)
|
|
frappe.db.set_value("Kapital Bank Customer", kb_customer.name, {
|
|
"status": "Mapped",
|
|
"mapped_customer": existing,
|
|
"customer_group": existing_group,
|
|
"territory": existing_territory,
|
|
"payment_terms": payment_terms or None,
|
|
}, update_modified=False)
|
|
linked += 1
|
|
continue
|
|
|
|
party = frappe.new_doc("Customer")
|
|
party.customer_name = customer_name
|
|
party.customer_type = "Company"
|
|
party.customer_group = customer_group or None
|
|
party.territory = territory or None
|
|
|
|
if kb_customer.tax_id:
|
|
party.tax_id = kb_customer.tax_id
|
|
|
|
if payment_terms:
|
|
party.payment_terms = payment_terms
|
|
|
|
party.insert(ignore_permissions=True)
|
|
|
|
# Update mapping row
|
|
mapping.erp_customer = party.name
|
|
mapping.mapping_type = "Automatic"
|
|
|
|
# Update KB Customer status
|
|
frappe.db.set_value("Kapital Bank Customer", kb_customer.name, {
|
|
"status": "Mapped",
|
|
"mapped_customer": party.name,
|
|
"customer_group": party.customer_group,
|
|
"territory": party.territory,
|
|
"payment_terms": payment_terms or None,
|
|
}, update_modified=False)
|
|
|
|
created += 1
|
|
|
|
except Exception as e:
|
|
frappe.log_error(f"create_unmapped_customers: error for {kb_customer.customer_name}: {e}", "Kapital Bank Customer Creation")
|
|
|
|
if created > 0 or linked > 0:
|
|
doc.save(ignore_permissions=True)
|
|
|
|
frappe.db.commit()
|
|
return {"success": True, "created_count": created, "linked_count": linked}
|
|
|
|
except Exception as e:
|
|
frappe.log_error(f"create_unmapped_customers: {e}\n{frappe.get_traceback()}", "Kapital Bank Customer Creation")
|
|
return {"success": False, "message": str(e)}
|
|
|
|
|
|
@frappe.whitelist()
|
|
def create_unmapped_suppliers(settings_name=None):
|
|
"""Create Supplier records for unmapped suppliers from Settings.supplier_mappings table."""
|
|
try:
|
|
doc = frappe.get_doc("Kapital Bank Settings")
|
|
|
|
unmapped = []
|
|
for mapping in doc.supplier_mappings:
|
|
if not mapping.erp_supplier and mapping.kb_supplier_name:
|
|
if frappe.db.exists("Kapital Bank Supplier", mapping.kb_supplier_name):
|
|
kb_supplier = frappe.get_doc("Kapital Bank Supplier", mapping.kb_supplier_name)
|
|
unmapped.append((mapping, kb_supplier))
|
|
|
|
if not unmapped:
|
|
return {"success": True, "created_count": 0, "message": "No unmapped suppliers in table to create"}
|
|
|
|
resolved = []
|
|
for mapping, _kb_supplier in unmapped:
|
|
sg, err = _resolve_leaf_node("Supplier Group", mapping.supplier_group or doc.default_supplier_group, "Supplier Group")
|
|
if err:
|
|
return {"success": False, "message": err}
|
|
resolved.append(sg)
|
|
|
|
created = 0
|
|
linked = 0
|
|
for (mapping, kb_supplier), supplier_group in zip(unmapped, resolved):
|
|
try:
|
|
supplier_name = kb_supplier.supplier_name
|
|
payment_terms = mapping.payment_terms or doc.default_payment_terms
|
|
|
|
existing = _find_existing_party("Supplier", "supplier_name", supplier_name, kb_supplier.tax_id)
|
|
if existing:
|
|
mapping.erp_supplier = existing
|
|
mapping.mapping_type = "Manual"
|
|
existing_group = frappe.db.get_value("Supplier", existing, "supplier_group")
|
|
frappe.db.set_value("Kapital Bank Supplier", kb_supplier.name, {
|
|
"status": "Mapped",
|
|
"mapped_supplier": existing,
|
|
"supplier_group": existing_group,
|
|
"payment_terms": payment_terms or None,
|
|
}, update_modified=False)
|
|
linked += 1
|
|
continue
|
|
|
|
party = frappe.new_doc("Supplier")
|
|
party.supplier_name = supplier_name
|
|
party.supplier_type = "Company"
|
|
party.supplier_group = supplier_group or None
|
|
|
|
if kb_supplier.tax_id:
|
|
party.tax_id = kb_supplier.tax_id
|
|
|
|
if payment_terms:
|
|
party.payment_terms = payment_terms
|
|
|
|
party.insert(ignore_permissions=True)
|
|
|
|
# Update mapping row
|
|
mapping.erp_supplier = party.name
|
|
mapping.mapping_type = "Automatic"
|
|
|
|
# Update KB Supplier status
|
|
frappe.db.set_value("Kapital Bank Supplier", kb_supplier.name, {
|
|
"status": "Mapped",
|
|
"mapped_supplier": party.name,
|
|
"supplier_group": party.supplier_group,
|
|
"payment_terms": payment_terms or None,
|
|
}, update_modified=False)
|
|
|
|
created += 1
|
|
|
|
except Exception as e:
|
|
frappe.log_error(f"create_unmapped_suppliers: error for {kb_supplier.supplier_name}: {e}", "Kapital Bank Supplier Creation")
|
|
|
|
if created > 0 or linked > 0:
|
|
doc.save(ignore_permissions=True)
|
|
|
|
frappe.db.commit()
|
|
return {"success": True, "created_count": created, "linked_count": linked}
|
|
|
|
except Exception as e:
|
|
frappe.log_error(f"create_unmapped_suppliers: {e}\n{frappe.get_traceback()}", "Kapital Bank Supplier Creation")
|
|
return {"success": False, "message": str(e)}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# ACCOUNT / CARD → BANK ACCOUNT MATCHING & CREATION
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
@frappe.whitelist()
|
|
def match_similar_accounts():
|
|
"""Auto-match account mappings to ERPNext Bank Accounts by IBAN."""
|
|
try:
|
|
doc = frappe.get_doc("Kapital Bank Settings")
|
|
|
|
erp_bank_accounts = frappe.get_all(
|
|
"Bank Account",
|
|
fields=["name", "bank_account_no", "iban"],
|
|
)
|
|
|
|
# Build lookup: iban/bank_account_no → (name, account)
|
|
iban_index = {}
|
|
for ba in erp_bank_accounts:
|
|
if ba.iban:
|
|
iban_index[ba.iban.strip()] = ba
|
|
if ba.bank_account_no:
|
|
iban_index[ba.bank_account_no.strip()] = ba
|
|
|
|
matched = 0
|
|
total_processed = 0
|
|
|
|
for row in doc.account_mappings:
|
|
if row.bank_account or not row.iban:
|
|
continue
|
|
total_processed += 1
|
|
|
|
match = iban_index.get(row.iban.strip())
|
|
if match:
|
|
row.bank_account = match.name
|
|
# Fetch GL account from Bank Account
|
|
gl_account = frappe.db.get_value("Bank Account", match.name, "account")
|
|
if gl_account:
|
|
row.gl_account = gl_account
|
|
|
|
frappe.db.set_value("Kapital Bank Account", row.iban, {
|
|
"status": "Mapped",
|
|
"bank_account": match.name,
|
|
}, update_modified=False)
|
|
matched += 1
|
|
|
|
if matched > 0:
|
|
doc.save(ignore_permissions=True)
|
|
|
|
frappe.db.commit()
|
|
return {"success": True, "matched_count": matched, "total_processed": total_processed}
|
|
|
|
except Exception as e:
|
|
frappe.log_error(f"match_similar_accounts: {e}\n{frappe.get_traceback()}", "Kapital Bank Account Matching")
|
|
return {"success": False, "message": str(e)}
|
|
|
|
|
|
@frappe.whitelist()
|
|
def create_unmapped_accounts(settings_name=None):
|
|
"""Create Bank Account records for unmapped accounts in account_mappings."""
|
|
try:
|
|
doc = frappe.get_doc("Kapital Bank Settings")
|
|
|
|
unmapped = []
|
|
for row in doc.account_mappings:
|
|
if not row.bank_account and row.iban:
|
|
unmapped.append(row)
|
|
|
|
if not unmapped:
|
|
return {"success": True, "created_count": 0, "message": "No unmapped accounts to create"}
|
|
|
|
default_bank = doc.default_bank
|
|
default_company = erpnext.get_default_company()
|
|
|
|
# Bank Account requires both Company and Bank — surface a clear message
|
|
# instead of ERPNext's mandatory-field error mid-creation.
|
|
if any(not frappe.db.exists("Bank Account", {"bank_account_no": r.iban}) for r in unmapped):
|
|
if not default_company:
|
|
return {"success": False, "message": "Set a Default Company in Global Defaults before creating Bank Accounts."}
|
|
if not default_bank:
|
|
return {"success": False, "message": "Set a Default Bank on Kapital Bank Settings before creating Bank Accounts."}
|
|
|
|
created = 0
|
|
for row in unmapped:
|
|
try:
|
|
# Check if Bank Account already exists for this IBAN
|
|
existing = frappe.db.get_value("Bank Account", {"bank_account_no": row.iban}, "name")
|
|
if existing:
|
|
row.bank_account = existing
|
|
gl_account = frappe.db.get_value("Bank Account", existing, "account")
|
|
if gl_account:
|
|
row.gl_account = gl_account
|
|
frappe.db.set_value("Kapital Bank Account", row.iban, {
|
|
"status": "Mapped",
|
|
"bank_account": existing,
|
|
}, update_modified=False)
|
|
created += 1
|
|
continue
|
|
|
|
account_name = row.account_label or row.iban
|
|
|
|
ba = frappe.new_doc("Bank Account")
|
|
ba.account_name = account_name
|
|
ba.bank_account_no = row.iban
|
|
if default_bank:
|
|
ba.bank = default_bank
|
|
if default_company:
|
|
ba.company = default_company
|
|
ba.is_company_account = 1
|
|
ba.insert(ignore_permissions=True)
|
|
|
|
row.bank_account = ba.name
|
|
gl_account = frappe.db.get_value("Bank Account", ba.name, "account")
|
|
if gl_account:
|
|
row.gl_account = gl_account
|
|
|
|
frappe.db.set_value("Kapital Bank Account", row.iban, {
|
|
"status": "Mapped",
|
|
"bank_account": ba.name,
|
|
}, update_modified=False)
|
|
created += 1
|
|
|
|
except Exception as e:
|
|
frappe.log_error(f"create_unmapped_accounts: error for {row.iban}: {e}", "Kapital Bank Account Creation")
|
|
|
|
if created > 0:
|
|
doc.save(ignore_permissions=True)
|
|
|
|
frappe.db.commit()
|
|
return {"success": True, "created_count": created}
|
|
|
|
except Exception as e:
|
|
frappe.log_error(f"create_unmapped_accounts: {e}\n{frappe.get_traceback()}", "Kapital Bank Account Creation")
|
|
return {"success": False, "message": str(e)}
|
|
|
|
|
|
@frappe.whitelist()
|
|
def match_similar_cards():
|
|
"""Auto-match card mappings to ERPNext Bank Accounts by account_number."""
|
|
try:
|
|
doc = frappe.get_doc("Kapital Bank Settings")
|
|
|
|
erp_bank_accounts = frappe.get_all(
|
|
"Bank Account",
|
|
fields=["name", "bank_account_no"],
|
|
)
|
|
|
|
# Build lookup: bank_account_no → Bank Account
|
|
acno_index = {}
|
|
for ba in erp_bank_accounts:
|
|
if ba.bank_account_no:
|
|
acno_index[ba.bank_account_no.strip()] = ba
|
|
|
|
matched = 0
|
|
total_processed = 0
|
|
|
|
for row in doc.card_mappings:
|
|
if row.bank_account or not row.account_number:
|
|
continue
|
|
total_processed += 1
|
|
|
|
match = acno_index.get(row.account_number.strip())
|
|
if match:
|
|
row.bank_account = match.name
|
|
gl_account = frappe.db.get_value("Bank Account", match.name, "account")
|
|
if gl_account:
|
|
row.gl_account = gl_account
|
|
|
|
frappe.db.set_value("Kapital Bank Card", {"account_number": row.account_number}, {
|
|
"status": "Mapped",
|
|
"bank_account": match.name,
|
|
}, update_modified=False)
|
|
matched += 1
|
|
|
|
if matched > 0:
|
|
doc.save(ignore_permissions=True)
|
|
|
|
frappe.db.commit()
|
|
return {"success": True, "matched_count": matched, "total_processed": total_processed}
|
|
|
|
except Exception as e:
|
|
frappe.log_error(f"match_similar_cards: {e}\n{frappe.get_traceback()}", "Kapital Bank Card Matching")
|
|
return {"success": False, "message": str(e)}
|
|
|
|
|
|
@frappe.whitelist()
|
|
def create_unmapped_cards(settings_name=None):
|
|
"""Create Bank Account records for unmapped cards in card_mappings."""
|
|
try:
|
|
doc = frappe.get_doc("Kapital Bank Settings")
|
|
|
|
unmapped = []
|
|
for row in doc.card_mappings:
|
|
if not row.bank_account and row.account_number:
|
|
unmapped.append(row)
|
|
|
|
if not unmapped:
|
|
return {"success": True, "created_count": 0, "message": "No unmapped cards to create"}
|
|
|
|
default_bank = doc.default_bank
|
|
default_company = erpnext.get_default_company()
|
|
|
|
if any(not frappe.db.exists("Bank Account", {"bank_account_no": r.account_number}) for r in unmapped):
|
|
if not default_company:
|
|
return {"success": False, "message": "Set a Default Company in Global Defaults before creating Bank Accounts."}
|
|
if not default_bank:
|
|
return {"success": False, "message": "Set a Default Bank on Kapital Bank Settings before creating Bank Accounts."}
|
|
|
|
created = 0
|
|
for row in unmapped:
|
|
try:
|
|
# Check if Bank Account already exists for this account_number
|
|
existing = frappe.db.get_value("Bank Account", {"bank_account_no": row.account_number}, "name")
|
|
if existing:
|
|
row.bank_account = existing
|
|
gl_account = frappe.db.get_value("Bank Account", existing, "account")
|
|
if gl_account:
|
|
row.gl_account = gl_account
|
|
frappe.db.set_value("Kapital Bank Card", {"account_number": row.account_number}, {
|
|
"status": "Mapped",
|
|
"bank_account": existing,
|
|
}, update_modified=False)
|
|
created += 1
|
|
continue
|
|
|
|
account_name = row.card_label or row.account_number
|
|
|
|
ba = frappe.new_doc("Bank Account")
|
|
ba.account_name = account_name
|
|
ba.bank_account_no = row.account_number
|
|
if default_bank:
|
|
ba.bank = default_bank
|
|
if default_company:
|
|
ba.company = default_company
|
|
ba.is_company_account = 1
|
|
ba.insert(ignore_permissions=True)
|
|
|
|
row.bank_account = ba.name
|
|
gl_account = frappe.db.get_value("Bank Account", ba.name, "account")
|
|
if gl_account:
|
|
row.gl_account = gl_account
|
|
|
|
frappe.db.set_value("Kapital Bank Card", {"account_number": row.account_number}, {
|
|
"status": "Mapped",
|
|
"bank_account": ba.name,
|
|
}, update_modified=False)
|
|
created += 1
|
|
|
|
except Exception as e:
|
|
frappe.log_error(f"create_unmapped_cards: error for {row.account_number}: {e}", "Kapital Bank Card Creation")
|
|
|
|
if created > 0:
|
|
doc.save(ignore_permissions=True)
|
|
|
|
frappe.db.commit()
|
|
return {"success": True, "created_count": created}
|
|
|
|
except Exception as e:
|
|
frappe.log_error(f"create_unmapped_cards: {e}\n{frappe.get_traceback()}", "Kapital Bank Card Creation")
|
|
return {"success": False, "message": str(e)}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# PURPOSE LOADING FROM STATEMENTS
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
@frappe.whitelist()
|
|
def load_purposes_from_statements(from_date, to_date, login_name=None):
|
|
"""Load unique purposes from account statements into Kapital Bank Purpose registry."""
|
|
try:
|
|
# Get all registered accounts
|
|
accounts = frappe.get_all(
|
|
"Kapital Bank Account",
|
|
fields=["name", "cust_ac_no"],
|
|
)
|
|
|
|
if not accounts:
|
|
return {"success": True, "created": 0, "total_found": 0, "message": "No accounts in registry"}
|
|
|
|
client = BIRBankClient(login_name)
|
|
# purpose_text → set of directions ("D", "C")
|
|
purpose_directions = {}
|
|
|
|
for account in accounts:
|
|
if not account.cust_ac_no:
|
|
continue
|
|
try:
|
|
resp = client.get(
|
|
"/b2b/account/portal/v3/statements",
|
|
params={
|
|
"accountNumber": account.cust_ac_no,
|
|
"fromDate": _iso_date(from_date),
|
|
"toDate": _iso_date(to_date),
|
|
}
|
|
)
|
|
if not resp.ok:
|
|
continue
|
|
|
|
data = resp.json()
|
|
statement_list = data.get("statementList", [])
|
|
|
|
for txn in statement_list:
|
|
purpose = (txn.get("purpose") or "").strip()
|
|
dr_cr = (txn.get("drcrInd") or "").upper()
|
|
if purpose:
|
|
if purpose not in purpose_directions:
|
|
purpose_directions[purpose] = set()
|
|
if dr_cr in ("D", "C"):
|
|
purpose_directions[purpose].add(dr_cr)
|
|
|
|
except Exception:
|
|
continue
|
|
|
|
# Create Kapital Bank Purpose records for new purposes
|
|
created = updated = 0
|
|
for purpose in sorted(purpose_directions):
|
|
dirs = purpose_directions[purpose]
|
|
if "D" in dirs and "C" in dirs:
|
|
direction = "Both"
|
|
elif "C" in dirs:
|
|
direction = "Receive"
|
|
else:
|
|
direction = "Pay"
|
|
|
|
existing = frappe.db.get_value("Kapital Bank Purpose", {"purpose_keyword": purpose}, ["name", "direction"], as_dict=True)
|
|
if existing:
|
|
# Update direction if it changed (e.g. was Pay, now seen as Both)
|
|
if existing.direction != direction and direction == "Both":
|
|
frappe.db.set_value("Kapital Bank Purpose", existing.name, "direction", "Both", update_modified=False)
|
|
updated += 1
|
|
else:
|
|
try:
|
|
doc = frappe.new_doc("Kapital Bank Purpose")
|
|
doc.purpose_keyword = purpose
|
|
doc.direction = direction
|
|
doc.status = "New"
|
|
doc.insert(ignore_permissions=True)
|
|
created += 1
|
|
except frappe.DuplicateEntryError:
|
|
pass
|
|
except Exception as ins_e:
|
|
frappe.log_error(f"Insert purpose failed for {purpose}: {ins_e}", "Kapital Bank Purpose Loading")
|
|
|
|
if created > 0:
|
|
frappe.db.commit()
|
|
|
|
return {"success": True, "created": created, "updated": updated, "total_found": len(purpose_directions)}
|
|
|
|
except Exception as e:
|
|
frappe.log_error(f"load_purposes_from_statements: {e}\n{frappe.get_traceback()}", "Kapital Bank Purpose Loading")
|
|
return {"success": False, "message": str(e)}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# RAW API ACCESSORS (for reports / manual use)
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
@frappe.whitelist()
|
|
def get_accounts(login_name=None):
|
|
"""GET /b2b/accounts/portal/v1 — raw account list (not saved to registry)."""
|
|
try:
|
|
client = BIRBankClient(login_name)
|
|
data = client.get_json("/b2b/accounts/portal/v1")
|
|
accounts = data.get("accounts", [])
|
|
return {"success": True, "accounts": accounts}
|
|
except Exception as e:
|
|
frappe.log_error(f"get_accounts failed: {e}\n{frappe.get_traceback()}", "Kapital Bank API")
|
|
return {"success": False, "message": str(e)}
|
|
|
|
|
|
@frappe.whitelist()
|
|
def get_account_statement(login_name=None, iban=None, from_date=None, to_date=None):
|
|
"""GET /b2b/account/portal/v3/statements — account statement for a date range.
|
|
|
|
Note: `iban` here is the account number (custAcNo), not the IBAN — the v3
|
|
endpoint rejects a raw IBAN.
|
|
"""
|
|
try:
|
|
client = BIRBankClient(login_name)
|
|
params = {"accountNumber": iban, "fromDate": _iso_date(from_date), "toDate": _iso_date(to_date)}
|
|
data = client.get_json("/b2b/account/portal/v3/statements", params=params)
|
|
return {"success": True, "data": data}
|
|
except Exception as e:
|
|
frappe.log_error(f"get_account_statement failed: {e}\n{frappe.get_traceback()}", "Kapital Bank API")
|
|
return {"success": False, "message": str(e)}
|
|
|
|
|
|
@frappe.whitelist()
|
|
def get_cards(login_name=None):
|
|
"""GET /b2b/cards/portal/v1 — raw card list (not saved to registry)."""
|
|
try:
|
|
client = BIRBankClient(login_name)
|
|
data = client.get_json("/b2b/cards/portal/v1")
|
|
cards = data.get("cards", [])
|
|
return {"success": True, "cards": cards}
|
|
except Exception as e:
|
|
frappe.log_error(f"get_cards failed: {e}\n{frappe.get_traceback()}", "Kapital Bank API")
|
|
return {"success": False, "message": str(e)}
|
|
|
|
|
|
@frappe.whitelist()
|
|
def get_card_statement(login_name=None, account_no=None, from_date=None, to_date=None):
|
|
"""GET /b2b/cards/portal/v1/statements — card statement (first page) for a date range."""
|
|
try:
|
|
client = BIRBankClient(login_name)
|
|
pan = frappe.db.get_value("Kapital Bank Card", account_no, "pan") or ""
|
|
pan_last4 = re.sub(r"\D", "", pan)[-4:]
|
|
params = {
|
|
"accountNo": account_no,
|
|
"panLast4": pan_last4,
|
|
"dateFrom": str(from_date) if from_date else None,
|
|
"dateTo": str(to_date) if to_date else None,
|
|
"pageId": 0,
|
|
}
|
|
data = client.get_json("/b2b/cards/portal/v1/statements", params=params)
|
|
return {"success": True, "data": data}
|
|
except Exception as e:
|
|
frappe.log_error(f"get_card_statement failed: {e}\n{frappe.get_traceback()}", "Kapital Bank API")
|
|
return {"success": False, "message": str(e)}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# STATEMENT IMPORT (Kapital Bank → Payment Entry)
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
def _parse_txn_date(trn_dt):
|
|
"""Parse transaction date string into a date object. Returns None on failure."""
|
|
trn_dt = (trn_dt or "").strip()
|
|
if not trn_dt:
|
|
return None
|
|
# v3 portal statements return ISO dates ("2026-05-14"); legacy fell back to
|
|
# the "May 14, 2026" form, which we still accept for safety.
|
|
for fmt in ("%Y-%m-%d", "%b %d, %Y", "%b %d,%Y"):
|
|
try:
|
|
return datetime.strptime(trn_dt, fmt).date()
|
|
except ValueError:
|
|
continue
|
|
return None
|
|
|
|
|
|
def _parse_card_txn_date(date_str):
|
|
"""Parse a card transaction date into a date object.
|
|
|
|
The portal echoes DD.MM.YYYY, but accept ISO and date-time variants too so a
|
|
format change can't silently drop every card transaction. Returns None on failure.
|
|
"""
|
|
date_str = (date_str or "").strip()
|
|
if not date_str:
|
|
return None
|
|
for fmt in ("%d.%m.%Y", "%Y-%m-%d", "%d.%m.%Y %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
|
|
try:
|
|
return datetime.strptime(date_str, fmt).date()
|
|
except ValueError:
|
|
continue
|
|
return None
|
|
|
|
|
|
def _parse_counterparty(contr_raw):
|
|
"""Parse contrAccount string → (name, voen)."""
|
|
parts = [p.strip() for p in (contr_raw or "").split(" / ")]
|
|
contr_name = parts[1] if len(parts) > 1 else ""
|
|
contr_voen = parts[2] if len(parts) > 2 else ""
|
|
return contr_name, contr_voen
|
|
|
|
|
|
@frappe.whitelist()
|
|
def get_statement_transactions(from_date, to_date, account_iban, login_name=None):
|
|
"""Fetch bank statement and return parsed transactions for UI selection.
|
|
|
|
Filters out duplicates (already imported by reference_no).
|
|
Returns: {success, transactions: [...], total_fetched, skipped_duplicates}
|
|
"""
|
|
try:
|
|
account_iban = account_iban.strip()
|
|
|
|
# Find cust_ac_no for this IBAN
|
|
cust_ac_no = frappe.db.get_value("Kapital Bank Account", account_iban, "cust_ac_no")
|
|
if not cust_ac_no:
|
|
return {"success": False, "message": f"Account {account_iban} not found in registry."}
|
|
|
|
# Fetch statement
|
|
client = BIRBankClient(login_name)
|
|
resp = client.get(
|
|
"/b2b/account/portal/v3/statements",
|
|
params={
|
|
"accountNumber": cust_ac_no,
|
|
"fromDate": _iso_date(from_date),
|
|
"toDate": _iso_date(to_date),
|
|
}
|
|
)
|
|
if not resp.ok:
|
|
return {"success": False, "message": _api_error_message(resp)}
|
|
|
|
data = resp.json()
|
|
statement_list = data.get("statementList", [])
|
|
|
|
# Use the account's own currency for all transactions from this account
|
|
account_currency = frappe.db.get_value("Kapital Bank Account", account_iban, "currency") or "AZN"
|
|
|
|
transactions = []
|
|
skipped_duplicates = 0
|
|
|
|
for txn in statement_list:
|
|
trn_ref = (txn.get("trnRefNo") or "").strip()
|
|
|
|
# Deduplication via tracking doctype
|
|
if trn_ref and frappe.db.exists("Kapital Bank Transaction", {"reference_no": trn_ref}):
|
|
skipped_duplicates += 1
|
|
continue
|
|
|
|
# Parse date
|
|
parsed_date = _parse_txn_date(txn.get("trnDt"))
|
|
if not parsed_date:
|
|
continue
|
|
|
|
# acCcy = transaction currency per the bank; fcyAmount is in acCcy; lcyAmount is always AZN.
|
|
# We need the amount in account_currency so BT deposit/withdrawal is correct.
|
|
ac_ccy = (txn.get("acCcy") or "").strip().upper()
|
|
fcy_amount = abs(flt(txn.get("fcyAmount") or 0))
|
|
lcy_amount = abs(flt(txn.get("lcyAmount") or 0))
|
|
|
|
if not ac_ccy or ac_ccy == account_currency.upper():
|
|
# Same currency — fcyAmount is in account_currency
|
|
amount = fcy_amount or lcy_amount
|
|
elif account_currency.upper() == "AZN":
|
|
# Cross-currency on AZN account: lcyAmount gives the correct AZN value
|
|
amount = lcy_amount or fcy_amount
|
|
else:
|
|
# Non-AZN account with different acCcy: lcyAmount (AZN) as fallback
|
|
amount = lcy_amount or fcy_amount
|
|
currency = account_currency
|
|
|
|
# Direction
|
|
dr_cr = (txn.get("drcrInd") or "").upper()
|
|
|
|
# Counterparty
|
|
contr_raw = txn.get("contrAccount") or ""
|
|
contr_parts = [p.strip() for p in contr_raw.split(" / ")]
|
|
contr_iban = contr_parts[0] if contr_parts else ""
|
|
contr_name = contr_parts[1] if len(contr_parts) > 1 else ""
|
|
contr_voen = contr_parts[2] if len(contr_parts) > 2 else ""
|
|
|
|
# Purpose
|
|
purpose = (txn.get("purpose") or "").strip()
|
|
|
|
transactions.append({
|
|
"ref_no": trn_ref,
|
|
"date": str(parsed_date),
|
|
"counterparty": contr_name,
|
|
"contr_voen": contr_voen,
|
|
"amount": amount,
|
|
"currency": currency,
|
|
"kb_currency": (txn.get("acCcy") or "").strip(),
|
|
"drcr": dr_cr or "D",
|
|
"purpose": purpose,
|
|
"source_type": "account",
|
|
"source_label": account_iban,
|
|
})
|
|
|
|
return {
|
|
"success": True,
|
|
"transactions": transactions,
|
|
"total_fetched": len(statement_list),
|
|
"skipped_duplicates": skipped_duplicates,
|
|
}
|
|
|
|
except Exception as e:
|
|
frappe.log_error(f"get_statement_transactions: {e}\n{frappe.get_traceback()}", "Kapital Bank Import Statement")
|
|
return {"success": False, "message": str(e)}
|
|
|
|
|
|
@frappe.whitelist()
|
|
def get_card_statement_transactions(from_date, to_date, card_account_number, login_name=None):
|
|
"""Fetch card statement and return parsed transactions for UI selection.
|
|
|
|
Uses GET /b2b/cards/portal/v1/statements (paginated). Requires the card's
|
|
account number plus the last 4 digits of the PAN.
|
|
Filters out duplicates (already imported by reference_no).
|
|
Returns: {success, transactions: [...], total_fetched, skipped_duplicates}
|
|
"""
|
|
try:
|
|
card_account_number = card_account_number.strip()
|
|
|
|
# Verify card exists
|
|
if not frappe.db.exists("Kapital Bank Card", card_account_number):
|
|
return {"success": False, "message": f"Card {card_account_number} not found in registry."}
|
|
|
|
# Card statements are limited to 90 days by the API
|
|
from frappe.utils import date_diff, getdate
|
|
if date_diff(getdate(to_date), getdate(from_date)) > 90:
|
|
return {"success": False, "message": "Card statement date range cannot exceed 90 days."}
|
|
|
|
card_doc = frappe.db.get_value("Kapital Bank Card", card_account_number, ["pan", "currency"], as_dict=True)
|
|
pan = (card_doc.pan if card_doc else None) or card_account_number
|
|
card_currency = (card_doc.currency if card_doc else None) or "AZN"
|
|
|
|
# The v1 card-statement endpoint needs the last 4 PAN digits, not the masked PAN.
|
|
pan_last4 = re.sub(r"\D", "", pan)[-4:]
|
|
if len(pan_last4) != 4:
|
|
return {"success": False, "message": f"Cannot derive last 4 PAN digits for card {card_account_number}."}
|
|
|
|
# Fetch all pages (date format: YYYY-MM-DD, raw Frappe date)
|
|
client = BIRBankClient(login_name)
|
|
operation_list = []
|
|
page_id = 0
|
|
MAX_PAGES = 500 # safety cap against a misbehaving `next` flag
|
|
while page_id < MAX_PAGES:
|
|
resp = client.get(
|
|
"/b2b/cards/portal/v1/statements",
|
|
params={
|
|
"accountNo": card_account_number,
|
|
"panLast4": pan_last4,
|
|
"dateFrom": str(from_date) if from_date else None,
|
|
"dateTo": str(to_date) if to_date else None,
|
|
"pageId": page_id,
|
|
}
|
|
)
|
|
if not resp.ok:
|
|
return {"success": False, "message": _api_error_message(resp)}
|
|
|
|
data = resp.json()
|
|
operation_list.extend(data.get("cardStatements", []))
|
|
|
|
if not data.get("next"):
|
|
break
|
|
page_id += 1
|
|
|
|
transactions = []
|
|
skipped_duplicates = 0
|
|
|
|
for txn in operation_list:
|
|
txn_id = str(txn.get("id") or "").strip()
|
|
|
|
# Deduplication via tracking doctype
|
|
if txn_id and frappe.db.exists("Kapital Bank Transaction", {"reference_no": txn_id}):
|
|
skipped_duplicates += 1
|
|
continue
|
|
|
|
# Parse date (DD.MM.YYYY format)
|
|
parsed_date = _parse_card_txn_date(txn.get("operationDate"))
|
|
if not parsed_date:
|
|
continue
|
|
|
|
# Amount and currency
|
|
amount = abs(flt(txn.get("amount") or txn.get("originalAmount") or 0))
|
|
currency = card_currency
|
|
|
|
# Direction: DEBIT/CREDIT → D/C
|
|
direction = (txn.get("direction") or "").upper()
|
|
dr_cr = "D" if direction == "DEBIT" else "C"
|
|
|
|
# Purpose (operationName serves as both purpose and counterparty description)
|
|
operation_name = (txn.get("operationName") or "").strip()
|
|
|
|
# Build enriched description from all available remark fields
|
|
desc_parts = [operation_name] if operation_name else []
|
|
for field in ("fullRemark", "shortRemark", "description"):
|
|
val = (txn.get(field) or "").strip()
|
|
if val and val not in desc_parts:
|
|
desc_parts.append(val)
|
|
full_description = " || ".join(desc_parts)
|
|
|
|
transactions.append({
|
|
"ref_no": txn_id,
|
|
"date": str(parsed_date),
|
|
"counterparty": "",
|
|
"contr_voen": "",
|
|
"amount": amount,
|
|
"currency": currency,
|
|
"drcr": dr_cr,
|
|
"purpose": operation_name,
|
|
"full_description": full_description,
|
|
"source_type": "card",
|
|
"source_label": f"Card: {pan}",
|
|
})
|
|
|
|
return {
|
|
"success": True,
|
|
"transactions": transactions,
|
|
"total_fetched": len(operation_list),
|
|
"skipped_duplicates": skipped_duplicates,
|
|
}
|
|
|
|
except Exception as e:
|
|
frappe.log_error(f"get_card_statement_transactions: {e}\n{frappe.get_traceback()}", "Kapital Bank Import Card Statement")
|
|
return {"success": False, "message": str(e)}
|
|
|
|
|
|
def _build_import_lookups(settings):
|
|
"""Build lookup dicts once for bulk import."""
|
|
purpose_rules = []
|
|
for row in settings.transaction_mappings:
|
|
if not row.paid_from or not row.paid_to:
|
|
continue
|
|
if not row.purpose_keyword and not row.counterparty and not row.counterparty_type:
|
|
continue
|
|
|
|
keyword_lower = ""
|
|
if row.purpose_keyword:
|
|
keyword = frappe.db.get_value("Kapital Bank Purpose", row.purpose_keyword, "purpose_keyword") or ""
|
|
keyword_lower = keyword.lower()
|
|
|
|
cp_name = ""
|
|
cp_voen = ""
|
|
if row.counterparty and row.counterparty_type:
|
|
if row.counterparty_type == "Kapital Bank Customer":
|
|
cp_data = frappe.db.get_value("Kapital Bank Customer", row.counterparty, ["customer_name", "tax_id"], as_dict=True)
|
|
else:
|
|
cp_data = frappe.db.get_value("Kapital Bank Supplier", row.counterparty, ["supplier_name", "tax_id"], as_dict=True)
|
|
if cp_data:
|
|
cp_name = (cp_data.get("customer_name") or cp_data.get("supplier_name") or "").strip()
|
|
cp_voen = (cp_data.get("tax_id") or "").strip()
|
|
|
|
purpose_rules.append((
|
|
keyword_lower,
|
|
row.paid_from,
|
|
row.paid_to,
|
|
getattr(row, "payment_type", None) or "",
|
|
cp_name,
|
|
cp_voen,
|
|
getattr(row, "counterparty_type", None) or "",
|
|
getattr(row, "document_type", None) or "Payment Entry",
|
|
getattr(row, "cost_center", None) or "",
|
|
bool(getattr(row, "multi_currency", 0)),
|
|
(getattr(row, "currency", None) or "").strip().upper(),
|
|
))
|
|
|
|
# Each key maps to a dict with optional "Customer" and "Supplier" entries.
|
|
# This preserves both when the same name/VOEN exists in both registries,
|
|
# allowing payment_type-aware selection at import time.
|
|
voen_to_party = {}
|
|
name_to_party = {}
|
|
|
|
for row in settings.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:
|
|
kb_customer_name = frappe.db.get_value("Kapital Bank Customer", row.kb_customer_name, "customer_name")
|
|
if kb_customer_name:
|
|
name_to_party.setdefault(kb_customer_name.strip(), {})["Customer"] = entry
|
|
|
|
for row in settings.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:
|
|
kb_supplier_name = frappe.db.get_value("Kapital Bank Supplier", row.kb_supplier_name, "supplier_name")
|
|
if kb_supplier_name:
|
|
name_to_party.setdefault(kb_supplier_name.strip(), {})["Supplier"] = entry
|
|
|
|
return purpose_rules, voen_to_party, name_to_party
|
|
|
|
|
|
def _import_one_transaction(txn_data, settings, purpose_rules, purpose_threshold,
|
|
voen_to_party, name_to_party):
|
|
"""Import a single transaction using pre-built lookups. Returns result dict."""
|
|
ref_no = txn_data.get("ref_no", "")
|
|
parsed_date = txn_data.get("date")
|
|
amount = abs(flt(txn_data.get("amount", 0)))
|
|
dr_cr = (txn_data.get("drcr") or "D").upper()
|
|
payment_type = "Pay" if dr_cr == "D" else "Receive"
|
|
purpose = (txn_data.get("purpose") or "").strip()
|
|
contr_name = (txn_data.get("counterparty") or "").strip()
|
|
contr_voen = (txn_data.get("contr_voen") or "").strip()
|
|
source_type = txn_data.get("source_type", "account")
|
|
|
|
# Purpose mapping with fuzzy matching — priority: both > purpose-only > counterparty-only
|
|
purpose_lower = purpose.lower()
|
|
paid_from = paid_to = None
|
|
|
|
both_rules = []
|
|
purpose_only_rules = []
|
|
counterparty_only_rules = []
|
|
fallback_rules = []
|
|
txn_currency = (txn_data.get("currency") or "").strip().upper()
|
|
for rule in purpose_rules:
|
|
keyword, pf, pt, rule_pt, cp_name, cp_voen, cp_type, doc_type, rule_cc, rule_mc, rule_currency = rule
|
|
has_keyword = bool(keyword)
|
|
has_cp = bool(cp_name or cp_voen)
|
|
if has_keyword and has_cp:
|
|
both_rules.append(rule)
|
|
elif has_keyword:
|
|
purpose_only_rules.append(rule)
|
|
elif has_cp:
|
|
counterparty_only_rules.append(rule)
|
|
elif cp_type:
|
|
fallback_rules.append(rule)
|
|
|
|
def _counterparty_matches(cp_name, cp_voen):
|
|
if cp_voen and contr_voen and cp_voen == contr_voen:
|
|
return True
|
|
if cp_name and contr_name and cp_name == contr_name:
|
|
return True
|
|
return False
|
|
|
|
def _match_purpose_rules(rules, check_counterparty):
|
|
best_score = 0
|
|
best_pf = best_pt = best_doc_type = best_cc = best_mc = None
|
|
for keyword, pf, pt, rule_pt, cp_name, cp_voen, cp_type, doc_type, rule_cc, rule_mc, rule_currency in rules:
|
|
if rule_pt and rule_pt != payment_type:
|
|
continue
|
|
if rule_currency and rule_currency != txn_currency:
|
|
continue
|
|
if check_counterparty and not _counterparty_matches(cp_name, cp_voen):
|
|
continue
|
|
if keyword:
|
|
if keyword in purpose_lower:
|
|
return pf, pt, doc_type, rule_cc, rule_mc
|
|
score = SequenceMatcher(None, purpose_lower, keyword).ratio()
|
|
if score > best_score and score >= purpose_threshold:
|
|
best_score = score
|
|
best_pf, best_pt, best_doc_type, best_cc, best_mc = pf, pt, doc_type, rule_cc, rule_mc
|
|
else:
|
|
return pf, pt, doc_type, rule_cc, rule_mc
|
|
return best_pf, best_pt, best_doc_type, best_cc, best_mc
|
|
|
|
doc_type = "Payment Entry"
|
|
cost_center = ""
|
|
multi_currency = False
|
|
|
|
# Phase 1: rules with BOTH purpose keyword AND counterparty
|
|
paid_from, paid_to, matched_doc_type, matched_cc, matched_mc = _match_purpose_rules(both_rules, check_counterparty=True)
|
|
if matched_doc_type:
|
|
doc_type = matched_doc_type
|
|
cost_center = matched_cc or ""
|
|
multi_currency = matched_mc or False
|
|
|
|
# Phase 2: rules with only purpose keyword
|
|
if not paid_from or not paid_to:
|
|
paid_from, paid_to, matched_doc_type, matched_cc, matched_mc = _match_purpose_rules(purpose_only_rules, check_counterparty=False)
|
|
if matched_doc_type:
|
|
doc_type = matched_doc_type
|
|
cost_center = matched_cc or ""
|
|
multi_currency = matched_mc or False
|
|
|
|
# Phase 3: rules with only counterparty
|
|
if not paid_from or not paid_to:
|
|
paid_from, paid_to, matched_doc_type, matched_cc, matched_mc = _match_purpose_rules(counterparty_only_rules, check_counterparty=True)
|
|
if matched_doc_type:
|
|
doc_type = matched_doc_type
|
|
cost_center = matched_cc or ""
|
|
multi_currency = matched_mc or False
|
|
|
|
# Phase 4: fallback rules — counterparty_type only, no specific counterparty
|
|
if not paid_from or not paid_to:
|
|
inferred_cp_type = "Customer" if payment_type == "Receive" else "Supplier"
|
|
for keyword, pf, pt, rule_pt, cp_name, cp_voen, cp_type, doc_type, rule_cc, rule_mc in fallback_rules:
|
|
if rule_pt and rule_pt != payment_type:
|
|
continue
|
|
if cp_type and cp_type != inferred_cp_type:
|
|
continue
|
|
paid_from, paid_to = pf, pt
|
|
doc_type = doc_type
|
|
cost_center = rule_cc or ""
|
|
multi_currency = rule_mc or False
|
|
break
|
|
|
|
if not paid_from or not paid_to:
|
|
return {
|
|
"success": False,
|
|
"error_type": "unmapped_purpose",
|
|
"message": f"No purpose mapping for: {purpose}",
|
|
"unmatched_purpose": [{"purpose": purpose}],
|
|
"txn_data": txn_data,
|
|
}
|
|
|
|
# Party mapping: VOEN → name → None
|
|
# When a key maps to both Customer and Supplier, prefer the one matching payment_type.
|
|
def _resolve_party(candidates):
|
|
preferred = "Customer" if payment_type == "Receive" else "Supplier"
|
|
fallback = "Supplier" if preferred == "Customer" else "Customer"
|
|
entry = candidates.get(preferred) or candidates.get(fallback)
|
|
return (entry["party_type"], entry["erp_party"]) if entry else (None, None)
|
|
|
|
party_type = None
|
|
erp_party = None
|
|
if contr_voen and contr_voen in voen_to_party:
|
|
party_type, erp_party = _resolve_party(voen_to_party[contr_voen])
|
|
elif contr_name and contr_name in name_to_party:
|
|
party_type, erp_party = _resolve_party(name_to_party[contr_name])
|
|
|
|
if not party_type or not erp_party:
|
|
if source_type == "card" and not contr_name and not contr_voen:
|
|
return {
|
|
"success": False,
|
|
"error_type": "unmapped_party",
|
|
"message": f"Card transaction has no counterparty. Purpose: {purpose}",
|
|
"unmatched_party": [{"name": "(card transaction)", "voen": ""}],
|
|
"txn_data": txn_data,
|
|
}
|
|
return {
|
|
"success": False,
|
|
"error_type": "unmapped_party",
|
|
"message": f"No party mapping for: {contr_name} (VOEN: {contr_voen})",
|
|
"unmatched_party": [{"name": contr_name, "voen": contr_voen}],
|
|
"txn_data": txn_data,
|
|
}
|
|
|
|
if doc_type == "Journal Entry":
|
|
from erpnext.setup.utils import get_exchange_rate
|
|
|
|
# Create Journal Entry
|
|
je = frappe.new_doc("Journal Entry")
|
|
je.voucher_type = "Bank Entry"
|
|
je.posting_date = parsed_date
|
|
je.company = erpnext.get_default_company()
|
|
je.cheque_no = ref_no
|
|
je.cheque_date = parsed_date
|
|
je.user_remark = purpose
|
|
je.expense_income_type = "Expense" if payment_type == "Pay" else "Income"
|
|
|
|
# Compute per-account amounts with proper multi-currency handling
|
|
currency_precision = cint(frappe.db.get_single_value("System Settings", "currency_precision") or 2)
|
|
company_currency = erpnext.get_company_currency(erpnext.get_default_company())
|
|
txn_currency = (txn_data.get("currency") or company_currency).strip()
|
|
|
|
pf_account = frappe.get_cached_value("Account", paid_from, ["account_type", "account_currency"], as_dict=True) or {}
|
|
pt_account = frappe.get_cached_value("Account", paid_to, ["account_type", "account_currency"], as_dict=True) or {}
|
|
pf_currency = pf_account.get("account_currency") or company_currency
|
|
pt_currency = pt_account.get("account_currency") or company_currency
|
|
|
|
is_multi = pf_currency != company_currency or pt_currency != company_currency
|
|
if is_multi or multi_currency:
|
|
je.multi_currency = 1
|
|
|
|
pf_rate = 1 if pf_currency == company_currency else get_exchange_rate(pf_currency, company_currency, parsed_date)
|
|
pt_rate = 1 if pt_currency == company_currency else get_exchange_rate(pt_currency, company_currency, parsed_date)
|
|
|
|
if txn_currency == pf_currency:
|
|
txn_rate = pf_rate
|
|
elif txn_currency == pt_currency:
|
|
txn_rate = pt_rate
|
|
elif txn_currency == company_currency:
|
|
txn_rate = 1
|
|
else:
|
|
txn_rate = get_exchange_rate(txn_currency, company_currency, parsed_date)
|
|
|
|
amount_in_company = flt(amount * txn_rate, currency_precision)
|
|
|
|
pf_amount = amount if pf_currency == txn_currency else (
|
|
amount_in_company if pf_currency == company_currency else
|
|
flt(amount_in_company / pf_rate, currency_precision) if pf_rate else amount_in_company
|
|
)
|
|
pt_amount = amount if pt_currency == txn_currency else (
|
|
amount_in_company if pt_currency == company_currency else
|
|
flt(amount_in_company / pt_rate, currency_precision) if pt_rate else amount_in_company
|
|
)
|
|
|
|
if pf_currency != company_currency and pf_amount:
|
|
pf_rate = amount_in_company / pf_amount
|
|
if pt_currency != company_currency and pt_amount:
|
|
pt_rate = amount_in_company / pt_amount
|
|
|
|
# Party can only be set on Receivable/Payable accounts
|
|
party_accounts = {"Receivable", "Payable"}
|
|
pf_type = pf_account.get("account_type") or ""
|
|
pt_type = pt_account.get("account_type") or ""
|
|
pf_party = (party_type, erp_party) if pf_type in party_accounts else (None, None)
|
|
pt_party = (party_type, erp_party) if pt_type in party_accounts else (None, None)
|
|
|
|
je_row_extra = {}
|
|
if cost_center:
|
|
je_row_extra["cost_center"] = cost_center
|
|
|
|
# paid_from is always credited, paid_to is always debited.
|
|
je.append("accounts", {
|
|
"account": paid_from,
|
|
"exchange_rate": pf_rate,
|
|
"credit_in_account_currency": pf_amount,
|
|
"debit_in_account_currency": 0,
|
|
"party_type": pf_party[0],
|
|
"party": pf_party[1],
|
|
**je_row_extra,
|
|
})
|
|
je.append("accounts", {
|
|
"account": paid_to,
|
|
"exchange_rate": pt_rate,
|
|
"debit_in_account_currency": pt_amount,
|
|
"credit_in_account_currency": 0,
|
|
"party_type": pt_party[0],
|
|
"party": pt_party[1],
|
|
**je_row_extra,
|
|
})
|
|
|
|
try:
|
|
je.insert(ignore_permissions=True)
|
|
je.submit()
|
|
except Exception as je_err:
|
|
frappe.db.rollback()
|
|
frappe.local.message_log = []
|
|
frappe.log_error(
|
|
f"JE creation failed for ref {ref_no}: {je_err}\n{frappe.get_traceback()}",
|
|
"KB JE Import Error",
|
|
)
|
|
frappe.db.commit()
|
|
return {
|
|
"success": False,
|
|
"error_type": "import_error",
|
|
"message": str(je_err),
|
|
"txn_data": txn_data,
|
|
}
|
|
|
|
_create_transaction_record(
|
|
reference_no=ref_no,
|
|
posting_date=parsed_date,
|
|
amount=amount,
|
|
currency=txn_data.get("currency", "AZN"),
|
|
party=contr_name,
|
|
source_type="Account" if source_type == "account" else "Card",
|
|
source_label=txn_data.get("source_label", ""),
|
|
journal_entry=je.name,
|
|
commit=False,
|
|
)
|
|
|
|
return {"success": True, "journal_entry": je.name}
|
|
|
|
# Create Payment Entry
|
|
pe = frappe.new_doc("Payment Entry")
|
|
pe.payment_type = payment_type
|
|
pe.company = erpnext.get_default_company()
|
|
pe.posting_date = parsed_date
|
|
pe.paid_from = paid_from
|
|
pe.paid_to = paid_to
|
|
pe.paid_amount = amount
|
|
pe.received_amount = amount
|
|
pe.reference_no = ref_no
|
|
pe.reference_date = parsed_date
|
|
pe.purpose = purpose
|
|
pe.remarks = purpose
|
|
pe.party_type = party_type
|
|
pe.party = erp_party
|
|
if cost_center:
|
|
pe.cost_center = cost_center
|
|
|
|
try:
|
|
pe.insert(ignore_permissions=True)
|
|
pe.submit()
|
|
except Exception as pe_err:
|
|
frappe.db.rollback()
|
|
frappe.local.message_log = []
|
|
frappe.log_error(
|
|
f"PE creation failed for ref {ref_no}: {pe_err}\n{frappe.get_traceback()}",
|
|
"KB PE Import Error",
|
|
)
|
|
frappe.db.commit()
|
|
return {
|
|
"success": False,
|
|
"error_type": "import_error",
|
|
"message": str(pe_err),
|
|
"txn_data": txn_data,
|
|
}
|
|
|
|
# Create tracking record (no individual commit — bulk commit at the end)
|
|
_create_transaction_record(
|
|
reference_no=ref_no,
|
|
posting_date=parsed_date,
|
|
amount=amount,
|
|
currency=txn_data.get("currency", "AZN"),
|
|
party=contr_name,
|
|
source_type="Account" if source_type == "account" else "Card",
|
|
source_label=txn_data.get("source_label", ""),
|
|
payment_entry=pe.name,
|
|
commit=False,
|
|
)
|
|
|
|
return {"success": True, "payment_entry": pe.name}
|
|
|
|
|
|
@frappe.whitelist()
|
|
def import_bulk_transactions(txn_list):
|
|
"""Enqueue bulk import as a background job for realtime progress via Socket.IO."""
|
|
if isinstance(txn_list, str):
|
|
txn_list = json.loads(txn_list)
|
|
|
|
user = frappe.session.user
|
|
|
|
frappe.enqueue(
|
|
_process_bulk_import,
|
|
txn_list=txn_list,
|
|
user=user,
|
|
queue="default",
|
|
timeout=600,
|
|
)
|
|
|
|
return {"success": True, "enqueued": True, "total": len(txn_list)}
|
|
|
|
|
|
def _process_bulk_import(txn_list, user):
|
|
"""Background job: process all transactions with realtime progress."""
|
|
frappe.log_error(f"PE import job started: {len(txn_list)} transactions, user={user}", "KB PE Import Start")
|
|
frappe.db.commit()
|
|
|
|
try:
|
|
frappe.set_user(user)
|
|
|
|
settings = frappe.get_single("Kapital Bank Settings")
|
|
purpose_threshold = flt(settings.similarity_threshold_purpose or 70) / 100.0
|
|
purpose_rules, voen_to_party, name_to_party = _build_import_lookups(settings)
|
|
|
|
total = len(txn_list)
|
|
imported_count = 0
|
|
errors = []
|
|
|
|
for idx, txn_data in enumerate(txn_list):
|
|
frappe.publish_realtime(
|
|
"kb_import_progress",
|
|
{"current": idx + 1, "total": total},
|
|
user=user,
|
|
)
|
|
|
|
result = _import_one_transaction(
|
|
txn_data, settings, purpose_rules, purpose_threshold,
|
|
voen_to_party, name_to_party,
|
|
)
|
|
|
|
if result.get("success"):
|
|
imported_count += 1
|
|
else:
|
|
errors.append(result)
|
|
|
|
# Commit after each successful transaction so progress is durable
|
|
frappe.db.commit()
|
|
|
|
frappe.log_error(
|
|
f"PE import finished: {imported_count}/{total} imported, {len(errors)} errors",
|
|
"KB PE Import Complete",
|
|
)
|
|
frappe.db.commit()
|
|
|
|
frappe.publish_realtime(
|
|
"kb_import_complete",
|
|
{"total": total, "imported": imported_count, "errors": errors},
|
|
user=user,
|
|
)
|
|
except Exception:
|
|
frappe.db.rollback()
|
|
frappe.log_error(
|
|
f"Bulk PE import failed: {frappe.get_traceback()}",
|
|
"KB PE Import Error",
|
|
)
|
|
frappe.db.commit()
|
|
|
|
|
|
@frappe.whitelist()
|
|
def import_single_transaction(txn_data, account_iban, login_name=None):
|
|
"""Import a single transaction (legacy endpoint, kept for compatibility)."""
|
|
try:
|
|
if isinstance(txn_data, str):
|
|
txn_data = json.loads(txn_data)
|
|
|
|
settings = frappe.get_single("Kapital Bank Settings")
|
|
purpose_threshold = flt(settings.similarity_threshold_purpose or 70) / 100.0
|
|
purpose_rules, voen_to_party, name_to_party = _build_import_lookups(settings)
|
|
|
|
result = _import_one_transaction(
|
|
txn_data, settings, purpose_rules, purpose_threshold,
|
|
voen_to_party, name_to_party,
|
|
)
|
|
if result.get("success"):
|
|
frappe.db.commit()
|
|
return result
|
|
|
|
except Exception as e:
|
|
frappe.local.message_log = []
|
|
frappe.log_error(
|
|
f"import_single_transaction failed: {e}\n{frappe.get_traceback()}",
|
|
"KB Single Import Error",
|
|
)
|
|
frappe.db.commit()
|
|
return {"success": False, "error_type": "import_error", "message": str(e)}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# TRANSACTION TRACKING
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
def _create_transaction_record(reference_no, posting_date, amount, currency,
|
|
party, source_type, source_label, payment_entry=None,
|
|
journal_entry=None, bank_transaction=None, commit=True):
|
|
"""Create a Kapital Bank Transaction record to track imported transaction."""
|
|
reference_no = str(reference_no).strip()
|
|
if not reference_no:
|
|
return
|
|
|
|
existing = frappe.db.get_value("Kapital Bank Transaction", {"reference_no": reference_no}, "name")
|
|
if existing:
|
|
return existing
|
|
|
|
try:
|
|
doc = frappe.new_doc("Kapital Bank Transaction")
|
|
doc.reference_no = reference_no
|
|
doc.posting_date = posting_date
|
|
doc.amount = amount
|
|
doc.currency = currency
|
|
doc.party = party
|
|
doc.source_type = source_type
|
|
doc.source_label = source_label
|
|
if payment_entry:
|
|
doc.payment_entry = payment_entry
|
|
if journal_entry:
|
|
doc.journal_entry = journal_entry
|
|
if bank_transaction:
|
|
doc.bank_transaction = bank_transaction
|
|
doc.status = "Imported"
|
|
doc.import_date = frappe.utils.now_datetime()
|
|
doc.insert(ignore_permissions=True)
|
|
if commit:
|
|
frappe.db.commit()
|
|
return doc.name
|
|
except Exception as e:
|
|
frappe.log_error(
|
|
f"_create_transaction_record failed for {reference_no}: {e}\n{frappe.get_traceback()}",
|
|
"Kapital Bank Transaction Tracking",
|
|
)
|
|
|
|
|
|
def on_delete_payment_entry(doc, method):
|
|
"""Delete linked Kapital Bank Transaction records when Payment Entry is deleted."""
|
|
try:
|
|
records = frappe.get_all(
|
|
"Kapital Bank Transaction",
|
|
filters={"payment_entry": doc.name},
|
|
fields=["name"],
|
|
)
|
|
for record in records:
|
|
frappe.delete_doc("Kapital Bank Transaction", record.name, ignore_permissions=True, force=True)
|
|
if records:
|
|
frappe.db.commit()
|
|
except Exception as e:
|
|
frappe.log_error(
|
|
f"Error deleting Kapital Bank Transaction for PE {doc.name}: {e}\n{frappe.get_traceback()}",
|
|
"Kapital Bank Transaction Cleanup",
|
|
)
|
|
|
|
|
|
def on_cancel_payment_entry(doc, method):
|
|
"""Delete linked Kapital Bank Transaction records when Payment Entry is cancelled."""
|
|
try:
|
|
records = frappe.get_all(
|
|
"Kapital Bank Transaction",
|
|
filters={"payment_entry": doc.name},
|
|
fields=["name"],
|
|
)
|
|
for record in records:
|
|
frappe.db.delete("Kapital Bank Transaction", {"name": record.name})
|
|
if records:
|
|
frappe.db.commit()
|
|
except Exception as e:
|
|
frappe.log_error(
|
|
f"Error deleting Kapital Bank Transaction for cancelled PE {doc.name}: {e}\n{frappe.get_traceback()}",
|
|
"Kapital Bank Transaction Cleanup",
|
|
)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# JOURNAL ENTRY CLEANUP
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
def on_delete_journal_entry(doc, method):
|
|
"""Delete linked Kapital Bank Transaction records when Journal Entry is deleted."""
|
|
try:
|
|
records = frappe.get_all(
|
|
"Kapital Bank Transaction",
|
|
filters={"journal_entry": doc.name},
|
|
fields=["name"],
|
|
)
|
|
for record in records:
|
|
frappe.delete_doc("Kapital Bank Transaction", record.name, ignore_permissions=True, force=True)
|
|
if records:
|
|
frappe.db.commit()
|
|
except Exception as e:
|
|
frappe.log_error(
|
|
f"Error deleting Kapital Bank Transaction for JE {doc.name}: {e}\n{frappe.get_traceback()}",
|
|
"Kapital Bank Transaction Cleanup",
|
|
)
|
|
|
|
|
|
def on_cancel_journal_entry(doc, method):
|
|
"""Delete linked Kapital Bank Transaction records when Journal Entry is cancelled."""
|
|
try:
|
|
records = frappe.get_all(
|
|
"Kapital Bank Transaction",
|
|
filters={"journal_entry": doc.name},
|
|
fields=["name"],
|
|
)
|
|
for record in records:
|
|
frappe.db.delete("Kapital Bank Transaction", {"name": record.name})
|
|
if records:
|
|
frappe.db.commit()
|
|
except Exception as e:
|
|
frappe.log_error(
|
|
f"Error deleting Kapital Bank Transaction for cancelled JE {doc.name}: {e}\n{frappe.get_traceback()}",
|
|
"Kapital Bank Transaction Cleanup",
|
|
)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# BANK TRANSACTION CLEANUP
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
def on_delete_bank_transaction(doc, method):
|
|
"""Delete linked Kapital Bank Transaction records when Bank Transaction is deleted."""
|
|
try:
|
|
records = frappe.get_all(
|
|
"Kapital Bank Transaction",
|
|
filters={"bank_transaction": doc.name},
|
|
fields=["name"],
|
|
)
|
|
for record in records:
|
|
frappe.delete_doc("Kapital Bank Transaction", record.name, ignore_permissions=True, force=True)
|
|
if records:
|
|
frappe.db.commit()
|
|
except Exception as e:
|
|
frappe.log_error(
|
|
f"Error deleting Kapital Bank Transaction for BT {doc.name}: {e}\n{frappe.get_traceback()}",
|
|
"Kapital Bank Transaction Cleanup",
|
|
)
|
|
|
|
|
|
def on_cancel_bank_transaction(doc, method):
|
|
"""Delete linked Kapital Bank Transaction records when Bank Transaction is cancelled."""
|
|
try:
|
|
records = frappe.get_all(
|
|
"Kapital Bank Transaction",
|
|
filters={"bank_transaction": doc.name},
|
|
fields=["name"],
|
|
)
|
|
for record in records:
|
|
frappe.db.delete("Kapital Bank Transaction", {"name": record.name})
|
|
if records:
|
|
frappe.db.commit()
|
|
except Exception as e:
|
|
frappe.log_error(
|
|
f"Error deleting Kapital Bank Transaction for cancelled BT {doc.name}: {e}\n{frappe.get_traceback()}",
|
|
"Kapital Bank Transaction Cleanup",
|
|
)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# BANK TRANSACTION IMPORT (simpler path — no purpose/party mappings required)
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
def _build_bank_account_lookup(settings):
|
|
"""Build (IBAN/card-account-number, currency) → Bank Account lookup from settings mappings.
|
|
|
|
Also builds a fallback IBAN-only lookup for cases where currency is not specified in mapping.
|
|
Returns: {(source_id, currency): bank_account, ...}, {source_id: bank_account, ...}
|
|
"""
|
|
ba_lookup_by_currency = {}
|
|
ba_lookup_fallback = {}
|
|
|
|
for row in settings.account_mappings:
|
|
if row.iban and row.bank_account:
|
|
iban = row.iban.strip()
|
|
ccy = (row.currency or "").strip().upper()
|
|
if ccy:
|
|
ba_lookup_by_currency[(iban, ccy)] = row.bank_account
|
|
ba_lookup_fallback[iban] = row.bank_account
|
|
|
|
for row in settings.card_mappings:
|
|
if row.account_number and row.bank_account:
|
|
acc_no = row.account_number.strip()
|
|
ccy = (getattr(row, "currency", "") or "").strip().upper()
|
|
if ccy:
|
|
ba_lookup_by_currency[(acc_no, ccy)] = row.bank_account
|
|
ba_lookup_fallback[acc_no] = row.bank_account
|
|
|
|
return ba_lookup_by_currency, ba_lookup_fallback
|
|
|
|
|
|
def _import_one_bank_transaction(txn_data, settings, voen_to_party, name_to_party,
|
|
ba_lookup_by_currency, ba_lookup_fallback):
|
|
"""Import a single transaction as a Bank Transaction document. Returns result dict."""
|
|
ref_no = txn_data.get("ref_no", "")
|
|
parsed_date = txn_data.get("date")
|
|
amount = abs(flt(txn_data.get("amount", 0)))
|
|
dr_cr = (txn_data.get("drcr") or "D").upper()
|
|
purpose = (txn_data.get("purpose") or "").strip()
|
|
contr_name = (txn_data.get("counterparty") or "").strip()
|
|
contr_voen = (txn_data.get("contr_voen") or "").strip()
|
|
currency = (txn_data.get("currency") or "AZN").strip().upper()
|
|
source_type = txn_data.get("source_type", "account")
|
|
source_label = txn_data.get("source_label", "")
|
|
|
|
# Determine bank_account from source identifier + currency
|
|
bank_account = None
|
|
source_keys = []
|
|
if source_type == "account":
|
|
source_id = txn_data.get("source_id", "")
|
|
if source_id:
|
|
source_keys.append(source_id.strip())
|
|
if source_label:
|
|
source_keys.append(source_label.strip())
|
|
elif source_type == "card":
|
|
source_id = txn_data.get("source_id", "")
|
|
if source_id:
|
|
source_keys.append(source_id.strip())
|
|
if source_label:
|
|
card_num = source_label.replace("Card: ", "").strip()
|
|
source_keys.append(card_num)
|
|
|
|
# Try currency-specific lookup first, then fallback
|
|
for key in source_keys:
|
|
bank_account = ba_lookup_by_currency.get((key, currency))
|
|
if bank_account:
|
|
break
|
|
if not bank_account:
|
|
for key in source_keys:
|
|
bank_account = ba_lookup_fallback.get(key)
|
|
if bank_account:
|
|
break
|
|
|
|
if not bank_account:
|
|
source_display = source_keys[0] if source_keys else source_label
|
|
return {
|
|
"success": False,
|
|
"error_type": "unmapped_bank_account",
|
|
"message": f"No bank account mapping for {source_display}",
|
|
"txn_data": txn_data,
|
|
}
|
|
|
|
# Use the bank account's own currency to avoid mismatch errors in Frappe
|
|
gl_account = frappe.db.get_value("Bank Account", bank_account, "account")
|
|
if gl_account:
|
|
currency = frappe.db.get_value("Account", gl_account, "account_currency") or currency
|
|
|
|
# Optional party mapping: VÖEN → name → None
|
|
# C (credit, money in) → prefer Customer; D (debit, money out) → prefer Supplier
|
|
def _resolve_party(candidates):
|
|
preferred = "Customer" if dr_cr == "C" else "Supplier"
|
|
fallback = "Supplier" if preferred == "Customer" else "Customer"
|
|
entry = candidates.get(preferred) or candidates.get(fallback)
|
|
return (entry["party_type"], entry["erp_party"]) if entry else (None, None)
|
|
|
|
party_type = None
|
|
erp_party = None
|
|
if contr_voen and contr_voen in voen_to_party:
|
|
party_type, erp_party = _resolve_party(voen_to_party[contr_voen])
|
|
elif contr_name and contr_name in name_to_party:
|
|
party_type, erp_party = _resolve_party(name_to_party[contr_name])
|
|
|
|
# Create Bank Transaction
|
|
bt = frappe.new_doc("Bank Transaction")
|
|
bt.date = parsed_date
|
|
bt.deposit = amount if dr_cr == "C" else 0
|
|
bt.withdrawal = amount if dr_cr == "D" else 0
|
|
bt.currency = currency
|
|
bt.description = txn_data.get("full_description") or purpose
|
|
bt.reference_number = ref_no
|
|
bt.transaction_id = ref_no
|
|
bt.company = erpnext.get_default_company()
|
|
bt.bank_party_name = contr_name or purpose
|
|
|
|
bt.bank_account = bank_account
|
|
|
|
kb_currency = (txn_data.get("kb_currency") or "").strip()
|
|
if kb_currency:
|
|
bt.kb_currency = kb_currency
|
|
|
|
if party_type and erp_party:
|
|
bt.party_type = party_type
|
|
bt.party = erp_party
|
|
|
|
try:
|
|
bt.insert(ignore_permissions=True)
|
|
bt.submit()
|
|
except Exception as bt_err:
|
|
frappe.db.rollback()
|
|
frappe.local.message_log = []
|
|
return {
|
|
"success": False,
|
|
"error_type": "import_error",
|
|
"message": str(bt_err),
|
|
"txn_data": txn_data,
|
|
}
|
|
|
|
# Create tracking record
|
|
_create_transaction_record(
|
|
reference_no=ref_no,
|
|
posting_date=parsed_date,
|
|
amount=amount,
|
|
currency=currency,
|
|
party=contr_name,
|
|
source_type="Account" if source_type == "account" else "Card",
|
|
source_label=source_label,
|
|
bank_transaction=bt.name,
|
|
commit=False,
|
|
)
|
|
|
|
return {"success": True, "bank_transaction": bt.name}
|
|
|
|
|
|
@frappe.whitelist()
|
|
def import_bulk_bank_transactions(txn_list):
|
|
"""Enqueue bulk Bank Transaction import as a background job."""
|
|
if isinstance(txn_list, str):
|
|
txn_list = json.loads(txn_list)
|
|
|
|
user = frappe.session.user
|
|
|
|
frappe.enqueue(
|
|
_process_bulk_bt_import,
|
|
txn_list=txn_list,
|
|
user=user,
|
|
queue="default",
|
|
timeout=600,
|
|
)
|
|
|
|
return {"success": True, "enqueued": True, "total": len(txn_list)}
|
|
|
|
|
|
def _process_bulk_bt_import(txn_list, user):
|
|
"""Background job: import transactions as Bank Transaction with realtime progress."""
|
|
frappe.log_error(f"BT import job started: {len(txn_list)} transactions, user={user}", "KB BT Import Start")
|
|
frappe.db.commit()
|
|
|
|
try:
|
|
frappe.set_user(user)
|
|
|
|
settings = frappe.get_single("Kapital Bank Settings")
|
|
_purpose_rules, voen_to_party, name_to_party = _build_import_lookups(settings)
|
|
ba_lookup_by_currency, ba_lookup_fallback = _build_bank_account_lookup(settings)
|
|
|
|
total = len(txn_list)
|
|
imported_count = 0
|
|
errors = []
|
|
|
|
for idx, txn_data in enumerate(txn_list):
|
|
frappe.publish_realtime(
|
|
"kb_bt_import_progress",
|
|
{"current": idx + 1, "total": total},
|
|
user=user,
|
|
)
|
|
|
|
result = _import_one_bank_transaction(
|
|
txn_data, settings, voen_to_party, name_to_party,
|
|
ba_lookup_by_currency, ba_lookup_fallback,
|
|
)
|
|
|
|
if result.get("success"):
|
|
imported_count += 1
|
|
else:
|
|
errors.append(result)
|
|
|
|
frappe.db.commit()
|
|
|
|
error_sample = "\n".join(
|
|
f" [{e.get('error_type')}] {e.get('message', '')[:200]}"
|
|
for e in errors[:10]
|
|
)
|
|
frappe.log_error(
|
|
f"BT import finished: {imported_count}/{total} imported, {len(errors)} errors\n"
|
|
f"First errors:\n{error_sample}",
|
|
"KB BT Import Complete",
|
|
)
|
|
frappe.db.commit()
|
|
|
|
frappe.publish_realtime(
|
|
"kb_bt_import_complete",
|
|
{"total": total, "imported": imported_count, "errors": errors},
|
|
user=user,
|
|
)
|
|
except Exception:
|
|
frappe.db.rollback()
|
|
frappe.log_error(
|
|
f"Bulk BT import failed: {frappe.get_traceback()}",
|
|
"KB BT Import Error",
|
|
)
|
|
frappe.db.commit()
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# INTERNAL HELPERS
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
_AZERI_MAP = str.maketrans("ƏəÜüÖöĞğİıÇ窺", "EeUuOoGgIiCcSs")
|
|
|
|
def _normalize(text, consider_azeri=True):
|
|
"""Normalize text for similarity comparison.
|
|
Optionally replaces Azərbaycan characters before lowercasing."""
|
|
if not text:
|
|
return ""
|
|
s = str(text)
|
|
if consider_azeri:
|
|
s = s.translate(_AZERI_MAP)
|
|
return " ".join(s.lower().split())
|
|
|
|
|
|
def _iso_date(d):
|
|
"""Normalise a date string/object → ISO YYYY-MM-DD (the format the v3 portal
|
|
statement endpoints expect). Returns None for falsy input."""
|
|
if not d:
|
|
return None
|
|
if hasattr(d, 'strftime'):
|
|
return d.strftime('%Y-%m-%d')
|
|
return str(d)
|