bug fixes
This commit is contained in:
parent
fe3ab9a38f
commit
6cd30ca4bb
|
|
@ -70,10 +70,24 @@ class BIRBankClient:
|
|||
|
||||
def get_json(self, path, params=None):
|
||||
resp = self.get(path, params=params)
|
||||
resp.raise_for_status()
|
||||
self._check_response(resp)
|
||||
return resp.json()
|
||||
|
||||
def post_json(self, path, json=None):
|
||||
resp = self.post(path, json=json)
|
||||
resp.raise_for_status()
|
||||
self._check_response(resp)
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -12,6 +12,18 @@ def _mask(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)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -128,7 +140,7 @@ def load_counterparties_from_statements(from_date, to_date, login_name=None):
|
|||
)
|
||||
debug_log.append(f" HTTP {resp.status_code}")
|
||||
if not resp.ok:
|
||||
debug_log.append(f" Response body: {_mask(resp.text[:300])}")
|
||||
debug_log.append(f" Error: {_api_error_message(resp)}")
|
||||
continue
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
|
|
@ -243,6 +255,7 @@ def get_kb_reference_data_summary():
|
|||
"cards": counts("Kapital Bank Card", {"status": "Mapped"}),
|
||||
"customers": counts("Kapital Bank Customer", {"status": "Mapped"}),
|
||||
"suppliers": counts("Kapital Bank Supplier", {"status": "Mapped"}),
|
||||
"purposes": counts("Kapital Bank Purpose", {"status": "Mapped"}),
|
||||
}
|
||||
return {"success": True, "summary": summary}
|
||||
except Exception as e:
|
||||
|
|
@ -297,6 +310,15 @@ def get_kb_reference_data_list(data_type, limit=100, offset=0):
|
|||
order_by="supplier_name asc"
|
||||
)
|
||||
|
||||
elif data_type == "purposes":
|
||||
total = frappe.db.count("Kapital Bank Purpose")
|
||||
data = frappe.get_all(
|
||||
"Kapital Bank Purpose",
|
||||
fields=["name", "purpose_keyword", "direction", "status"],
|
||||
limit=limit, start=offset,
|
||||
order_by="purpose_keyword asc"
|
||||
)
|
||||
|
||||
else:
|
||||
return {"success": False, "message": f"Unknown data_type: {data_type}"}
|
||||
|
||||
|
|
@ -394,6 +416,26 @@ def get_unmapped_suppliers():
|
|||
return {"success": False, "message": str(e)}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_unmapped_purposes():
|
||||
"""Return Kapital Bank Purposes with status='New' not yet in 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
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -994,17 +1036,8 @@ def create_unmapped_cards(settings_name=None):
|
|||
|
||||
@frappe.whitelist()
|
||||
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:
|
||||
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
|
||||
accounts = frappe.get_all(
|
||||
"Kapital Bank Account",
|
||||
|
|
@ -1012,10 +1045,11 @@ def load_purposes_from_statements(from_date, to_date, login_name=None):
|
|||
)
|
||||
|
||||
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)
|
||||
all_purposes = set()
|
||||
# purpose_text → set of directions ("D", "C")
|
||||
purpose_directions = {}
|
||||
|
||||
for account in accounts:
|
||||
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:
|
||||
purpose = (txn.get("purpose") or "").strip()
|
||||
dr_cr = (txn.get("drcrInd") or "").upper()
|
||||
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:
|
||||
continue
|
||||
|
||||
# Add new purposes not already in the mapping table
|
||||
added = 0
|
||||
for purpose in sorted(all_purposes):
|
||||
if purpose.lower() not in existing_keywords:
|
||||
doc.append("purpose_mappings", {
|
||||
"purpose_keyword": purpose,
|
||||
})
|
||||
existing_keywords.add(purpose.lower())
|
||||
added += 1
|
||||
# Create Kapital Bank Purpose records for new purposes
|
||||
created = updated = 0
|
||||
for purpose in sorted(purpose_directions):
|
||||
dirs = purpose_directions[purpose]
|
||||
if "D" in dirs and "C" in dirs:
|
||||
direction = "Both"
|
||||
elif "C" in dirs:
|
||||
direction = "Receive"
|
||||
else:
|
||||
direction = "Pay"
|
||||
|
||||
if added > 0:
|
||||
doc.save(ignore_permissions=True)
|
||||
existing = frappe.db.get_value("Kapital Bank Purpose", {"purpose_keyword": purpose}, ["name", "direction"], as_dict=True)
|
||||
if existing:
|
||||
# Update direction if it changed (e.g. was Pay, now seen as Both)
|
||||
if existing.direction != direction and direction == "Both":
|
||||
frappe.db.set_value("Kapital Bank Purpose", existing.name, "direction", "Both", update_modified=False)
|
||||
updated += 1
|
||||
else:
|
||||
try:
|
||||
doc = frappe.new_doc("Kapital Bank Purpose")
|
||||
doc.purpose_keyword = purpose
|
||||
doc.direction = direction
|
||||
doc.status = "New"
|
||||
doc.insert(ignore_permissions=True)
|
||||
created += 1
|
||||
except frappe.DuplicateEntryError:
|
||||
pass
|
||||
except Exception as ins_e:
|
||||
frappe.log_error(f"Insert purpose failed for {purpose}: {ins_e}", "Kapital Bank Purpose Loading")
|
||||
|
||||
if created > 0:
|
||||
frappe.db.commit()
|
||||
|
||||
return {"success": True, "added_count": added, "total_found": len(all_purposes)}
|
||||
return {"success": True, "created": created, "updated": updated, "total_found": len(purpose_directions)}
|
||||
|
||||
except Exception as e:
|
||||
frappe.log_error(f"load_purposes_from_statements: {e}\n{frappe.get_traceback()}", "Kapital Bank Purpose Loading")
|
||||
|
|
@ -1192,13 +1249,13 @@ def get_statement_transactions(from_date, to_date, account_iban, login_name=None
|
|||
}
|
||||
)
|
||||
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()
|
||||
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}"}
|
||||
return {"success": False, "message": resp_msg or f"API error code={resp_code}"}
|
||||
|
||||
statement_list = (
|
||||
data.get("responseData", {})
|
||||
|
|
@ -1293,13 +1350,13 @@ def get_card_statement_transactions(from_date, to_date, card_account_number, log
|
|||
}
|
||||
)
|
||||
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()
|
||||
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}"}
|
||||
return {"success": False, "message": resp_msg or f"API error code={resp_code}"}
|
||||
|
||||
# Card statements use responseData.operation[] (not operations.statementList[])
|
||||
operation_list = data.get("responseData", {}).get("operation", [])
|
||||
|
|
@ -1361,7 +1418,9 @@ def _build_import_lookups(settings):
|
|||
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, 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 = {}
|
||||
name_to_party = {}
|
||||
|
|
@ -1712,22 +1771,37 @@ def on_cancel_bank_transaction(doc, method):
|
|||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _build_bank_account_lookup(settings):
|
||||
"""Build IBAN/card-account-number → Bank Account lookup from settings mappings."""
|
||||
ba_lookup = {}
|
||||
"""Build (IBAN/card-account-number, currency) → Bank Account lookup from settings mappings.
|
||||
|
||||
Also builds a fallback IBAN-only lookup for cases where currency is not specified in mapping.
|
||||
Returns: {(source_id, currency): bank_account, ...}, {source_id: bank_account, ...}
|
||||
"""
|
||||
ba_lookup_by_currency = {}
|
||||
ba_lookup_fallback = {}
|
||||
|
||||
for row in settings.account_mappings:
|
||||
if row.iban and row.bank_account:
|
||||
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:
|
||||
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,
|
||||
ba_lookup):
|
||||
ba_lookup_by_currency, ba_lookup_fallback):
|
||||
"""Import a single transaction as a Bank Transaction document. Returns result dict."""
|
||||
ref_no = txn_data.get("ref_no", "")
|
||||
parsed_date = txn_data.get("date")
|
||||
|
|
@ -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()
|
||||
contr_name = (txn_data.get("counterparty") 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_label = txn_data.get("source_label", "")
|
||||
|
||||
# Determine bank_account from source identifier
|
||||
# Determine bank_account from source identifier + currency
|
||||
bank_account = None
|
||||
source_keys = []
|
||||
if source_type == "account":
|
||||
# source_label is the IBAN for accounts
|
||||
source_id = txn_data.get("source_id", "")
|
||||
if source_id:
|
||||
bank_account = ba_lookup.get(source_id.strip())
|
||||
if not bank_account and source_label:
|
||||
bank_account = ba_lookup.get(source_label.strip())
|
||||
source_keys.append(source_id.strip())
|
||||
if source_label:
|
||||
source_keys.append(source_label.strip())
|
||||
elif source_type == "card":
|
||||
# For cards, try to extract account number from source_label ("Card: XXXX")
|
||||
source_id = txn_data.get("source_id", "")
|
||||
if source_id:
|
||||
bank_account = ba_lookup.get(source_id.strip())
|
||||
if not bank_account and source_label:
|
||||
source_keys.append(source_id.strip())
|
||||
if source_label:
|
||||
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
|
||||
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.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:
|
||||
bt.party_type = party_type
|
||||
|
|
@ -1841,7 +1933,7 @@ def _process_bulk_bt_import(txn_list, user):
|
|||
|
||||
settings = frappe.get_single("Kapital Bank 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)
|
||||
imported_count = 0
|
||||
|
|
@ -1855,7 +1947,8 @@ def _process_bulk_bt_import(txn_list, user):
|
|||
)
|
||||
|
||||
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"):
|
||||
|
|
|
|||
|
|
@ -427,6 +427,8 @@ KBBTImport.import = {
|
|||
let currentSourceIdx = 0;
|
||||
let allTransactions = [];
|
||||
let totalSkipped = 0;
|
||||
let seenRefNos = new Set();
|
||||
let crossAccountDuplicates = 0;
|
||||
|
||||
function fetchNext() {
|
||||
if (currentSourceIdx >= totalSources) {
|
||||
|
|
@ -445,9 +447,12 @@ KBBTImport.import = {
|
|||
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({
|
||||
message: __('Skipped {0} already imported transactions', [totalSkipped]),
|
||||
message: __('Skipped: {0}', [parts.join(', ')]),
|
||||
indicator: 'blue'
|
||||
}, 5);
|
||||
}
|
||||
|
|
@ -485,7 +490,15 @@ KBBTImport.import = {
|
|||
txn.source_id = source.id;
|
||||
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);
|
||||
} else {
|
||||
frappe.show_alert({
|
||||
|
|
@ -669,7 +682,9 @@ KBBTImport.import = {
|
|||
// Collect errors for the error panel
|
||||
(data.errors || []).forEach(function(err) {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -388,6 +388,8 @@ KBImport.import = {
|
|||
let currentSourceIdx = 0;
|
||||
let allTransactions = [];
|
||||
let totalSkipped = 0;
|
||||
let seenRefNos = new Set();
|
||||
let crossAccountDuplicates = 0;
|
||||
|
||||
function fetchNext() {
|
||||
if (currentSourceIdx >= totalSources) {
|
||||
|
|
@ -406,9 +408,12 @@ KBImport.import = {
|
|||
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({
|
||||
message: __('Skipped {0} already imported transactions', [totalSkipped]),
|
||||
message: __('Skipped: {0}', [parts.join(', ')]),
|
||||
indicator: 'blue'
|
||||
}, 5);
|
||||
}
|
||||
|
|
@ -443,7 +448,15 @@ KBImport.import = {
|
|||
callback: function(r) {
|
||||
if (r.message && r.message.success) {
|
||||
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);
|
||||
} else {
|
||||
frappe.show_alert({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
|
||||
class TestKapitalBankPurpose(FrappeTestCase):
|
||||
pass
|
||||
|
|
@ -14,9 +14,10 @@
|
|||
"fields": [
|
||||
{
|
||||
"fieldname": "purpose_keyword",
|
||||
"fieldtype": "Data",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Purpose Keyword",
|
||||
"options": "Kapital Bank Purpose",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -138,15 +138,11 @@ frappe.ui.form.on('Kapital Bank Settings', {
|
|||
}, __('Suppliers'));
|
||||
|
||||
// ── Purpose buttons ───────────────────────────────────────────────────
|
||||
frm.add_custom_button(__('Load purposes from statements'), () => {
|
||||
frm.add_custom_button(__('Add unmapped purposes'), () => {
|
||||
if (frm.is_dirty()) {
|
||||
frappe.confirm(
|
||||
__('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)
|
||||
);
|
||||
frm.save().then(() => add_unmapped_purposes(frm));
|
||||
} else {
|
||||
_load_purposes(frm);
|
||||
add_unmapped_purposes(frm);
|
||||
}
|
||||
}, __('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
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -381,27 +332,26 @@ function show_load_data_dialog(frm) {
|
|||
function open_load_dialog(frm, summary) {
|
||||
let summary_html = `<div style="background:#f8f9fa;padding:15px;border-radius:6px;margin-bottom:15px;">
|
||||
<h5 style="margin-bottom:15px;">${__('Current Registry Data')}</h5>
|
||||
<div class="row">`;
|
||||
<div style="display:flex;gap:10px;">`;
|
||||
|
||||
const types = [
|
||||
{ key: 'accounts', label: __('Accounts') },
|
||||
{ key: 'cards', label: __('Cards') },
|
||||
{ key: 'customers', label: __('Customers') },
|
||||
{ key: 'suppliers', label: __('Suppliers') }
|
||||
{ key: 'suppliers', label: __('Suppliers') },
|
||||
{ key: 'purposes', label: __('Purposes') }
|
||||
];
|
||||
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 += `<div class="col-sm-3">
|
||||
<div class="text-center" style="border:1px solid #ddd;padding:10px;border-radius:4px;background:#fff;">
|
||||
summary_html += `<div style="flex:1;text-align:center;border:1px solid #ddd;padding:10px;border-radius:4px;background:#fff;">
|
||||
<h6 style="margin-bottom:8px;">${t.label}</h6>
|
||||
<div><strong>${d.total}</strong> ${__('total')}</div>
|
||||
<div style="color:#28a745;"><strong>${d.mapped}</strong> ${__('mapped')}</div>
|
||||
<div style="color:#dc3545;"><strong>${unmapped}</strong> ${__('unmapped')}</div>
|
||||
<div style="font-size:11px;color:#666;margin-top:4px;">${pct}% ${__('mapped')}</div>
|
||||
</div>
|
||||
</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 d = new frappe.ui.Dialog({
|
||||
size: 'large',
|
||||
title: __('Load Data from Kapital Bank'),
|
||||
fields: [
|
||||
{ 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: 'col_break', fieldtype: 'Column Break' },
|
||||
{ fieldname: 'date_to', fieldtype: 'Date', label: __('Date To'), default: last_day, reqd: 0 },
|
||||
{ fieldname: 'sec_what', fieldtype: 'Section Break', label: __('What to load') },
|
||||
{ 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,
|
||||
description: __('Fetch business cards from GET /cards') },
|
||||
description: __('Fetch current business cards') },
|
||||
{ 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(values) {
|
||||
if (values.load_counterparties && (!values.date_from || !values.date_to)) {
|
||||
frappe.msgprint(__('Please set Date From and Date To to load counterparties from statements.'));
|
||||
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/purposes from statements.'));
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
frappe.show_alert({ message: __('Nothing selected'), indicator: 'orange' }, 3);
|
||||
|
|
@ -488,11 +455,15 @@ function start_loading(frm, values) {
|
|||
|
||||
let current = 0;
|
||||
const results = [];
|
||||
let has_errors = false;
|
||||
|
||||
function run_next() {
|
||||
if (current >= operations.length) {
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
|
@ -509,7 +480,9 @@ function start_loading(frm, values) {
|
|||
method: op.method,
|
||||
args: op.args,
|
||||
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++;
|
||||
frappe.show_progress(
|
||||
__('Loading Data'),
|
||||
|
|
@ -522,6 +495,7 @@ function start_loading(frm, values) {
|
|||
setTimeout(run_next, 100);
|
||||
},
|
||||
error() {
|
||||
has_errors = true;
|
||||
results.push(op.label + ': error');
|
||||
current++;
|
||||
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) {
|
||||
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>'),
|
||||
|
|
@ -721,6 +734,7 @@ function load_data_tabs(frm) {
|
|||
load_cards_tab();
|
||||
load_customers_tab();
|
||||
load_suppliers_tab();
|
||||
load_purposes_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
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@
|
|||
"customers_list_html",
|
||||
"suppliers_tab",
|
||||
"suppliers_list_html",
|
||||
"purposes_tab",
|
||||
"purposes_list_html",
|
||||
"mappings_tab",
|
||||
"mappings_tab_html",
|
||||
"account_mappings_tab",
|
||||
|
|
@ -198,6 +200,18 @@
|
|||
"label": "Suppliers List",
|
||||
"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",
|
||||
"fieldtype": "Tab Break",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,12 @@ class KapitalBankTransaction(Document):
|
|||
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):
|
||||
if not self.is_new():
|
||||
|
|
|
|||
Loading…
Reference in New Issue