import streamlit as st import pandas as pd import time import requests import datetime from core.sheets_cache import get_cached_dfs_batch, get_cached_df from core import liens_logistique from modules import carte_html from core.email_client import get_credential as _get_credential from core import graphiques_contexte from modules.jasmine_widget import render_scoped_widget from core.errors import log_swallowed, est_quota # --- CONSTANTES --- GEOLOC_OVERRIDES_SHEET = "Geoloc_Overrides" GEOLOC_OVERRIDES_HEADERS = ["ID", "Lat", "Lon", "Source", "Date", "Note"] # Feuilles lues pour le croisement (adresses, IP, telephones...) — toutes via le cache. FEUILLES_RENSEIGNEMENT = ["Clients_KYC", "Garants_KYC", "Prets_Master", "Prets_Requests", "Signatures_Contrats", "Geoloc_Verifications", "AUDIT_LOG"] # Colonnes d'identite gardees en TEXTE brut (pas de numerisation : telephones, pieces, IP). COLONNES_TEXTE = {"Telephone", "Tel_Urgence", "WhatsApp_Urgence", "ID_Officiel", "Numero_Fiscal", "Employeur", "Email", "Email_Urgence", "IP_Client", "IP_Demande", "IP_Signature", "IP_Preteur", "IP", "GPS_Signature", "GPS_Preteur"} # Mapping nom de pays (LISTE_PAYS de kyc_form.py) -> code ISO2, pour cibler # correctement le geocodeur. Un pays absent de ce mapping (ex: "Autre") ne # doit JAMAIS retomber sur 'sn' par defaut : ca casse le geocodage de tous # les clients hors Senegal. PAYS_VERS_ISO2 = { "sénégal": "sn", "senegal": "sn", "cameroun": "cm", "gabon": "ga", "congo": "cg", "guinée": "gn", "guinee": "gn", "côte d'ivoire": "ci", "cote d'ivoire": "ci", "mali": "ml", "tchad": "td", "france": "fr", "canada": "ca", "maroc": "ma", } def _pays_to_iso2(pays: str): """Traduit un nom de pays (LISTE_PAYS de kyc_form.py) en code ISO2 pour LocationIQ. Retourne None si inconnu — NE JAMAIS retomber sur 'sn' par defaut, ca casse le geocodage des clients hors Senegal (Gabon, Cameroun, France, Maroc, Nigeria... presents dans les donnees reelles).""" if not pays: return None return PAYS_VERS_ISO2.get(str(pays).strip().lower()) def _build_full_address(row) -> str: """Concatene Adresse + Quartier + Ville + Pays_Residence pour lever l'ambiguite d'une adresse seule (ex: 'Fass batiment' existe a la fois a Dakar et pres de Kaolack — sans le quartier/ville, le geocodeur tape au hasard).""" parts = [] for col in ("Adresse", "Quartier", "Ville", "Pays_Residence"): val = str(row.get(col, "")).strip() if val and val.upper() not in ("", "-- À SÉLECTIONNER --", "NAN"): parts.append(val) return ", ".join(parts) @st.cache_data(show_spinner=False, ttl=3600) def geocode_addresses(df_clients, id_col='ID_Client'): """Géocodage via LocationIQ, sur l'adresse complete (adresse + quartier + ville + pays) et un code pays dynamique par client.""" if 'Adresse' not in df_clients.columns or id_col not in df_clients.columns: return pd.DataFrame() LOCATIONIQ_TOKEN = _get_credential("LOCATIONIQ_API_KEY") if not LOCATIONIQ_TOKEN: return pd.DataFrame() geocoded_data = [] for index, row in df_clients.iterrows(): full_address = _build_full_address(row) address = str(row['Adresse']).strip() client_id = row[id_col] if not full_address: continue try: url = "https://us1.locationiq.com/v1/search.php" params = { 'key': LOCATIONIQ_TOKEN, 'q': full_address, 'format': 'json', 'limit': 1, 'accept-language': 'fr' } iso2 = _pays_to_iso2(row.get('Pays_Residence')) if iso2: params['countrycodes'] = iso2 response = requests.get(url, params=params, timeout=10) if response.status_code == 200: data = response.json() if data and len(data) > 0: result = data[0] lat = float(result['lat']) lon = float(result['lon']) geocoded_data.append({ "ID": client_id, "Nom": row.get('Nom_Complet', 'Client Inconnu'), "Adresse": address, "lat": lat, "lon": lon, "Revenus": row.get('Revenus_Mensuels', 0), "Email": row.get('Email', ''), }) time.sleep(1.2) except Exception as e: pass return pd.DataFrame(geocoded_data) def _ensure_worksheet(sh, title, headers): """Ouvre l'onglet ; le cree avec ses en-tetes s'il n'existe pas encore (meme convention que Prefiltre_Logs dans kyc_form.py).""" try: return sh.worksheet(title) except Exception: ws = sh.add_worksheet(title=title, rows=500, cols=max(len(headers), 5)) ws.append_row(headers) return ws @st.cache_data(show_spinner=False, ttl=60) def load_geoloc_overrides(_client, sheet_name): """Charge les corrections manuelles de position — tolerant a l'absence de l'onglet (la carte fonctionne sans, avec le geocodage automatique).""" try: sh = _client.open(sheet_name) ws = sh.worksheet(GEOLOC_OVERRIDES_SHEET) return pd.DataFrame(ws.get_all_records()) except Exception: return pd.DataFrame(columns=GEOLOC_OVERRIDES_HEADERS) def save_geoloc_override(client, sheet_name, id_client, lat, lon, note=""): """Enregistre/actualise une correction manuelle de position, persistante (prioritaire sur le geocodage automatique a chaque rechargement).""" sh = client.open(sheet_name) ws = _ensure_worksheet(sh, GEOLOC_OVERRIDES_SHEET, GEOLOC_OVERRIDES_HEADERS) records = ws.get_all_records() now_str = datetime.datetime.now().strftime("%d-%m-%Y %H:%M:%S") new_row = [id_client, lat, lon, "correction_manuelle", now_str, note] for i, rec in enumerate(records): if str(rec.get("ID", "")).strip() == str(id_client).strip(): ws.update(f"A{i + 2}:F{i + 2}", [new_row]) break else: ws.append_row(new_row) load_geoloc_overrides.clear() def _lire_feuilles(sh, sheet_name) -> dict: """Toutes les feuilles de renseignement en UN appel API (cache 60 s). Seules les feuilles existantes sont demandees : une feuille absente (ex. Geoloc_Verifications jamais creee) donne un DataFrame vide au lieu de faire echouer toute la lecture groupee.""" titres = {f["properties"]["title"] for f in sh.fetch_sheet_metadata().get("sheets", [])} presentes = [nom for nom in FEUILLES_RENSEIGNEMENT if nom in titres] dfs = {nom: pd.DataFrame() for nom in FEUILLES_RENSEIGNEMENT} if presentes: ignore = {nom: COLONNES_TEXTE for nom in presentes} dfs.update(get_cached_dfs_batch(sh, sheet_name, presentes, numericise_ignore_columns=ignore)) return dfs def resume_carte(donnees: dict, max_lieux: int = 40, max_groupes: int = 20) -> dict: """Résumé compact de la carte (lieux, personnes, liens) envoyé au chat JASMINE.""" lieux = sorted(donnees["lieux"], key=lambda l: (-{"critique": 3, "eleve": 2, "attention": 1}.get(l["niveau"], 0), -l["n"])) return { "lieux": [{ "label": l["label"], "lat": l["lat"], "lon": l["lon"], "niveau_risque": l["niveau"], "prets_en_risque": l["risque"], "adresse_declaree": l["declare"], "nb_personnes": l["n"], "personnes": [{"id": m["id"], "nom": m["nom"], "type": m["kind"], "statut_pret": m["statut"], "roles": m["roles"], "tel": m["tel"], "ip": m["ip"], "email": m["email"]} for m in l["membres"][:8]], } for l in lieux[:max_lieux]], "liens": [{ "type": g["libelle"], "valeur": g["valeur"], "niveau": g["niveau"], "score": g["score"], "garant_emprunteur": g["garant_emprunteur"], "personnes": [e["id"] for e in g["entites"]], "nb_lieux": len(g["lieux"]), } for g in donnees["groupes"][:max_groupes]], } def _positions_declarees(df_geo, df_overrides): """{id: (lat, lon)} : geocodage automatique, corrige par les positions manuelles.""" pos = {} if df_geo is not None and not df_geo.empty: for _, r in df_geo.iterrows(): pos[str(r["ID"])] = (float(r["lat"]), float(r["lon"])) if df_overrides is not None and not df_overrides.empty and "ID" in df_overrides.columns: for _, r in df_overrides.iterrows(): try: pos[str(r["ID"])] = (float(str(r["Lat"]).replace(",", ".")), float(str(r["Lon"]).replace(",", "."))) except (TypeError, ValueError): continue return pos def show_map_dashboard(client, sheet_name): """Vue de renseignement : une carte plein ecran ou chaque point est un lieu (adresse declaree ou position observee). Clic = dossier du lieu + liens vers les autres lieux partageant une IP, un telephone, un email, une piece, une adresse ou un employeur.""" st.markdown('