bug fixes
This commit is contained in:
parent
fe3ab9a38f
commit
6cd30ca4bb
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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"):
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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": [
|
"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
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue