refactor(bank-integration): registries only via "Load Data"; resolve party at reconcile time

The Excel import dialog no longer has the "Also load counterparties &
purposes" checkbox — BT import now only creates Bank Transactions.
Registries (Bank Integration Customer/Supplier/Purpose) are populated
exclusively through the "Load Data" button on the Bank Integration form.

Since BTs are now always imported without a party, Create & Reconcile
resolves it: a BT's bank_party_name (the counterparty text from the
file) is looked up in the customer/supplier mappings, and if that
counterparty is mapped to an ERPNext party, the txn gets that party —
so counterparty-based transaction-mapping rules match and the created
PE/JE carries the party. A safety guard: a party is only attached to a
Payment Entry when one of the GL accounts is Receivable/Payable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ali 2026-05-12 13:01:27 +00:00
parent 42cac5a006
commit fcdfffbcf0
3 changed files with 64 additions and 54 deletions

View File

@ -48,6 +48,40 @@ def _extract_messages(message_log):
return " | ".join(p for p in parts if p) return " | ".join(p for p in parts if p)
def _build_name_to_party(settings):
"""Counterparty text -> {"Customer": erp_customer} / {"Supplier": erp_supplier},
built from the mapped rows of customer_mappings / supplier_mappings."""
idx = {}
for row in settings.customer_mappings:
if not row.erp_customer or not row.bi_customer_name:
continue
cust_name = frappe.db.get_value("Bank Integration Customer", row.bi_customer_name, "customer_name")
if cust_name:
idx.setdefault(cust_name.strip(), {})["Customer"] = row.erp_customer
for row in settings.supplier_mappings:
if not row.erp_supplier or not row.bi_supplier_name:
continue
supp_name = frappe.db.get_value("Bank Integration Supplier", row.bi_supplier_name, "supplier_name")
if supp_name:
idx.setdefault(supp_name.strip(), {})["Supplier"] = row.erp_supplier
return idx
def _resolve_party_from_name(name, name_to_party, payment_type):
"""(party_type, erp_party) for `name`, or (None, None). Prefers Customer for
incoming (Receive) and Supplier for outgoing (Pay), falling back to the other."""
cands = name_to_party.get((name or "").strip())
if not cands:
return None, None
preferred = "Customer" if payment_type == "Receive" else "Supplier"
other = "Supplier" if preferred == "Customer" else "Customer"
if preferred in cands:
return preferred, cands[preferred]
if other in cands:
return other, cands[other]
return None, None
@frappe.whitelist() @frappe.whitelist()
def create_purpose_mappings(transactions, bank_integration, paid_from=None, paid_to=None, def create_purpose_mappings(transactions, bank_integration, paid_from=None, paid_to=None,
document_type="Payment Entry", mode="Both"): document_type="Payment Entry", mode="Both"):
@ -67,6 +101,20 @@ def create_purpose_mappings(transactions, bank_integration, paid_from=None, paid
settings = frappe.get_doc("Bank Integration", bank_integration) settings = frappe.get_doc("Bank Integration", bank_integration)
# Bank Transactions are imported without a party (the mapping is what tells
# us who they are). Resolve it now, from the customer/supplier mappings, so
# both counterparty-based rule matching and the created PE/JE pick it up.
name_to_party = _build_name_to_party(settings)
for txn in txn_list:
if txn.get("party") or not txn.get("bank_transaction_name"):
continue
bp_name = frappe.db.get_value("Bank Transaction", txn["bank_transaction_name"], "bank_party_name")
payment_type = "Pay" if txn.get("drcr") == "D" else "Receive"
pt, ep = _resolve_party_from_name(bp_name, name_to_party, payment_type)
if pt and ep:
txn["party_type"] = pt
txn["party"] = ep
if mode in ("Both", "Mappings Only"): if mode in ("Both", "Mappings Only"):
existing_purpose = { existing_purpose = {
(row.purpose_keyword, row.payment_type, (row.currency or "").upper()) (row.purpose_keyword, row.payment_type, (row.currency or "").upper())
@ -457,9 +505,14 @@ def _create_payment_entry_for_brt(txn, paid_from, paid_to, bank_txn):
pe.reference_no = bank_txn.reference_number or txn.get("reference_number") or "" pe.reference_no = bank_txn.reference_number or txn.get("reference_number") or ""
pe.reference_date = posting_date pe.reference_date = posting_date
pe.remarks = bank_txn.description or txn.get("purpose") or "" pe.remarks = bank_txn.description or txn.get("purpose") or ""
# Only attach a party if one of the GL accounts is a party account —
# otherwise ERPNext rejects the PE ("Party Account ... is required").
if party_type and party: if party_type and party:
pe.party_type = party_type pf_type = frappe.get_cached_value("Account", paid_from, "account_type") or ""
pe.party = party pt_type = frappe.get_cached_value("Account", paid_to, "account_type") or ""
if pf_type in ("Receivable", "Payable") or pt_type in ("Receivable", "Payable"):
pe.party_type = party_type
pe.party = party
frappe.local.message_log = [] frappe.local.message_log = []
pe.insert(ignore_permissions=True) pe.insert(ignore_permissions=True)

View File

@ -48,13 +48,13 @@ def parse_excel_for_preview(file_url, preset_name, bank_integration, bank_accoun
@frappe.whitelist() @frappe.whitelist()
def import_bulk_bt(txn_list, bank_integration, bank_account, load_registries=1): def import_bulk_bt(txn_list, bank_integration, bank_account):
"""Enqueue bulk Bank Transaction creation as a background job. """Enqueue bulk Bank Transaction creation as a background job.
load_registries: when truthy, also upsert counterparties ( Bank Integration Creates Bank Transactions only registries (Bank Integration Customer /
Customer/Supplier by direction) and purpose keywords ( Bank Integration Supplier / Purpose) are populated separately via the "Load Data" button on
Purpose) with status=New, exactly as before. When falsy, only Bank the Bank Integration form. Party is resolved later, at "Create & Reconcile"
Transactions are created. time, from the mappings.
""" """
if isinstance(txn_list, str): if isinstance(txn_list, str):
txn_list = json.loads(txn_list) txn_list = json.loads(txn_list)
@ -67,7 +67,6 @@ def import_bulk_bt(txn_list, bank_integration, bank_account, load_registries=1):
bank_integration=bank_integration, bank_integration=bank_integration,
bank_account=bank_account, bank_account=bank_account,
user=user, user=user,
load_registries=cint(load_registries),
queue="default", queue="default",
timeout=600, timeout=600,
) )
@ -75,8 +74,8 @@ def import_bulk_bt(txn_list, bank_integration, bank_account, load_registries=1):
return {"success": True, "enqueued": True, "total": len(txn_list)} return {"success": True, "enqueued": True, "total": len(txn_list)}
def _process_bulk_bt_import(txn_list, bank_integration, bank_account, user, load_registries=1): def _process_bulk_bt_import(txn_list, bank_integration, bank_account, user):
"""Background job: create BT (+ optionally upsert registry rows) for each transaction.""" """Background job: create one Bank Transaction per row."""
frappe.set_user(user) frappe.set_user(user)
bi = frappe.get_doc("Bank Integration", bank_integration) bi = frappe.get_doc("Bank Integration", bank_integration)
@ -91,9 +90,6 @@ def _process_bulk_bt_import(txn_list, bank_integration, bank_account, user, load
total = len(txn_list) total = len(txn_list)
imported_count = 0 imported_count = 0
errors = [] errors = []
new_customers = 0
new_suppliers = 0
new_purposes = 0
for idx, txn in enumerate(txn_list): for idx, txn in enumerate(txn_list):
frappe.publish_realtime( frappe.publish_realtime(
@ -104,24 +100,8 @@ def _process_bulk_bt_import(txn_list, bank_integration, bank_account, user, load
try: try:
counterparty = (txn.get("counterparty") or "").strip() counterparty = (txn.get("counterparty") or "").strip()
contr_voen = (txn.get("contr_voen") or "").strip()
contr_iban = (txn.get("contr_iban") or "").strip()
drcr = (txn.get("drcr") or "D").upper() drcr = (txn.get("drcr") or "D").upper()
purpose = (txn.get("purpose") or "").strip() purpose = (txn.get("purpose") or "").strip()
if load_registries:
if counterparty:
if drcr == "C":
if _upsert_customer(counterparty, contr_voen, contr_iban, bank_integration):
new_customers += 1
else:
if _upsert_supplier(counterparty, contr_voen, contr_iban, bank_integration):
new_suppliers += 1
if purpose:
if _upsert_purpose(purpose, drcr, bank_integration):
new_purposes += 1
ref_no = (txn.get("ref_no") or "").strip() ref_no = (txn.get("ref_no") or "").strip()
amount = abs(flt(txn.get("amount", 0))) amount = abs(flt(txn.get("amount", 0)))
@ -163,21 +143,10 @@ def _process_bulk_bt_import(txn_list, bank_integration, bank_account, user, load
frappe.publish_realtime( frappe.publish_realtime(
"bi_bt_import_complete", "bi_bt_import_complete",
{ {"total": total, "imported": imported_count, "errors": errors},
"total": total,
"imported": imported_count,
"errors": errors,
"new_customers": new_customers,
"new_suppliers": new_suppliers,
"new_purposes": new_purposes,
},
user=user, user=user,
) )
frappe.log_error( frappe.log_error(f"BI BT import done: {imported_count}/{total}", "BI BT Import Complete")
f"BI BT import done: {imported_count}/{total}, "
f"+{new_customers} customers / +{new_suppliers} suppliers / +{new_purposes} purposes",
"BI BT Import Complete",
)
def _upsert_customer(name, voen, iban, bank_integration): def _upsert_customer(name, voen, iban, bank_integration):

View File

@ -84,13 +84,6 @@ const BIExcelImport = {
label: __('Excel File'), label: __('Excel File'),
reqd: 1, reqd: 1,
}, },
{
fieldname: 'load_registries',
fieldtype: 'Check',
label: __('Also load counterparties & purposes'),
default: 1,
description: __('Extract unique counterparties and purpose keywords from the file into the Bank Integration registries (status: New).'),
},
], ],
primary_action_label: __('Parse'), primary_action_label: __('Parse'),
primary_action(values) { primary_action(values) {
@ -221,13 +214,9 @@ const BIExcelImport = {
const ok = d.imported || 0; const ok = d.imported || 0;
const errCount = (d.errors || []).length; const errCount = (d.errors || []).length;
const newC = d.new_customers || 0;
const newS = d.new_suppliers || 0;
const newP = d.new_purposes || 0;
let msg = '<div>' + __('Imported: <b>{0}</b>', [ok]) + '</div>'; let msg = '<div>' + __('Imported: <b>{0}</b>', [ok]) + '</div>';
msg += '<div>' + __('Errors: <b>{0}</b>', [errCount]) + '</div>'; msg += '<div>' + __('Errors: <b>{0}</b>', [errCount]) + '</div>';
msg += '<div>' + __('New unmapped — customers: {0}, suppliers: {1}, purposes: {2}', [newC, newS, newP]) + '</div>';
const indicator = errCount === 0 ? 'green' : (ok > 0 ? 'orange' : 'red'); const indicator = errCount === 0 ? 'green' : (ok > 0 ? 'orange' : 'red');
const title = errCount === 0 const title = errCount === 0
@ -258,7 +247,6 @@ const BIExcelImport = {
txn_list: JSON.stringify(selected), txn_list: JSON.stringify(selected),
bank_integration: values.bank_integration, bank_integration: values.bank_integration,
bank_account: values.bank_account, bank_account: values.bank_account,
load_registries: values.load_registries ? 1 : 0,
}, },
error() { error() {
frappe.realtime.off('bi_bt_import_progress'); frappe.realtime.off('bi_bt_import_progress');