From 4a560dbde5c347774b7b6951ecfc173f5b179d56 Mon Sep 17 00:00:00 2001 From: Ali <010109ali@gmail.com> Date: Thu, 26 Feb 2026 22:04:10 +0400 Subject: [PATCH] payment entry import works --- kapital_bank/bank_api.py | 795 +++++++++++++----- kapital_bank/client/payment_entry.js | 638 ++++++++++++-- kapital_bank/hooks.py | 17 +- .../kapital_bank_client_mapping.json | 61 -- .../__init__.py | 0 .../kapital_bank_customer.json | 130 +++ .../kapital_bank_customer.py} | 4 +- .../__init__.py | 0 .../kapital_bank_customer_mapping.json | 77 ++ .../kapital_bank_customer_mapping.py | 6 + .../kapital_bank_purpose_mapping/__init__.py | 0 .../kapital_bank_purpose_mapping.json | 59 ++ .../kapital_bank_purpose_mapping.py} | 2 +- .../kapital_bank_settings.js | 425 +++++++--- .../kapital_bank_settings.json | 87 +- .../kapital_bank_settings.py | 72 +- .../doctype/kapital_bank_supplier/__init__.py | 0 .../kapital_bank_supplier.json} | 75 +- .../kapital_bank_supplier.py | 6 + .../kapital_bank_supplier_mapping/__init__.py | 0 .../kapital_bank_supplier_mapping.json | 70 ++ .../kapital_bank_supplier_mapping.py | 6 + kapital_bank/payment_api.py | 278 +----- kapital_bank/setup.py | 86 +- 24 files changed, 2042 insertions(+), 852 deletions(-) delete mode 100644 kapital_bank/kapital_bank/doctype/kapital_bank_client_mapping/kapital_bank_client_mapping.json rename kapital_bank/kapital_bank/doctype/{kapital_bank_client => kapital_bank_customer}/__init__.py (100%) create mode 100644 kapital_bank/kapital_bank/doctype/kapital_bank_customer/kapital_bank_customer.json rename kapital_bank/kapital_bank/doctype/{kapital_bank_client_mapping/kapital_bank_client_mapping.py => kapital_bank_customer/kapital_bank_customer.py} (53%) rename kapital_bank/kapital_bank/doctype/{kapital_bank_client_mapping => kapital_bank_customer_mapping}/__init__.py (100%) create mode 100644 kapital_bank/kapital_bank/doctype/kapital_bank_customer_mapping/kapital_bank_customer_mapping.json create mode 100644 kapital_bank/kapital_bank/doctype/kapital_bank_customer_mapping/kapital_bank_customer_mapping.py create mode 100644 kapital_bank/kapital_bank/doctype/kapital_bank_purpose_mapping/__init__.py create mode 100644 kapital_bank/kapital_bank/doctype/kapital_bank_purpose_mapping/kapital_bank_purpose_mapping.json rename kapital_bank/kapital_bank/doctype/{kapital_bank_client/kapital_bank_client.py => kapital_bank_purpose_mapping/kapital_bank_purpose_mapping.py} (61%) create mode 100644 kapital_bank/kapital_bank/doctype/kapital_bank_supplier/__init__.py rename kapital_bank/kapital_bank/doctype/{kapital_bank_client/kapital_bank_client.json => kapital_bank_supplier/kapital_bank_supplier.json} (51%) create mode 100644 kapital_bank/kapital_bank/doctype/kapital_bank_supplier/kapital_bank_supplier.py create mode 100644 kapital_bank/kapital_bank/doctype/kapital_bank_supplier_mapping/__init__.py create mode 100644 kapital_bank/kapital_bank/doctype/kapital_bank_supplier_mapping/kapital_bank_supplier_mapping.json create mode 100644 kapital_bank/kapital_bank/doctype/kapital_bank_supplier_mapping/kapital_bank_supplier_mapping.py diff --git a/kapital_bank/bank_api.py b/kapital_bank/bank_api.py index fa65a7a..d768da3 100644 --- a/kapital_bank/bank_api.py +++ b/kapital_bank/bank_api.py @@ -1,5 +1,7 @@ +import json import re import frappe +from datetime import datetime from frappe.utils import cint, flt from difflib import SequenceMatcher from kapital_bank.api import BIRBankClient @@ -98,11 +100,11 @@ def load_cards_to_registry(login_name=None): @frappe.whitelist() -def load_clients_from_statements(from_date, to_date, login_name=None): +def load_counterparties_from_statements(from_date, to_date, login_name=None): """ Fetch account statements for all registered accounts and extract counterparties - (contrAccount field) into the Kapital Bank Client registry. - Analogous to invoice_az loading customers/suppliers from invoice documents. + 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) @@ -111,12 +113,11 @@ def load_clients_from_statements(from_date, to_date, login_name=None): if not accounts: return {"success": False, "message": "No accounts in registry. Load accounts first."} - created = skipped = 0 + customers_created = suppliers_created = skipped = 0 debug_log = [] for acc in accounts: iban = acc.iban - # API expects the numeric account number (custAcNo), not the full IBAN account_number = acc.cust_ac_no or iban debug_log.append(f"Account: {_mask(iban)} custAcNo={_mask(account_number)} ({from_date} → {to_date})") @@ -132,7 +133,7 @@ def load_clients_from_statements(from_date, to_date, login_name=None): data = resp.json() except Exception as e: debug_log.append(f" Error: {e}") - frappe.log_error(f"Statement fetch failed for {_mask(iban)}: {e}", "KB Load Clients") + frappe.log_error(f"Statement fetch failed for {_mask(iban)}: {e}", "KB Load Counterparties") continue resp_obj = data.get("response", {}) @@ -151,60 +152,76 @@ def load_clients_from_statements(from_date, to_date, login_name=None): ) debug_log.append(f" statementList entries: {len(statement_list)}") - # Log first 3 entries to understand the real contrAccount format - if statement_list: - debug_log.append(f" First entry keys: {list(statement_list[0].keys())}") - for i, e in enumerate(statement_list[:3]): - ca = _mask(e.get('contrAccount', 'N/A')) - pu = e.get('purpose', '')[:60] - debug_log.append(f" Entry[{i}] contrAccount={ca!r} purpose={pu!r}") - for entry in statement_list: contr_raw = (entry.get("contrAccount") or "").strip() if not contr_raw: continue - # contrAccount format: "IBAN / COMPANY NAME / VÖEN" - # e.g. "AZ12AIIB400...000 / TEST COMPANY LLC / 4800123456" parts = [p.strip() for p in contr_raw.split(" / ")] if len(parts) < 2: - continue # unrecognised format, skip + continue + contr_iban = parts[0] contr_name = parts[1] contr_voen = parts[2] if len(parts) >= 3 else "" if not contr_name: continue - # Deduplicate: by VÖEN if present, else by client_name - if contr_voen and frappe.db.exists("Kapital Bank Client", {"tax_id": contr_voen}): - skipped += 1 - continue - if not contr_voen and frappe.db.exists("Kapital Bank Client", {"client_name": contr_name}): + # 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 - doc = frappe.new_doc("Kapital Bank Client") - doc.client_name = contr_name - doc.tax_id = contr_voen or None # NULL doesn't violate unique constraint - doc.status = "New" + # 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) - created += 1 + 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 client failed for {_mask(contr_raw)}: {ins_e}", "KB Load Clients") + frappe.log_error(f"Insert counterparty failed for {_mask(contr_raw)}: {ins_e}", "KB Load Counterparties") frappe.db.commit() + frappe.log_error("\n".join(debug_log), "KB Load Counterparties Debug") - # Always log the debug info so we can diagnose issues - frappe.log_error("\n".join(debug_log), "KB Load Clients Debug") - - return {"success": True, "created": created, "skipped": skipped, "debug": debug_log} + 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_clients_from_statements: {e}\n{frappe.get_traceback()}", "KB Bank API") + frappe.log_error(f"load_counterparties_from_statements: {e}\n{frappe.get_traceback()}", "KB Bank API") return {"success": False, "message": str(e)} @@ -222,9 +239,10 @@ def get_kb_reference_data_summary(): return {"total": total, "mapped": mapped} summary = { - "accounts": counts("Kapital Bank Account", {"status": "Mapped"}), - "cards": counts("Kapital Bank Card", {"status": "Mapped"}), - "clients": counts("Kapital Bank Client", {"status": ["in", ["Mapped", "Active"]]}), + "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"}), } return {"success": True, "summary": summary} except Exception as e: @@ -259,14 +277,24 @@ def get_kb_reference_data_list(data_type, limit=100, offset=0): order_by="card_type asc" ) - elif data_type == "clients": - total = frappe.db.count("Kapital Bank Client") + elif data_type == "customers": + total = frappe.db.count("Kapital Bank Customer") data = frappe.get_all( - "Kapital Bank Client", - fields=["name", "client_name", "tax_id", "iban", "bank_code", - "status", "mapped_party_type", "mapped_party", "creation"], + "Kapital Bank Customer", + fields=["name", "customer_name", "tax_id", "iban", "bank_code", + "status", "mapped_customer", "creation"], limit=limit, start=offset, - order_by="client_name asc" + 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" ) else: @@ -308,105 +336,110 @@ def get_unmapped_accounts(): @frappe.whitelist() -def get_unmapped_clients(): - """Return Kapital Bank Clients with status='New' not yet in client_mappings.""" +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.client_name for row in settings.client_mappings if row.client_name} + already_mapped = {row.kb_customer_name for row in settings.customer_mappings if row.kb_customer_name} - clients = frappe.get_all( - "Kapital Bank Client", + customers = frappe.get_all( + "Kapital Bank Customer", filters={"status": "New"}, - fields=["name", "client_name", "tax_id", "iban", "mapped_party_type", "mapped_party"] + fields=["name", "customer_name", "tax_id", "iban"] ) - unmapped = [c for c in clients if c.client_name not in already_mapped] - return {"success": True, "clients": unmapped} + 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), "KB get_unmapped_clients") + frappe.log_error(str(e), "KB 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), "KB get_unmapped_suppliers") return {"success": False, "message": str(e)} # ═══════════════════════════════════════════════════════════════════════════════ -# CLIENT MATCHING & CREATION +# CUSTOMER MATCHING & CREATION # ═══════════════════════════════════════════════════════════════════════════════ @frappe.whitelist() -def match_similar_clients(): - """Auto-match Kapital Bank Clients to ERPNext Customer/Supplier by name similarity. - Follows invoice_az pattern exactly.""" +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 or 95) / 100.0 consider_azeri = bool(doc.consider_azeri_chars) - new_clients = frappe.get_all( - "Kapital Bank Client", + new_customers = frappe.get_all( + "Kapital Bank Customer", filters={"status": "New"}, - fields=["name", "client_name", "tax_id"] + fields=["name", "customer_name", "tax_id"] ) - if not new_clients: + if not new_customers: return {"success": True, "matched_count": 0, "total_processed": 0} - customers = frappe.get_all("Customer", fields=["name", "customer_name", "tax_id"]) - suppliers = frappe.get_all("Supplier", fields=["name", "supplier_name", "tax_id"]) + erp_customers = frappe.get_all("Customer", fields=["name", "customer_name", "tax_id"]) - # VÖEN lookup: tax_id → (party_name, party_type) for exact matching + # VÖEN lookup for exact matching voen_index = {} - for c in customers: + for c in erp_customers: if c.tax_id: - voen_index[c.tax_id.strip()] = (c.name, "Customer") - for s in suppliers: - if s.tax_id: - voen_index[s.tax_id.strip()] = (s.name, "Supplier") + voen_index[c.tax_id.strip()] = c.name - # Build dict of existing mappings: client_name → erp_party + # Build dict of existing mappings: kb_customer_name → erp_customer existing_mappings = { - row.client_name: row.erp_party - for row in doc.client_mappings if row.client_name + row.kb_customer_name: row.erp_customer + for row in doc.customer_mappings if row.kb_customer_name } - # Group 1: clients already in the table but with empty erp_party - clients_with_empty_mapping = [] - for client_name, erp_party in existing_mappings.items(): - if not erp_party: - for c in new_clients: - if c.client_name == client_name: - clients_with_empty_mapping.append(c) + # 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: clients not in the table at all - clients_not_in_table = [c for c in new_clients if c.client_name not in existing_mappings] + # 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] - # Process both groups (same as invoice_az) - clients_to_process = clients_not_in_table + clients_with_empty_mapping + customers_to_process = customers_not_in_table + customers_with_empty_mapping matched = 0 - for client in clients_to_process: + for customer in customers_to_process: best_party = None - best_type = None # Priority 1: exact VÖEN match - if client.tax_id and client.tax_id.strip() in voen_index: - best_party, best_type = voen_index[client.tax_id.strip()] + 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(client.client_name, consider_azeri) + kb_name = _normalize(customer.customer_name, consider_azeri) best_score = 0 - for c in customers: + 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 - best_type = "Customer" - - for s in suppliers: - score = SequenceMatcher(None, kb_name, _normalize(s.supplier_name, consider_azeri)).ratio() - if score > best_score: - best_score = score - best_party = s.name - best_type = "Supplier" if best_score < threshold: best_party = None @@ -414,28 +447,25 @@ def match_similar_clients(): if best_party: # Update existing row if present, else append new row existing_idx = None - for idx, row in enumerate(doc.client_mappings): - if row.client_name == client.client_name: + 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.client_mappings[existing_idx].party_type = best_type - doc.client_mappings[existing_idx].erp_party = best_party - doc.client_mappings[existing_idx].mapping_type = "Automatic" + doc.customer_mappings[existing_idx].erp_customer = best_party + doc.customer_mappings[existing_idx].mapping_type = "Automatic" else: - doc.append("client_mappings", { - "client_name": client.client_name, - "tax_id": client.tax_id or None, - "party_type": best_type, - "erp_party": best_party, - "mapping_type": "Automatic", + 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 Client", client.name, { - "mapped_party_type": best_type, - "mapped_party": best_party, - "status": "Mapped", + frappe.db.set_value("Kapital Bank Customer", customer.name, { + "mapped_customer": best_party, + "status": "Mapped", }, update_modified=False) matched += 1 @@ -443,112 +473,254 @@ def match_similar_clients(): doc.save(ignore_permissions=True) frappe.db.commit() - return {"success": True, "matched_count": matched, "total_processed": len(clients_to_process)} + return {"success": True, "matched_count": matched, "total_processed": len(customers_to_process)} except Exception as e: - frappe.log_error(f"match_similar_clients: {e}\n{frappe.get_traceback()}", "KB Client Matching") + frappe.log_error(f"match_similar_customers: {e}\n{frappe.get_traceback()}", "KB Customer Matching") return {"success": False, "message": str(e)} @frappe.whitelist() -def create_unmapped_clients(): - """Create Customer/Supplier records for New Kapital Bank Clients that have no mapped party. - Follows invoice_az pattern: also appends created rows to Settings.client_mappings.""" +def match_similar_suppliers(): + """Auto-match Kapital Bank Suppliers to ERPNext Suppliers by name similarity.""" try: - clients = frappe.get_all( - "Kapital Bank Client", - filters={"status": "New"}, - fields=["name", "client_name", "tax_id", "mapped_party_type"] - ) - doc = frappe.get_doc("Kapital Bank Settings") - already_in_mappings = {row.client_name for row in doc.client_mappings if row.client_name} + threshold = flt(doc.similarity_threshold or 95) / 100.0 + consider_azeri = bool(doc.consider_azeri_chars) - created = customers_count = suppliers_count = 0 + 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} - for c in clients: - if not c.mapped_party_type: - continue - try: - if c.mapped_party_type == "Customer": - party = frappe.new_doc("Customer") - party.customer_name = c.client_name - if doc.default_customer_group: - party.customer_group = doc.default_customer_group - if doc.default_territory: - party.territory = doc.default_territory - if doc.default_payment_terms: - party.payment_terms = doc.default_payment_terms - party.insert(ignore_permissions=True) - customers_count += 1 - elif c.mapped_party_type == "Supplier": - party = frappe.new_doc("Supplier") - party.supplier_name = c.client_name - if doc.default_supplier_group: - party.supplier_group = doc.default_supplier_group - if doc.default_payment_terms: - party.payment_terms = doc.default_payment_terms - party.insert(ignore_permissions=True) - suppliers_count += 1 + 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()}", "KB 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"} + + created = 0 + for mapping, kb_customer in unmapped: + try: + customer_name = kb_customer.customer_name + if frappe.db.exists("Customer", {"customer_name": customer_name}): continue - frappe.db.set_value("Kapital Bank Client", c.name, { - "mapped_party_type": c.mapped_party_type, - "mapped_party": party.name, - "status": "Mapped", - }, update_modified=False) + party = frappe.new_doc("Customer") + party.customer_name = customer_name + party.customer_type = "Company" - if c.client_name not in already_in_mappings: - doc.append("client_mappings", { - "client_name": c.client_name, - "tax_id": c.tax_id or None, - "party_type": c.mapped_party_type, - "erp_party": party.name, - "mapping_type": "Manual", - }) - already_in_mappings.add(c.client_name) + customer_group = mapping.customer_group or doc.default_customer_group + if not customer_group or not frappe.db.exists("Customer Group", customer_group): + customer_group = "All Customer Groups" + party.customer_group = customer_group + + territory = mapping.territory or doc.default_territory or "All Territories" + if not frappe.db.exists("Territory", territory): + territory = "All Territories" + party.territory = territory + + if kb_customer.tax_id: + party.tax_id = kb_customer.tax_id + + payment_terms = mapping.payment_terms or doc.default_payment_terms + 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 frappe.DuplicateEntryError: - existing = None - if c.mapped_party_type == "Customer": - existing = frappe.db.get_value("Customer", {"customer_name": c.client_name}, "name") - elif c.mapped_party_type == "Supplier": - existing = frappe.db.get_value("Supplier", {"supplier_name": c.client_name}, "name") - - if existing: - frappe.db.set_value("Kapital Bank Client", c.name, { - "mapped_party": existing, - "status": "Mapped", - }, update_modified=False) - if c.client_name not in already_in_mappings: - doc.append("client_mappings", { - "client_name": c.client_name, - "tax_id": c.tax_id or None, - "party_type": c.mapped_party_type, - "erp_party": existing, - "mapping_type": "Manual", - }) - already_in_mappings.add(c.client_name) - - except Exception as inner_e: - frappe.log_error(f"create_unmapped_clients: error for {c.name}: {inner_e}", "KB Client Creation") + except Exception as e: + frappe.log_error(f"create_unmapped_customers: error for {kb_customer.customer_name}: {e}", "KB Customer Creation") if created > 0: doc.save(ignore_permissions=True) frappe.db.commit() - return { - "success": True, - "created_count": created, - "customers_count": customers_count, - "suppliers_count": suppliers_count, - } + return {"success": True, "created_count": created} except Exception as e: - frappe.log_error(f"create_unmapped_clients: {e}\n{frappe.get_traceback()}", "KB Client Creation") + frappe.log_error(f"create_unmapped_customers: {e}\n{frappe.get_traceback()}", "KB 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"} + + created = 0 + for mapping, kb_supplier in unmapped: + try: + supplier_name = kb_supplier.supplier_name + if frappe.db.exists("Supplier", {"supplier_name": supplier_name}): + continue + + party = frappe.new_doc("Supplier") + party.supplier_name = supplier_name + party.supplier_type = "Company" + + supplier_group = mapping.supplier_group or doc.default_supplier_group + if not supplier_group or not frappe.db.exists("Supplier Group", supplier_group): + supplier_group = "All Supplier Groups" + party.supplier_group = supplier_group + + if kb_supplier.tax_id: + party.tax_id = kb_supplier.tax_id + + payment_terms = mapping.payment_terms or doc.default_payment_terms + 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}", "KB Supplier 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_suppliers: {e}\n{frappe.get_traceback()}", "KB Supplier Creation") return {"success": False, "message": str(e)} @@ -610,6 +782,245 @@ def get_card_statement(login_name=None, account_no=None, from_date=None, to_date return {"success": False, "message": str(e)} +# ═══════════════════════════════════════════════════════════════════════════════ +# STATEMENT IMPORT (BIRBank → 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 + for fmt in ("%b %d, %Y", "%b %d,%Y"): + try: + return datetime.strptime(trn_dt, 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( + "/v2/statement/account", + params={ + "accountNumber": cust_ac_no, + "fromDate": _fmt_date(from_date), + "toDate": _fmt_date(to_date), + } + ) + if not resp.ok: + return {"success": False, "message": f"HTTP {resp.status_code}: {resp.text[:200]}"} + + data = resp.json() + resp_code = data.get("response", {}).get("code", "?") + if resp_code != "0": + resp_msg = data.get("response", {}).get("message", "") + return {"success": False, "message": f"API error code={resp_code}: {resp_msg}"} + + statement_list = ( + data.get("responseData", {}) + .get("operations", {}) + .get("statementList", []) + ) + + transactions = [] + skipped_duplicates = 0 + + for txn in statement_list: + trn_ref = (txn.get("trnRefNo") or "").strip() + + # Deduplication + if trn_ref and frappe.db.exists("Payment Entry", {"reference_no": trn_ref}): + skipped_duplicates += 1 + continue + + # Parse date + parsed_date = _parse_txn_date(txn.get("trnDt")) + if not parsed_date: + continue + + # Amount and currency + amount = abs(flt(txn.get("fcyAmount") or txn.get("lcyAmount") or 0)) + currency = (txn.get("acCcy") or "AZN").strip() + + # Direction + dr_cr = (txn.get("drcrInd") or "").upper() + + # Counterparty + contr_name, contr_voen = _parse_counterparty(txn.get("contrAccount")) + + # 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, + "drcr": dr_cr or "D", + "purpose": purpose, + "raw_data": txn, + }) + + 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()}", "KB Import Statement") + return {"success": False, "message": str(e)} + + +@frappe.whitelist() +def import_single_transaction(txn_data, account_iban, login_name=None): + """Import a single transaction → create Payment Entry. + + txn_data comes as JSON string from the JS client. + Returns: {success, payment_entry} or {success: False, error_type, message} + """ + try: + if isinstance(txn_data, str): + txn_data = json.loads(txn_data) + + settings = frappe.get_single("Kapital Bank Settings") + + # Build lookup dicts + purpose_rules = [] + for row in settings.purpose_mappings: + if row.purpose_keyword and row.paid_from and row.paid_to: + purpose_rules.append((row.purpose_keyword.lower(), row.paid_from, row.paid_to)) + + # Build party lookups from BOTH customer and supplier mappings + 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[row.tax_id.strip()] = entry + if row.kb_customer_name: + # Resolve KB Customer name → customer_name for name matching + kb_customer_name = frappe.db.get_value("Kapital Bank Customer", row.kb_customer_name, "customer_name") + if kb_customer_name: + name_to_party[kb_customer_name.strip()] = 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[row.tax_id.strip()] = 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[kb_supplier_name.strip()] = entry + + # Parse fields + 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() + + # Purpose mapping + purpose_lower = purpose.lower() + paid_from = paid_to = None + for keyword, pf, pt in purpose_rules: + if keyword in purpose_lower: + paid_from = pf + paid_to = pt + 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}], + } + + # Party mapping: VOEN → name → None + party_type = None + erp_party = None + if contr_voen and contr_voen in voen_to_party: + party_type = voen_to_party[contr_voen]["party_type"] + erp_party = voen_to_party[contr_voen]["erp_party"] + elif contr_name and contr_name in name_to_party: + party_type = name_to_party[contr_name]["party_type"] + erp_party = name_to_party[contr_name]["erp_party"] + + if not party_type or not erp_party: + 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}], + } + + # Create Payment Entry + pe = frappe.new_doc("Payment Entry") + pe.payment_type = payment_type + pe.company = settings.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.remarks = purpose + pe.party_type = party_type + pe.party = erp_party + + try: + pe.insert(ignore_permissions=True) + pe.submit() + except Exception as pe_err: + frappe.db.rollback() + frappe.local.message_log = [] + return {"success": False, "error_type": "import_error", "message": str(pe_err)} + + return {"success": True, "payment_entry": pe.name} + + except Exception as e: + frappe.local.message_log = [] + return {"success": False, "error_type": "import_error", "message": str(e)} + + # ═══════════════════════════════════════════════════════════════════════════════ # INTERNAL HELPERS # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/kapital_bank/client/payment_entry.js b/kapital_bank/client/payment_entry.js index 694a056..10620a1 100644 --- a/kapital_bank/client/payment_entry.js +++ b/kapital_bank/client/payment_entry.js @@ -1,72 +1,592 @@ -frappe.ui.form.on('Payment Entry', { - refresh(frm) { - if (frm.doc.docstatus !== 1) return; +const KBImport = { + cancelLoading: false, + loadingErrors: [], + PROGRESS_UPDATE_DELAY: 50, +}; - const kb_status = frm.doc.kb_transfer_status; +// ======= ERROR MANAGEMENT ======= +KBImport.errors = { + add: function(txnData, errorType, errorMessage, additionalData = {}) { + const errorEntry = { + ref_no: txnData.ref_no || 'Unknown', + date: txnData.date || 'Unknown', + counterparty: txnData.counterparty || 'Unknown', + amount: txnData.amount || 0, + error_type: errorType, + error_message: errorMessage, + timestamp: new Date().toISOString(), + ...additionalData + }; - frm.add_custom_button(__('Send Transfer'), () => { - kb_send(frm, 'kapital_bank.payment_api.send_transfer', __('Transfer sent')); - }, __('Kapital Bank')); + KBImport.loadingErrors.push(errorEntry); + console.error('KB Import Error:', errorEntry); + }, - frm.add_custom_button(__('Send Card Transfer'), () => { - kb_send(frm, 'kapital_bank.payment_api.send_card_transfer', __('Card transfer sent')); - }, __('Kapital Bank')); + clear: function() { + frappe.confirm( + __('Are you sure you want to clear the error log?'), + function() { + KBImport.loadingErrors = []; + frappe.show_alert({ + message: __('Error log cleared'), + indicator: 'green' + }, 2); + } + ); + }, - frm.add_custom_button(__('Send VAT Payment'), () => { - kb_send(frm, 'kapital_bank.payment_api.send_vat_payment', __('VAT payment sent')); - }, __('Kapital Bank')); + showDetails: function() { + if (KBImport.loadingErrors.length === 0) { + frappe.msgprint(__('No errors to display')); + return; + } - frm.add_custom_button(__('Check Status'), () => { - frappe.call({ - method: 'kapital_bank.payment_api.check_and_update_status', - args: { payment_entry_name: frm.doc.name }, - freeze: true, - freeze_message: __('Checking status...'), - callback(r) { - if (r.message && r.message.success) { - frappe.show_alert({ - message: __('Status: {0}', [r.message.status]), - indicator: 'blue' - }, 5); - frm.reload_doc(); - } else { - frappe.msgprint({ - title: __('Error'), - message: r.message ? r.message.message : __('Unknown error'), - indicator: 'red' - }); - } + let errorsHtml = '
'; + + KBImport.loadingErrors.forEach(function(error) { + let documentDate = 'No date'; + if (error.date && error.date !== 'Unknown') { + try { + documentDate = moment(error.date).format('DD.MM.YYYY'); + } catch (e) { + documentDate = 'Invalid date'; } - }); - }, __('Kapital Bank')); + } - // Show KB status badge if present - if (kb_status) { - const color = kb_status === 'Confirmed' ? 'green' - : kb_status === 'Cancelled' ? 'red' - : 'blue'; - frm.dashboard.add_indicator(__('KB: {0}', [kb_status]), color); + errorsHtml += '
'; + + // Header + errorsHtml += '
'; + errorsHtml += '
'; + errorsHtml += '
' + + '' + error.ref_no + '
'; + errorsHtml += '' + error.error_type + ''; + errorsHtml += '
'; + errorsHtml += '' + error.counterparty + ''; + errorsHtml += '
'; + + // Error message + errorsHtml += '
'; + errorsHtml += '' + error.error_message + ''; + errorsHtml += '
'; + + // Details — unmapped purpose + if (error.unmatched_purpose && error.unmatched_purpose.length > 0) { + errorsHtml += '
'; + errorsHtml += '
' + __('Unmapped purpose:') + '
'; + errorsHtml += '
    '; + error.unmatched_purpose.forEach(function(item) { + errorsHtml += '
  • ' + item.purpose + '
  • '; + }); + errorsHtml += '
'; + } + + // Details — unmapped party + if (error.unmatched_party && error.unmatched_party.length > 0) { + errorsHtml += '
'; + errorsHtml += '
' + __('Unmapped party:') + '
'; + errorsHtml += '
    '; + error.unmatched_party.forEach(function(party) { + errorsHtml += '
  • ' + party.name; + if (party.voen) { + errorsHtml += ' (VOEN: ' + party.voen + ')'; + } + errorsHtml += '
  • '; + }); + errorsHtml += '
'; + } + + errorsHtml += '
'; + errorsHtml += '' + documentDate + ''; + errorsHtml += '
'; + + errorsHtml += '
'; + }); + + errorsHtml += '
'; + + // Statistics + const errorTypes = {}; + KBImport.loadingErrors.forEach(function(error) { + errorTypes[error.error_type] = (errorTypes[error.error_type] || 0) + 1; + }); + + let statsHtml = '
'; + statsHtml += '
'; + statsHtml += '
'; + statsHtml += '' + __('Total Errors:') + ' ' + KBImport.loadingErrors.length + '
'; + statsHtml += ''; + Object.keys(errorTypes).forEach(function(type, index) { + if (index > 0) statsHtml += ' • '; + statsHtml += type + ': ' + errorTypes[type]; + }); + statsHtml += ''; + statsHtml += '
'; + statsHtml += '
'; + statsHtml += ''; + statsHtml += ''; + statsHtml += '
'; + + const d = new frappe.ui.Dialog({ + title: __('Error Details') + ' (' + KBImport.loadingErrors.length + ')', + size: 'large', + fields: [ + { + fieldname: 'stats_html', + fieldtype: 'HTML', + options: statsHtml + }, + { + fieldname: 'errors_html', + fieldtype: 'HTML', + options: '
' + errorsHtml + '
' + } + ], + primary_action_label: __('Close'), + primary_action: function() { + d.hide(); + } + }); + + d.show(); + + setTimeout(function() { + $('#kb-export-csv-dialog-btn').off('click').on('click', function() { + KBImport.errors.exportCSV(); + }); + + $('#kb-clear-log-dialog-btn').off('click').on('click', function() { + KBImport.errors.clear(); + d.hide(); + }); + }, 200); + }, + + exportCSV: function() { + if (KBImport.loadingErrors.length === 0) { + frappe.msgprint(__('No errors to export')); + return; + } + + try { + let csvContent = "data:text/csv;charset=utf-8,"; + csvContent += "Ref No,Date,Counterparty,Amount,Error Type,Error Message,Timestamp\n"; + + KBImport.loadingErrors.forEach(function(error) { + const cleanMessage = error.error_message.replace(/"/g, '""').replace(/\n/g, ' ').replace(/\r/g, ' '); + const cleanCounterparty = (error.counterparty || '').replace(/"/g, '""'); + + const row = [ + error.ref_no, + error.date, + cleanCounterparty, + error.amount, + error.error_type, + cleanMessage, + moment(error.timestamp).format('DD.MM.YYYY HH:mm:ss') + ].map(function(field) { + return '"' + (field || '') + '"'; + }).join(','); + + csvContent += row + "\n"; + }); + + const encodedUri = encodeURI(csvContent); + const link = document.createElement("a"); + link.setAttribute("href", encodedUri); + link.setAttribute("download", "kb_import_errors_" + moment().format('YYYY-MM-DD_HH-mm-ss') + ".csv"); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + frappe.show_alert({ + message: __('Error log exported successfully'), + indicator: 'green' + }, 3); + } catch (e) { + console.error('Export error:', e); + frappe.show_alert({ + message: __('Failed to export error log'), + indicator: 'red' + }, 3); + } + }, + + showSummary: function(totalTransactions, processedCount, errorsCount) { + if (errorsCount === 0) { + frappe.msgprint({ + title: __('Import Completed Successfully'), + indicator: 'green', + message: __('All ') + totalTransactions + __(' transactions were imported successfully.') + }); + } else { + const title = errorsCount === totalTransactions ? __('Import Failed') : __('Import Completed with Errors'); + const indicator = processedCount > 0 ? 'orange' : 'red'; + + let message = '
'; + if (processedCount > 0) { + message += __('Successfully imported: ') + '' + processedCount + '' + __(' transactions') + '
'; + } + message += __('Failed: ') + '' + errorsCount + '' + __(' transactions'); + message += '
'; + + message += '
' + + '' + + '' + + '
'; + + const summaryDialog = frappe.msgprint({ + title: title, + indicator: indicator, + message: message + }); + + setTimeout(function() { + $('#kb-view-error-details-btn').off('click').on('click', function() { + summaryDialog.hide(); + KBImport.errors.showDetails(); + }); + + $('#kb-export-error-log-btn').off('click').on('click', function() { + KBImport.errors.exportCSV(); + }); + }, 200); } } -}); +}; -function kb_send(frm, method, success_msg) { - frappe.call({ - method: method, - args: { payment_entry_name: frm.doc.name }, - freeze: true, - freeze_message: __('Sending to BIRBank...'), - callback(r) { - if (r.message && r.message.success) { - frappe.show_alert({ message: success_msg, indicator: 'green' }, 5); - frm.reload_doc(); - } else { - frappe.msgprint({ - title: __('BIRBank Error'), - message: r.message ? r.message.message : __('Unknown error'), - indicator: 'red' +// ======= IMPORT MODULE ======= +KBImport.import = { + showDialog: function() { + frappe.call({ + method: 'frappe.client.get', + args: { doctype: 'Kapital Bank Settings', name: 'Kapital Bank Settings' }, + callback: function(r) { + if (!r.message) { + frappe.msgprint({ title: __('Error'), indicator: 'red', message: __('Could not load Kapital Bank Settings') }); + return; + } + const mappings = r.message.account_mappings || []; + const iban_options = mappings.filter(m => m.iban).map(m => m.iban); + if (iban_options.length === 0) { + frappe.msgprint({ + title: __('No Accounts'), + indicator: 'orange', + message: __('Please add account mappings in Kapital Bank Settings first.') + }); + return; + } + + const today = frappe.datetime.get_today(); + const first_day = frappe.datetime.month_start(today); + + const d = new frappe.ui.Dialog({ + title: __('Import Bank Transactions'), + fields: [ + { fieldname: 'sec_period', fieldtype: 'Section Break', label: __('Period') }, + { fieldname: 'date_from', fieldtype: 'Date', label: __('Date From'), reqd: 1, default: first_day }, + { fieldname: 'col_break', fieldtype: 'Column Break' }, + { fieldname: 'date_to', fieldtype: 'Date', label: __('Date To'), reqd: 1, default: today }, + { fieldname: 'sec_account', fieldtype: 'Section Break', label: __('Account') }, + { + fieldname: 'account_iban', + fieldtype: 'Select', + label: __('Account IBAN'), + reqd: 1, + options: iban_options.join('\n') + } + ], + primary_action_label: __('Search'), + primary_action: function() { + const values = d.get_values(); + d.hide(); + KBImport.import.loadTransactions(values.date_from, values.date_to, values.account_iban); + } }); + d.show(); } + }); + }, + + loadTransactions: function(fromDate, toDate, accountIban) { + frappe.show_progress(__('Loading...'), 0, 100, __('Fetching transactions from Kapital Bank...')); + + frappe.call({ + method: 'kapital_bank.bank_api.get_statement_transactions', + args: { + from_date: fromDate, + to_date: toDate, + account_iban: accountIban + }, + callback: function(r) { + frappe.hide_progress(); + + if (r.message && r.message.success) { + const txns = r.message.transactions || []; + const skipped = r.message.skipped_duplicates || 0; + + if (txns.length === 0) { + let msg = __('No new transactions found for the specified period.'); + if (skipped > 0) { + msg += ' ' + __('({0} already imported)', [skipped]); + } + frappe.msgprint({ + title: __('Information'), + indicator: 'blue', + message: msg + }); + return; + } + + if (skipped > 0) { + frappe.show_alert({ + message: __('Skipped {0} already imported transactions', [skipped]), + indicator: 'blue' + }, 5); + } + + KBImport.import.showTransactionSelection(txns, accountIban); + } else { + frappe.msgprint({ + title: __('Error'), + indicator: 'red', + message: r.message ? r.message.message : __('Unknown error') + }); + } + }, + error: function() { + frappe.hide_progress(); + frappe.msgprint({ title: __('Error'), indicator: 'red', message: __('Network error while fetching transactions') }); + } + }); + }, + + showTransactionSelection: function(txns, accountIban) { + let txnTable = '
'; + txnTable += '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + + txns.forEach(function(txn, idx) { + const displayDate = txn.date ? moment(txn.date).format('DD.MM.YYYY') : ''; + const amountFmt = parseFloat(txn.amount || 0).toFixed(2); + const drcrLabel = txn.drcr === 'D' + ? '' + __('Pay') + '' + : '' + __('Receive') + ''; + + txnTable += '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + }); + + txnTable += '
' + __('Ref No') + '' + __('Date') + '' + __('Counterparty') + '' + __('Amount') + '' + __('Type') + '' + __('Purpose') + '
' + (txn.ref_no || '') + '' + displayDate + '' + (txn.counterparty || '') + '' + amountFmt + ' ' + (txn.currency || '') + '' + drcrLabel + '' + (txn.purpose || '') + '
'; + + const infoMessage = '
' + + __('Transactions found: ') + txns.length + + '
'; + + const d = new frappe.ui.Dialog({ + title: __('Select Transactions to Import'), + size: 'large', + fields: [ + { + fieldname: 'info_html', + fieldtype: 'HTML', + options: infoMessage + }, + { + fieldname: 'txns_html', + fieldtype: 'HTML', + options: txnTable + } + ], + primary_action_label: __('Load selected'), + primary_action: function() { + const selectedTxns = []; + d.$wrapper.find('.kb-select-txn:checked').each(function() { + const idx = $(this).data('idx'); + selectedTxns.push(txns[idx]); + }); + + if (selectedTxns.length === 0) { + frappe.msgprint({ + title: __('Warning'), + indicator: 'orange', + message: __('No transactions selected') + }); + return; + } + + d.hide(); + KBImport.cancelLoading = false; + KBImport.import.loadSelectedTransactions(selectedTxns, accountIban, 0, 0); + }, + secondary_action_label: __('Cancel'), + secondary_action: function() { + d.hide(); + } + }); + + d.$wrapper.find('.modal-dialog').css({ + 'max-width': '80%', + 'width': '80%', + 'margin': '30px auto' + }); + + d.$wrapper.find('.modal-body').css({ + 'padding': '15px' + }); + + d.show(); + + d.$wrapper.find('.kb-select-all-txns').on('change', function() { + const isChecked = $(this).prop('checked'); + d.$wrapper.find('.kb-select-txn').prop('checked', isChecked); + }); + }, + + loadSelectedTransactions: function(txnList, accountIban, processedCount, currentIndex) { + if (processedCount === 0 && currentIndex === 0) { + KBImport.loadingErrors = []; + window.kbTotalToProcess = txnList.length; } - }); -} + + $(document).off('progress-cancel.kb_loading_txns'); + + if (txnList.length === 0) { + frappe.hide_progress(); + + const errorsCount = KBImport.loadingErrors.length; + const totalTxns = window.kbTotalToProcess || processedCount; + + KBImport.errors.showSummary(totalTxns, processedCount, errorsCount); + window.kbTotalToProcess = null; + + if (cur_list) { + cur_list.refresh(); + } + + return; + } + + if (KBImport.cancelLoading) { + txnList = []; + frappe.hide_progress(); + KBImport.import.loadSelectedTransactions(txnList, accountIban, processedCount, currentIndex); + return; + } + + const txnData = txnList.shift(); + const isLast = txnList.length === 0; + const totalToProcess = window.kbTotalToProcess || (processedCount + txnList.length + 1); + + currentIndex++; + + frappe.show_progress(__('Importing Transactions'), currentIndex - 1, totalToProcess, + __('Importing transaction ') + currentIndex + __(' of ') + totalToProcess, null, true); + + $(document).on('progress-cancel.kb_loading_txns', function() { + KBImport.cancelLoading = true; + frappe.show_alert({ + message: __('Cancelling import... Finishing current transaction.'), + indicator: 'orange' + }, 3); + frappe.hide_progress(); + }); + + frappe.call({ + method: 'kapital_bank.bank_api.import_single_transaction', + args: { + txn_data: JSON.stringify(txnData), + account_iban: accountIban + }, + callback: function(importR) { + KBImport.import._handleImportResult(importR, txnData, + txnList, accountIban, processedCount, currentIndex, isLast); + }, + error: function(xhr, status, error) { + const errorMessage = 'Network error during import: ' + (error || 'Unknown network error'); + KBImport.errors.add(txnData, 'Network Error', errorMessage); + KBImport.import._continueOrFinish(txnList, accountIban, processedCount, currentIndex, isLast); + } + }); + }, + + _handleImportResult: function(importR, txnData, txnList, accountIban, processedCount, currentIndex, isLast) { + if (importR.message && importR.message.success) { + frappe.show_alert({ + message: __('Transaction imported: ') + (txnData.ref_no || ''), + indicator: 'green' + }, 3); + + processedCount++; + KBImport.import._continueOrFinish(txnList, accountIban, processedCount, currentIndex, isLast); + + } else if (importR.message && importR.message.error_type === 'unmapped_purpose') { + KBImport.errors.add(txnData, 'Unmapped Purpose', importR.message.message, { + unmatched_purpose: importR.message.unmatched_purpose || [] + }); + KBImport.import._continueOrFinish(txnList, accountIban, processedCount, currentIndex, isLast); + + } else if (importR.message && importR.message.error_type === 'unmapped_party') { + KBImport.errors.add(txnData, 'Unmapped Party', importR.message.message, { + unmatched_party: importR.message.unmatched_party || [] + }); + KBImport.import._continueOrFinish(txnList, accountIban, processedCount, currentIndex, isLast); + + } else { + const errorMessage = importR.message ? importR.message.message : 'Unknown import error'; + KBImport.errors.add(txnData, 'Import Error', errorMessage); + KBImport.import._continueOrFinish(txnList, accountIban, processedCount, currentIndex, isLast); + } + }, + + _continueOrFinish: function(txnList, accountIban, processedCount, currentIndex, isLast) { + if (isLast) { + frappe.hide_progress(); + $(document).off('progress-cancel.kb_loading_txns'); + + const totalToProcess = window.kbTotalToProcess || processedCount; + const errorsCount = KBImport.loadingErrors.length; + KBImport.errors.showSummary(totalToProcess, processedCount, errorsCount); + + if (cur_list) { + cur_list.refresh(); + } + return; + } + + setTimeout(function() { + if (txnList.length === 0) { + frappe.hide_progress(); + } + + KBImport.import.loadSelectedTransactions(txnList, accountIban, processedCount, currentIndex); + }, KBImport.PROGRESS_UPDATE_DELAY); + } +}; + +// ======= LIST VIEW SETUP ======= +frappe.listview_settings['Payment Entry'] = { + onload(listview) { + listview.page.add_menu_item(__('Import from Kapital Bank'), function() { + KBImport.import.showDialog(); + }); + } +}; diff --git a/kapital_bank/hooks.py b/kapital_bank/hooks.py index 0d38b45..842cc25 100644 --- a/kapital_bank/hooks.py +++ b/kapital_bank/hooks.py @@ -9,11 +9,13 @@ app_license = "unlicense" after_install = "kapital_bank.setup.after_install" after_migrate = "kapital_bank.setup.after_migrate" -# Document Events -doc_events = { - "Payment Entry": { - "on_cancel": "kapital_bank.payment_api.on_cancel_payment_entry" - } +# JS for ERPNext forms +doctype_js = { + "Payment Entry": "client/payment_entry.js" +} + +doctype_list_js = { + "Payment Entry": "client/payment_entry.js" } # Scheduled Tasks (refresh JWT every 4 minutes) @@ -23,11 +25,6 @@ scheduler_events = { } } -# JS for ERPNext forms -doctype_js = { - "Payment Entry": "kapital_bank/client/payment_entry.js" -} - # Apps # ------------------ diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_client_mapping/kapital_bank_client_mapping.json b/kapital_bank/kapital_bank/doctype/kapital_bank_client_mapping/kapital_bank_client_mapping.json deleted file mode 100644 index 6c67657..0000000 --- a/kapital_bank/kapital_bank/doctype/kapital_bank_client_mapping/kapital_bank_client_mapping.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "actions": [], - "creation": "2026-02-24 00:00:00.000000", - "doctype": "DocType", - "engine": "InnoDB", - "field_order": [ - "tax_id", - "client_name", - "party_type", - "erp_party", - "mapping_type" - ], - "fields": [ - { - "fieldname": "tax_id", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Tax ID (VÖEN)" - }, - { - "fieldname": "client_name", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Client Name" - }, - { - "fieldname": "party_type", - "fieldtype": "Select", - "in_list_view": 1, - "label": "Party Type", - "options": "\nCustomer\nSupplier" - }, - { - "fieldname": "erp_party", - "fieldtype": "Dynamic Link", - "in_list_view": 1, - "label": "ERP Party", - "options": "party_type" - }, - { - "default": "Manual", - "fieldname": "mapping_type", - "fieldtype": "Select", - "label": "Mapping Type", - "options": "Manual\nAutomatic", - "read_only": 1 - } - ], - "index_web_pages_for_search": 1, - "istable": 1, - "links": [], - "modified": "2026-02-24 00:00:00.000000", - "modified_by": "Administrator", - "module": "Kapital Bank", - "name": "Kapital Bank Client Mapping", - "owner": "Administrator", - "permissions": [], - "sort_field": "modified", - "sort_order": "DESC", - "states": [] -} diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_client/__init__.py b/kapital_bank/kapital_bank/doctype/kapital_bank_customer/__init__.py similarity index 100% rename from kapital_bank/kapital_bank/doctype/kapital_bank_client/__init__.py rename to kapital_bank/kapital_bank/doctype/kapital_bank_customer/__init__.py diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_customer/kapital_bank_customer.json b/kapital_bank/kapital_bank/doctype/kapital_bank_customer/kapital_bank_customer.json new file mode 100644 index 0000000..e74d76b --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_customer/kapital_bank_customer.json @@ -0,0 +1,130 @@ +{ + "actions": [], + "autoname": "field:customer_name", + "creation": "2026-02-26 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "customer_name", + "tax_id", + "status", + "column_break_1", + "iban", + "bank_code", + "customer_mapping_section", + "mapped_customer", + "customer_group", + "territory", + "payment_terms" + ], + "fields": [ + { + "fieldname": "customer_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Customer Name", + "length": 500, + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "tax_id", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Tax ID" + }, + { + "default": "New", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "New\nMapped", + "read_only": 1 + }, + { + "fieldname": "column_break_1", + "fieldtype": "Column Break" + }, + { + "fieldname": "iban", + "fieldtype": "Data", + "label": "IBAN" + }, + { + "fieldname": "bank_code", + "fieldtype": "Data", + "label": "Bank Code" + }, + { + "fieldname": "customer_mapping_section", + "fieldtype": "Section Break", + "label": "Customer Mapping" + }, + { + "fieldname": "mapped_customer", + "fieldtype": "Link", + "label": "Mapped Customer", + "options": "Customer", + "read_only": 1 + }, + { + "fieldname": "customer_group", + "fieldtype": "Link", + "label": "Customer Group", + "options": "Customer Group", + "read_only": 1 + }, + { + "fieldname": "territory", + "fieldtype": "Link", + "label": "Territory", + "options": "Territory", + "read_only": 1 + }, + { + "fieldname": "payment_terms", + "fieldtype": "Link", + "label": "Payment Terms", + "options": "Payment Terms Template", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-02-26 00:00:00.000000", + "modified_by": "Administrator", + "module": "Kapital Bank", + "name": "Kapital Bank Customer", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_client_mapping/kapital_bank_client_mapping.py b/kapital_bank/kapital_bank/doctype/kapital_bank_customer/kapital_bank_customer.py similarity index 53% rename from kapital_bank/kapital_bank/doctype/kapital_bank_client_mapping/kapital_bank_client_mapping.py rename to kapital_bank/kapital_bank/doctype/kapital_bank_customer/kapital_bank_customer.py index d07bb91..ecb8c1c 100644 --- a/kapital_bank/kapital_bank/doctype/kapital_bank_client_mapping/kapital_bank_client_mapping.py +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_customer/kapital_bank_customer.py @@ -2,5 +2,5 @@ import frappe from frappe.model.document import Document -class KapitalBankClientMapping(Document): - pass +class KapitalBankCustomer(Document): + pass diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_client_mapping/__init__.py b/kapital_bank/kapital_bank/doctype/kapital_bank_customer_mapping/__init__.py similarity index 100% rename from kapital_bank/kapital_bank/doctype/kapital_bank_client_mapping/__init__.py rename to kapital_bank/kapital_bank/doctype/kapital_bank_customer_mapping/__init__.py diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_customer_mapping/kapital_bank_customer_mapping.json b/kapital_bank/kapital_bank/doctype/kapital_bank_customer_mapping/kapital_bank_customer_mapping.json new file mode 100644 index 0000000..a4418ef --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_customer_mapping/kapital_bank_customer_mapping.json @@ -0,0 +1,77 @@ +{ + "actions": [], + "creation": "2026-02-26 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "kb_customer_name", + "tax_id", + "erp_customer", + "customer_group", + "territory", + "payment_terms", + "mapping_type" + ], + "fields": [ + { + "fieldname": "kb_customer_name", + "fieldtype": "Link", + "in_list_view": 1, + "label": "KB Customer Name", + "options": "Kapital Bank Customer", + "reqd": 1 + }, + { + "fieldname": "tax_id", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Tax ID" + }, + { + "fieldname": "erp_customer", + "fieldtype": "Link", + "in_list_view": 1, + "label": "ERP Customer", + "options": "Customer" + }, + { + "fieldname": "customer_group", + "fieldtype": "Link", + "label": "Customer Group", + "options": "Customer Group" + }, + { + "fieldname": "territory", + "fieldtype": "Link", + "label": "Territory", + "options": "Territory" + }, + { + "fieldname": "payment_terms", + "fieldtype": "Link", + "label": "Payment Terms", + "options": "Payment Terms Template" + }, + { + "default": "Manual", + "fieldname": "mapping_type", + "fieldtype": "Select", + "label": "Mapping Type", + "options": "Manual\nAutomatic", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-02-26 00:00:00.000000", + "modified_by": "Administrator", + "module": "Kapital Bank", + "name": "Kapital Bank Customer Mapping", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_customer_mapping/kapital_bank_customer_mapping.py b/kapital_bank/kapital_bank/doctype/kapital_bank_customer_mapping/kapital_bank_customer_mapping.py new file mode 100644 index 0000000..35cd54b --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_customer_mapping/kapital_bank_customer_mapping.py @@ -0,0 +1,6 @@ +import frappe +from frappe.model.document import Document + + +class KapitalBankCustomerMapping(Document): + pass diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_purpose_mapping/__init__.py b/kapital_bank/kapital_bank/doctype/kapital_bank_purpose_mapping/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_purpose_mapping/kapital_bank_purpose_mapping.json b/kapital_bank/kapital_bank/doctype/kapital_bank_purpose_mapping/kapital_bank_purpose_mapping.json new file mode 100644 index 0000000..e784a1c --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_purpose_mapping/kapital_bank_purpose_mapping.json @@ -0,0 +1,59 @@ +{ + "actions": [], + "creation": "2026-02-26 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "purpose_keyword", + "column_break_1", + "paid_from", + "paid_to", + "notes" + ], + "fields": [ + { + "fieldname": "purpose_keyword", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Purpose Keyword", + "reqd": 1 + }, + { + "fieldname": "column_break_1", + "fieldtype": "Column Break" + }, + { + "fieldname": "paid_from", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Paid From (Account)", + "options": "Account", + "reqd": 1 + }, + { + "fieldname": "paid_to", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Paid To (Account)", + "options": "Account", + "reqd": 1 + }, + { + "fieldname": "notes", + "fieldtype": "Data", + "label": "Notes" + } + ], + "index_web_pages_for_search": 0, + "istable": 1, + "links": [], + "modified": "2026-02-26 00:00:00.000000", + "modified_by": "Administrator", + "module": "Kapital Bank", + "name": "Kapital Bank Purpose Mapping", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_client/kapital_bank_client.py b/kapital_bank/kapital_bank/doctype/kapital_bank_purpose_mapping/kapital_bank_purpose_mapping.py similarity index 61% rename from kapital_bank/kapital_bank/doctype/kapital_bank_client/kapital_bank_client.py rename to kapital_bank/kapital_bank/doctype/kapital_bank_purpose_mapping/kapital_bank_purpose_mapping.py index 1232bc2..fa0ae11 100644 --- a/kapital_bank/kapital_bank/doctype/kapital_bank_client/kapital_bank_client.py +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_purpose_mapping/kapital_bank_purpose_mapping.py @@ -2,5 +2,5 @@ import frappe from frappe.model.document import Document -class KapitalBankClient(Document): +class KapitalBankPurposeMapping(Document): pass diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_settings/kapital_bank_settings.js b/kapital_bank/kapital_bank/doctype/kapital_bank_settings/kapital_bank_settings.js index b367017..543ea0b 100644 --- a/kapital_bank/kapital_bank/doctype/kapital_bank_settings/kapital_bank_settings.js +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_settings/kapital_bank_settings.js @@ -14,70 +14,127 @@ frappe.ui.form.on('Kapital Bank Settings', { } }, __('Accounts')); - // ── Clients buttons ─────────────────────────────────────────────────── - frm.add_custom_button(__('Match clients by similar name'), () => { - frappe.confirm( - __('This will automatically match unmapped clients with similar Customer/Supplier names. Continue?'), - () => { - frappe.show_alert({ message: __('Matching clients...'), indicator: 'blue' }); - frappe.call({ - method: 'kapital_bank.bank_api.match_similar_clients', - callback(r) { - if (r.message && r.message.success) { - frappe.show_alert({ - message: __('Matched {0} of {1} clients', [r.message.matched_count, r.message.total_processed]), - indicator: 'green' - }, 5); - frm.reload_doc(); - } else { - frappe.msgprint({ title: __('Error'), indicator: 'red', message: r.message ? r.message.message : __('Error matching clients') }); - } - } - }); - } - ); - }, __('Clients')); - - frm.add_custom_button(__('Create matching clients'), () => { - frappe.confirm( - __('
WARNING! This will create new Customer/Supplier records for all unmapped clients!

This action is irreversible. Are you sure?

'), - () => { - frappe.confirm(__('Are you really sure? This cannot be undone.'), () => { - frappe.call({ - method: 'kapital_bank.bank_api.create_unmapped_clients', - freeze: true, - freeze_message: __('Creating clients...'), - callback(r) { - if (r.message && r.message.success) { - frappe.show_alert({ - message: __('Created {0} new parties ({1} customers, {2} suppliers)', - [r.message.created_count, r.message.customers_count, r.message.suppliers_count]), - indicator: 'green' - }, 6); - frm.reload_doc(); - } else { - frappe.msgprint({ title: __('Error'), indicator: 'red', message: r.message ? r.message.message : __('Error creating clients') }); - } - } - }); - }); - } - ); - }, __('Clients')); - - frm.add_custom_button(__('Add unmapped clients'), () => { + // ── Customers buttons ───────────────────────────────────────────────── + frm.add_custom_button(__('Match customers by similar name'), () => { if (frm.is_dirty()) { - frm.save().then(() => add_unmapped_clients(frm)); + frappe.confirm( + __('Document contains unsaved changes. Save before performing the operation?'), + () => frm.save().then(() => _match_customers(frm)), + () => frappe.show_alert({ message: __('Operation canceled. Please save the document first.'), indicator: 'red' }, 5) + ); } else { - add_unmapped_clients(frm); + _match_customers(frm); } - }, __('Clients')); + }, __('Customers')); + + frm.add_custom_button(__('Create matching customers'), () => { + if (frm.is_dirty()) { + frappe.confirm( + __('Document contains unsaved changes. Save before performing the operation?'), + () => frm.save().then(() => create_unmapped_customers(frm)), + () => frappe.show_alert({ message: __('Operation canceled. Please save the document first.'), indicator: 'red' }, 5) + ); + } else { + create_unmapped_customers(frm); + } + }, __('Customers')); + + frm.add_custom_button(__('Add unmapped customers'), () => { + if (frm.is_dirty()) { + frm.save().then(() => add_unmapped_customers(frm)); + } else { + add_unmapped_customers(frm); + } + }, __('Customers')); + + // ── Suppliers buttons ───────────────────────────────────────────────── + frm.add_custom_button(__('Match suppliers by similar name'), () => { + if (frm.is_dirty()) { + frappe.confirm( + __('Document contains unsaved changes. Save before performing the operation?'), + () => frm.save().then(() => _match_suppliers(frm)), + () => frappe.show_alert({ message: __('Operation canceled. Please save the document first.'), indicator: 'red' }, 5) + ); + } else { + _match_suppliers(frm); + } + }, __('Suppliers')); + + frm.add_custom_button(__('Create matching suppliers'), () => { + if (frm.is_dirty()) { + frappe.confirm( + __('Document contains unsaved changes. Save before performing the operation?'), + () => frm.save().then(() => create_unmapped_suppliers(frm)), + () => frappe.show_alert({ message: __('Operation canceled. Please save the document first.'), indicator: 'red' }, 5) + ); + } else { + create_unmapped_suppliers(frm); + } + }, __('Suppliers')); + + frm.add_custom_button(__('Add unmapped suppliers'), () => { + if (frm.is_dirty()) { + frm.save().then(() => add_unmapped_suppliers(frm)); + } else { + add_unmapped_suppliers(frm); + } + }, __('Suppliers')); // ── Populate data tabs ──────────────────────────────────────────────── load_data_tabs(frm); } }); +// ═══════════════════════════════════════════════════════════════════════════════ +// MATCH HELPERS +// ═══════════════════════════════════════════════════════════════════════════════ + +function _match_customers(frm) { + frappe.confirm( + __('This will automatically match unmapped customers with similar Customer names. Continue?'), + () => { + frappe.show_alert({ message: __('Matching customers...'), indicator: 'blue' }); + frappe.call({ + method: 'kapital_bank.bank_api.match_similar_customers', + callback(r) { + if (r.message && r.message.success) { + frappe.show_alert({ + message: __('Matched {0} of {1} customers', [r.message.matched_count, r.message.total_processed]), + indicator: 'green' + }, 5); + frm.reload_doc(); + } else { + frappe.msgprint({ title: __('Error'), indicator: 'red', message: r.message ? r.message.message : __('Error matching customers') }); + } + } + }); + } + ); +} + +function _match_suppliers(frm) { + frappe.confirm( + __('This will automatically match unmapped suppliers with similar Supplier names. Continue?'), + () => { + frappe.show_alert({ message: __('Matching suppliers...'), indicator: 'blue' }); + frappe.call({ + method: 'kapital_bank.bank_api.match_similar_suppliers', + callback(r) { + if (r.message && r.message.success) { + frappe.show_alert({ + message: __('Matched {0} of {1} suppliers', [r.message.matched_count, r.message.total_processed]), + indicator: 'green' + }, 5); + frm.reload_doc(); + } else { + frappe.msgprint({ title: __('Error'), indicator: 'red', message: r.message ? r.message.message : __('Error matching suppliers') }); + } + } + }); + } + ); +} + // ═══════════════════════════════════════════════════════════════════════════════ // LOAD DATA DIALOG // ═══════════════════════════════════════════════════════════════════════════════ @@ -98,15 +155,16 @@ function open_load_dialog(frm, summary) {
`; const types = [ - { key: 'accounts', label: __('Accounts') }, - { key: 'cards', label: __('Cards') }, - { key: 'clients', label: __('Clients') } + { key: 'accounts', label: __('Accounts') }, + { key: 'cards', label: __('Cards') }, + { key: 'customers', label: __('Customers') }, + { key: 'suppliers', label: __('Suppliers') } ]; types.forEach(t => { const d = summary[t.key] || { total: 0, mapped: 0 }; const unmapped = d.total - d.mapped; const pct = d.total > 0 ? Math.round(d.mapped / d.total * 100) : 0; - summary_html += `
+ summary_html += `
${t.label}
${d.total} ${__('total')}
@@ -126,7 +184,7 @@ function open_load_dialog(frm, summary) { title: __('Load Data from BIRBank'), fields: [ { fieldname: 'summary_html', fieldtype: 'HTML', options: summary_html }, - { fieldname: 'sec_period', fieldtype: 'Section Break', label: __('Period (for clients from statements)') }, + { fieldname: 'sec_period', fieldtype: 'Section Break', label: __('Period (for counterparties from statements)') }, { fieldname: 'date_from', fieldtype: 'Date', label: __('Date From'), default: first_day, reqd: 0 }, { fieldname: 'col_break', fieldtype: 'Column Break' }, { fieldname: 'date_to', fieldtype: 'Date', label: __('Date To'), default: last_day, reqd: 0 }, @@ -135,13 +193,13 @@ function open_load_dialog(frm, summary) { description: __('Fetch current accounts from GET /accounts') }, { fieldname: 'load_cards', fieldtype: 'Check', label: __('Load Cards'), default: 1, description: __('Fetch business cards from GET /cards') }, - { fieldname: 'load_clients', fieldtype: 'Check', label: __('Load Clients from statements'), default: 1, + { fieldname: 'load_counterparties', fieldtype: 'Check', label: __('Load Counterparties from statements'), default: 1, description: __('Extract counterparties from account statement transactions for the selected period') } ], primary_action_label: __('Load'), primary_action(values) { - if (values.load_clients && (!values.date_from || !values.date_to)) { - frappe.msgprint(__('Please set Date From and Date To to load clients from statements.')); + if (values.load_counterparties && (!values.date_from || !values.date_to)) { + frappe.msgprint(__('Please set Date From and Date To to load counterparties from statements.')); return; } d.hide(); @@ -152,7 +210,7 @@ function open_load_dialog(frm, summary) { } function start_loading(frm, values) { - let total = (values.load_accounts ? 1 : 0) + (values.load_cards ? 1 : 0) + (values.load_clients ? 1 : 0); + let total = (values.load_accounts ? 1 : 0) + (values.load_cards ? 1 : 0) + (values.load_counterparties ? 1 : 0); if (total === 0) { frappe.show_alert({ message: __('Nothing selected'), indicator: 'orange' }, 3); return; } let done = 0; @@ -198,19 +256,20 @@ function start_loading(frm, values) { }); } - if (values.load_clients) { - frappe.show_progress(__('Loading clients...'), 50, 100, __('Fetching statements to extract counterparties...')); + if (values.load_counterparties) { + frappe.show_progress(__('Loading counterparties...'), 50, 100, __('Fetching statements to extract counterparties...')); frappe.call({ - method: 'kapital_bank.bank_api.load_clients_from_statements', + method: 'kapital_bank.bank_api.load_counterparties_from_statements', args: { from_date: values.date_from, to_date: values.date_to }, callback(r) { if (r.message && r.message.success) { - on_done(__('Clients: {0} new, {1} skipped (already exist)', [r.message.created, r.message.skipped])); + on_done(__('Counterparties: {0} customers, {1} suppliers, {2} skipped', + [r.message.customers_created, r.message.suppliers_created, r.message.skipped])); } else { - on_done(__('Clients error: {0}', [r.message ? r.message.message : 'unknown'])); + on_done(__('Counterparties error: {0}', [r.message ? r.message.message : 'unknown'])); } }, - error() { on_done(__('Clients: network error')); } + error() { on_done(__('Counterparties: network error')); } }); } } @@ -247,36 +306,126 @@ function add_unmapped_accounts(frm) { }); } -function add_unmapped_clients(frm) { +function add_unmapped_customers(frm) { frappe.call({ - method: 'kapital_bank.bank_api.get_unmapped_clients', + method: 'kapital_bank.bank_api.get_unmapped_customers', callback(r) { if (!r.message || !r.message.success) { frappe.msgprint({ title: __('Error'), indicator: 'red', message: r.message ? r.message.message : __('Error') }); return; } - const clients = r.message.clients; - if (!clients || clients.length === 0) { - frappe.show_alert({ message: __('No unmapped clients found'), indicator: 'blue' }, 5); + const customers = r.message.customers; + if (!customers || customers.length === 0) { + frappe.show_alert({ message: __('No unmapped customers found'), indicator: 'blue' }, 5); return; } - clients.forEach(c => { - const row = frm.add_child('client_mappings'); - row.tax_id = c.tax_id || ''; - row.client_name = c.client_name || ''; - row.party_type = c.mapped_party_type || ''; - row.erp_party = c.mapped_party || ''; + customers.forEach(c => { + const row = frm.add_child('customer_mappings'); + row.kb_customer_name = c.name; + row.tax_id = c.tax_id || ''; row.mapping_type = 'Manual'; }); - frm.refresh_field('client_mappings'); + frm.refresh_field('customer_mappings'); frm.save().then(() => { - frappe.show_alert({ message: __('Added {0} clients to mapping', [clients.length]), indicator: 'green' }, 5); - load_clients_tab(); + frappe.show_alert({ message: __('Added {0} customers to mapping', [customers.length]), indicator: 'green' }, 5); + load_customers_tab(); }); } }); } +function add_unmapped_suppliers(frm) { + frappe.call({ + method: 'kapital_bank.bank_api.get_unmapped_suppliers', + callback(r) { + if (!r.message || !r.message.success) { + frappe.msgprint({ title: __('Error'), indicator: 'red', message: r.message ? r.message.message : __('Error') }); + return; + } + const suppliers = r.message.suppliers; + if (!suppliers || suppliers.length === 0) { + frappe.show_alert({ message: __('No unmapped suppliers found'), indicator: 'blue' }, 5); + return; + } + suppliers.forEach(s => { + const row = frm.add_child('supplier_mappings'); + row.kb_supplier_name = s.name; + row.tax_id = s.tax_id || ''; + row.mapping_type = 'Manual'; + }); + frm.refresh_field('supplier_mappings'); + frm.save().then(() => { + frappe.show_alert({ message: __('Added {0} suppliers to mapping', [suppliers.length]), indicator: 'green' }, 5); + load_suppliers_tab(); + }); + } + }); +} + +function create_unmapped_customers(frm) { + frappe.confirm( + __('
WARNING! This will create new Customer records for all unmapped customers!

This action is irreversible. Are you sure?

'), + function() { + frappe.confirm( + __('Are you really sure? This cannot be undone.'), + function() { + frappe.call({ + method: 'kapital_bank.bank_api.create_unmapped_customers', + args: { 'settings_name': frm.doc.name }, + callback: function(r) { + if (r.message && r.message.success) { + frappe.show_alert({ + message: __('Created {0} new customers', [r.message.created_count]), + indicator: 'green' + }, 6); + frm.reload_doc(); + } else { + frappe.msgprint({ + title: __('Error'), + indicator: 'red', + message: r.message ? r.message.message : __('Error creating customers') + }); + } + } + }); + } + ); + } + ); +} + +function create_unmapped_suppliers(frm) { + frappe.confirm( + __('
WARNING! This will create new Supplier records for all unmapped suppliers!

This action is irreversible. Are you sure?

'), + function() { + frappe.confirm( + __('Are you really sure? This cannot be undone.'), + function() { + frappe.call({ + method: 'kapital_bank.bank_api.create_unmapped_suppliers', + args: { 'settings_name': frm.doc.name }, + callback: function(r) { + if (r.message && r.message.success) { + frappe.show_alert({ + message: __('Created {0} new suppliers', [r.message.created_count]), + indicator: 'green' + }, 6); + frm.reload_doc(); + } else { + frappe.msgprint({ + title: __('Error'), + indicator: 'red', + message: r.message ? r.message.message : __('Error creating suppliers') + }); + } + } + }); + } + ); + } + ); +} + // ═══════════════════════════════════════════════════════════════════════════════ // DATA TABS // ═══════════════════════════════════════════════════════════════════════════════ @@ -284,7 +433,8 @@ function add_unmapped_clients(frm) { function load_data_tabs(frm) { load_accounts_tab(); load_cards_tab(); - load_clients_tab(); + load_customers_tab(); + load_suppliers_tab(); } // ─── Accounts tab ──────────────────────────────────────────────────────────── @@ -409,64 +559,121 @@ function render_cards_list(cards, total_count) { }); } -// ─── Clients tab ───────────────────────────────────────────────────────────── +// ─── Customers tab ─────────────────────────────────────────────────────────── -function load_clients_tab() { +function load_customers_tab() { frappe.call({ method: 'kapital_bank.bank_api.get_kb_reference_data_list', - args: { data_type: 'clients', limit: 100, offset: 0 }, + args: { data_type: 'customers', limit: 100, offset: 0 }, callback(r) { if (r.message && r.message.success) { - render_clients_list(r.message.data, r.message.total_count); + render_customers_list(r.message.data, r.message.total_count); } else { const msg = r.message ? r.message.message : __('Unknown error'); - $('#kb-clients-container').html(error_html(msg, load_clients_tab)); + $('#kb-customers-container').html(error_html(msg, load_customers_tab)); } }, - error() { $('#kb-clients-container').html(error_html(__('Network error'), load_clients_tab)); } + error() { $('#kb-customers-container').html(error_html(__('Network error'), load_customers_tab)); } }); } -function render_clients_list(clients, total_count) { - let html = list_header(__('Clients'), total_count, 'kb-refresh-clients'); +function render_customers_list(customers, total_count) { + let html = list_header(__('Customers'), total_count, 'kb-refresh-customers'); - if (clients && clients.length > 0) { + if (customers && customers.length > 0) { html += ` - - + + - `; - clients.forEach(c => { - const pill = c.status === 'Mapped' ? 'green' : (c.status === 'Active' ? 'blue' : 'red'); - const mapped_to = c.mapped_party ? `${c.mapped_party}` : '-'; + customers.forEach(c => { + const pill = c.status === 'Mapped' ? 'green' : 'red'; + const mapped_to = c.mapped_customer ? `${c.mapped_customer}` : '-'; const created = c.creation ? frappe.datetime.str_to_user(c.creation) : '-'; html += ` - + - `; }); html += '
${__('Client Name')}${__('Tax ID (VÖEN)')}${__('Customer Name')}${__('Tax ID (VOEN)')} ${__('IBAN')} ${__('Status')}${__('Party Type')} ${__('Mapped To')} ${__('Created')}
${c.client_name}${c.customer_name} ${c.tax_id || '-'} ${c.iban || '-'} ${__(c.status)}${c.mapped_party_type || '-'} ${mapped_to} ${created}
'; - if (total_count > clients.length) { - html += `

${__('Showing {0} of {1}', [clients.length, total_count])} — ${__('View All')}

`; + if (total_count > customers.length) { + html += `

${__('Showing {0} of {1}', [customers.length, total_count])} — ${__('View All')}

`; } } else { - html += empty_state(__('No clients registered yet.')); + html += empty_state(__('No customers registered yet.')); } - $('#kb-clients-container').html(html); - $('#kb-refresh-clients').on('click', () => { - $('#kb-clients-container').html(loading_html()); - load_clients_tab(); + $('#kb-customers-container').html(html); + $('#kb-refresh-customers').on('click', () => { + $('#kb-customers-container').html(loading_html()); + load_customers_tab(); + }); +} + +// ─── Suppliers tab ─────────────────────────────────────────────────────────── + +function load_suppliers_tab() { + frappe.call({ + method: 'kapital_bank.bank_api.get_kb_reference_data_list', + args: { data_type: 'suppliers', limit: 100, offset: 0 }, + callback(r) { + if (r.message && r.message.success) { + render_suppliers_list(r.message.data, r.message.total_count); + } else { + const msg = r.message ? r.message.message : __('Unknown error'); + $('#kb-suppliers-container').html(error_html(msg, load_suppliers_tab)); + } + }, + error() { $('#kb-suppliers-container').html(error_html(__('Network error'), load_suppliers_tab)); } + }); +} + +function render_suppliers_list(suppliers, total_count) { + let html = list_header(__('Suppliers'), total_count, 'kb-refresh-suppliers'); + + if (suppliers && suppliers.length > 0) { + html += ` + + + + + + + + `; + suppliers.forEach(s => { + const pill = s.status === 'Mapped' ? 'green' : 'red'; + const mapped_to = s.mapped_supplier ? `${s.mapped_supplier}` : '-'; + const created = s.creation ? frappe.datetime.str_to_user(s.creation) : '-'; + html += ` + + + + + + + `; + }); + html += '
${__('Supplier Name')}${__('Tax ID (VOEN)')}${__('IBAN')}${__('Status')}${__('Mapped To')}${__('Created')}
${s.supplier_name}${s.tax_id || '-'}${s.iban || '-'}${__(s.status)}${mapped_to}${created}
'; + if (total_count > suppliers.length) { + html += `

${__('Showing {0} of {1}', [suppliers.length, total_count])} — ${__('View All')}

`; + } + } else { + html += empty_state(__('No suppliers registered yet.')); + } + + $('#kb-suppliers-container').html(html); + $('#kb-refresh-suppliers').on('click', () => { + $('#kb-suppliers-container').html(loading_html()); + load_suppliers_tab(); }); } diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_settings/kapital_bank_settings.json b/kapital_bank/kapital_bank/doctype/kapital_bank_settings/kapital_bank_settings.json index b7976d8..fa6d505 100644 --- a/kapital_bank/kapital_bank/doctype/kapital_bank_settings/kapital_bank_settings.json +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_settings/kapital_bank_settings.json @@ -22,14 +22,22 @@ "accounts_list_html", "cards_tab", "cards_list_html", - "clients_tab", - "clients_list_html", + "customers_tab", + "customers_list_html", + "suppliers_tab", + "suppliers_list_html", "account_mappings_tab", "account_mappings_section", "account_mappings", - "client_mappings_tab", - "client_mappings_section", - "client_mappings" + "customer_mappings_tab", + "customer_mappings_section", + "customer_mappings", + "supplier_mappings_tab", + "supplier_mappings_section", + "supplier_mappings", + "purpose_mappings_tab", + "purpose_mappings_section", + "purpose_mappings" ], "fields": [ { @@ -135,15 +143,26 @@ "options": "
Click \"Load Data\" to fetch cards from BIRBank
" }, { - "fieldname": "clients_tab", + "fieldname": "customers_tab", "fieldtype": "Tab Break", - "label": "Clients" + "label": "Customers" }, { - "fieldname": "clients_list_html", + "fieldname": "customers_list_html", "fieldtype": "HTML", - "label": "Clients List", - "options": "
Loading...
" + "label": "Customers List", + "options": "
Loading...
" + }, + { + "fieldname": "suppliers_tab", + "fieldtype": "Tab Break", + "label": "Suppliers" + }, + { + "fieldname": "suppliers_list_html", + "fieldtype": "HTML", + "label": "Suppliers List", + "options": "
Loading...
" }, { "fieldname": "account_mappings_tab", @@ -162,26 +181,58 @@ "options": "Kapital Bank Account Mapping" }, { - "fieldname": "client_mappings_tab", + "fieldname": "customer_mappings_tab", "fieldtype": "Tab Break", - "label": "Client Mappings" + "label": "Customer Mappings" }, { - "fieldname": "client_mappings_section", + "fieldname": "customer_mappings_section", "fieldtype": "Section Break", - "label": "Client Mappings" + "label": "Customer Mappings" }, { - "fieldname": "client_mappings", + "fieldname": "customer_mappings", "fieldtype": "Table", - "label": "Client Mappings", - "options": "Kapital Bank Client Mapping" + "label": "Customer Mappings", + "options": "Kapital Bank Customer Mapping" + }, + { + "fieldname": "supplier_mappings_tab", + "fieldtype": "Tab Break", + "label": "Supplier Mappings" + }, + { + "fieldname": "supplier_mappings_section", + "fieldtype": "Section Break", + "label": "Supplier Mappings" + }, + { + "fieldname": "supplier_mappings", + "fieldtype": "Table", + "label": "Supplier Mappings", + "options": "Kapital Bank Supplier Mapping" + }, + { + "fieldname": "purpose_mappings_tab", + "fieldtype": "Tab Break", + "label": "Purpose Mappings" + }, + { + "fieldname": "purpose_mappings_section", + "fieldtype": "Section Break", + "label": "Purpose Mappings" + }, + { + "fieldname": "purpose_mappings", + "fieldtype": "Table", + "label": "Purpose Mappings", + "options": "Kapital Bank Purpose Mapping" } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-02-25 00:00:00.000000", + "modified": "2026-02-26 00:00:00.000000", "modified_by": "Administrator", "module": "Kapital Bank", "name": "Kapital Bank Settings", diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_settings/kapital_bank_settings.py b/kapital_bank/kapital_bank/doctype/kapital_bank_settings/kapital_bank_settings.py index 5b9609a..d6c2357 100644 --- a/kapital_bank/kapital_bank/doctype/kapital_bank_settings/kapital_bank_settings.py +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_settings/kapital_bank_settings.py @@ -3,29 +3,55 @@ from frappe.model.document import Document class KapitalBankSettings(Document): - def on_update(self): - self._sync_client_statuses() + def on_update(self): + self._sync_customer_statuses() + self._sync_supplier_statuses() - def _sync_client_statuses(self): - """Reset Kapital Bank Client status to 'New' for clients removed from client_mappings.""" - mapped_names = {row.client_name for row in self.client_mappings if row.client_name} + def _sync_customer_statuses(self): + """Reset Kapital Bank Customer status to 'New' for customers removed from customer_mappings.""" + mapped_names = {row.kb_customer_name for row in self.customer_mappings if row.kb_customer_name} - # Find clients marked as Mapped but no longer present in the mapping table - orphaned = frappe.get_all( - "Kapital Bank Client", - filters={"status": "Mapped"}, - fields=["name", "client_name"], - ) + orphaned = frappe.get_all( + "Kapital Bank Customer", + filters={"status": "Mapped"}, + fields=["name", "customer_name"], + ) - for client in orphaned: - if client.client_name not in mapped_names: - frappe.db.set_value( - "Kapital Bank Client", - client.name, - { - "status": "New", - "mapped_party_type": None, - "mapped_party": None, - }, - update_modified=False, - ) + for customer in orphaned: + if customer.name not in mapped_names: + frappe.db.set_value( + "Kapital Bank Customer", + customer.name, + { + "status": "New", + "mapped_customer": None, + "customer_group": None, + "territory": None, + "payment_terms": None, + }, + update_modified=False, + ) + + def _sync_supplier_statuses(self): + """Reset Kapital Bank Supplier status to 'New' for suppliers removed from supplier_mappings.""" + mapped_names = {row.kb_supplier_name for row in self.supplier_mappings if row.kb_supplier_name} + + orphaned = frappe.get_all( + "Kapital Bank Supplier", + filters={"status": "Mapped"}, + fields=["name", "supplier_name"], + ) + + for supplier in orphaned: + if supplier.name not in mapped_names: + frappe.db.set_value( + "Kapital Bank Supplier", + supplier.name, + { + "status": "New", + "mapped_supplier": None, + "supplier_group": None, + "payment_terms": None, + }, + update_modified=False, + ) diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_supplier/__init__.py b/kapital_bank/kapital_bank/doctype/kapital_bank_supplier/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_client/kapital_bank_client.json b/kapital_bank/kapital_bank/doctype/kapital_bank_supplier/kapital_bank_supplier.json similarity index 51% rename from kapital_bank/kapital_bank/doctype/kapital_bank_client/kapital_bank_client.json rename to kapital_bank/kapital_bank/doctype/kapital_bank_supplier/kapital_bank_supplier.json index 689a1b3..586503d 100644 --- a/kapital_bank/kapital_bank/doctype/kapital_bank_client/kapital_bank_client.json +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_supplier/kapital_bank_supplier.json @@ -1,35 +1,36 @@ { "actions": [], - "allow_rename": 1, - "autoname": "field:client_name", - "creation": "2026-02-24 00:00:00.000000", + "autoname": "field:supplier_name", + "creation": "2026-02-26 00:00:00.000000", "doctype": "DocType", "engine": "InnoDB", "field_order": [ - "client_name", + "supplier_name", "tax_id", "status", "column_break_1", "iban", "bank_code", - "mapping_section", - "mapped_party_type", - "mapped_party" + "supplier_mapping_section", + "mapped_supplier", + "supplier_group", + "payment_terms" ], "fields": [ { - "fieldname": "client_name", + "fieldname": "supplier_name", "fieldtype": "Data", "in_list_view": 1, - "label": "Client Name", - "reqd": 1 + "label": "Supplier Name", + "length": 500, + "reqd": 1, + "unique": 1 }, { "fieldname": "tax_id", "fieldtype": "Data", "in_list_view": 1, - "label": "Tax ID (VÖEN)", - "unique": 1 + "label": "Tax ID" }, { "default": "New", @@ -37,7 +38,8 @@ "fieldtype": "Select", "in_list_view": 1, "label": "Status", - "options": "New\nMapped\nActive" + "options": "New\nMapped", + "read_only": 1 }, { "fieldname": "column_break_1", @@ -54,29 +56,38 @@ "label": "Bank Code" }, { - "fieldname": "mapping_section", + "fieldname": "supplier_mapping_section", "fieldtype": "Section Break", - "label": "ERP Mapping" + "label": "Supplier Mapping" }, { - "fieldname": "mapped_party_type", - "fieldtype": "Select", - "label": "Mapped Party Type", - "options": "\nCustomer\nSupplier" + "fieldname": "mapped_supplier", + "fieldtype": "Link", + "label": "Mapped Supplier", + "options": "Supplier", + "read_only": 1 }, { - "fieldname": "mapped_party", - "fieldtype": "Dynamic Link", - "label": "Mapped Party", - "options": "mapped_party_type" + "fieldname": "supplier_group", + "fieldtype": "Link", + "label": "Supplier Group", + "options": "Supplier Group", + "read_only": 1 + }, + { + "fieldname": "payment_terms", + "fieldtype": "Link", + "label": "Payment Terms", + "options": "Payment Terms Template", + "read_only": 1 } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2026-02-24 00:00:00.000000", + "modified": "2026-02-26 00:00:00.000000", "modified_by": "Administrator", "module": "Kapital Bank", - "name": "Kapital Bank Client", + "name": "Kapital Bank Supplier", "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ @@ -91,11 +102,21 @@ "role": "System Manager", "share": 1, "write": 1 + }, + { + "create": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1, + "write": 1 } ], - "row_format": "Dynamic", "sort_field": "modified", "sort_order": "DESC", "states": [], - "title_field": "client_name" + "track_changes": 1 } diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_supplier/kapital_bank_supplier.py b/kapital_bank/kapital_bank/doctype/kapital_bank_supplier/kapital_bank_supplier.py new file mode 100644 index 0000000..cbd96c6 --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_supplier/kapital_bank_supplier.py @@ -0,0 +1,6 @@ +import frappe +from frappe.model.document import Document + + +class KapitalBankSupplier(Document): + pass diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_supplier_mapping/__init__.py b/kapital_bank/kapital_bank/doctype/kapital_bank_supplier_mapping/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_supplier_mapping/kapital_bank_supplier_mapping.json b/kapital_bank/kapital_bank/doctype/kapital_bank_supplier_mapping/kapital_bank_supplier_mapping.json new file mode 100644 index 0000000..f4f3115 --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_supplier_mapping/kapital_bank_supplier_mapping.json @@ -0,0 +1,70 @@ +{ + "actions": [], + "creation": "2026-02-26 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "kb_supplier_name", + "tax_id", + "erp_supplier", + "supplier_group", + "payment_terms", + "mapping_type" + ], + "fields": [ + { + "fieldname": "kb_supplier_name", + "fieldtype": "Link", + "in_list_view": 1, + "label": "KB Supplier Name", + "options": "Kapital Bank Supplier", + "reqd": 1 + }, + { + "fieldname": "tax_id", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Tax ID" + }, + { + "fieldname": "erp_supplier", + "fieldtype": "Link", + "in_list_view": 1, + "label": "ERP Supplier", + "options": "Supplier" + }, + { + "fieldname": "supplier_group", + "fieldtype": "Link", + "label": "Supplier Group", + "options": "Supplier Group" + }, + { + "fieldname": "payment_terms", + "fieldtype": "Link", + "label": "Payment Terms", + "options": "Payment Terms Template" + }, + { + "default": "Manual", + "fieldname": "mapping_type", + "fieldtype": "Select", + "label": "Mapping Type", + "options": "Manual\nAutomatic", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-02-26 00:00:00.000000", + "modified_by": "Administrator", + "module": "Kapital Bank", + "name": "Kapital Bank Supplier Mapping", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_supplier_mapping/kapital_bank_supplier_mapping.py b/kapital_bank/kapital_bank/doctype/kapital_bank_supplier_mapping/kapital_bank_supplier_mapping.py new file mode 100644 index 0000000..6633d59 --- /dev/null +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_supplier_mapping/kapital_bank_supplier_mapping.py @@ -0,0 +1,6 @@ +import frappe +from frappe.model.document import Document + + +class KapitalBankSupplierMapping(Document): + pass diff --git a/kapital_bank/payment_api.py b/kapital_bank/payment_api.py index 9238546..0fb16f9 100644 --- a/kapital_bank/payment_api.py +++ b/kapital_bank/payment_api.py @@ -1,277 +1 @@ -import frappe -import json -from frappe.utils import nowdate -from kapital_bank.api import BIRBankClient -from kapital_bank.auth import get_default_login - - -def _get_client(login_name=None): - return BIRBankClient(login_name) - - -def _pe_doc(payment_entry_name): - return frappe.get_doc("Payment Entry", payment_entry_name) - - -@frappe.whitelist() -def send_transfer(payment_entry_name, login_name=None): - """POST /api/b2b/v2/internal-transfer — domestic bank transfer.""" - pe = _pe_doc(payment_entry_name) - client = _get_client(login_name) - - operation_name = pe.get("kb_operation_name") or pe.name[:20] - - payload = { - "fromAccount": pe.get("kb_from_iban") or pe.paid_from, - "transferData": { - "operationName": operation_name, - "benBankCode": pe.get("kb_ben_bank_code", ""), - "toAccount": pe.get("kb_to_iban") or pe.paid_to, - "toTaxNo": pe.get("kb_to_tax_no", ""), - "toCustName": pe.party_name or pe.party, - "amount": pe.paid_amount, - "purpose1": (pe.remarks or "")[:64], - "purpose2": "", - } - } - - try: - data = client.post_json("/v2/internal-transfer", json=payload) - except Exception as e: - frappe.log_error(f"send_transfer failed for {payment_entry_name}: {e}\n{frappe.get_traceback()}", "KB Payment API") - return {"success": False, "message": str(e)} - - status = data.get("status", "") - operation_id = data.get("operationId", "") - - if status == "Success": - frappe.db.set_value("Payment Entry", payment_entry_name, { - "kb_operation_name": operation_name, - "kb_bank_id": operation_id, - "kb_transfer_status": "Submitted", - "kb_transfer_type": "Internal Transfer", - }, update_modified=False) - frappe.db.commit() - return {"success": True, "operation_id": operation_id} - else: - err = data.get("message") or data.get("errorMessage") or str(data) - frappe.log_error(f"send_transfer error for {payment_entry_name}: {err}", "KB Payment API") - return {"success": False, "message": err} - - -@frappe.whitelist() -def send_bulk_transfers(payment_entry_names, login_name=None): - """POST /api/b2b/v2/internal-transfers — bulk domestic transfer (up to 50).""" - if isinstance(payment_entry_names, str): - payment_entry_names = json.loads(payment_entry_names) - - client = _get_client(login_name) - results = [] - - # BIRBank requires a sign file for bulk transfers. - # We send the JSON part as a multipart form. - transfer_datas = [] - for name in payment_entry_names: - pe = _pe_doc(name) - transfer_datas.append({ - "operationName": pe.get("kb_operation_name") or pe.name[:20], - "benBankCode": pe.get("kb_ben_bank_code", ""), - "toAccount": pe.get("kb_to_iban") or pe.paid_to, - "toTaxNo": pe.get("kb_to_tax_no", ""), - "toCustName": pe.party_name or pe.party, - "amount": pe.paid_amount, - "purpose1": (pe.remarks or "")[:64], - }) - - first_pe = _pe_doc(payment_entry_names[0]) - payload_json = json.dumps({ - "fromAccount": first_pe.get("kb_from_iban") or first_pe.paid_from, - "transferDatas": transfer_datas, - }) - - try: - resp = client.post("/v2/internal-transfers", data={"json": payload_json}, files=[]) - resp.raise_for_status() - data = resp.json() - except Exception as e: - frappe.log_error(f"send_bulk_transfers failed: {e}\n{frappe.get_traceback()}", "KB Payment API") - return {"success": False, "message": str(e)} - - successes = data.get("successes", {}).get("transferDatas", []) - errors = data.get("errors", {}).get("transferDatas", []) - - for item in successes: - op_name = item.get("operationName") - op_id = item.get("operationId") - frappe.db.set_value("Payment Entry", op_name, { - "kb_bank_id": op_id, - "kb_transfer_status": "Submitted", - "kb_transfer_type": "Internal Transfer", - }, update_modified=False) - - frappe.db.commit() - return { - "success": True, - "successes": len(successes), - "errors": [e.get("errorMessages") for e in errors], - } - - -@frappe.whitelist() -def send_card_transfer(payment_entry_name, login_name=None): - """POST /api/b2b/transfer/account-to-card — transfer to business card.""" - pe = _pe_doc(payment_entry_name) - client = _get_client(login_name) - - operation_name = pe.get("kb_operation_name") or pe.name[:20] - payload_data = { - "operationName": operation_name, - "fromAccount": pe.get("kb_from_iban") or pe.paid_from, - "toAccount": pe.get("kb_to_iban") or pe.paid_to, - "amount": str(pe.paid_amount), - "purpose": pe.get("kb_purpose_code") or "1", - } - - try: - resp = client.post( - "/transfer/account-to-card", - data={"json": frappe.as_json(payload_data)}, - files=[] - ) - resp.raise_for_status() - data = resp.json() - except Exception as e: - frappe.log_error(f"send_card_transfer failed for {payment_entry_name}: {e}\n{frappe.get_traceback()}", "KB Payment API") - return {"success": False, "message": str(e)} - - status = data.get("status", "") - operation_id = data.get("data", {}).get("operationId", "") - - if status == "Success": - frappe.db.set_value("Payment Entry", payment_entry_name, { - "kb_operation_name": operation_name, - "kb_bank_id": operation_id, - "kb_transfer_status": "Submitted", - "kb_transfer_type": "Card Transfer", - }, update_modified=False) - frappe.db.commit() - return {"success": True, "operation_id": operation_id} - else: - err = data.get("message") or str(data) - return {"success": False, "message": err} - - -@frappe.whitelist() -def send_vat_payment(payment_entry_name, login_name=None): - """POST /api/b2b/internal-transfer/vat — 18% VAT payment.""" - pe = _pe_doc(payment_entry_name) - client = _get_client(login_name) - - operation_name = pe.get("kb_operation_name") or pe.name[:20] - payload = { - "operationName": operation_name, - "amount": pe.paid_amount if pe.paid_amount else None, - "purpose": pe.remarks or None, - } - - try: - data = client.post_json("/internal-transfer/vat", json=payload) - except Exception as e: - frappe.log_error(f"send_vat_payment failed for {payment_entry_name}: {e}\n{frappe.get_traceback()}", "KB Payment API") - return {"success": False, "message": str(e)} - - status = data.get("status", "") - resp_data = data.get("data", {}) - operation_id = resp_data.get("operationId", "") - - if status == "Success": - frappe.db.set_value("Payment Entry", payment_entry_name, { - "kb_operation_name": operation_name, - "kb_bank_id": operation_id, - "kb_transfer_status": "Submitted", - "kb_transfer_type": "VAT Payment", - }, update_modified=False) - frappe.db.commit() - return {"success": True, "operation_id": operation_id} - else: - err = data.get("message") or str(data) - return {"success": False, "message": err} - - -@frappe.whitelist() -def get_transfer_status(operation_name, login_name=None): - """GET /api/b2b/internal-transfer/status — check transfer status.""" - try: - client = _get_client(login_name) - data = client.get_json("/internal-transfer/status", params={"operatorName": operation_name}) - status_list = data.get("data", []) - return {"success": True, "status": data.get("status"), "data": status_list} - except Exception as e: - frappe.log_error(f"get_transfer_status failed for {operation_name}: {e}\n{frappe.get_traceback()}", "KB Payment API") - return {"success": False, "message": str(e)} - - -@frappe.whitelist() -def check_and_update_status(payment_entry_name, login_name=None): - """Check status and update Payment Entry kb_transfer_status field.""" - pe = _pe_doc(payment_entry_name) - operation_name = pe.get("kb_operation_name") - if not operation_name: - return {"success": False, "message": "No KB operation name on this Payment Entry"} - - result = get_transfer_status(operation_name, login_name) - if not result.get("success"): - return result - - status_data = result.get("data", []) - if status_data: - bank_status = status_data[0].get("status", "") - frappe.db.set_value("Payment Entry", payment_entry_name, "kb_transfer_status", bank_status, update_modified=False) - frappe.db.commit() - return {"success": True, "status": bank_status, "data": status_data} - - return {"success": True, "status": "Unknown", "data": []} - - -@frappe.whitelist() -def cancel_transfer(payment_entry_name, login_name=None): - """DELETE /api/b2b/internal-transfer — cancel a pending transfer.""" - pe = _pe_doc(payment_entry_name) - operation_name = pe.get("kb_operation_name") - if not operation_name: - return {"success": False, "message": "No KB operation name on this Payment Entry"} - - try: - client = _get_client(login_name) - resp = client.delete("/internal-transfer", params={"operationId": operation_name}) - resp.raise_for_status() - data = resp.json() - except Exception as e: - frappe.log_error(f"cancel_transfer failed for {payment_entry_name}: {e}\n{frappe.get_traceback()}", "KB Payment API") - return {"success": False, "message": str(e)} - - if data.get("message") == "Success": - frappe.db.set_value("Payment Entry", payment_entry_name, "kb_transfer_status", "Cancelled", update_modified=False) - frappe.db.commit() - return {"success": True} - else: - return {"success": False, "message": str(data)} - - -def on_cancel_payment_entry(doc, method): - """doc_events hook — try to cancel in BIRBank when Payment Entry is cancelled.""" - if not doc.get("kb_operation_name"): - return - - status = doc.get("kb_transfer_status", "") - # Don't try to cancel if already confirmed/completed - if status in ("Confirmed", "Completed", "Cancelled"): - return - - try: - cancel_transfer(doc.name) - except Exception as e: - frappe.log_error( - f"Auto-cancel failed for Payment Entry {doc.name}: {e}\n{frappe.get_traceback()}", - "KB Payment Entry Cancel" - ) +# Removed: outgoing payment functions were removed. diff --git a/kapital_bank/setup.py b/kapital_bank/setup.py index 2b323aa..2cc7e61 100644 --- a/kapital_bank/setup.py +++ b/kapital_bank/setup.py @@ -1,83 +1,23 @@ import frappe -from frappe.custom.doctype.custom_field.custom_field import create_custom_fields def after_install(): - create_payment_entry_custom_fields() + cleanup_payment_entry_custom_fields() def after_migrate(): - create_payment_entry_custom_fields() + cleanup_payment_entry_custom_fields() -def create_payment_entry_custom_fields(): - custom_fields = { - "Payment Entry": [ - { - "fieldname": "kb_section", - "fieldtype": "Section Break", - "label": "Kapital Bank", - "insert_after": "letter_head", - "collapsible": 1, - }, - { - "fieldname": "kb_transfer_type", - "fieldtype": "Select", - "label": "KB Transfer Type", - "options": "\nInternal Transfer\nCard Transfer\nVAT Payment", - "insert_after": "kb_section", - }, - { - "fieldname": "kb_operation_name", - "fieldtype": "Data", - "label": "KB Operation Name", - "insert_after": "kb_transfer_type", - "read_only": 1, - }, - { - "fieldname": "kb_bank_id", - "fieldtype": "Data", - "label": "KB Bank ID", - "insert_after": "kb_operation_name", - "read_only": 1, - }, - { - "fieldname": "kb_transfer_status", - "fieldtype": "Data", - "label": "KB Transfer Status", - "insert_after": "kb_bank_id", - "read_only": 1, - }, - { - "fieldname": "kb_column_break", - "fieldtype": "Column Break", - "insert_after": "kb_transfer_status", - }, - { - "fieldname": "kb_from_iban", - "fieldtype": "Data", - "label": "KB From IBAN", - "insert_after": "kb_column_break", - }, - { - "fieldname": "kb_to_iban", - "fieldtype": "Data", - "label": "KB To IBAN", - "insert_after": "kb_from_iban", - }, - { - "fieldname": "kb_to_tax_no", - "fieldtype": "Data", - "label": "KB To Tax No (VÖEN)", - "insert_after": "kb_to_iban", - }, - { - "fieldname": "kb_ben_bank_code", - "fieldtype": "Data", - "label": "KB Ben Bank Code", - "insert_after": "kb_to_tax_no", - }, - ] - } - create_custom_fields(custom_fields, ignore_validate=True) +KB_FIELDS = [ + "kb_section", "kb_transfer_type", "kb_operation_name", "kb_bank_id", + "kb_transfer_status", "kb_column_break", "kb_from_iban", "kb_to_iban", + "kb_to_tax_no", "kb_ben_bank_code", +] + + +def cleanup_payment_entry_custom_fields(): + for fieldname in KB_FIELDS: + if frappe.db.exists("Custom Field", {"dt": "Payment Entry", "fieldname": fieldname}): + frappe.delete_doc("Custom Field", f"Payment Entry-{fieldname}", ignore_missing=True) frappe.db.commit()