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('
', unsafe_allow_html=True) st.markdown("### RENSEIGNEMENT TERRAIN — CARTE DES LIENS") st.caption("Croisement des adresses, IP, téléphones, emails et pièces (KYC, demandes, signatures, " "vérifications, journal d'audit). Un lien est un indice à vérifier, jamais une preuve.") try: sh = client.open(sheet_name) dfs = _lire_feuilles(sh, sheet_name) except Exception as e: if est_quota(e): raise # quota Sheets : bandeau jaune gere par streamlit_app.py log_swallowed("Lecture des feuilles de renseignement echouee", e) st.error(f"Erreur de lecture : {e}") st.markdown('
', unsafe_allow_html=True) return df_clients = dfs["Clients_KYC"] if df_clients.empty: st.info("Aucune donnée client.") st.markdown('', unsafe_allow_html=True) return # === GEOCODAGE (clients + garants) — cache 1 h, corrections manuelles prioritaires === with st.spinner("Triangulation des adresses..."): df_geo_clients = geocode_addresses(df_clients) df_geo_garants = geocode_addresses(dfs["Garants_KYC"], 'ID_Garant') if not dfs["Garants_KYC"].empty else None if not _get_credential("LOCATIONIQ_API_KEY"): st.warning("[ATTENTION] Clé LOCATIONIQ_API_KEY absente des secrets — seules les positions GPS observées sont placées.") geo = [d for d in (df_geo_clients, df_geo_garants) if d is not None and not d.empty] positions = _positions_declarees(pd.concat(geo, ignore_index=True) if geo else None, load_geoloc_overrides(client, sheet_name)) # === ANALYSE DES LIENS === resultat = liens_logistique.analyser({ "clients": df_clients, "garants": dfs["Garants_KYC"], "prets": dfs["Prets_Master"], "requests": dfs["Prets_Requests"], "signatures": dfs["Signatures_Contrats"], "verifs": dfs["Geoloc_Verifications"], "audit": dfs["AUDIT_LOG"], }, positions) stats = resultat["stats"] m1, m2, m3, m4 = st.columns(4) m1.metric("LIEUX", f"{stats['lieux']}") m2.metric("PERSONNES", f"{stats['entites']}") m3.metric("LIENS ÉLEVÉS / CRITIQUES", f"{stats['critiques']}") m4.metric("PRÊTS EN RETARD / LITIGE", f"{stats['a_risque']}") if not resultat["lieux"]: st.info("Aucun lieu à afficher : ni adresse géocodée, ni position observée.") st.markdown('', unsafe_allow_html=True) return # === CARTE PLEIN ECRAN (tout en HTML/JS, aucune lecture Sheets au clic) === donnees = carte_html.donnees_carte(resultat) # Contenu de la carte pour le chat JASMINE (la carte est un components.html, pas un graphique Streamlit) carte_resume = resume_carte(donnees) graphiques_contexte.enregistrer_carte("Carte de renseignement — liens entre lieux", carte_resume) token = _get_credential("MAPBOX_ACCESS_TOKEN") if not token: st.warning("[ATTENTION] Clé MAPBOX_ACCESS_TOKEN absente des secrets — carte indisponible.") else: st.components.v1.html(carte_html.construire_html(token, donnees, hauteur=760), height=780) render_scoped_widget( client, sheet_name, scope_label="Carte de renseignement logistique (lieux, personnes, IP, téléphones, liens entre dossiers)", context_data={"statistiques": stats, "carte": carte_resume}, key_prefix="logistique_carte", ) st.markdown('', unsafe_allow_html=True)