bug fixes

This commit is contained in:
Ali 2026-03-04 21:50:37 +04:00
parent fe3ab9a38f
commit 6cd30ca4bb
12 changed files with 457 additions and 127 deletions

View File

@ -70,10 +70,24 @@ class BIRBankClient:
def get_json(self, path, params=None): def get_json(self, path, params=None):
resp = self.get(path, params=params) resp = self.get(path, params=params)
resp.raise_for_status() self._check_response(resp)
return resp.json() return resp.json()
def post_json(self, path, json=None): def post_json(self, path, json=None):
resp = self.post(path, json=json) resp = self.post(path, json=json)
resp.raise_for_status() self._check_response(resp)
return resp.json() return resp.json()
def _check_response(self, resp):
"""Raise with the bank's own message if available, otherwise fall back to HTTP error."""
if resp.ok:
return
try:
body = resp.json()
api_resp = body.get("response", {})
msg = api_resp.get("message", "")
if msg:
raise requests.HTTPError(msg, response=resp)
except (ValueError, KeyError):
pass
resp.raise_for_status()

View File

@ -12,6 +12,18 @@ def _mask(text):
return re.sub(r'\d{7,}', lambda m: m.group()[:3] + '***' + m.group()[-3:], str(text)) return re.sub(r'\d{7,}', lambda m: m.group()[:3] + '***' + m.group()[-3:], str(text))
def _api_error_message(resp):
"""Extract the bank's own error message from response, or fall back to HTTP status text."""
try:
body = resp.json()
msg = body.get("response", {}).get("message", "")
if msg:
return msg
except (ValueError, KeyError):
pass
return f"HTTP {resp.status_code}: {resp.text[:200]}"
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
# REGISTRY LOADERS (fetch from Kapital Bank → save to local DocTypes) # REGISTRY LOADERS (fetch from Kapital Bank → save to local DocTypes)
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
@ -128,7 +140,7 @@ def load_counterparties_from_statements(from_date, to_date, login_name=None):
) )
debug_log.append(f" HTTP {resp.status_code}") debug_log.append(f" HTTP {resp.status_code}")
if not resp.ok: if not resp.ok:
debug_log.append(f" Response body: {_mask(resp.text[:300])}") debug_log.append(f" Error: {_api_error_message(resp)}")
continue continue
data = resp.json() data = resp.json()
except Exception as e: except Exception as e:
@ -243,6 +255,7 @@ def get_kb_reference_data_summary():
"cards": counts("Kapital Bank Card", {"status": "Mapped"}), "cards": counts("Kapital Bank Card", {"status": "Mapped"}),
"customers": counts("Kapital Bank Customer", {"status": "Mapped"}), "customers": counts("Kapital Bank Customer", {"status": "Mapped"}),
"suppliers": counts("Kapital Bank Supplier", {"status": "Mapped"}), "suppliers": counts("Kapital Bank Supplier", {"status": "Mapped"}),
"purposes": counts("Kapital Bank Purpose", {"status": "Mapped"}),
} }
return {"success": True, "summary": summary} return {"success": True, "summary": summary}
except Exception as e: except Exception as e:
@ -297,6 +310,15 @@ def get_kb_reference_data_list(data_type, limit=100, offset=0):
order_by="supplier_name asc" order_by="supplier_name asc"
) )
elif data_type == "purposes":
total = frappe.db.count("Kapital Bank Purpose")
data = frappe.get_all(
"Kapital Bank Purpose",
fields=["name", "purpose_keyword", "direction", "status"],
limit=limit, start=offset,
order_by="purpose_keyword asc"
)
else: else:
return {"success": False, "message": f"Unknown data_type: {data_type}"} return {"success": False, "message": f"Unknown data_type: {data_type}"}
@ -394,6 +416,26 @@ def get_unmapped_suppliers():
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
@frappe.whitelist()
def get_unmapped_purposes():
"""Return Kapital Bank Purposes with status='New' not yet in purpose_mappings."""
try:
settings = frappe.get_single("Kapital Bank Settings")
already_mapped = {row.purpose_keyword for row in settings.purpose_mappings if row.purpose_keyword}
purposes = frappe.get_all(
"Kapital Bank Purpose",
filters={"status": "New"},
fields=["name", "purpose_keyword", "direction"]
)
unmapped = [p for p in purposes if p.name not in already_mapped]
return {"success": True, "purposes": unmapped}
except Exception as e:
frappe.log_error(str(e), "Kapital Bank get_unmapped_purposes")
return {"success": False, "message": str(e)}
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
# CUSTOMER MATCHING & CREATION # CUSTOMER MATCHING & CREATION
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
@ -994,17 +1036,8 @@ def create_unmapped_cards(settings_name=None):
@frappe.whitelist() @frappe.whitelist()
def load_purposes_from_statements(from_date, to_date, login_name=None): def load_purposes_from_statements(from_date, to_date, login_name=None):
"""Load unique purposes from account statements and add new ones to purpose_mappings.""" """Load unique purposes from account statements into Kapital Bank Purpose registry."""
try: try:
doc = frappe.get_doc("Kapital Bank Settings")
# Collect existing purpose keywords (lowercased for comparison)
existing_keywords = {
row.purpose_keyword.lower()
for row in doc.purpose_mappings
if row.purpose_keyword
}
# Get all registered accounts # Get all registered accounts
accounts = frappe.get_all( accounts = frappe.get_all(
"Kapital Bank Account", "Kapital Bank Account",
@ -1012,10 +1045,11 @@ def load_purposes_from_statements(from_date, to_date, login_name=None):
) )
if not accounts: if not accounts:
return {"success": True, "added_count": 0, "message": "No accounts in registry"} return {"success": True, "created": 0, "total_found": 0, "message": "No accounts in registry"}
client = BIRBankClient(login_name) client = BIRBankClient(login_name)
all_purposes = set() # purpose_text → set of directions ("D", "C")
purpose_directions = {}
for account in accounts: for account in accounts:
if not account.cust_ac_no: if not account.cust_ac_no:
@ -1045,27 +1079,50 @@ def load_purposes_from_statements(from_date, to_date, login_name=None):
for txn in statement_list: for txn in statement_list:
purpose = (txn.get("purpose") or "").strip() purpose = (txn.get("purpose") or "").strip()
dr_cr = (txn.get("drcrInd") or "").upper()
if purpose: if purpose:
all_purposes.add(purpose) if purpose not in purpose_directions:
purpose_directions[purpose] = set()
if dr_cr in ("D", "C"):
purpose_directions[purpose].add(dr_cr)
except Exception: except Exception:
continue continue
# Add new purposes not already in the mapping table # Create Kapital Bank Purpose records for new purposes
added = 0 created = updated = 0
for purpose in sorted(all_purposes): for purpose in sorted(purpose_directions):
if purpose.lower() not in existing_keywords: dirs = purpose_directions[purpose]
doc.append("purpose_mappings", { if "D" in dirs and "C" in dirs:
"purpose_keyword": purpose, direction = "Both"
}) elif "C" in dirs:
existing_keywords.add(purpose.lower()) direction = "Receive"
added += 1 else:
direction = "Pay"
if added > 0: existing = frappe.db.get_value("Kapital Bank Purpose", {"purpose_keyword": purpose}, ["name", "direction"], as_dict=True)
doc.save(ignore_permissions=True) if existing:
# Update direction if it changed (e.g. was Pay, now seen as Both)
if existing.direction != direction and direction == "Both":
frappe.db.set_value("Kapital Bank Purpose", existing.name, "direction", "Both", update_modified=False)
updated += 1
else:
try:
doc = frappe.new_doc("Kapital Bank Purpose")
doc.purpose_keyword = purpose
doc.direction = direction
doc.status = "New"
doc.insert(ignore_permissions=True)
created += 1
except frappe.DuplicateEntryError:
pass
except Exception as ins_e:
frappe.log_error(f"Insert purpose failed for {purpose}: {ins_e}", "Kapital Bank Purpose Loading")
if created > 0:
frappe.db.commit() frappe.db.commit()
return {"success": True, "added_count": added, "total_found": len(all_purposes)} return {"success": True, "created": created, "updated": updated, "total_found": len(purpose_directions)}
except Exception as e: except Exception as e:
frappe.log_error(f"load_purposes_from_statements: {e}\n{frappe.get_traceback()}", "Kapital Bank Purpose Loading") frappe.log_error(f"load_purposes_from_statements: {e}\n{frappe.get_traceback()}", "Kapital Bank Purpose Loading")
@ -1192,13 +1249,13 @@ def get_statement_transactions(from_date, to_date, account_iban, login_name=None
} }
) )
if not resp.ok: if not resp.ok:
return {"success": False, "message": f"HTTP {resp.status_code}: {resp.text[:200]}"} return {"success": False, "message": _api_error_message(resp)}
data = resp.json() data = resp.json()
resp_code = data.get("response", {}).get("code", "?") resp_code = data.get("response", {}).get("code", "?")
if resp_code != "0": if resp_code != "0":
resp_msg = data.get("response", {}).get("message", "") resp_msg = data.get("response", {}).get("message", "")
return {"success": False, "message": f"API error code={resp_code}: {resp_msg}"} return {"success": False, "message": resp_msg or f"API error code={resp_code}"}
statement_list = ( statement_list = (
data.get("responseData", {}) data.get("responseData", {})
@ -1293,13 +1350,13 @@ def get_card_statement_transactions(from_date, to_date, card_account_number, log
} }
) )
if not resp.ok: if not resp.ok:
return {"success": False, "message": f"HTTP {resp.status_code}: {resp.text[:200]}"} return {"success": False, "message": _api_error_message(resp)}
data = resp.json() data = resp.json()
resp_code = data.get("response", {}).get("code", "?") resp_code = data.get("response", {}).get("code", "?")
if resp_code != "0": if resp_code != "0":
resp_msg = data.get("response", {}).get("message", "") resp_msg = data.get("response", {}).get("message", "")
return {"success": False, "message": f"API error code={resp_code}: {resp_msg}"} return {"success": False, "message": resp_msg or f"API error code={resp_code}"}
# Card statements use responseData.operation[] (not operations.statementList[]) # Card statements use responseData.operation[] (not operations.statementList[])
operation_list = data.get("responseData", {}).get("operation", []) operation_list = data.get("responseData", {}).get("operation", [])
@ -1361,7 +1418,9 @@ def _build_import_lookups(settings):
purpose_rules = [] purpose_rules = []
for row in settings.purpose_mappings: for row in settings.purpose_mappings:
if row.purpose_keyword and row.paid_from and row.paid_to: 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, getattr(row, "payment_type", None) or "")) keyword = frappe.db.get_value("Kapital Bank Purpose", row.purpose_keyword, "purpose_keyword") or ""
if keyword:
purpose_rules.append((keyword.lower(), row.paid_from, row.paid_to, getattr(row, "payment_type", None) or ""))
voen_to_party = {} voen_to_party = {}
name_to_party = {} name_to_party = {}
@ -1712,22 +1771,37 @@ def on_cancel_bank_transaction(doc, method):
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
def _build_bank_account_lookup(settings): def _build_bank_account_lookup(settings):
"""Build IBAN/card-account-number → Bank Account lookup from settings mappings.""" """Build (IBAN/card-account-number, currency) → Bank Account lookup from settings mappings.
ba_lookup = {}
Also builds a fallback IBAN-only lookup for cases where currency is not specified in mapping.
Returns: {(source_id, currency): bank_account, ...}, {source_id: bank_account, ...}
"""
ba_lookup_by_currency = {}
ba_lookup_fallback = {}
for row in settings.account_mappings: for row in settings.account_mappings:
if row.iban and row.bank_account: if row.iban and row.bank_account:
ba_lookup[row.iban.strip()] = row.bank_account iban = row.iban.strip()
ccy = (row.currency or "").strip().upper()
if ccy:
ba_lookup_by_currency[(iban, ccy)] = row.bank_account
else:
ba_lookup_fallback[iban] = row.bank_account
for row in settings.card_mappings: for row in settings.card_mappings:
if row.account_number and row.bank_account: if row.account_number and row.bank_account:
ba_lookup[row.account_number.strip()] = row.bank_account acc_no = row.account_number.strip()
ccy = (getattr(row, "currency", "") or "").strip().upper()
if ccy:
ba_lookup_by_currency[(acc_no, ccy)] = row.bank_account
else:
ba_lookup_fallback[acc_no] = row.bank_account
return ba_lookup return ba_lookup_by_currency, ba_lookup_fallback
def _import_one_bank_transaction(txn_data, settings, voen_to_party, name_to_party, def _import_one_bank_transaction(txn_data, settings, voen_to_party, name_to_party,
ba_lookup): ba_lookup_by_currency, ba_lookup_fallback):
"""Import a single transaction as a Bank Transaction document. Returns result dict.""" """Import a single transaction as a Bank Transaction document. Returns result dict."""
ref_no = txn_data.get("ref_no", "") ref_no = txn_data.get("ref_no", "")
parsed_date = txn_data.get("date") parsed_date = txn_data.get("date")
@ -1736,27 +1810,46 @@ def _import_one_bank_transaction(txn_data, settings, voen_to_party, name_to_part
purpose = (txn_data.get("purpose") or "").strip() purpose = (txn_data.get("purpose") or "").strip()
contr_name = (txn_data.get("counterparty") or "").strip() contr_name = (txn_data.get("counterparty") or "").strip()
contr_voen = (txn_data.get("contr_voen") or "").strip() contr_voen = (txn_data.get("contr_voen") or "").strip()
currency = txn_data.get("currency", "AZN") currency = (txn_data.get("currency") or "AZN").strip().upper()
source_type = txn_data.get("source_type", "account") source_type = txn_data.get("source_type", "account")
source_label = txn_data.get("source_label", "") source_label = txn_data.get("source_label", "")
# Determine bank_account from source identifier # Determine bank_account from source identifier + currency
bank_account = None bank_account = None
source_keys = []
if source_type == "account": if source_type == "account":
# source_label is the IBAN for accounts
source_id = txn_data.get("source_id", "") source_id = txn_data.get("source_id", "")
if source_id: if source_id:
bank_account = ba_lookup.get(source_id.strip()) source_keys.append(source_id.strip())
if not bank_account and source_label: if source_label:
bank_account = ba_lookup.get(source_label.strip()) source_keys.append(source_label.strip())
elif source_type == "card": elif source_type == "card":
# For cards, try to extract account number from source_label ("Card: XXXX")
source_id = txn_data.get("source_id", "") source_id = txn_data.get("source_id", "")
if source_id: if source_id:
bank_account = ba_lookup.get(source_id.strip()) source_keys.append(source_id.strip())
if not bank_account and source_label: if source_label:
card_num = source_label.replace("Card: ", "").strip() card_num = source_label.replace("Card: ", "").strip()
bank_account = ba_lookup.get(card_num) source_keys.append(card_num)
# Try currency-specific lookup first, then fallback
for key in source_keys:
bank_account = ba_lookup_by_currency.get((key, currency))
if bank_account:
break
if not bank_account:
for key in source_keys:
bank_account = ba_lookup_fallback.get(key)
if bank_account:
break
if not bank_account:
source_display = source_keys[0] if source_keys else source_label
return {
"success": False,
"error_type": "unmapped_bank_account",
"message": f"No bank account mapping for {source_display} with currency {currency}",
"txn_data": txn_data,
}
# Optional party mapping: VÖEN → name → None # Optional party mapping: VÖEN → name → None
party_type = None party_type = None
@ -1780,8 +1873,7 @@ def _import_one_bank_transaction(txn_data, settings, voen_to_party, name_to_part
bt.company = settings.default_company bt.company = settings.default_company
bt.bank_party_name = contr_name or purpose bt.bank_party_name = contr_name or purpose
if bank_account: bt.bank_account = bank_account
bt.bank_account = bank_account
if party_type and erp_party: if party_type and erp_party:
bt.party_type = party_type bt.party_type = party_type
@ -1841,7 +1933,7 @@ def _process_bulk_bt_import(txn_list, user):
settings = frappe.get_single("Kapital Bank Settings") settings = frappe.get_single("Kapital Bank Settings")
_purpose_rules, voen_to_party, name_to_party = _build_import_lookups(settings) _purpose_rules, voen_to_party, name_to_party = _build_import_lookups(settings)
ba_lookup = _build_bank_account_lookup(settings) ba_lookup_by_currency, ba_lookup_fallback = _build_bank_account_lookup(settings)
total = len(txn_list) total = len(txn_list)
imported_count = 0 imported_count = 0
@ -1855,7 +1947,8 @@ def _process_bulk_bt_import(txn_list, user):
) )
result = _import_one_bank_transaction( result = _import_one_bank_transaction(
txn_data, settings, voen_to_party, name_to_party, ba_lookup, txn_data, settings, voen_to_party, name_to_party,
ba_lookup_by_currency, ba_lookup_fallback,
) )
if result.get("success"): if result.get("success"):

