fix(bank-integration): symmetric Azeri translit + VOEN-first dedup

Two related correctness fixes:

1. Asymmetric Azerbaijani normalization.
   consider_azeri_chars was only honoured by match_similar_customers/suppliers
   when populating registries. At Create & Reconcile time both the party-name
   lookup and the fuzzy purpose matcher compared raw lowercased strings, so a
   party matched via "Şirkət" ↔ "Sirket" translit at registry time would not
   resolve when the BT arrived, and a purpose rule keyed on "Mədaxil" would
   fall short of fuzzy threshold against "Medaxil".

   - name_to_party now indexes each row under strict / lowercase / translit /
     translit+lowercase variants based on its effective Case Mode + Azeri
     Translit (new per-row Select fields that override the globals, same
     pattern as case sensitivity).
   - _find_mapping_for_txn takes an `az` flag and applies translit to both
     keyword cache and txn purpose/party before comparison.

2. Customer / Supplier dedup ignored VOEN.
   create_unmapped_customers / create_unmapped_suppliers checked only by
   customer_name / supplier_name, so a registry record sharing a VOEN with an
   existing party under a slightly different name would create a duplicate.
   Now: tax_id is checked first, name as fallback; when an existing party is
   found, the mapping row is linked to it (instead of silently `continue`ing
   without linking). Response includes 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:39 +00:00
parent 70215e200c
commit a3ae46b978
4 changed files with 149 additions and 33 deletions

View File

