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:
parent
42cac5a006
commit
fcdfffbcf0
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue