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)