diff --git a/jey_erp/bank_integration/create_reconcile.py b/jey_erp/bank_integration/create_reconcile.py index 23bed0f..2f61954 100644 --- a/jey_erp/bank_integration/create_reconcile.py +++ b/jey_erp/bank_integration/create_reconcile.py @@ -1,235 +1,411 @@ """Universal "Create & Reconcile" backend for Bank Reconciliation Tool. -Works for both Bank Integration records and Kapital Bank Settings via mapping_resolver. +A faithful port of kapital_bank/mapping.py:create_purpose_mappings, generalised +to work with a Bank Integration record. For Bank Accounts mapped to Kapital Bank +Settings, the BRT client calls kapital_bank.mapping.create_purpose_mappings +directly (kb's proven backend stays untouched); this module handles the new +Bank Integration source. Three modes: "Both" / "Mappings Only" / +"Documents & Reconcile" — same semantics as kb. """ import json -from difflib import SequenceMatcher import frappe -from frappe import _ from frappe.utils import cint, flt -from jey_erp.bank_integration.mapping_resolver import resolve_mappings_for_bank_account +PURPOSE_DOCTYPE = "Bank Integration Purpose" + + +def _extract_messages(message_log): + parts = [] + for m in message_log: + if isinstance(m, dict): + parts.append(m.get("message") or m.get("msg") or "") + else: + parts.append(str(m)) + return " | ".join(p for p in parts if p) @frappe.whitelist() -def create_documents_for_bts(bt_names, bank_account): - """For each Bank Transaction in `bt_names`, find a matching mapping and - create+reconcile a Payment Entry / Journal Entry.""" - if isinstance(bt_names, str): - bt_names = json.loads(bt_names) +def create_purpose_mappings(transactions, bank_integration, paid_from=None, paid_to=None, + document_type="Payment Entry", mode="Both"): + """Bulk-create transaction mappings and/or documents from selected BRT transactions.""" + try: + txn_list = json.loads(transactions) if isinstance(transactions, str) else transactions - if not bt_names: - return {"success": True, "created_docs": 0, "reconciled": 0, "errors": []} + do_mappings = mode == "Mappings Only" + do_documents = mode in ("Both", "Documents & Reconcile") + + created_mappings = 0 + already_mapped = 0 + skipped_no_data = 0 + created_docs = 0 + reconciled = 0 + errors = [] + + settings = frappe.get_doc("Bank Integration", bank_integration) + + if mode in ("Both", "Mappings Only"): + existing_purpose = { + (row.purpose_keyword, row.payment_type, (row.currency or "").upper()) + for row in settings.transaction_mappings + if row.purpose_keyword and row.payment_type + } + existing_party_only = { + (row.counterparty_type or "", row.counterparty or "", row.payment_type, (row.currency or "").upper()) + for row in settings.transaction_mappings + if not row.purpose_keyword and row.counterparty and row.payment_type + } + + if do_mappings: + unique_purposes = {} + unique_parties = {} + + for txn in txn_list: + purpose = (txn.get("purpose") or "").strip() + party_type = (txn.get("party_type") or "").strip() + party = (txn.get("party") or "").strip() + payment_type = "Pay" if txn.get("drcr") == "D" else "Receive" + + bt_name = txn.get("bank_transaction_name") + txn_currency = (frappe.db.get_value("Bank Transaction", bt_name, "currency") or "").strip().upper() if bt_name else "" + + if not purpose and not party: + skipped_no_data += 1 + continue + + if purpose: + key = (purpose, payment_type, txn_currency) + if key not in unique_purposes: + unique_purposes[key] = { + "count": 0, + "counterparty_type": party_type, + "counterparty": party, + } + unique_purposes[key]["count"] += 1 + else: + key = (party_type, party, payment_type, txn_currency) + if key not in unique_parties: + unique_parties[key] = { + "count": 0, + "counterparty_type": party_type, + "counterparty": party, + "payment_type": payment_type, + } + unique_parties[key]["count"] += 1 + + for (purpose_text, payment_type, txn_currency), data in unique_purposes.items(): + purpose_name = _ensure_purpose(purpose_text, bank_integration) + is_foreign = txn_currency and txn_currency != "AZN" + + if (purpose_name, payment_type, txn_currency) in existing_purpose: + already_mapped += 1 + continue + + settings.append("transaction_mappings", { + "purpose_keyword": purpose_name, + "payment_type": payment_type, + "paid_from": paid_from, + "paid_to": paid_to, + "document_type": document_type, + "counterparty_type": data["counterparty_type"] or None, + "counterparty": data["counterparty"] or None, + "currency": txn_currency or None, + "multi_currency": 1 if is_foreign else 0, + }) + existing_purpose.add((purpose_name, payment_type, txn_currency)) + created_mappings += 1 + + for (party_type_k, party_k, payment_type, txn_currency), data in unique_parties.items(): + is_foreign = txn_currency and txn_currency != "AZN" + + if (party_type_k, party_k, payment_type, txn_currency) in existing_party_only: + already_mapped += 1 + continue + + settings.append("transaction_mappings", { + "purpose_keyword": None, + "payment_type": payment_type, + "paid_from": paid_from, + "paid_to": paid_to, + "document_type": document_type, + "counterparty_type": data["counterparty_type"] or None, + "counterparty": data["counterparty"] or None, + "currency": txn_currency or None, + "multi_currency": 1 if is_foreign else 0, + }) + existing_party_only.add((party_type_k, party_k, payment_type, txn_currency)) + created_mappings += 1 + + if created_mappings > 0: + settings.save(ignore_permissions=True) + for (purpose_text, _payment_type, _txn_currency) in unique_purposes: + purpose_name = frappe.db.get_value( + PURPOSE_DOCTYPE, + {"purpose_keyword": purpose_text, "parent_bank_integration": bank_integration}, + "name", + ) + if purpose_name: + frappe.db.set_value(PURPOSE_DOCTYPE, purpose_name, "status", "Mapped", update_modified=False) + frappe.db.commit() + + if do_documents: + if mode == "Documents & Reconcile": + purpose_text_cache = {} + for row in settings.transaction_mappings: + if row.purpose_keyword and row.purpose_keyword not in purpose_text_cache: + text = frappe.db.get_value(PURPOSE_DOCTYPE, row.purpose_keyword, "purpose_keyword") or "" + purpose_text_cache[row.purpose_keyword] = text.lower() + txn_mappings = settings.transaction_mappings + else: + purpose_text_cache = {} + txn_mappings = None + + for txn in txn_list: + if not txn.get("bank_transaction_name"): + errors.append({ + "reference_number": txn.get("reference_number") or "", + "message": "No bank transaction name", + }) + continue + + if mode == "Documents & Reconcile": + mapping_row = _find_mapping_for_txn(txn, txn_mappings, purpose_text_cache) + if not mapping_row: + errors.append({ + "reference_number": txn.get("reference_number") or "", + "message": f"No mapping found for purpose: '{txn.get('purpose', '')!s:.60}', party: '{txn.get('party', '')}'", + }) + continue + txn_paid_from = mapping_row.paid_from + txn_paid_to = mapping_row.paid_to + txn_doc_type = mapping_row.document_type or "Payment Entry" + txn_multi_currency = bool(mapping_row.multi_currency) + txn_mapping_currency = (mapping_row.currency or "").strip() + else: + txn_paid_from = paid_from + txn_paid_to = paid_to + txn_doc_type = document_type + txn_multi_currency = False + txn_mapping_currency = "" + + result = _create_and_reconcile_doc(txn, txn_paid_from, txn_paid_to, txn_doc_type, txn_multi_currency, txn_mapping_currency) + if result.get("success"): + created_docs += 1 + if result.get("reconciled"): + reconciled += 1 + elif result.get("error"): + errors.append({ + "reference_number": txn.get("reference_number") or "", + "message": result.get("error"), + }) + + if mode == "Both": + purpose = (txn.get("purpose") or "").strip() + party_type = (txn.get("party_type") or "").strip() + party = (txn.get("party") or "").strip() + payment_type = "Pay" if txn.get("drcr") == "D" else "Receive" + bt_name = txn.get("bank_transaction_name") + txn_currency = (frappe.db.get_value("Bank Transaction", bt_name, "currency") or "").strip().upper() if bt_name else "" + is_foreign = txn_currency and txn_currency != "AZN" + + if purpose: + purpose_name = _ensure_purpose(purpose, bank_integration) + key = (purpose_name, payment_type, txn_currency) + if key not in existing_purpose: + settings.append("transaction_mappings", { + "purpose_keyword": purpose_name, + "payment_type": payment_type, + "paid_from": paid_from, + "paid_to": paid_to, + "document_type": document_type, + "counterparty_type": party_type or None, + "counterparty": party or None, + "currency": txn_currency or None, + "multi_currency": 1 if is_foreign else 0, + }) + existing_purpose.add(key) + created_mappings += 1 + elif party: + key = (party_type, party, payment_type, txn_currency) + if key not in existing_party_only: + settings.append("transaction_mappings", { + "purpose_keyword": None, + "payment_type": payment_type, + "paid_from": paid_from, + "paid_to": paid_to, + "document_type": document_type, + "counterparty_type": party_type or None, + "counterparty": party or None, + "currency": txn_currency or None, + "multi_currency": 1 if is_foreign else 0, + }) + existing_party_only.add(key) + created_mappings += 1 + else: + skipped_no_data += 1 + else: + errors.append({ + "reference_number": txn.get("reference_number") or "", + "message": result.get("error") or "Unknown error", + }) + + if mode == "Both" and created_mappings > 0: + settings.save(ignore_permissions=True) + frappe.db.commit() - mappings = resolve_mappings_for_bank_account(bank_account) - if not mappings: return { - "success": False, - "message": _("Bank Account is not linked to any Bank Integration. " - "Pick one in the Bank Reconciliation Tool first."), + "success": True, + "created_mappings": created_mappings, + "already_mapped": already_mapped, + "skipped_no_data": skipped_no_data, + "created_docs": created_docs, + "reconciled": reconciled, + "errors": errors, } - created_docs = 0 - reconciled = 0 - errors = [] - - for bt_name in bt_names: - try: - result = _process_one(bt_name, mappings) - if result.get("success"): - created_docs += 1 - if result.get("reconciled"): - reconciled += 1 - else: - errors.append({ - "bank_transaction": bt_name, - "reference_number": result.get("reference_number"), - "message": result.get("error") or "Unknown error", - }) - except Exception as e: - frappe.db.rollback() - frappe.local.message_log = [] - frappe.log_error( - f"create_documents_for_bts failed for {bt_name}: {e}\n{frappe.get_traceback()}", - "BI Create Reconcile", - ) - errors.append({"bank_transaction": bt_name, "message": str(e)}) - - return { - "success": True, - "created_docs": created_docs, - "reconciled": reconciled, - "errors": errors, - "total": len(bt_names), - } - - -def _process_one(bt_name, mappings): - bt = frappe.get_doc("Bank Transaction", bt_name) - - if (bt.unallocated_amount or 0) == 0: - return {"success": False, "reference_number": bt.reference_number, - "error": "Bank Transaction already fully reconciled"} - - deposit = flt(bt.deposit or 0) - withdrawal = flt(bt.withdrawal or 0) - if deposit > 0: - drcr = "C" - amount = deposit - payment_type = "Receive" - elif withdrawal > 0: - drcr = "D" - amount = withdrawal - payment_type = "Pay" - else: - return {"success": False, "reference_number": bt.reference_number, - "error": "BT has zero amount"} - - purpose = (bt.description or "").strip() - contr_name = (bt.bank_party_name or "").strip() - - # Find party via mappings (voen lookup not used — BT has no VOEN) - party_type = None - erp_party = None - if contr_name and contr_name in mappings.get("name_to_party", {}): - entries = mappings["name_to_party"][contr_name] - preferred = "Customer" if payment_type == "Receive" else "Supplier" - fallback = "Supplier" if preferred == "Customer" else "Customer" - entry = entries.get(preferred) or entries.get(fallback) - if entry: - party_type = entry["party_type"] - erp_party = entry["erp_party"] - - # Find a matching transaction mapping - rule = _find_rule(mappings.get("purpose_rules", []), purpose, contr_name, payment_type, - drcr, mappings.get("similarity_threshold_purpose", 0.7)) - if not rule: - return {"success": False, "reference_number": bt.reference_number, - "error": f"No transaction mapping for purpose='{purpose[:60]}', party='{contr_name}'"} - - doc_type = rule.get("document_type") or "Payment Entry" - paid_from = rule["paid_from"] - paid_to = rule["paid_to"] - - if doc_type == "Journal Entry": - create_result = _create_journal_entry( - bt, paid_from, paid_to, payment_type, amount, - party_type, erp_party, rule.get("cost_center"), - rule.get("multi_currency", False), - ) - else: - create_result = _create_payment_entry( - bt, paid_from, paid_to, payment_type, amount, - party_type, erp_party, rule.get("cost_center"), - ) - - if not create_result.get("success"): - return {"success": False, "reference_number": bt.reference_number, - "error": create_result.get("error")} - - doc_name = create_result["doc_name"] - - try: - from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import ( - reconcile_vouchers, - ) - reconcile_vouchers( - bt_name, - json.dumps([{ - "payment_doctype": doc_type, - "payment_name": doc_name, - "amount": amount, - }]), - ) - return {"success": True, "doc_name": doc_name, "reconciled": True} - except Exception as re: - frappe.db.rollback() - frappe.local.message_log = [] + except Exception as e: frappe.log_error( - f"reconcile_vouchers failed for {doc_name}: {re}\n{frappe.get_traceback()}", + f"create_purpose_mappings: {e}\n{frappe.get_traceback()}", "BI Create Reconcile", ) - return {"success": False, "reference_number": bt.reference_number, "error": str(re)} + return {"success": False, "message": str(e)} -def _find_rule(rules, purpose, party_name, payment_type, drcr, threshold): - """Return the best matching rule. Priority: - purpose+counterparty > purpose-only > counterparty-only > counterparty_type fallback.""" - purpose_lower = purpose.lower().strip() +def _find_mapping_for_txn(txn, transaction_mappings, purpose_text_cache): + """Find the best matching transaction mapping row for a BRT txn. - both = [] - purpose_only = [] - counterparty_only = [] - fallback = [] + Priority: rows matching both purpose+counterparty > purpose-only > counterparty-only > fallback. + """ + payment_type = "Pay" if txn.get("drcr") == "D" else "Receive" + txn_purpose = (txn.get("purpose") or "").lower() + txn_party = (txn.get("party") or "").lower() - for r in rules: - if r.get("payment_type") and r["payment_type"] != payment_type: + bt_name = txn.get("bank_transaction_name") + txn_currency = (frappe.db.get_value("Bank Transaction", bt_name, "currency") or "").strip().upper() if bt_name else "" + + both_rules = [] + purpose_only_rules = [] + counterparty_only_rules = [] + fallback_rules = [] + + for row in transaction_mappings: + if not row.paid_from or not row.paid_to: + continue + if row.payment_type and row.payment_type != payment_type: + continue + row_currency = (row.currency or "").strip().upper() + if row_currency and row_currency != txn_currency: continue - has_kw = bool(r.get("purpose_keyword")) - has_cp = bool(r.get("counterparty_name") or r.get("counterparty_voen")) - if has_kw and has_cp: - both.append(r) - elif has_kw: - purpose_only.append(r) - elif has_cp: - counterparty_only.append(r) - elif r.get("counterparty_type"): - fallback.append(r) - def _cp_match(r): - cn = (r.get("counterparty_name") or "").strip() - return cn and cn == party_name + has_purpose = bool(row.purpose_keyword) + has_counterparty = bool(row.counterparty) - def _kw_match(r): - kw = (r.get("purpose_keyword") or "").strip().lower() - if not kw: - return False - if kw in purpose_lower: - return True - # fuzzy - score = SequenceMatcher(None, purpose_lower, kw).ratio() - return score >= threshold + if has_purpose and has_counterparty: + both_rules.append(row) + elif has_purpose: + purpose_only_rules.append(row) + elif has_counterparty: + counterparty_only_rules.append(row) + elif row.counterparty_type: + fallback_rules.append(row) - for r in both: - if _kw_match(r) and _cp_match(r): - return r + def _purpose_matches(row): + keyword_text = purpose_text_cache.get(row.purpose_keyword, "") + return keyword_text and keyword_text in txn_purpose - for r in purpose_only: - if _kw_match(r): - return r + def _counterparty_matches(row): + return row.counterparty.lower() == txn_party - for r in counterparty_only: - if _cp_match(r): - return r + for row in both_rules: + if _purpose_matches(row) and _counterparty_matches(row): + return row - inferred_type = "Customer" if payment_type == "Receive" else "Supplier" - for r in fallback: - if r.get("counterparty_type") == inferred_type: - return r + for row in purpose_only_rules: + if _purpose_matches(row): + return row + + for row in counterparty_only_rules: + if _counterparty_matches(row): + return row + + inferred_cp_type = "Customer" if payment_type == "Receive" else "Supplier" + for row in fallback_rules: + if row.counterparty_type == inferred_cp_type: + return row return None -def _create_payment_entry(bt, paid_from, paid_to, payment_type, amount, party_type, erp_party, cost_center): +def _create_and_reconcile_doc(txn, paid_from, paid_to, document_type, multi_currency=False, mapping_currency=""): + """Create a Payment Entry or Journal Entry and reconcile it with the bank transaction.""" + bank_transaction_name = txn.get("bank_transaction_name") try: + bank_txn = frappe.get_doc("Bank Transaction", bank_transaction_name) + + if document_type == "Journal Entry": + result = _create_journal_entry_for_brt(txn, paid_from, paid_to, bank_txn, multi_currency, mapping_currency) + else: + result = _create_payment_entry_for_brt(txn, paid_from, paid_to, bank_txn) + + if not result.get("success"): + return result + + doc_name = result["doc_name"] + + try: + from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import ( + reconcile_vouchers, + ) + reconcile_vouchers( + bank_transaction_name, + json.dumps([{ + "payment_doctype": document_type, + "payment_name": doc_name, + "amount": abs(float(txn.get("amount") or 0)), + }]), + ) + return {"success": True, "doc_name": doc_name, "reconciled": True} + except Exception as re: + frappe.db.rollback() + frappe.local.message_log = [] + frappe.log_error( + f"reconcile_vouchers failed for {doc_name}: {re}\n{frappe.get_traceback()}", + "BI Create Reconcile", + ) + return {"success": False, "error": str(re)} + + except Exception as e: + frappe.db.rollback() + frappe.local.message_log = [] + frappe.log_error( + f"_create_and_reconcile_doc failed for {bank_transaction_name}: {e}\n{frappe.get_traceback()}", + "BI Create Reconcile", + ) + return {"success": False, "error": str(e)} + + +def _create_payment_entry_for_brt(txn, paid_from, paid_to, bank_txn): + try: + amount = abs(float(txn.get("amount") or 0)) + party_type = (txn.get("party_type") or "").strip() + party = (txn.get("party") or "").strip() + posting_date = bank_txn.date or txn.get("date") + pe = frappe.new_doc("Payment Entry") - pe.payment_type = payment_type - pe.company = bt.company - pe.posting_date = bt.date + pe.payment_type = "Pay" if txn.get("drcr") == "D" else "Receive" + pe.company = bank_txn.company + pe.posting_date = posting_date pe.paid_from = paid_from pe.paid_to = paid_to pe.paid_amount = amount pe.received_amount = amount - pe.reference_no = bt.reference_number or bt.transaction_id or bt.name - pe.reference_date = bt.date - pe.remarks = bt.description or "" - if party_type and erp_party: + pe.reference_no = bank_txn.reference_number or txn.get("reference_number") or "" + pe.reference_date = posting_date + pe.remarks = bank_txn.description or txn.get("purpose") or "" + if party_type and party: pe.party_type = party_type - pe.party = erp_party - if cost_center: - pe.cost_center = cost_center + pe.party = party frappe.local.message_log = [] pe.insert(ignore_permissions=True) @@ -247,42 +423,41 @@ def _create_payment_entry(bt, paid_from, paid_to, payment_type, amount, party_ty frappe.db.rollback() frappe.local.message_log = [] frappe.log_error( - f"_create_payment_entry failed: {e}\n{frappe.get_traceback()}", + f"_create_payment_entry_for_brt failed: {e}\n{frappe.get_traceback()}", "BI Create Reconcile", ) return {"success": False, "error": str(e)} -def _create_journal_entry(bt, paid_from, paid_to, payment_type, amount, party_type, erp_party, - cost_center, force_multi_currency): +def _create_journal_entry_for_brt(txn, paid_from, paid_to, bank_txn, multi_currency=False, mapping_currency=""): try: import erpnext from erpnext.setup.utils import get_exchange_rate - is_pay = payment_type == "Pay" - posting_date = bt.date - ref_no = bt.reference_number or bt.transaction_id or bt.name + amount = abs(float(txn.get("amount") or 0)) + party_type = (txn.get("party_type") or "").strip() + party = (txn.get("party") or "").strip() + is_pay = txn.get("drcr") == "D" + posting_date = bank_txn.date or txn.get("date") + ref_no = bank_txn.reference_number or txn.get("reference_number") or "" - company_currency = erpnext.get_company_currency(bt.company) - from_account = frappe.get_cached_value( - "Account", paid_from, ["account_type", "account_currency"], as_dict=True - ) or {} - to_account = frappe.get_cached_value( - "Account", paid_to, ["account_type", "account_currency"], as_dict=True - ) or {} + company_currency = erpnext.get_company_currency(bank_txn.company) + + from_account = frappe.get_cached_value("Account", paid_from, ["account_type", "account_currency"], as_dict=True) or {} + to_account = frappe.get_cached_value("Account", paid_to, ["account_type", "account_currency"], as_dict=True) or {} paid_from_type = from_account.get("account_type") or "" paid_to_type = to_account.get("account_type") or "" from_currency = from_account.get("account_currency") or company_currency to_currency = to_account.get("account_currency") or company_currency - is_multi = ( - from_currency != company_currency - or to_currency != company_currency - or bool(force_multi_currency) - ) + is_multi = from_currency != company_currency or to_currency != company_currency or bool(multi_currency) - precision = cint(frappe.db.get_single_value("System Settings", "currency_precision") or 2) - txn_currency = (bt.currency or company_currency).strip() + currency_precision = cint(frappe.db.get_single_value("System Settings", "currency_precision") or 2) + txn_currency = ( + txn.get("currency") + or (getattr(bank_txn, "currency", None) or "") + or company_currency + ).strip() from_rate = 1 if from_currency == company_currency else get_exchange_rate(from_currency, company_currency, posting_date) to_rate = 1 if to_currency == company_currency else get_exchange_rate(to_currency, company_currency, posting_date) @@ -296,21 +471,21 @@ def _create_journal_entry(bt, paid_from, paid_to, payment_type, amount, party_ty else: txn_rate = get_exchange_rate(txn_currency, company_currency, posting_date) - amount_in_company = flt(amount * txn_rate, precision) + amount_in_company = flt(amount * txn_rate, currency_precision) if from_currency == txn_currency: from_amount = amount elif from_currency == company_currency: from_amount = amount_in_company else: - from_amount = flt(amount_in_company / from_rate, precision) if from_rate else amount_in_company + from_amount = flt(amount_in_company / from_rate, currency_precision) if from_rate else amount_in_company if to_currency == txn_currency: to_amount = amount elif to_currency == company_currency: to_amount = amount_in_company else: - to_amount = flt(amount_in_company / to_rate, precision) if to_rate else amount_in_company + to_amount = flt(amount_in_company / to_rate, currency_precision) if to_rate else amount_in_company if from_currency != company_currency and from_amount: from_rate = amount_in_company / from_amount @@ -320,39 +495,48 @@ def _create_journal_entry(bt, paid_from, paid_to, payment_type, amount, party_ty je = frappe.new_doc("Journal Entry") je.voucher_type = "Bank Entry" je.posting_date = posting_date - je.company = bt.company + je.company = bank_txn.company je.cheque_no = ref_no je.cheque_date = posting_date - je.user_remark = bt.description or "" + je.user_remark = bank_txn.description or txn.get("purpose") or "" je.expense_income_type = "Expense" if is_pay else "Income" if is_multi: je.multi_currency = 1 - # paid_from is always credited, paid_to is always debited. - from_row = { - "account": paid_from, - "exchange_rate": from_rate, - "credit_in_account_currency": from_amount, - "debit_in_account_currency": 0, - } - to_row = { - "account": paid_to, - "exchange_rate": to_rate, - "debit_in_account_currency": to_amount, - "credit_in_account_currency": 0, - } + if is_pay: + from_row = { + "account": paid_from, + "exchange_rate": from_rate, + "credit_in_account_currency": from_amount, + "debit_in_account_currency": 0, + } + to_row = { + "account": paid_to, + "exchange_rate": to_rate, + "debit_in_account_currency": to_amount, + "credit_in_account_currency": 0, + } + else: + from_row = { + "account": paid_from, + "exchange_rate": from_rate, + "credit_in_account_currency": from_amount, + "debit_in_account_currency": 0, + } + to_row = { + "account": paid_to, + "exchange_rate": to_rate, + "debit_in_account_currency": to_amount, + "credit_in_account_currency": 0, + } - if party_type and erp_party: + if party_type and party: if paid_from_type in ("Receivable", "Payable"): from_row["party_type"] = party_type - from_row["party"] = erp_party + from_row["party"] = party if paid_to_type in ("Receivable", "Payable"): to_row["party_type"] = party_type - to_row["party"] = erp_party - - if cost_center: - from_row["cost_center"] = cost_center - to_row["cost_center"] = cost_center + to_row["party"] = party je.append("accounts", from_row) je.append("accounts", to_row) @@ -373,17 +557,36 @@ def _create_journal_entry(bt, paid_from, paid_to, payment_type, amount, party_ty frappe.db.rollback() frappe.local.message_log = [] frappe.log_error( - f"_create_journal_entry failed: {e}\n{frappe.get_traceback()}", + f"_create_journal_entry_for_brt failed: {e}\n{frappe.get_traceback()}", "BI Create Reconcile", ) return {"success": False, "error": str(e)} -def _extract_messages(message_log): - parts = [] - for m in message_log: - if isinstance(m, dict): - parts.append(m.get("message") or m.get("msg") or "") - else: - parts.append(str(m)) - return " | ".join(p for p in parts if p) +def _ensure_purpose(purpose_text, bank_integration): + """Return the name of an existing or newly created Bank Integration Purpose for this integration.""" + existing = frappe.db.get_value( + PURPOSE_DOCTYPE, + {"purpose_keyword": purpose_text, "parent_bank_integration": bank_integration}, + "name", + ) + if existing: + return existing + + try: + doc = frappe.new_doc(PURPOSE_DOCTYPE) + doc.purpose_keyword = purpose_text + doc.direction = "Both" + doc.status = "New" + doc.parent_bank_integration = bank_integration + doc.insert(ignore_permissions=True) + return doc.name + except frappe.DuplicateEntryError: + return frappe.db.get_value( + PURPOSE_DOCTYPE, + {"purpose_keyword": purpose_text, "parent_bank_integration": bank_integration}, + "name", + ) + except Exception as e: + frappe.log_error(f"Insert purpose failed for {purpose_text}: {e}", "BI Purpose") + raise diff --git a/jey_erp/public/js/bank_reconciliation_tool.js b/jey_erp/public/js/bank_reconciliation_tool.js index c56e583..6aa49a5 100644 --- a/jey_erp/public/js/bank_reconciliation_tool.js +++ b/jey_erp/public/js/bank_reconciliation_tool.js @@ -1,25 +1,26 @@ -// Bank Reconciliation Tool extensions for jey_erp: -// 1. Adds "Bank Transaction" column showing the BT name (with link). -// 2. Rebuilds DataTable with a checkbox column. -// 3. Adds a floating "Create & Reconcile" toolbar above the DataTable -// that operates on selected rows. The user confirms which Bank Integration -// (or Kapital Bank Settings) provides the mappings; default is taken from -// the Bank Account's hidden bank_integration field, and the chosen value -// is written back so it pre-fills next time. +// Bank Reconciliation Tool extensions for jey_erp. +// +// (1) refresh — patches DataTableManager prototype once to add a +// "Bank Transaction" column (BT name with a link). +// (2) render — fires on every "Get Unreconciled Entries"; rebuilds the +// DataTable with a checkbox column and injects a "Create & Reconcile" +// toolbar. Selected rows are mapped to Payment Entry / Journal Entry +// using either a Bank Integration record's mappings or Kapital Bank +// Settings' mappings (chosen in the dialog; default comes from the +// Bank Account's hidden bank_integration field, written back on submit). +// +// The dialog / mapping logic is a port of kapital_bank's BRT extension, +// generalised to pick the mapping source. frappe.ui.form.on("Bank Reconciliation Tool", { refresh(frm) { - // Make BRT fill the page (existing fallback) - frm.$wrapper.closest('.page-container').css('--page-max-width', 'none'); + frm.$wrapper.closest(".page-container").css("--page-max-width", "none"); - // Patch the DataTableManager prototype once: adds the "Bank Transaction" column. frappe.require("bank-reconciliation-tool.bundle.js", function () { const DTM = erpnext.accounts.bank_reconciliation.DataTableManager; if (!DTM || DTM.prototype._jey_erp_patched) return; - const proto = DTM.prototype; - // === 1. Add "Bank Transaction" column before the Actions column === const orig_get_dt_columns = proto.get_dt_columns; proto.get_dt_columns = function () { orig_get_dt_columns.call(this); @@ -34,7 +35,6 @@ frappe.ui.form.on("Bank Reconciliation Tool", { }); }; - // Insert bank transaction name (row["name"]) before the Actions button const orig_format_row = proto.format_row; proto.format_row = function (row) { const result = orig_format_row.call(this, row); @@ -46,9 +46,6 @@ frappe.ui.form.on("Bank Reconciliation Tool", { }); }, - // `render` fires every time the reconciliation tool is (re)built — i.e. on - // every "Get Unreconciled Entries". Each time a brand-new DataTableManager - // is created, so we re-poll for it and re-add the checkbox column + toolbar. render(frm) { if (frm._bi_check_interval) clearInterval(frm._bi_check_interval); frm._bi_check_interval = setInterval(function () { @@ -63,11 +60,10 @@ frappe.ui.form.on("Bank Reconciliation Tool", { }); const BIBRT = { - enhance(frm, dtm) { + enhance: function (frm, dtm) { dtm._bi_enhanced = true; dtm._bi_frm = frm; - // Rebuild DataTable with checkboxColumn: true dtm.datatable.destroy(); dtm.datatable = new frappe.DataTable(dtm.$reconciliation_tool_dt.get(0), { columns: dtm.columns, @@ -83,13 +79,14 @@ const BIBRT = { BIBRT.injectToolbar(dtm); }, - injectToolbar(dtm) { + injectToolbar: function (dtm) { $("#bi-brt-toolbar").remove(); + const $toolbar = $(`