jey_erp/jey_erp/bank_integration/import_api.py

346 lines
11 KiB
Python

"""Whitelisted import endpoints + background job for Bank Integration Excel import."""
import json
import frappe
from frappe import _
from frappe.utils import cint, flt
from jey_erp.bank_integration.excel_parser import parse_excel
@frappe.whitelist()
def parse_excel_for_preview(file_url, preset_name, bank_integration, bank_account):
"""Parse Excel and return preview transactions, deduped against existing BTs."""
transactions = parse_excel(file_url, preset_name)
if not transactions:
return {"success": True, "transactions": [], "skipped_duplicates": 0, "total_parsed": 0}
ref_nos = [t["ref_no"] for t in transactions if t.get("ref_no")]
existing = set()
if ref_nos:
rows = frappe.get_all(
"Bank Transaction",
filters={
"reference_number": ["in", ref_nos],
"bank_account": bank_account,
},
fields=["reference_number"],
limit_page_length=0,
)
existing = {r.reference_number for r in rows}
fresh = []
skipped = 0
for t in transactions:
if t.get("ref_no") and t["ref_no"] in existing:
skipped += 1
else:
fresh.append(t)
return {
"success": True,
"transactions": fresh,
"skipped_duplicates": skipped,
"total_parsed": len(transactions),
}
@frappe.whitelist()
def import_bulk_bt(txn_list, bank_integration, bank_account):
"""Enqueue bulk Bank Transaction creation as a background job.
Creates Bank Transactions only — registries (Bank Integration Customer /
Supplier / Purpose) are populated separately via the "Load Data" button on
the Bank Integration form. Party is resolved later, at "Create & Reconcile"
time, from the mappings.
"""
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,
bank_integration=bank_integration,
bank_account=bank_account,
user=user,
queue="default",
timeout=600,
)
return {"success": True, "enqueued": True, "total": len(txn_list)}
def _process_bulk_bt_import(txn_list, bank_integration, bank_account, user):
"""Background job: create one Bank Transaction per row."""
frappe.set_user(user)
bi = frappe.get_doc("Bank Integration", bank_integration)
company = bi.default_company
ba_doc = frappe.get_doc("Bank Account", bank_account)
ba_account = ba_doc.account
ba_currency = (
frappe.db.get_value("Account", ba_account, "account_currency") if ba_account else None
) or "AZN"
total = len(txn_list)
imported_count = 0
errors = []
for idx, txn in enumerate(txn_list):
frappe.publish_realtime(
"bi_bt_import_progress",
{"current": idx + 1, "total": total},
user=user,
)
try:
counterparty = (txn.get("counterparty") or "").strip()
drcr = (txn.get("drcr") or "D").upper()
purpose = (txn.get("purpose") or "").strip()
ref_no = (txn.get("ref_no") or "").strip()
amount = abs(flt(txn.get("amount", 0)))
bt = frappe.new_doc("Bank Transaction")
bt.date = txn.get("date")
bt.deposit = amount if drcr == "C" else 0
bt.withdrawal = amount if drcr == "D" else 0
bt.currency = ba_currency
bt.description = txn.get("description") or purpose
bt.reference_number = ref_no
bt.transaction_id = ref_no
bt.company = company
bt.bank_party_name = counterparty or purpose
bt.bank_account = bank_account
bt.insert(ignore_permissions=True)
bt.submit()
imported_count += 1
frappe.db.commit()
except Exception as e:
frappe.db.rollback()
frappe.local.message_log = []
errors.append({
"ref_no": txn.get("ref_no"),
"counterparty": txn.get("counterparty"),
"amount": txn.get("amount"),
"date": txn.get("date"),
"error_type": "import_error",
"message": str(e)[:500],
"txn_data": txn,
})
frappe.log_error(
f"BI BT import failed for ref {txn.get('ref_no')}: {e}\n{frappe.get_traceback()}",
"BI BT Import Error",
)
frappe.db.commit()
frappe.publish_realtime(
"bi_bt_import_complete",
{"total": total, "imported": imported_count, "errors": errors},
user=user,
)
frappe.log_error(f"BI BT import done: {imported_count}/{total}", "BI BT Import Complete")
def _upsert_customer(name, voen, iban, bank_integration):
"""Returns True if new record was created."""
if voen and frappe.db.exists("Bank Integration Customer", {
"tax_id": voen, "parent_bank_integration": bank_integration
}):
return False
if frappe.db.exists("Bank Integration Customer", {
"customer_name": name, "parent_bank_integration": bank_integration
}):
return False
doc = frappe.new_doc("Bank Integration Customer")
doc.customer_name = name
doc.tax_id = voen or None
doc.iban = iban or None
doc.status = "New"
doc.parent_bank_integration = bank_integration
try:
doc.insert(ignore_permissions=True)
return True
except frappe.DuplicateEntryError:
return False
def _upsert_supplier(name, voen, iban, bank_integration):
if voen and frappe.db.exists("Bank Integration Supplier", {
"tax_id": voen, "parent_bank_integration": bank_integration
}):
return False
if frappe.db.exists("Bank Integration Supplier", {
"supplier_name": name, "parent_bank_integration": bank_integration
}):
return False
doc = frappe.new_doc("Bank Integration Supplier")
doc.supplier_name = name
doc.tax_id = voen or None
doc.iban = iban or None
doc.status = "New"
doc.parent_bank_integration = bank_integration
try:
doc.insert(ignore_permissions=True)
return True
except frappe.DuplicateEntryError:
return False
def _upsert_purpose(purpose, drcr, bank_integration):
existing = frappe.db.get_value("Bank Integration Purpose", {
"purpose_keyword": purpose,
"parent_bank_integration": bank_integration,
}, ["name", "direction"], as_dict=True)
new_dir = "Receive" if drcr == "C" else "Pay"
if existing:
if existing.direction and existing.direction != new_dir and existing.direction != "Both":
frappe.db.set_value("Bank Integration Purpose", existing.name, "direction", "Both", update_modified=False)
return False
doc = frappe.new_doc("Bank Integration Purpose")
doc.purpose_keyword = purpose
doc.direction = new_dir
doc.status = "New"
doc.parent_bank_integration = bank_integration
try:
doc.insert(ignore_permissions=True)
return True
except frappe.DuplicateEntryError:
return False
@frappe.whitelist()
def get_bi_reference_data_list(bank_integration, data_type, limit=100, offset=0):
"""Paginated rows for the Bank Integration "Data" tab subtabs.
data_type ∈ {accounts, customers, suppliers, purposes}.
"""
limit = cint(limit)
offset = cint(offset)
try:
if data_type == "accounts":
total = frappe.db.count("Bank Integration Account Mapping", filters={
"parent": bank_integration, "parenttype": "Bank Integration"
})
data = frappe.get_all(
"Bank Integration Account Mapping",
filters={"parent": bank_integration, "parenttype": "Bank Integration"},
fields=["name", "iban", "account_label", "currency", "bank_account", "gl_account"],
limit=limit, start=offset, order_by="idx asc",
)
elif data_type == "customers":
total = frappe.db.count("Bank Integration Customer", filters={"parent_bank_integration": bank_integration})
data = frappe.get_all(
"Bank Integration Customer",
filters={"parent_bank_integration": bank_integration},
fields=["name", "customer_name", "tax_id", "iban", "status", "mapped_customer", "creation"],
limit=limit, start=offset, order_by="customer_name asc",
)
elif data_type == "suppliers":
total = frappe.db.count("Bank Integration Supplier", filters={"parent_bank_integration": bank_integration})
data = frappe.get_all(
"Bank Integration Supplier",
filters={"parent_bank_integration": bank_integration},
fields=["name", "supplier_name", "tax_id", "iban", "status", "mapped_supplier", "creation"],
limit=limit, start=offset, order_by="supplier_name asc",
)
elif data_type == "purposes":
total = frappe.db.count("Bank Integration Purpose", filters={"parent_bank_integration": bank_integration})
data = frappe.get_all(
"Bank Integration Purpose",
filters={"parent_bank_integration": bank_integration},
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_bi_reference_data_list({data_type}): {e}\n{frappe.get_traceback()}", "BI Reference Data")
return {"success": False, "message": str(e)}
@frappe.whitelist()
def update_bank_account_bi_default(bank_account, bi_type, bi_name):
"""Save the user's BRT-time choice to the BA's hidden fields."""
if not bank_account:
frappe.throw(_("Bank Account required"))
if bi_type and bi_type not in ("Bank Integration", "Kapital Bank Settings"):
frappe.throw(_("Invalid Bank Integration Type"))
frappe.db.set_value("Bank Account", bank_account, {
"bank_integration_type": bi_type or None,
"bank_integration": bi_name or None,
}, update_modified=False)
frappe.db.commit()
return {"success": True}
@frappe.whitelist()
def load_registries_from_excel(file_url, preset_name, bank_integration,
load_customers=1, load_suppliers=1, load_purposes=1):
"""Parse an Excel statement and upsert ONLY the selected reference registries
(Bank Integration Customer / Supplier / Purpose) — no Bank Transactions.
This is the "Load Data" action on the Bank Integration form, the file-based
counterpart to kapital_bank's API-driven counterparty/purpose loaders."""
load_customers = cint(load_customers)
load_suppliers = cint(load_suppliers)
load_purposes = cint(load_purposes)
if not (load_customers or load_suppliers or load_purposes):
return {"success": False, "message": _("Select at least one type to load.")}
transactions = parse_excel(file_url, preset_name)
new_c = new_s = new_p = 0
for txn in transactions:
counterparty = (txn.get("counterparty") or "").strip()
contr_voen = (txn.get("contr_voen") or "").strip()
contr_iban = (txn.get("contr_iban") or "").strip()
drcr = (txn.get("drcr") or "D").upper()
purpose = (txn.get("purpose") or "").strip()
if counterparty:
if drcr == "C" and load_customers:
if _upsert_customer(counterparty, contr_voen, contr_iban, bank_integration):
new_c += 1
elif drcr == "D" and load_suppliers:
if _upsert_supplier(counterparty, contr_voen, contr_iban, bank_integration):
new_s += 1
if purpose and load_purposes:
if _upsert_purpose(purpose, drcr, bank_integration):
new_p += 1
frappe.db.commit()
return {
"success": True,
"total_rows": len(transactions),
"new_customers": new_c,
"new_suppliers": new_s,
"new_purposes": new_p,
}