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
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue