diff --git a/kapital_bank/client/bank_reconciliation_tool.js b/kapital_bank/client/bank_reconciliation_tool.js index 3115ac1..ef8f79e 100644 --- a/kapital_bank/client/bank_reconciliation_tool.js +++ b/kapital_bank/client/bank_reconciliation_tool.js @@ -179,6 +179,7 @@ const KBBRTMapping = { options: "Both\nMappings Only\nDocuments & Reconcile", default: "Both", reqd: 1, + onchange: function () { KBBRTMapping._applyModeVisibility(d); }, }, { fieldname: "preview_html", fieldtype: "HTML", options: previewHtml }, { fieldname: "sec_options", fieldtype: "Section Break", label: __("Options") }, @@ -188,7 +189,6 @@ const KBBRTMapping = { label: __("Document Type"), options: "Payment Entry\nJournal Entry", default: "Payment Entry", - reqd: 1, }, { fieldname: "use_original_purpose", @@ -210,33 +210,46 @@ const KBBRTMapping = { { fieldname: "sec_accounts", fieldtype: "Section Break", label: __("GL Accounts") }, { fieldname: "paid_from", fieldtype: "Link", options: "Account", - label: __("Paid From (Account)"), reqd: 1, + label: __("Paid From (Account)"), }, { fieldname: "col_break", fieldtype: "Column Break" }, { fieldname: "paid_to", fieldtype: "Link", options: "Account", - label: __("Paid To (Account)"), reqd: 1, + label: __("Paid To (Account)"), }, ], primary_action_label: __("Create & Reconcile"), primary_action: function () { - const values = d.get_values(); + const isDR = d.get_value("mode") === "Documents & Reconcile"; + const values = d.get_values(true); if (!values) return; + if (!isDR) { + const missing = []; + if (!values.paid_from) missing.push(__("Paid From (Account)")); + if (!values.paid_to) missing.push(__("Paid To (Account)")); + if (missing.length) { + frappe.msgprint({ title: __("Missing Values"), indicator: "red", + message: __("Following fields are required:") + "
" + missing.join("
") }); + return; + } + } let finalTxns = txns.slice(); - if (!values.use_original_purpose) { - const cp = (values.custom_purpose || "").trim(); - if (cp) { - finalTxns = txns.map(t => Object.assign({}, t, { purpose: cp })); - } else { - finalTxns = txns.map(t => Object.assign({}, t, { purpose: "" })); - if (finalTxns.every(t => !t.party)) { - frappe.msgprint({ - title: __("Validation Error"), - indicator: "red", - message: __("Either a purpose keyword or a party is required."), - }); - return; + if (values.mode !== "Documents & Reconcile") { + if (!values.use_original_purpose) { + const cp = (values.custom_purpose || "").trim(); + if (cp) { + finalTxns = txns.map(t => Object.assign({}, t, { purpose: cp })); + } else { + finalTxns = txns.map(t => Object.assign({}, t, { purpose: "" })); + if (finalTxns.every(t => !t.party)) { + frappe.msgprint({ + title: __("Validation Error"), + indicator: "red", + message: __("Either a purpose keyword or a party is required."), + }); + return; + } } } } @@ -247,6 +260,7 @@ const KBBRTMapping = { d._kb_frm = dtm._kb_frm; d.show(); + KBBRTMapping._applyModeVisibility(d); // Pre-fill custom purpose from the Description inline filter if active const $fi = $(`.${dtm._kb_scopeClass} .dt-row-filter input.dt-filter[data-name="Description"]`); @@ -256,6 +270,14 @@ const KBBRTMapping = { } }, + _applyModeVisibility: function (d) { + const isDR = d.get_value("mode") === "Documents & Reconcile"; + const toHide = ["preview_html", "sec_options", "document_type", "use_original_purpose", + "custom_purpose", "sec_accounts", "paid_from", "col_break", "paid_to"]; + toHide.forEach(fn => d.set_df_property(fn, "hidden", isDR ? 1 : 0)); + d.refresh_fields(toHide); + }, + submitMappings: function (dialog, txns, paid_from, paid_to, document_type, mode) { dialog.disable_primary_action(); frappe.call({ diff --git a/kapital_bank/mapping.py b/kapital_bank/mapping.py index 39de095..9ce9e87 100644 --- a/kapital_bank/mapping.py +++ b/kapital_bank/mapping.py @@ -4,7 +4,7 @@ import frappe @frappe.whitelist() -def create_purpose_mappings(transactions, paid_from, paid_to, document_type="Payment Entry", mode="Both"): +def create_purpose_mappings(transactions, 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 @@ -19,8 +19,11 @@ def create_purpose_mappings(transactions, paid_from, paid_to, document_type="Pay reconciled = 0 errors = [] - if do_mappings: + needs_settings = do_mappings or mode == "Documents & Reconcile" + if needs_settings: settings = frappe.get_single("Kapital Bank Settings") + + if do_mappings: existing_purpose = { (row.purpose_keyword, row.payment_type) for row in settings.transaction_mappings @@ -118,6 +121,17 @@ def create_purpose_mappings(transactions, paid_from, paid_to, document_type="Pay frappe.db.commit() if do_documents: + txn_mappings = settings.transaction_mappings if mode == "Documents & Reconcile" else None + + 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("Kapital Bank Purpose", row.purpose_keyword, "purpose_keyword") or "" + purpose_text_cache[row.purpose_keyword] = text.lower() + else: + purpose_text_cache = {} + for txn in txn_list: if not txn.get("bank_transaction_name"): errors.append({ @@ -126,7 +140,23 @@ def create_purpose_mappings(transactions, paid_from, paid_to, document_type="Pay }) continue - result = _create_and_reconcile_doc(txn, paid_from, paid_to, document_type) + 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" + else: + txn_paid_from = paid_from + txn_paid_to = paid_to + txn_doc_type = document_type + + result = _create_and_reconcile_doc(txn, txn_paid_from, txn_paid_to, txn_doc_type) if result.get("success"): created_docs += 1 if result.get("reconciled"): @@ -160,6 +190,58 @@ def create_purpose_mappings(transactions, paid_from, paid_to, document_type="Pay return {"success": False, "message": str(e)} +def _find_mapping_for_txn(txn, transaction_mappings, purpose_text_cache): + """Find the best matching transaction mapping row for a BRT txn. + + Priority: rows matching both purpose+counterparty > purpose-only > counterparty-only. + Returns the first matching row or None. + """ + payment_type = "Pay" if txn.get("drcr") == "D" else "Receive" + txn_purpose = (txn.get("purpose") or "").lower() + txn_party = (txn.get("party") or "").lower() + + both_rules = [] + purpose_only_rules = [] + counterparty_only_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 + + has_purpose = bool(row.purpose_keyword) + has_counterparty = bool(row.counterparty) + + 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) + + def _purpose_matches(row): + keyword_text = purpose_text_cache.get(row.purpose_keyword, "") + return keyword_text and keyword_text in txn_purpose + + def _counterparty_matches(row): + return row.counterparty.lower() == txn_party + + for row in both_rules: + if _purpose_matches(row) and _counterparty_matches(row): + return row + + for row in purpose_only_rules: + if _purpose_matches(row): + return row + + for row in counterparty_only_rules: + if _counterparty_matches(row): + return row + + return None + + def _create_and_reconcile_doc(txn, paid_from, paid_to, document_type): """Create a Payment Entry or Journal Entry and reconcile it with the bank transaction.""" bank_transaction_name = txn.get("bank_transaction_name")