Spaces:
Running
Running
File size: 12,784 Bytes
7d8f0ad 7453c6d 2cffa8f 1030d8c 2fe4365 57c9004 5ba382e 6916dd5 2cffa8f 1030d8c 6f4a72e 1030d8c 6916dd5 7453c6d 1030d8c 6916dd5 1030d8c 59055d8 6f4a72e 7d8f0ad 6916dd5 7d8f0ad 6916dd5 7453c6d 1030d8c 6916dd5 7d8f0ad d5a050b 7453c6d d5a050b 6916dd5 d5a050b 7453c6d d5a050b 7453c6d 6916dd5 7453c6d 6916dd5 7453c6d 6916dd5 d5a050b 6916dd5 7453c6d 6916dd5 7453c6d 6916dd5 2cffa8f 6916dd5 7453c6d 2cffa8f 6916dd5 7d8f0ad 6916dd5 1030d8c 5ba382e 1030d8c 57c9004 1030d8c 8536923 1030d8c 7d8f0ad 1030d8c 7d8f0ad 5ba382e 2cffa8f 5735847 7d8f0ad 1030d8c 2cffa8f 5735847 7d8f0ad 1030d8c 5735847 7d8f0ad 1030d8c 57c9004 1030d8c 57c9004 1030d8c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 | 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)
|