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")