fix: symmetric Azeri translit + VOEN-first dedup

Mirrors the same correctness fixes now in jey_erp.bank_integration:

- Party-name lookup at Create & Reconcile + fuzzy purpose matcher now apply
  Azerbaijani transliteration when settings.consider_azeri_chars is on, with
  per-row override via the new Azeri Translit Select on Customer/Supplier
  Mappings (same pattern as case sensitivity).
- create_unmapped_customers / create_unmapped_suppliers now look up existing
  parties by tax_id first, then by name; when found, the mapping row is
  linked to the existing party instead of silently skipping it. Response
  reports linked_count alongside created_count.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ali 2026-05-14 10:45:43 +00:00
parent fd3f74d3ed
commit 53d4eb1cc7
4 changed files with 139 additions and 24 deletions

View File

@ -44,6 +44,24 @@ def _resolve_leaf_node(doctype, configured, label):
return configured, None return configured, None
def _find_existing_party(doctype, name_field, name, tax_id):
"""VOEN-first lookup: a matching tax_id wins over a name match.
Returns the existing party's name, or None.
"""
tid = (tax_id or "").strip()
if tid:
existing = frappe.db.get_value(doctype, {"tax_id": tid}, "name")
if existing:
return existing
clean_name = (name or "").strip()
if clean_name:
existing = frappe.db.get_value(doctype, {name_field: clean_name}, "name")
if existing:
return existing
return None
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
# REGISTRY LOADERS (fetch from Kapital Bank → save to local DocTypes) # REGISTRY LOADERS (fetch from Kapital Bank → save to local DocTypes)
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
@ -691,10 +709,27 @@ def create_unmapped_customers(settings_name=None):
resolved.append((cg, terr)) resolved.append((cg, terr))
created = 0 created = 0
linked = 0
for (mapping, kb_customer), (customer_group, territory) in zip(unmapped, resolved): for (mapping, kb_customer), (customer_group, territory) in zip(unmapped, resolved):
try: try:
customer_name = kb_customer.customer_name customer_name = kb_customer.customer_name
if frappe.db.exists("Customer", {"customer_name": customer_name}): payment_terms = mapping.payment_terms or doc.default_payment_terms
existing = _find_existing_party("Customer", "customer_name", customer_name, kb_customer.tax_id)
if existing:
mapping.erp_customer = existing
mapping.mapping_type = "Manual"
existing_group, existing_territory = frappe.db.get_value(
"Customer", existing, ["customer_group", "territory"]
) or (None, None)
frappe.db.set_value("Kapital Bank Customer", kb_customer.name, {
"status": "Mapped",
"mapped_customer": existing,
"customer_group": existing_group,
"territory": existing_territory,
"payment_terms": payment_terms or None,
}, update_modified=False)
linked += 1
continue continue
party = frappe.new_doc("Customer") party = frappe.new_doc("Customer")
@ -706,7 +741,6 @@ def create_unmapped_customers(settings_name=None):
if kb_customer.tax_id: if kb_customer.tax_id:
party.tax_id = kb_customer.tax_id party.tax_id = kb_customer.tax_id
payment_terms = mapping.payment_terms or doc.default_payment_terms
if payment_terms: if payment_terms:
party.payment_terms = payment_terms party.payment_terms = payment_terms
@ -730,11 +764,11 @@ def create_unmapped_customers(settings_name=None):
except Exception as e: except Exception as e:
frappe.log_error(f"create_unmapped_customers: error for {kb_customer.customer_name}: {e}", "Kapital Bank Customer Creation") frappe.log_error(f"create_unmapped_customers: error for {kb_customer.customer_name}: {e}", "Kapital Bank Customer Creation")
if created > 0: if created > 0 or linked > 0:
doc.save(ignore_permissions=True) doc.save(ignore_permissions=True)
frappe.db.commit() frappe.db.commit()
return {"success": True, "created_count": created} return {"success": True, "created_count": created, "linked_count": linked}
except Exception as e: except Exception as e:
frappe.log_error(f"create_unmapped_customers: {e}\n{frappe.get_traceback()}", "Kapital Bank Customer Creation") frappe.log_error(f"create_unmapped_customers: {e}\n{frappe.get_traceback()}", "Kapital Bank Customer Creation")
@ -765,10 +799,24 @@ def create_unmapped_suppliers(settings_name=None):
resolved.append(sg) resolved.append(sg)
created = 0 created = 0
linked = 0
for (mapping, kb_supplier), supplier_group in zip(unmapped, resolved): for (mapping, kb_supplier), supplier_group in zip(unmapped, resolved):
try: try:
supplier_name = kb_supplier.supplier_name supplier_name = kb_supplier.supplier_name
if frappe.db.exists("Supplier", {"supplier_name": supplier_name}): payment_terms = mapping.payment_terms or doc.default_payment_terms
existing = _find_existing_party("Supplier", "supplier_name", supplier_name, kb_supplier.tax_id)
if existing:
mapping.erp_supplier = existing
mapping.mapping_type = "Manual"
existing_group = frappe.db.get_value("Supplier", existing, "supplier_group")
frappe.db.set_value("Kapital Bank Supplier", kb_supplier.name, {
"status": "Mapped",
"mapped_supplier": existing,
"supplier_group": existing_group,
"payment_terms": payment_terms or None,
}, update_modified=False)
linked += 1
continue continue
party = frappe.new_doc("Supplier") party = frappe.new_doc("Supplier")
@ -779,7 +827,6 @@ def create_unmapped_suppliers(settings_name=None):
if kb_supplier.tax_id: if kb_supplier.tax_id:
party.tax_id = kb_supplier.tax_id party.tax_id = kb_supplier.tax_id
payment_terms = mapping.payment_terms or doc.default_payment_terms
if payment_terms: if payment_terms:
party.payment_terms = payment_terms party.payment_terms = payment_terms
@ -802,11 +849,11 @@ def create_unmapped_suppliers(settings_name=None):
except Exception as e: except Exception as e:
frappe.log_error(f"create_unmapped_suppliers: error for {kb_supplier.supplier_name}: {e}", "Kapital Bank Supplier Creation") frappe.log_error(f"create_unmapped_suppliers: error for {kb_supplier.supplier_name}: {e}", "Kapital Bank Supplier Creation")
if created > 0: if created > 0 or linked > 0:
doc.save(ignore_permissions=True) doc.save(ignore_permissions=True)
frappe.db.commit() frappe.db.commit()
return {"success": True, "created_count": created} return {"success": True, "created_count": created, "linked_count": linked}
except Exception as e: except Exception as e:
frappe.log_error(f"create_unmapped_suppliers: {e}\n{frappe.get_traceback()}", "Kapital Bank Supplier Creation") frappe.log_error(f"create_unmapped_suppliers: {e}\n{frappe.get_traceback()}", "Kapital Bank Supplier Creation")

View File

@ -11,6 +11,7 @@
"territory", "territory",
"payment_terms", "payment_terms",
"case_mode", "case_mode",
"azeri_mode",
"mapping_type" "mapping_type"
], ],
"fields": [ "fields": [
@ -63,6 +64,13 @@
"label": "Case Mode", "label": "Case Mode",
"options": "\nIgnore Case\nCase Sensitive" "options": "\nIgnore Case\nCase Sensitive"
}, },
{
"description": "Override the global 'Consider Azerbaijani Characters' for this row's party-name matching. Blank = use the global setting.",
"fieldname": "azeri_mode",
"fieldtype": "Select",
"label": "Azeri Translit",
"options": "\nApply Translit\nStrict (No Translit)"
},
{ {
"default": "Manual", "default": "Manual",
"fieldname": "mapping_type", "fieldname": "mapping_type",

View File

@ -10,6 +10,7 @@
"supplier_group", "supplier_group",
"payment_terms", "payment_terms",
"case_mode", "case_mode",
"azeri_mode",
"mapping_type" "mapping_type"
], ],
"fields": [ "fields": [
@ -55,6 +56,13 @@
"label": "Case Mode", "label": "Case Mode",
"options": "\nIgnore Case\nCase Sensitive" "options": "\nIgnore Case\nCase Sensitive"
}, },
{
"description": "Override the global 'Consider Azerbaijani Characters' for this row's party-name matching. Blank = use the global setting.",
"fieldname": "azeri_mode",
"fieldtype": "Select",
"label": "Azeri Translit",
"options": "\nApply Translit\nStrict (No Translit)"
},
{ {
"default": "Manual", "default": "Manual",
"fieldname": "mapping_type", "fieldname": "mapping_type",

View File

@ -5,6 +5,15 @@ import frappe
from frappe.utils import cint, flt from frappe.utils import cint, flt
# Same translit table used in kapital_bank.bank_api._AZERI_MAP; duplicated here
# to keep this module self-contained.
_AZERI_MAP = str.maketrans("ƏəÜüÖöĞğİıÇ窺", "EeUuOoGgIiCcSs")
def _translit_az(text):
return (text or "").translate(_AZERI_MAP)
def _partial_ratio(needle, haystack): def _partial_ratio(needle, haystack):
"""Best fuzzy ratio of `needle` against any equal-length window of `haystack` """Best fuzzy ratio of `needle` against any equal-length window of `haystack`
(like fuzzywuzzy.partial_ratio) lets a short purpose keyword fuzzily match (like fuzzywuzzy.partial_ratio) lets a short purpose keyword fuzzily match
@ -47,47 +56,78 @@ def _effective_case_insensitive(row, global_default):
return global_default return global_default
def _effective_azeri(row, global_default):
"""Per-row Azeri Translit mode overrides the global Consider Azerbaijani Characters flag."""
mode = (getattr(row, "azeri_mode", None) or "").strip()
if mode == "Apply Translit":
return True
if mode == "Strict (No Translit)":
return False
return global_default
def _build_name_to_party(settings): def _build_name_to_party(settings):
"""Counterparty text -> {"Customer": erp_customer} / {"Supplier": erp_supplier}. """Counterparty text -> {"Customer": erp_customer} / {"Supplier": erp_supplier}.
A row may be indexed under its strict key only, its lowercased key only, or Each row is indexed under up to four key variants depending on its effective
both depending on the row's Case Mode (which overrides the global Case Mode and Azeri Translit settings (both of which fall back to globals):
`case_insensitive_party_match` setting on Kapital Bank Settings). strict / lowercase / translit / translit + lowercase.
""" """
global_ci = bool(getattr(settings, "case_insensitive_party_match", 1)) global_ci = bool(getattr(settings, "case_insensitive_party_match", 1))
global_az = bool(getattr(settings, "consider_azeri_chars", 1))
idx = {} idx = {}
def _add(name, party_type, erp_party, ci): def _add(name, party_type, erp_party, ci, az):
key = (name or "").strip() key = (name or "").strip()
if not key: if not key:
return return
idx.setdefault(key, {})[party_type] = erp_party idx.setdefault(key, {})[party_type] = erp_party
if ci: if ci:
idx.setdefault(key.lower(), {})[party_type] = erp_party idx.setdefault(key.lower(), {})[party_type] = erp_party
if az:
translit = _translit_az(key)
if translit != key:
idx.setdefault(translit, {})[party_type] = erp_party
if ci:
idx.setdefault(translit.lower(), {})[party_type] = erp_party
for row in settings.customer_mappings: for row in settings.customer_mappings:
if not row.erp_customer or not row.kb_customer_name: if not row.erp_customer or not row.kb_customer_name:
continue continue
cust_name = frappe.db.get_value("Kapital Bank Customer", row.kb_customer_name, "customer_name") cust_name = frappe.db.get_value("Kapital Bank Customer", row.kb_customer_name, "customer_name")
if cust_name: if cust_name:
_add(cust_name, "Customer", row.erp_customer, _effective_case_insensitive(row, global_ci)) _add(
cust_name, "Customer", row.erp_customer,
_effective_case_insensitive(row, global_ci),
_effective_azeri(row, global_az),
)
for row in settings.supplier_mappings: for row in settings.supplier_mappings:
if not row.erp_supplier or not row.kb_supplier_name: if not row.erp_supplier or not row.kb_supplier_name:
continue continue
supp_name = frappe.db.get_value("Kapital Bank Supplier", row.kb_supplier_name, "supplier_name") supp_name = frappe.db.get_value("Kapital Bank Supplier", row.kb_supplier_name, "supplier_name")
if supp_name: if supp_name:
_add(supp_name, "Supplier", row.erp_supplier, _effective_case_insensitive(row, global_ci)) _add(
supp_name, "Supplier", row.erp_supplier,
_effective_case_insensitive(row, global_ci),
_effective_azeri(row, global_az),
)
return idx return idx
def _resolve_party_from_name(name, name_to_party, payment_type): def _resolve_party_from_name(name, name_to_party, payment_type):
"""(party_type, erp_party) for `name`, or (None, None). Tries an exact (strip-only) """(party_type, erp_party) for `name`, or (None, None). Tries up to four
lookup first; falls back to lowercased lookup so rows indexed case-insensitively lookup variants (strict / lower / translit / translit+lower); the index
can be reached from arbitrary Bank Transaction bank_party_name casing.""" encodes per-row case/azeri decisions, so flags aren't needed here."""
key = (name or "").strip() key = (name or "").strip()
if not key: if not key:
return None, None return None, None
cands = name_to_party.get(key) or name_to_party.get(key.lower()) translit = _translit_az(key)
cands = (
name_to_party.get(key)
or name_to_party.get(key.lower())
or (name_to_party.get(translit) if translit != key else None)
or (name_to_party.get(translit.lower()) if translit != key else None)
)
if not cands: if not cands:
return None, None return None, None
preferred = "Customer" if payment_type == "Receive" else "Supplier" preferred = "Customer" if payment_type == "Receive" else "Supplier"
@ -244,13 +284,14 @@ def create_purpose_mappings(transactions, paid_from=None, paid_to=None, document
if do_documents: if do_documents:
txn_mappings = settings.transaction_mappings if mode == "Documents & Reconcile" else None txn_mappings = settings.transaction_mappings if mode == "Documents & Reconcile" else None
purpose_threshold = flt(settings.similarity_threshold_purpose or 70) / 100.0 purpose_threshold = flt(settings.similarity_threshold_purpose or 70) / 100.0
az = bool(getattr(settings, "consider_azeri_chars", 1))
if mode == "Documents & Reconcile": if mode == "Documents & Reconcile":
purpose_text_cache = {} purpose_text_cache = {}
for row in settings.transaction_mappings: for row in settings.transaction_mappings:
if row.purpose_keyword and row.purpose_keyword not in purpose_text_cache: 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 "" text = frappe.db.get_value("Kapital Bank Purpose", row.purpose_keyword, "purpose_keyword") or ""
purpose_text_cache[row.purpose_keyword] = text.lower() purpose_text_cache[row.purpose_keyword] = _norm_text(text, az)
else: else:
purpose_text_cache = {} purpose_text_cache = {}
@ -263,7 +304,7 @@ def create_purpose_mappings(transactions, paid_from=None, paid_to=None, document
continue continue
if mode == "Documents & Reconcile": if mode == "Documents & Reconcile":
mapping_row = _find_mapping_for_txn(txn, txn_mappings, purpose_text_cache, purpose_threshold) mapping_row = _find_mapping_for_txn(txn, txn_mappings, purpose_text_cache, purpose_threshold, az)
if not mapping_row: if not mapping_row:
errors.append({ errors.append({
"reference_number": txn.get("reference_number") or "", "reference_number": txn.get("reference_number") or "",
@ -366,17 +407,28 @@ def create_purpose_mappings(transactions, paid_from=None, paid_to=None, document
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
def _find_mapping_for_txn(txn, transaction_mappings, purpose_text_cache, purpose_threshold): def _norm_text(text, az):
"""Lowercased + (optionally) Azeri-translit form for purpose/party comparison."""
s = (text or "").lower()
if az:
s = _translit_az(s)
return s
def _find_mapping_for_txn(txn, transaction_mappings, purpose_text_cache, purpose_threshold, az=False):
"""Find the best matching transaction mapping row for a BRT txn. """Find the best matching transaction mapping row for a BRT txn.
Priority: purpose+counterparty (exact) > purpose-only (exact) > Priority: purpose+counterparty (exact) > purpose-only (exact) >
purpose+counterparty (fuzzy) > purpose-only (fuzzy) > counterparty-only > purpose+counterparty (fuzzy) > purpose-only (fuzzy) > counterparty-only >
fallback by counterparty_type. "Exact" = the keyword is a substring of the fallback by counterparty_type. "Exact" = the keyword is a substring of the
transaction's description; "fuzzy" = partial-ratio >= purpose_threshold. transaction's description; "fuzzy" = partial-ratio >= purpose_threshold.
`az` toggles Azerbaijani transliteration of purpose / party text before
comparison (driven by settings.consider_azeri_chars).
""" """
payment_type = "Pay" if txn.get("drcr") == "D" else "Receive" payment_type = "Pay" if txn.get("drcr") == "D" else "Receive"
txn_purpose = (txn.get("purpose") or "").lower() txn_purpose = _norm_text(txn.get("purpose"), az)
txn_party = (txn.get("party") or "").lower() txn_party = _norm_text(txn.get("party"), az)
bt_name = txn.get("bank_transaction_name") bt_name = txn.get("bank_transaction_name")
if bt_name: if bt_name:
@ -423,7 +475,7 @@ def _find_mapping_for_txn(txn, transaction_mappings, purpose_text_cache, purpose
return _partial_ratio(kw, txn_purpose) if kw else 0.0 return _partial_ratio(kw, txn_purpose) if kw else 0.0
def _counterparty_matches(row): def _counterparty_matches(row):
return row.counterparty.lower() == txn_party return _norm_text(row.counterparty, az) == txn_party
def _best_fuzzy(rules, check_cp): def _best_fuzzy(rules, check_cp):
best = None best = None