diff --git a/l10n_es_account_statement_import_n43/tests/test_l10n_es_account_bank_statement_import_n43.py b/l10n_es_account_statement_import_n43/tests/test_l10n_es_account_bank_statement_import_n43.py index 010d34bdcbf..39033a9adb3 100644 --- a/l10n_es_account_statement_import_n43/tests/test_l10n_es_account_bank_statement_import_n43.py +++ b/l10n_es_account_statement_import_n43/tests/test_l10n_es_account_bank_statement_import_n43.py @@ -112,6 +112,150 @@ def test_import_n43(self): self.assertEqual(statement_lines[1].ref, "/") self.assertEqual(statement_lines[0].ref, "5540014210128010") + def test_sabadell_incoming_partner_detection(self): + """Test that Sabadell incoming transfers detect partner from + 'NOMBRE DEL ORDENANTE' prefix in concept 01.""" + partner = self.env["res.partner"].create( + {"name": "TEST EMPRESA SL", "company_id": self.env.company.id} + ) + wizard = self.env["account.statement.import"] + # Simulate Sabadell incoming transfer concept record: + # First 35 chars: "NOMBRE DEL ORDENANTE TEST EMP" + # Remaining: "RESA,S.L." + conceptos = { + "01": ("NOMBRE DEL ORDENANTE TEST EMP", "RESA,S.L."), + } + result = wizard._get_n43_partner_from_sabadell(conceptos) + self.assertEqual(result, partner) + + def test_sabadell_incoming_partner_no_suffix(self): + """Test Sabadell incoming detection for a person (no legal suffix).""" + partner = self.env["res.partner"].create( + {"name": "MARIA CARMEN SERRAT FREIXA", "company_id": self.env.company.id} + ) + wizard = self.env["account.statement.import"] + conceptos = { + "01": ("NOMBRE DEL ORDENANTE MARIA CARME", "N SERRAT FREIXA"), + } + result = wizard._get_n43_partner_from_sabadell(conceptos) + self.assertEqual(result, partner) + + def test_sabadell_ordenante_prefix(self): + """Test 'ORDENANTE DE LA TRANSFERENCIA' prefix with colon separator.""" + partner = self.env["res.partner"].create( + {"name": "ACME DISTRIBUCIONES SL", "company_id": self.env.company.id} + ) + wizard = self.env["account.statement.import"] + conceptos = { + "01": ( + "ORDENANTE DE LA TRANSFERENCIA :", + " ACME DISTRIBUCIONES S.L.", + ), + } + result = wizard._get_n43_partner_from_sabadell(conceptos) + self.assertEqual(result, partner) + + def test_sabadell_transferenc_prefix(self): + """Test 'TRANSFERENC. DE' prefix.""" + partner = self.env["res.partner"].create( + {"name": "PEDRO MARTINEZ SANCHEZ", "company_id": self.env.company.id} + ) + wizard = self.env["account.statement.import"] + conceptos = { + "01": ("TRANSFERENC. DE PEDRO MARTINEZ SAN", "CHEZ"), + } + result = wizard._get_n43_partner_from_sabadell(conceptos) + self.assertEqual(result, partner) + + def test_sabadell_beneficiario_transferencia_prefix(self): + """Test 'BENEFICIARIO DE LA TRANSFERENCIA' prefix (outgoing transfer).""" + partner = self.env["res.partner"].create( + {"name": "ANA GARCIA LOPEZ", "company_id": self.env.company.id} + ) + wizard = self.env["account.statement.import"] + conceptos = { + "01": ( + "BENEFICIARIO DE LA TRANSFERENCIA :", + " ANA GARCIA LOPEZ", + ), + } + result = wizard._get_n43_partner_from_sabadell(conceptos) + self.assertEqual(result, partner) + + def test_sabadell_beneficiario_prefix(self): + """Test 'BENEFICIARIO' prefix (short form, outgoing transfer).""" + partner = self.env["res.partner"].create( + {"name": "CARLOS RUIZ MARTINEZ", "company_id": self.env.company.id} + ) + wizard = self.env["account.statement.import"] + conceptos = { + "01": ("BENEFICIARIO CARLOS RUIZ MARTINEZ", ""), + } + result = wizard._get_n43_partner_from_sabadell(conceptos) + self.assertEqual(result, partner) + + def test_sabadell_core_sepa_prefix(self): + """Test 'CORE' prefix (SEPA direct debit) — name glued without space.""" + partner = self.env["res.partner"].create( + {"name": "Suministros Eolicos del Norte SL", "company_id": self.env.company.id} + ) + wizard = self.env["account.statement.import"] + conceptos = { + "01": ("CORESuministros Eolicos del Norte", " SL"), + } + result = wizard._get_n43_partner_from_sabadell(conceptos) + self.assertEqual(result, partner) + + def test_sabadell_word_boundary_match(self): + """Test word boundary regex fallback finds partner by unique word.""" + partner = self.env["res.partner"].create( + { + "name": "FUNDACION GREENFIELD EDUCATIVA SL", + "company_id": self.env.company.id, + } + ) + wizard = self.env["account.statement.import"] + # Name truncated — ilike on full extracted text won't match + conceptos = { + "01": ( + "NOMBRE DEL ORDENANTE FUNDACION G", + "REENFIELD EDUCATIVA DE ESPA", + ), + } + result = wizard._get_n43_partner_from_sabadell(conceptos) + self.assertEqual(result, partner) + + def test_sabadell_word_boundary_no_substring(self): + """Test word boundary regex does NOT match substrings. + + MARTIN should not match MARTINEZA — word boundaries prevent it. + """ + self.env["res.partner"].create( + {"name": "MARTINEZA, S.L.", "company_id": self.env.company.id} + ) + wizard = self.env["account.statement.import"] + conceptos = { + "01": ( + "NOMBRE DEL ORDENANTE MARTIN ROM", + "ERO LOPEZ", + ), + } + result = wizard._get_n43_partner_from_sabadell(conceptos) + self.assertFalse(result) + + def test_clean_partner_name_suffix(self): + """Test legal suffix removal for partner name matching.""" + wizard = self.env["account.statement.import"] + self.assertEqual( + wizard._clean_partner_name_suffix("VILA FOPE,S.L."), "VILA FOPE" + ) + self.assertEqual(wizard._clean_partner_name_suffix("EMPRESA S.A."), "EMPRESA") + self.assertEqual(wizard._clean_partner_name_suffix("CORP SLU"), "CORP") + self.assertEqual( + wizard._clean_partner_name_suffix("MARIA CARMEN SERRAT"), + "MARIA CARMEN SERRAT", + ) + def test_import_n43_fecha_oper(self): self.journal.n43_date_type = "fecha_oper" action = self.import_wizard.import_file_button() diff --git a/l10n_es_account_statement_import_n43/wizards/account_statement_import_n43.py b/l10n_es_account_statement_import_n43/wizards/account_statement_import_n43.py index d9b52565028..41de033b1df 100644 --- a/l10n_es_account_statement_import_n43/wizards/account_statement_import_n43.py +++ b/l10n_es_account_statement_import_n43/wizards/account_statement_import_n43.py @@ -3,6 +3,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). import logging +import re from datetime import datetime from odoo import _, api, exceptions, fields, models @@ -310,14 +311,99 @@ def _get_n43_partner_from_santander(self, conceptos): partner = partner_obj.search([("name", "ilike", name)], limit=1) return partner + _LEGAL_SUFFIX_RE = re.compile( + r"[,.\s]*(S\.?L\.?U?\.?|S\.?A\.?U?\.?|S\.?C\.?|S\.?C\.?P\.?)\s*$", + re.IGNORECASE, + ) + + def _clean_partner_name_suffix(self, name): + """Remove common Spanish legal form suffixes for fuzzy matching. + + Examples: "VILA FOPE,S.L." -> "VILA FOPE" + "EMPRESA S.A." -> "EMPRESA" + """ + return self._LEGAL_SUFFIX_RE.sub("", name).strip() + def _get_n43_partner_from_sabadell(self, conceptos): partner_obj = self.env["res.partner"] partner = partner_obj.browse() - # Try to match from partner name - if conceptos.get("01"): - name = conceptos["01"][1] - if name and len(name) > 7: - partner = partner_obj.search([("name", "ilike", name)], limit=1) + if not conceptos.get("01"): + return partner + # 1) Existing logic: try matching from second part of concept 01 + name = conceptos["01"][1] + if name and len(name) > 7: + partner = partner_obj.search([("name", "ilike", name)], limit=1) + if partner: + return partner + # 2) Transfer / direct debit detection: concept 01 starts with a + # known prefix followed by the partner name. + # Sabadell uses these formats for transfers and SEPA direct debits. + full_name = (conceptos["01"][0] + conceptos["01"][1]).strip() + prefixes = ( + "NOMBRE DEL ORDENANTE", + "ORDENANTE DE LA TRANSFERENCIA", + "BENEFICIARIO DE LA TRANSFERENCIA", + "TRANSFERENC. DE", + "BENEFICIARIO", + "CORE", + ) + extracted = "" + for prefix in prefixes: + if full_name.upper().startswith(prefix): + extracted = full_name[len(prefix) :].strip() + # Strip leading colon/spaces (ORDENANTE format uses ": name") + extracted = extracted.lstrip(": ").strip() + break + if extracted: + # Always strip legal suffixes before searching, as the partner + # name in Odoo may use a different notation (e.g., "SL" vs + # "S.L."). Searching by the core name avoids mismatches. + clean = self._clean_partner_name_suffix(extracted) + name_to_search = clean if clean and len(clean) > 3 else extracted + if len(name_to_search) > 3: + partner = partner_obj.search( + [("name", "ilike", name_to_search)], limit=1 + ) + # Last resort: search by individual words using word boundary + # matching to avoid substring false positives (e.g. CARMEN + # matching CARMENET). Only accept if exactly one partner matches. + if not partner: + _stop = { + "de", + "del", + "la", + "las", + "el", + "los", + "i", + "y", + "en", + "por", + "con", + "para", + "the", + "and", + } + words = [ + w + for w in name_to_search.split() + if len(w) > 3 and w.lower() not in _stop + ] + for word in words: + # Use raw SQL with PostgreSQL word boundary regex + # (\m = start, \M = end of word) to avoid substring + # false positives (e.g. CARMEN != CARMENET). + # Odoo domains don't support the ~* operator. + self.env.cr.execute( + "SELECT id FROM res_partner" + " WHERE name ~* %s AND active = true" + " LIMIT 2", + [r"\m" + word + r"\M"], + ) + rows = self.env.cr.fetchall() + if len(rows) == 1: + partner = partner_obj.browse(rows[0][0]) + break return partner def _get_n43_partner(self, line, journal): @@ -369,9 +455,13 @@ def _parse_single_file_n43(self, n43): for line in group["lines"]: conceptos = [] for concept_line in line["conceptos"]: - conceptos.extend( - x.strip() for x in line["conceptos"][concept_line] if x.strip() - ) + # Concatenate both halves of the same concept record + # without adding a space — they are continuous text + # split at the fixed-width boundary (position 39). + parts = line["conceptos"][concept_line] + joined = (parts[0] + parts[1]).strip() + if joined: + conceptos.append(joined) vals_line = { "payment_ref": " ".join(conceptos) or self._get_n43_ref(line)