fix(pct/cc): parse <Prenom> <NOM> et match email SANEF strict (prenom.nom@%)

Cas reel observe: contacts.name = juste le NOM de famille (DUFOUR, DELCOUR), avec
plusieurs entrees par nom (Antoine vs Jean-Charles DUFOUR par ex). Le match precedent
'%nom%' renvoyait des faux positifs.

Solution:
- Nettoie suffixes parasites: '(externe)', '- PCT', '- DBA' en fin
- Identifie nom de famille = token UPPERCASE longueur >= 3 (DUFOUR, DELCOUR, etc.)
- Prenom = 1er token qui n'est pas le nom
- Match SQL: LOWER(email) LIKE 'prenom.nom@%' insensible casse
- Pas de FROM nom de famille SEUL (qui matcherait des homonymes)

Pour 'Laurent DELCOUR' -> match strict laurent.delcour@%
Pour 'DUFOUR Antoine - PCT' -> nettoye en 'DUFOUR Antoine' -> match antoine.dufour@%
This commit is contained in:
Pierre & Lumière 2026-05-07 21:58:34 +02:00
parent 5e5803afa2
commit 98d0ad0a3d

View File

@ -1178,29 +1178,54 @@ def _fetch_pct_cc_emails(db, row_ids):
if len(part) >= 4 and re.search(r"[A-Za-zÀ-ÿ]{3}", part):
legacy_names.add(part)
# 4) Match par nom dans la table contacts pour les responsables/référents
# qui n'ont pas d'email côté servers. Match insensible casse + accents.
# 4) Match par parsing du nom complet → email SANEF (format prenom.nom@sanef.com)
# Le contacts.name en BDD est juste le NOM DE FAMILLE (ex 'DELCOUR'),
# et il y a souvent plusieurs DELCOUR — il faut matcher ET le nom ET le prénom.
if legacy_names:
# On construit une condition ILIKE pour chaque nom + ses variantes simples
names_list = list(legacy_names)[:50] # limite de sécurité
params = {}
ors = []
for i, n in enumerate(names_list):
params[f"n{i}"] = n
# Match exact (case insensible) ou nom contenu (utile pour 'DUFOUR Antoine' vs 'Antoine Dufour')
ors.append(f"LOWER(c.name) = LOWER(:n{i})")
ors.append(f"LOWER(c.name) LIKE LOWER('%' || :n{i} || '%')")
sql = (
"SELECT DISTINCT c.name, c.email FROM contacts c "
"WHERE c.email IS NOT NULL AND c.email <> '' "
f"AND ({' OR '.join(ors)})"
)
try:
matched = db.execute(text(sql), params).fetchall()
for c in matched:
_add(c.name, c.email)
except Exception as e:
print(f"[pct_cc] match par nom failed: {e}")
pairs = [] # liste de (prenom, nom)
for raw in legacy_names:
# Nettoie suffixes parasites courants : "- PCT", "- DBA", "(externe)", etc.
s = re.sub(r"\s*[\(\[].*?[\)\]]\s*", " ", raw)
s = re.sub(r"\s*-\s*[A-Z]{2,5}\s*$", "", s).strip() # "- PCT" en fin
if not s:
continue
tokens = re.split(r"\s+", s)
tokens = [t for t in tokens if t and t not in ("PCT", "DBA", "ext", "Ext")]
if len(tokens) < 2:
continue
# Identifie le nom de famille = token UPPERCASE de longueur >= 3
nom = None
for t in tokens:
# Match nom de famille : uniquement majuscules ASCII, longueur >= 3
# (gère 'DUFOUR', 'DELCOUR', 'GRAFFAGNINO' ; pas 'PCT' qui est trop court)
tt = t.replace("-", "").replace("'", "")
if len(tt) >= 3 and tt.isupper():
nom = t
break
if nom:
# Prénom = 1er token qui n'est pas le nom (souvent le 1er token)
prenom = next((t for t in tokens if t != nom), None)
else:
# Pas de UPPERCASE détecté : on suppose <Prénom> <Nom> et prend dernier token
prenom = tokens[0]
nom = tokens[-1]
if prenom and nom and prenom != nom:
pairs.append((prenom, nom))
# Pour chaque (prenom, nom), match strict sur email SANEF
for prenom, nom in pairs[:50]: # limite de sécurité
# email format SANEF : prenom.nom@... (insensible casse, accents non gérés en BDD)
email_pattern = f"{prenom.lower()}.{nom.lower()}@%"
try:
matched = db.execute(text("""
SELECT name, email FROM contacts
WHERE email IS NOT NULL AND email <> ''
AND LOWER(email) LIKE :pat
"""), {"pat": email_pattern}).fetchall()
for c in matched:
_add(f"{prenom} {nom}", c.email)
except Exception as e:
print(f"[pct_cc] match {prenom}.{nom} failed: {e}")
out.sort(key=lambda c: c["name"].lower())
return out