@ -16,6 +16,14 @@ from frappe.utils import cint, flt
PURPOSE_DOCTYPE = "Bank Integration Purpose" PURPOSE_DOCTYPE = "Bank Integration Purpose"
# Same translit table as jey_erp.bank_integration.matching._AZERI_MAP — kept
# inline here so this module doesn't depend on matching.py at import time.
_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`
@ -49,11 +57,7 @@ def _extract_messages(message_log):
def _effective_case_insensitive(row, global_default): def _effective_case_insensitive(row, global_default):
"""Per-row Case Mode overrides the global Ignore Case flag. """Per-row Case Mode overrides the global Ignore Case flag."""
Returns True if the party name for this row should be indexed/looked-up
case-insensitively, False otherwise.
"""
mode = (getattr(row, "case_mode", None) or "").strip() mode = (getattr(row, "case_mode", None) or "").strip()
if mode == "Ignore Case": if mode == "Ignore Case":
return True return True
@ -62,50 +66,85 @@ def _effective_case_insensitive(row, global_default):
return global_default return global_default
def _build_name_to_party(settings): def _effective_azeri(row, global_default):
"""Counterparty text -> {"Customer": erp_customer} / {"Supplier": erp_supplier}, """Per-row Azeri Translit mode overrides the global Consider Azerbaijani Characters flag."""
built from the mapped rows of customer_mappings / supplier_mappings. mode = (getattr(row, "azeri_mode", None) or "").strip()
if mode == "Apply Translit":
return True
if mode == "Strict (No Translit)":
return False
return global_default
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 def _build_name_to_party(settings):
`case_insensitive_party_match` setting). """Counterparty text -> {"Customer": erp_customer} / {"Supplier": erp_supplier}.
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 : original `.strip()` key
lowercase : when case-insensitive
translit : when azeri-translit
translit + lower : when both
""" """
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.bi_customer_name: if not row.erp_customer or not row.bi_customer_name:
continue continue
cust_name = frappe.db.get_value("Bank Integration Customer", row.bi_customer_name, "customer_name") cust_name = frappe.db.get_value("Bank Integration Customer", row.bi_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.bi_supplier_name: if not row.erp_supplier or not row.bi_supplier_name:
continue continue
supp_name = frappe.db.get_value("Bank Integration Supplier", row.bi_supplier_name, "supplier_name") supp_name = frappe.db.get_value("Bank Integration Supplier", row.bi_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).
lookup first; falls back to lowercased lookup so rows indexed case-insensitively
can be reached from arbitrary Bank Transaction bank_party_name casing. Tries up to four lookup variants strict, lowercase, translit, translit+lower.
The index encodes per-row decisions, so we don't need flags here.
Prefers Customer for incoming (Receive) and Supplier for outgoing (Pay), Prefers Customer for incoming (Receive) and Supplier for outgoing (Pay),
falling back to the other.""" falling back to the other.
"""
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"
@ -256,12 +295,13 @@ def create_purpose_mappings(transactions, bank_integration, paid_from=None, paid
if do_documents: if do_documents:
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(PURPOSE_DOCTYPE, row.purpose_keyword, "purpose_keyword") or "" text = frappe.db.get_value(PURPOSE_DOCTYPE, row.purpose_keyword, "purpose_keyword") or ""
purpose_text_cache[row.purpose_keyword] = text.lower() purpose_text_cache[row.purpose_keyword] = _norm_text(text, az)
txn_mappings = settings.transaction_mappings txn_mappings = settings.transaction_mappings
else: else:
purpose_text_cache = {} purpose_text_cache = {}
@ -276,7 +316,7 @@ def create_purpose_mappings(transactions, bank_integration, paid_from=None, paid
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 "",
@ -378,17 +418,28 @@ def create_purpose_mappings(transactions, bank_integration, paid_from=None, paid
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")
txn_currency = (frappe.db.get_value("Bank Transaction", bt_name, "currency") or "").strip().upper() if bt_name else "" txn_currency = (frappe.db.get_value("Bank Transaction", bt_name, "currency") or "").strip().upper() if bt_name else ""
@ -431,7 +482,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

View File

@ -4,6 +4,24 @@ import frappe
from frappe import _ from frappe import _
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
def _pick_leaf(doctype, configured, label): def _pick_leaf(doctype, configured, label):
"""Validate `configured` as a leaf node of an NSM tree doctype """Validate `configured` as a leaf node of an NSM tree doctype
(Customer Group / Supplier Group / Territory). (Customer Group / Supplier Group / Territory).
@ -47,10 +65,29 @@ def create_unmapped_customers(bank_integration):
] ]
created = 0 created = 0
linked = 0
for (mapping, bi_cust), (customer_group, territory) in zip(unmapped, resolved): for (mapping, bi_cust), (customer_group, territory) in zip(unmapped, resolved):
try: try:
customer_name = bi_cust.customer_name customer_name = bi_cust.customer_name
if frappe.db.exists("Customer", {"customer_name": customer_name}): payment_terms = mapping.payment_terms or bi.default_payment_terms
# VOEN beats name: if an existing Customer shares this tax_id, reuse it
# rather than creating a duplicate. Same for name as a fallback.
existing = _find_existing_party("Customer", "customer_name", customer_name, bi_cust.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("Bank Integration Customer", bi_cust.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")
@ -62,7 +99,6 @@ def create_unmapped_customers(bank_integration):
if bi_cust.tax_id: if bi_cust.tax_id:
party.tax_id = bi_cust.tax_id party.tax_id = bi_cust.tax_id
payment_terms = mapping.payment_terms or bi.default_payment_terms
if payment_terms: if payment_terms:
party.payment_terms = payment_terms party.payment_terms = payment_terms
@ -86,10 +122,10 @@ def create_unmapped_customers(bank_integration):
"BI Customer Creation", "BI Customer Creation",
) )
if created > 0: if created > 0 or linked > 0:
bi.save(ignore_permissions=True) bi.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}
@frappe.whitelist() @frappe.whitelist()
@ -112,10 +148,24 @@ def create_unmapped_suppliers(bank_integration):
] ]
created = 0 created = 0
linked = 0
for (mapping, bi_supp), supplier_group in zip(unmapped, resolved): for (mapping, bi_supp), supplier_group in zip(unmapped, resolved):
try: try:
supplier_name = bi_supp.supplier_name supplier_name = bi_supp.supplier_name
if frappe.db.exists("Supplier", {"supplier_name": supplier_name}): payment_terms = mapping.payment_terms or bi.default_payment_terms
existing = _find_existing_party("Supplier", "supplier_name", supplier_name, bi_supp.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("Bank Integration Supplier", bi_supp.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")
@ -126,7 +176,6 @@ def create_unmapped_suppliers(bank_integration):
if bi_supp.tax_id: if bi_supp.tax_id:
party.tax_id = bi_supp.tax_id party.tax_id = bi_supp.tax_id
payment_terms = mapping.payment_terms or bi.default_payment_terms
if payment_terms: if payment_terms:
party.payment_terms = payment_terms party.payment_terms = payment_terms
@ -149,10 +198,10 @@ def create_unmapped_suppliers(bank_integration):
"BI Supplier Creation", "BI Supplier Creation",
) )
if created > 0: if created > 0 or linked > 0:
bi.save(ignore_permissions=True) bi.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}
@frappe.whitelist() @frappe.whitelist()

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