View File

@ -427,6 +427,8 @@ KBBTImport.import = {
let currentSourceIdx = 0; let currentSourceIdx = 0;
let allTransactions = []; let allTransactions = [];
let totalSkipped = 0; let totalSkipped = 0;
let seenRefNos = new Set();
let crossAccountDuplicates = 0;
function fetchNext() { function fetchNext() {
if (currentSourceIdx >= totalSources) { if (currentSourceIdx >= totalSources) {
@ -445,9 +447,12 @@ KBBTImport.import = {
return; return;
} }
if (totalSkipped > 0) { if (totalSkipped > 0 || crossAccountDuplicates > 0) {
let parts = [];
if (totalSkipped > 0) parts.push(__('already imported: {0}', [totalSkipped]));
if (crossAccountDuplicates > 0) parts.push(__('cross-account duplicates: {0}', [crossAccountDuplicates]));
frappe.show_alert({ frappe.show_alert({
message: __('Skipped {0} already imported transactions', [totalSkipped]), message: __('Skipped: {0}', [parts.join(', ')]),
indicator: 'blue' indicator: 'blue'
}, 5); }, 5);
} }
@ -485,7 +490,15 @@ KBBTImport.import = {
txn.source_id = source.id; txn.source_id = source.id;
return txn; return txn;
}); });
allTransactions = allTransactions.concat(txns); // Deduplicate across multiple statements (cross-account transfers)
txns.forEach(function(txn) {
if (txn.ref_no && seenRefNos.has(txn.ref_no)) {
crossAccountDuplicates++;
} else {
if (txn.ref_no) seenRefNos.add(txn.ref_no);
allTransactions.push(txn);
}
});
totalSkipped += (r.message.skipped_duplicates || 0); totalSkipped += (r.message.skipped_duplicates || 0);
} else { } else {
frappe.show_alert({ frappe.show_alert({
@ -669,7 +682,9 @@ KBBTImport.import = {
// Collect errors for the error panel // Collect errors for the error panel
(data.errors || []).forEach(function(err) { (data.errors || []).forEach(function(err) {
const txnData = err.txn_data || {}; const txnData = err.txn_data || {};
KBBTImport.errors.add(txnData, 'Import Error', err.message); const errorType = err.error_type === 'unmapped_bank_account' ? 'Unmapped Bank Account'
: 'Import Error';
KBBTImport.errors.add(txnData, errorType, err.message);
}); });
KBBTImport.errors.showSummary(data.total, data.imported, data.errors ? data.errors.length : 0); KBBTImport.errors.showSummary(data.total, data.imported, data.errors ? data.errors.length : 0);

View File

@ -388,6 +388,8 @@ KBImport.import = {
let currentSourceIdx = 0; let currentSourceIdx = 0;
let allTransactions = []; let allTransactions = [];
let totalSkipped = 0; let totalSkipped = 0;
let seenRefNos = new Set();
let crossAccountDuplicates = 0;
function fetchNext() { function fetchNext() {
if (currentSourceIdx >= totalSources) { if (currentSourceIdx >= totalSources) {
@ -406,9 +408,12 @@ KBImport.import = {
return; return;
} }
if (totalSkipped > 0) { if (totalSkipped > 0 || crossAccountDuplicates > 0) {
let parts = [];
if (totalSkipped > 0) parts.push(__('already imported: {0}', [totalSkipped]));
if (crossAccountDuplicates > 0) parts.push(__('cross-account duplicates: {0}', [crossAccountDuplicates]));
frappe.show_alert({ frappe.show_alert({
message: __('Skipped {0} already imported transactions', [totalSkipped]), message: __('Skipped: {0}', [parts.join(', ')]),
indicator: 'blue' indicator: 'blue'
}, 5); }, 5);
} }
@ -443,7 +448,15 @@ KBImport.import = {
callback: function(r) { callback: function(r) {
if (r.message && r.message.success) { if (r.message && r.message.success) {
const txns = r.message.transactions || []; const txns = r.message.transactions || [];
allTransactions = allTransactions.concat(txns); // Deduplicate across multiple statements (cross-account transfers)
txns.forEach(function(txn) {
if (txn.ref_no && seenRefNos.has(txn.ref_no)) {
crossAccountDuplicates++;
} else {
if (txn.ref_no) seenRefNos.add(txn.ref_no);
allTransactions.push(txn);
}
});
totalSkipped += (r.message.skipped_duplicates || 0); totalSkipped += (r.message.skipped_duplicates || 0);
} else { } else {
frappe.show_alert({ frappe.show_alert({

View File

@ -0,0 +1,80 @@
{
"actions": [],
"autoname": "Prompt",
"creation": "2026-03-04 00:00:00.000000",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"purpose_keyword",
"column_break_1",
"direction",
"status"
],
"fields": [
{
"fieldname": "purpose_keyword",
"fieldtype": "Small Text",
"in_list_view": 1,
"label": "Purpose Keyword",
"reqd": 1,
"unique": 1
},
{
"fieldname": "column_break_1",
"fieldtype": "Column Break"
},
{
"fieldname": "direction",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Direction",
"options": "Pay\nReceive\nBoth"
},
{
"default": "New",
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Status",
"options": "New\nMapped",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-03-04 00:00:00.000000",
"modified_by": "Administrator",
"module": "Kapital Bank",
"name": "Kapital Bank Purpose",
"naming_rule": "By script",
"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
}

View File

@ -0,0 +1,23 @@
import frappe
from frappe.model.document import Document
class KapitalBankPurpose(Document):
def autoname(self):
if not self.purpose_keyword:
return
name = str(self.purpose_keyword)
if len(name) > 140:
name = name[:137] + "..."
counter = 1
original_name = name
while frappe.db.exists("Kapital Bank Purpose", name):
suffix = f"-{counter}"
max_length = 140 - len(suffix)
name = original_name[:max_length] + suffix
counter += 1
self.name = name

View File

@ -0,0 +1,6 @@
import frappe
from frappe.tests.utils import FrappeTestCase
class TestKapitalBankPurpose(FrappeTestCase):
pass

View File

@ -14,9 +14,10 @@
"fields": [ "fields": [
{ {
"fieldname": "purpose_keyword", "fieldname": "purpose_keyword",
"fieldtype": "Data", "fieldtype": "Link",
"in_list_view": 1, "in_list_view": 1,
"label": "Purpose Keyword", "label": "Purpose Keyword",
"options": "Kapital Bank Purpose",
"reqd": 1 "reqd": 1
}, },
{ {

View File

@ -138,15 +138,11 @@ frappe.ui.form.on('Kapital Bank Settings', {
}, __('Suppliers')); }, __('Suppliers'));
// ── Purpose buttons ─────────────────────────────────────────────────── // ── Purpose buttons ───────────────────────────────────────────────────
frm.add_custom_button(__('Load purposes from statements'), () => { frm.add_custom_button(__('Add unmapped purposes'), () => {
if (frm.is_dirty()) { if (frm.is_dirty()) {
frappe.confirm( frm.save().then(() => add_unmapped_purposes(frm));
__('Document contains unsaved changes. Save before performing the operation?'),
() => frm.save().then(() => _load_purposes(frm)),
() => frappe.show_alert({ message: __('Operation canceled. Please save the document first.'), indicator: 'red' }, 5)
);
} else { } else {
_load_purposes(frm); add_unmapped_purposes(frm);
} }
}, __('Purpose')); }, __('Purpose'));
@ -319,51 +315,6 @@ function create_unmapped_bank_accounts_for_cards(frm) {
); );
} }
// ═══════════════════════════════════════════════════════════════════════════════
// LOAD PURPOSES FROM STATEMENTS
// ═══════════════════════════════════════════════════════════════════════════════
function _load_purposes(frm) {
const today = frappe.datetime.get_today();
const first_day = frappe.datetime.month_start(today);
const d = new frappe.ui.Dialog({
title: __('Load Purposes from Statements'),
fields: [
{ 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 },
],
primary_action_label: __('Load'),
primary_action(values) {
d.hide();
frappe.show_alert({ message: __('Loading purposes from statements...'), indicator: 'blue' });
frappe.call({
method: 'kapital_bank.bank_api.load_purposes_from_statements',
args: { from_date: values.date_from, to_date: values.date_to },
callback(r) {
if (r.message && r.message.success) {
frappe.show_alert({
message: __('Added {0} new purposes (found {1} total)', [r.message.added_count, r.message.total_found]),
indicator: 'green'
}, 6);
if (r.message.added_count > 0) {
frm.reload_doc();
}
} else {
frappe.msgprint({
title: __('Error'),
indicator: 'red',
message: r.message ? r.message.message : __('Error loading purposes')
});
}
}
});
}
});
d.show();
}
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
// LOAD DATA DIALOG // LOAD DATA DIALOG
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
@ -381,27 +332,26 @@ function show_load_data_dialog(frm) {
function open_load_dialog(frm, summary) { function open_load_dialog(frm, summary) {
let summary_html = `<div style="background:#f8f9fa;padding:15px;border-radius:6px;margin-bottom:15px;"> let summary_html = `<div style="background:#f8f9fa;padding:15px;border-radius:6px;margin-bottom:15px;">
<h5 style="margin-bottom:15px;">${__('Current Registry Data')}</h5> <h5 style="margin-bottom:15px;">${__('Current Registry Data')}</h5>
<div class="row">`; <div style="display:flex;gap:10px;">`;
const types = [ const types = [
{ key: 'accounts', label: __('Accounts') }, { key: 'accounts', label: __('Accounts') },
{ key: 'cards', label: __('Cards') }, { key: 'cards', label: __('Cards') },
{ key: 'customers', label: __('Customers') }, { key: 'customers', label: __('Customers') },
{ key: 'suppliers', label: __('Suppliers') } { key: 'suppliers', label: __('Suppliers') },
{ key: 'purposes', label: __('Purposes') }
]; ];
types.forEach(t => { types.forEach(t => {
const d = summary[t.key] || { total: 0, mapped: 0 }; const d = summary[t.key] || { total: 0, mapped: 0 };
const unmapped = d.total - d.mapped; const unmapped = d.total - d.mapped;
const pct = d.total > 0 ? Math.round(d.mapped / d.total * 100) : 0; const pct = d.total > 0 ? Math.round(d.mapped / d.total * 100) : 0;
summary_html += `<div class="col-sm-3"> summary_html += `<div style="flex:1;text-align:center;border:1px solid #ddd;padding:10px;border-radius:4px;background:#fff;">
<div class="text-center" style="border:1px solid #ddd;padding:10px;border-radius:4px;background:#fff;">
<h6 style="margin-bottom:8px;">${t.label}</h6> <h6 style="margin-bottom:8px;">${t.label}</h6>
<div><strong>${d.total}</strong> ${__('total')}</div> <div><strong>${d.total}</strong> ${__('total')}</div>
<div style="color:#28a745;"><strong>${d.mapped}</strong> ${__('mapped')}</div> <div style="color:#28a745;"><strong>${d.mapped}</strong> ${__('mapped')}</div>
<div style="color:#dc3545;"><strong>${unmapped}</strong> ${__('unmapped')}</div> <div style="color:#dc3545;"><strong>${unmapped}</strong> ${__('unmapped')}</div>
<div style="font-size:11px;color:#666;margin-top:4px;">${pct}% ${__('mapped')}</div> <div style="font-size:11px;color:#666;margin-top:4px;">${pct}% ${__('mapped')}</div>
</div> </div>`;
</div>`;
}); });
summary_html += '</div></div>'; summary_html += '</div></div>';
@ -410,25 +360,28 @@ function open_load_dialog(frm, summary) {
const last_day = frappe.datetime.month_end(today); const last_day = frappe.datetime.month_end(today);
const d = new frappe.ui.Dialog({ const d = new frappe.ui.Dialog({
size: 'large',
title: __('Load Data from Kapital Bank'), title: __('Load Data from Kapital Bank'),
fields: [ fields: [
{ fieldname: 'summary_html', fieldtype: 'HTML', options: summary_html }, { fieldname: 'summary_html', fieldtype: 'HTML', options: summary_html },
{ fieldname: 'sec_period', fieldtype: 'Section Break', label: __('Period (for counterparties from statements)') }, { fieldname: 'sec_period', fieldtype: 'Section Break', label: __('Period (for counterparties and purposes from statements)') },
{ fieldname: 'date_from', fieldtype: 'Date', label: __('Date From'), default: first_day, reqd: 0 }, { fieldname: 'date_from', fieldtype: 'Date', label: __('Date From'), default: first_day, reqd: 0 },
{ fieldname: 'col_break', fieldtype: 'Column Break' }, { fieldname: 'col_break', fieldtype: 'Column Break' },
{ fieldname: 'date_to', fieldtype: 'Date', label: __('Date To'), default: last_day, reqd: 0 }, { fieldname: 'date_to', fieldtype: 'Date', label: __('Date To'), default: last_day, reqd: 0 },
{ fieldname: 'sec_what', fieldtype: 'Section Break', label: __('What to load') }, { fieldname: 'sec_what', fieldtype: 'Section Break', label: __('What to load') },
{ fieldname: 'load_accounts', fieldtype: 'Check', label: __('Load Accounts'), default: 1, { fieldname: 'load_accounts', fieldtype: 'Check', label: __('Load Accounts'), default: 1,
description: __('Fetch current accounts from GET /accounts') }, description: __('Fetch current accounts') },
{ fieldname: 'load_cards', fieldtype: 'Check', label: __('Load Cards'), default: 1, { fieldname: 'load_cards', fieldtype: 'Check', label: __('Load Cards'), default: 1,
description: __('Fetch business cards from GET /cards') }, description: __('Fetch current business cards') },
{ fieldname: 'load_counterparties', fieldtype: 'Check', label: __('Load Counterparties 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') } description: __('Extract counterparties from account statement transactions for the selected period') },
{ fieldname: 'load_purposes', fieldtype: 'Check', label: __('Load Purposes from statements'), default: 1,
description: __('Extract unique purposes from account statement transactions for the selected period') }
], ],
primary_action_label: __('Load'), primary_action_label: __('Load'),
primary_action(values) { primary_action(values) {
if (values.load_counterparties && (!values.date_from || !values.date_to)) { if ((values.load_counterparties || values.load_purposes) && (!values.date_from || !values.date_to)) {
frappe.msgprint(__('Please set Date From and Date To to load counterparties from statements.')); frappe.msgprint(__('Please set Date From and Date To to load counterparties/purposes from statements.'));
return; return;
} }
d.hide(); d.hide();
@ -480,6 +433,20 @@ function start_loading(frm, values) {
} }
}); });
} }
if (values.load_purposes) {
operations.push({
method: 'kapital_bank.bank_api.load_purposes_from_statements',
label: __('Purposes'),
args: { from_date: values.date_from, to_date: values.date_to },
format_result(r) {
if (r.message && r.message.success) {
return __('Purposes: {0} created, {1} updated ({2} found)',
[r.message.created, r.message.updated, r.message.total_found]);
}
return __('Purposes error: {0}', [r.message ? r.message.message : 'unknown']);
}
});
}
if (operations.length === 0) { if (operations.length === 0) {
frappe.show_alert({ message: __('Nothing selected'), indicator: 'orange' }, 3); frappe.show_alert({ message: __('Nothing selected'), indicator: 'orange' }, 3);
@ -488,11 +455,15 @@ function start_loading(frm, values) {
let current = 0; let current = 0;
const results = []; const results = [];
let has_errors = false;
function run_next() { function run_next() {
if (current >= operations.length) { if (current >= operations.length) {
frappe.hide_progress(); frappe.hide_progress();
frappe.show_alert({ message: results.join(' | '), indicator: 'green' }, 6); frappe.show_alert({
message: results.join(' | '),
indicator: has_errors ? 'red' : 'green'
}, 6);
frm.reload_doc(); frm.reload_doc();
return; return;
} }
@ -509,7 +480,9 @@ function start_loading(frm, values) {
method: op.method, method: op.method,
args: op.args, args: op.args,
callback(r) { callback(r) {
results.push(op.format_result(r)); const result_text = op.format_result(r);
if (!r.message || !r.message.success) has_errors = true;
results.push(result_text);
current++; current++;
frappe.show_progress( frappe.show_progress(
__('Loading Data'), __('Loading Data'),
@ -522,6 +495,7 @@ function start_loading(frm, values) {
setTimeout(run_next, 100); setTimeout(run_next, 100);
}, },
error() { error() {
has_errors = true;
results.push(op.label + ': error'); results.push(op.label + ': error');
current++; current++;
setTimeout(run_next, 100); setTimeout(run_next, 100);
@ -648,6 +622,45 @@ function add_unmapped_suppliers(frm) {
}); });
} }
function add_unmapped_purposes(frm) {
frappe.call({
method: 'kapital_bank.bank_api.get_unmapped_purposes',
callback(r) {
if (!r.message || !r.message.success) {
frappe.msgprint({ title: __('Error'), indicator: 'red', message: r.message ? r.message.message : __('Error') });
return;
}
const purposes = r.message.purposes;
if (!purposes || purposes.length === 0) {
frappe.show_alert({ message: __('No unmapped purposes found'), indicator: 'blue' }, 5);
return;
}
let added = 0;
purposes.forEach(p => {
if (p.direction === 'Both') {
const row_pay = frm.add_child('purpose_mappings');
row_pay.purpose_keyword = p.name;
row_pay.payment_type = 'Pay';
const row_recv = frm.add_child('purpose_mappings');
row_recv.purpose_keyword = p.name;
row_recv.payment_type = 'Receive';
added += 2;
} else {
const row = frm.add_child('purpose_mappings');
row.purpose_keyword = p.name;
row.payment_type = p.direction || '';
added += 1;
}
});
frm.refresh_field('purpose_mappings');
frm.save().then(() => {
frappe.show_alert({ message: __('Added {0} rows to mapping ({1} purposes)', [added, purposes.length]), indicator: 'green' }, 5);
load_purposes_tab();
});
}
});
}
function create_unmapped_customers(frm) { function create_unmapped_customers(frm) {
frappe.confirm( frappe.confirm(
__('<div style="color:red;font-weight:bold;">WARNING! This will create new Customer records for all unmapped customers!</div><p>This action is irreversible. Are you sure?</p>'), __('<div style="color:red;font-weight:bold;">WARNING! This will create new Customer records for all unmapped customers!</div><p>This action is irreversible. Are you sure?</p>'),
@ -721,6 +734,7 @@ function load_data_tabs(frm) {
load_cards_tab(); load_cards_tab();
load_customers_tab(); load_customers_tab();
load_suppliers_tab(); load_suppliers_tab();
load_purposes_tab();
} }
// ─── Accounts tab ──────────────────────────────────────────────────────────── // ─── Accounts tab ────────────────────────────────────────────────────────────
@ -963,6 +977,57 @@ function render_suppliers_list(suppliers, total_count) {
}); });
} }
// ─── Purposes tab ────────────────────────────────────────────────────────────
function load_purposes_tab() {
frappe.call({
method: 'kapital_bank.bank_api.get_kb_reference_data_list',
args: { data_type: 'purposes', limit: 100, offset: 0 },
callback(r) {
if (r.message && r.message.success) {
render_purposes_list(r.message.data, r.message.total_count);
} else {
const msg = r.message ? r.message.message : __('Unknown error');
$('#kb-purposes-container').html(error_html(msg, load_purposes_tab));
}
},
error() { $('#kb-purposes-container').html(error_html(__('Network error'), load_purposes_tab)); }
});
}
function render_purposes_list(purposes, total_count) {
let html = list_header(__('Purposes'), total_count, 'kb-refresh-purposes');
if (purposes && purposes.length > 0) {
html += `<table class="table table-bordered" style="font-size:13px;">
<thead class="grid-heading-row"><tr>
<th>${__('Purpose Keyword')}</th>
<th>${__('Direction')}</th>
<th>${__('Status')}</th>
</tr></thead><tbody>`;
purposes.forEach(p => {
const pill = p.status === 'Mapped' ? 'green' : 'red';
html += `<tr>
<td><a href="/app/kapital-bank-purpose/${encodeURIComponent(p.name)}">${p.purpose_keyword}</a></td>
<td>${__(p.direction || '-')}</td>
<td><span class="indicator-pill ${pill}">${__(p.status)}</span></td>
</tr>`;
});
html += '</tbody></table>';
if (total_count > purposes.length) {
html += `<p class="text-muted small">${__('Showing {0} of {1}', [purposes.length, total_count])} — <a href="/app/kapital-bank-purpose">${__('View All')}</a></p>`;
}
} else {
html += empty_state(__('No purposes registered yet. Use "Load Data" to extract purposes from statements.'));
}
$('#kb-purposes-container').html(html);
$('#kb-refresh-purposes').on('click', () => {
$('#kb-purposes-container').html(loading_html());
load_purposes_tab();
});
}
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
// HELPERS // HELPERS
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════

View File

@ -31,6 +31,8 @@
"customers_list_html", "customers_list_html",
"suppliers_tab", "suppliers_tab",
"suppliers_list_html", "suppliers_list_html",
"purposes_tab",
"purposes_list_html",
"mappings_tab", "mappings_tab",
"mappings_tab_html", "mappings_tab_html",
"account_mappings_tab", "account_mappings_tab",
@ -198,6 +200,18 @@
"label": "Suppliers List", "label": "Suppliers List",
"options": "<div id=\"kb-suppliers-container\"><div class=\"text-center text-muted\" style=\"padding:20px;\">Loading...</div></div>" "options": "<div id=\"kb-suppliers-container\"><div class=\"text-center text-muted\" style=\"padding:20px;\">Loading...</div></div>"
}, },
{
"fieldname": "purposes_tab",
"fieldtype": "Tab Break",
"js_parent_subtab": "Data",
"label": "Purposes"
},
{
"fieldname": "purposes_list_html",
"fieldtype": "HTML",
"label": "Purposes List",
"options": "<div id=\"kb-purposes-container\"><div class=\"text-center text-muted\" style=\"padding:20px;\">Loading...</div></div>"
},
{ {
"fieldname": "data_tab", "fieldname": "data_tab",
"fieldtype": "Tab Break", "fieldtype": "Tab Break",

View File

@ -11,6 +11,12 @@ class KapitalBankTransaction(Document):
self.name, self.payment_entry self.name, self.payment_entry
) )
) )
if self.bank_transaction and frappe.db.exists("Bank Transaction", self.bank_transaction):
frappe.throw(
_("Cannot delete {0} because it is linked with Bank Transaction {1}").format(
self.name, self.bank_transaction
)
)
def validate(self): def validate(self):
if not self.is_new(): if not self.is_new():