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:
parent
fd3f74d3ed
commit
53d4eb1cc7
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue