diff --git a/kapital_bank/bank_api.py b/kapital_bank/bank_api.py index 53d37f1..38fab62 100644 --- a/kapital_bank/bank_api.py +++ b/kapital_bank/bank_api.py @@ -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") diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_customer_mapping/kapital_bank_customer_mapping.json b/kapital_bank/kapital_bank/doctype/kapital_bank_customer_mapping/kapital_bank_customer_mapping.json index 60e01fd..a795a47 100644 --- a/kapital_bank/kapital_bank/doctype/kapital_bank_customer_mapping/kapital_bank_customer_mapping.json +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_customer_mapping/kapital_bank_customer_mapping.json @@ -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", diff --git a/kapital_bank/kapital_bank/doctype/kapital_bank_supplier_mapping/kapital_bank_supplier_mapping.json b/kapital_bank/kapital_bank/doctype/kapital_bank_supplier_mapping/kapital_bank_supplier_mapping.json index ba0ffe4..1bb9122 100644 --- a/kapital_bank/kapital_bank/doctype/kapital_bank_supplier_mapping/kapital_bank_supplier_mapping.json +++ b/kapital_bank/kapital_bank/doctype/kapital_bank_supplier_mapping/kapital_bank_supplier_mapping.json @@ -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", diff --git a/kapital_bank/mapping.py b/kapital_bank/mapping.py index d5f7a6b..f26446c 100644 --- a/kapital_bank/mapping.py +++ b/kapital_bank/mapping.py @@ -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