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
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)
# ═══════════════════════════════════════════════════════════════════════════════
@ -691,10 +709,27 @@ def create_unmapped_customers(settings_name=None):
resolved.append((cg, terr))
created = 0
linked = 0
for (mapping, kb_customer), (customer_group, territory) in zip(unmapped, resolved):
try:
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
party = frappe.new_doc("Customer")
@ -706,7 +741,6 @@ def create_unmapped_customers(settings_name=None):
if kb_customer.tax_id:
party.tax_id = kb_customer.tax_id
payment_terms = mapping.payment_terms or doc.default_payment_terms
if payment_terms:
party.payment_terms = payment_terms
@ -730,11 +764,11 @@ def create_unmapped_customers(settings_name=None):
except Exception as e:
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)
frappe.db.commit()
return {"success": True, "created_count": created}
return {"success": True, "created_count": created, "linked_count": linked}
except Exception as e:
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)
created = 0
linked = 0
for (mapping, kb_supplier), supplier_group in zip(unmapped, resolved):
try:
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
party = frappe.new_doc("Supplier")
@ -779,7 +827,6 @@ def create_unmapped_suppliers(settings_name=None):
if kb_supplier.tax_id:
party.tax_id = kb_supplier.tax_id
payment_terms = mapping.payment_terms or doc.default_payment_terms
if payment_terms:
party.payment_terms = payment_terms
@ -802,11 +849,11 @@ def create_unmapped_suppliers(settings_name=None):
except Exception as e:
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)
frappe.db.commit()
return {"success": True, "created_count": created}
return {"success": True, "created_count": created, "linked_count": linked}
except Exception as e:
frappe.log_error(f"create_unmapped_suppliers: {e}\n{frappe.get_traceback()}", "Kapital Bank Supplier Creation")

View File

@ -11,6 +11,7 @@
"territory",
"payment_terms",
"case_mode",
"azeri_mode",
"mapping_type"
],
"fields": [
@ -63,6 +64,13 @@
"label": "Case Mode",
"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",
"fieldname": "mapping_type",

View File

@ -10,6 +10,7 @@
"supplier_group",
"payment_terms",
"case_mode",
"azeri_mode",
"mapping_type"
],
"fields": [
@ -55,6 +56,13 @@
"label": "Case Mode",
"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",
"fieldname": "mapping_type",

View File

@ -5,6 +5,15 @@ import frappe
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):
"""Best fuzzy ratio of `needle` against any equal-length window of `haystack`
(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
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):
"""Counterparty text -> {"Customer": erp_customer} / {"Supplier": erp_supplier}.
A row may be indexed under its strict key only, its lowercased key only, or
both depending on the row's Case Mode (which overrides the global
`case_insensitive_party_match` setting on Kapital Bank Settings).
Each row is indexed under up to four key variants depending on its effective
Case Mode and Azeri Translit settings (both of which fall back to globals):
strict / lowercase / translit / translit + lowercase.
"""
global_ci = bool(getattr(settings, "case_insensitive_party_match", 1))
global_az = bool(getattr(settings, "consider_azeri_chars", 1))
idx = {}
def _add(name, party_type, erp_party, ci):
def _add(name, party_type, erp_party, ci, az):
key = (name or "").strip()
if not key:
return
idx.setdefault(key, {})[party_type] = erp_party
if ci:
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:
if not row.erp_customer or not row.kb_customer_name:
continue
cust_name = frappe.db.get_value("Kapital Bank Customer", row.kb_customer_name, "customer_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:
if not row.erp_supplier or not row.kb_supplier_name:
continue
supp_name = frappe.db.get_value("Kapital Bank Supplier", row.kb_supplier_name, "supplier_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
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)
lookup first; falls back to lowercased lookup so rows indexed case-insensitively
can be reached from arbitrary Bank Transaction bank_party_name casing."""
"""(party_type, erp_party) for `name`, or (None, None). Tries up to four
lookup variants (strict / lower / translit / translit+lower); the index
encodes per-row case/azeri decisions, so flags aren't needed here."""
key = (name or "").strip()
if not key:
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:
return None, None
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:
txn_mappings = settings.transaction_mappings if mode == "Documents & Reconcile" else None
purpose_threshold = flt(settings.similarity_threshold_purpose or 70) / 100.0
az = bool(getattr(settings, "consider_azeri_chars", 1))
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()
purpose_text_cache[row.purpose_keyword] = _norm_text(text, az)
else:
purpose_text_cache = {}
@ -263,7 +304,7 @@ def create_purpose_mappings(transactions, paid_from=None, paid_to=None, document
continue
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:
errors.append({
"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)}
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.
Priority: purpose+counterparty (exact) > purpose-only (exact) >
purpose+counterparty (fuzzy) > purpose-only (fuzzy) > counterparty-only >
fallback by counterparty_type. "Exact" = the keyword is a substring of the
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"
txn_purpose = (txn.get("purpose") or "").lower()
txn_party = (txn.get("party") or "").lower()
txn_purpose = _norm_text(txn.get("purpose"), az)
txn_party = _norm_text(txn.get("party"), az)
bt_name = txn.get("bank_transaction_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
def _counterparty_matches(row):
return row.counterparty.lower() == txn_party
return _norm_text(row.counterparty, az) == txn_party
def _best_fuzzy(rules, check_cp):
best = None