Vortex-Flux / src /modules /map_dashboard.py
jaimalklyde's picture
Claude Haiku 5.5
feat(jasmine): chat et contenu de la carte Logistique (Mapbox) transmis au chat
57c9004
Raw History Blame Contribute Delete
12.8 kB
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('<div id="renseignement-logistique">', 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('</div>', unsafe_allow_html=True)
return
df_clients = dfs["Clients_KYC"]
if df_clients.empty:
st.info("Aucune donnée client.")
st.markdown('</div>', 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('</div>', 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('</div>', unsafe_allow_html=True)