import streamlit as st
import pandas as pd
from datetime import datetime, date, timedelta
import os
import requests
import base64
import html
import re
import streamlit.components.v1 as components
import sys
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
from DocumentGen.AutoPDFGeneration import generer_contrat_pret, generer_reconnaissance_dette, generer_contrat_caution
from Analytics.AnalyseFinance import generer_tableau_amortissement, TAXE_TRANSFERT
from Analytics.AnalyseRepayment import get_client_repayment_stats
from Analytics import penalites_retard
from core.id_generator import generate_next_id
from core.sheets_cache import get_cached_dfs_batch, clear_sheets_cache
from core.email_client import get_admin_email
from core import signature as sg
from core import signature_service as svc
from core.geoip_client import resolve_ip_location
from core.ui_guards import rendre_bouton_protege, marquer_fait, liberer
from modules.jasmine_widget import render_scoped_widget
def _get_credential(key):
val = os.environ.get(key)
if val: return val
try:
if key in st.secrets: return st.secrets[key]
except Exception: pass
return None
def clean_num(val):
"""Nettoie toute chaîne de caractères pour en faire un float pur."""
if isinstance(val, str):
# Sheets en locale francaise : "50 000" porte une espace insecable (U+00A0 / U+202F),
# que replace(' ') ne retire pas -> float() echouait et le montant devenait 0.
val = re.sub(r"\s+", "", val).replace(',', '.').replace('XOF', '').replace('%', '')
try:
return float(val)
except (ValueError, TypeError) as e:
from core.errors import log_swallowed
log_swallowed(f"clean_num : valeur illisible (valeur : {val!r})", e)
return 0.0
def _parse_coordinate_pair(latitude, longitude):
"""Valide une paire et restaure une décimale perdue de façon cohérente."""
try:
latitude = float(str(latitude).strip().replace(",", "."))
longitude = float(str(longitude).strip().replace(",", "."))
except (TypeError, ValueError):
return None, None
both_out_of_range = abs(latitude) > 90 and abs(longitude) > 180
shifts = 0
while both_out_of_range and (abs(latitude) > 90 or abs(longitude) > 180) and shifts < 6:
latitude /= 10
longitude /= 10
shifts += 1
if abs(latitude) > 90 or abs(longitude) > 180:
return None, None
return latitude, longitude
def parse_french_locale_float(val, max_logical_value=None):
"""Lit un decimal du Sheet. La virgule francaise est desormais lue correctement a la
source (core/gspread_fr.py) : plus de division par 10 "tant que la valeur depasse le
maximum logique", qui reduisait a tort un taux legitime de 24,11 % en 2,41 %.
`max_logical_value` est conserve pour compatibilite et ignore."""
return round(clean_num(val), 2)
def get_col_safe(row, partial_name, default="N/A"):
"""Trouve une colonne même si l'utilisateur a changé son nom dans Google Sheets."""
for col in row.index:
if partial_name.lower() == col.lower(): return row[col]
for col in row.index:
if col.lower().startswith(partial_name.lower()): return row[col]
for col in row.index:
if partial_name.lower() in col.lower(): return row[col]
return default
def lancer_signature_octroi(client_gspread, sheet_name, req_data, client_data, garant_data, df_amort, prt_id, id_client):
"""Envoie les liens de signature (emprunteur + garant). Le pret reste EN_ATTENTE_SIGNATURE jusqu'a
ce que tous aient signe. Retourne (ok, detail)."""
sh = client_gspread.open(sheet_name)
loan = {**req_data, "ID_Pret": prt_id, "ID_Client": id_client,
"ID_Garant": (garant_data or {}).get("ID_Garant", req_data.get("ID_Garant", ""))}
snapshot = sg.construire_snapshot(loan, client_data, garant_data, df_amort)
try:
resultats = svc.lancer_signature_pret(sh, id_pret=prt_id, source=svc.SOURCE_MASTER, snapshot=snapshot)
except sg.SignatureConfigError as exc:
return False, str(exc)
echecs = [f"{r['role'].title()} : {r['detail']}" for r in resultats if not r["ok"]]
return (not echecs), " ; ".join(echecs)
def approuver_et_envoyer(req_data, client_data, garant_data, df_amort, prt_id):
"""Génère les PDF et fait deux tirs séparés (Client+CC Garant) et (Admin)"""
pdf_contrat = generer_contrat_pret(req_data, client_data, df_amort)
pdf_dette = generer_reconnaissance_dette(req_data, client_data)
pdf_caution = generer_contrat_caution(req_data, garant_data) if garant_data else None
api_key = _get_credential("BREVO_API_KEY")
sender_email = _get_credential("SENDER_EMAIL")
if not api_key or not sender_email: return False
html = f"""
', unsafe_allow_html=True)
col1, col2 = st.columns([3, 1])
id_pret = get_col_safe(req, 'ID_Pret')
nom_client = get_col_safe(req, 'Nom_Complet')
col1.markdown(f"### {id_pret} | {nom_client}")
col1.caption(f"{get_col_safe(req, 'Offre')} - {get_col_safe(req, 'Motif')} - {get_col_safe(req, 'Date_Creation')}")
with st.expander("VOIR LE DOSSIER COMPLET & RAPPORTS JASMINE"):
# --- 1. PROFIL FINANCIER DU CLIENT ---
st.markdown("#### PROFIL DU CLIENT (KYC)")
c_data = df_clients[df_clients['ID_Client'] == get_col_safe(req, 'ID_Client')]
if not c_data.empty:
c_data = c_data.iloc[0].to_dict()
rev = clean_num(c_data.get('Revenus_Mensuels', 0))
chg = clean_num(c_data.get('Charges_Estimees', 0))
rav = rev - chg
rav_str = f"{int(rav):,}".replace(",", " ")
rev_str = f"{int(rev):,}".replace(",", " ")
chg_str = f"{int(chg):,}".replace(",", " ")
st.markdown(f"""
Profession : {html.escape(str(c_data.get('Statut_Pro', 'N/A')))} | Secteur : {html.escape(str(c_data.get('Secteur_Activite', 'N/A')))}
Revenus : {rev_str} XOF | Charges : {chg_str} XOF | Reste à vivre : {rav_str} XOF
""", unsafe_allow_html=True)
else:
c_data = {}
st.warning("Données KYC du client introuvables.")
# --- 2. CONFIGURATION DU PRET ---
st.markdown("
", unsafe_allow_html=True)
st.markdown("#### DONNÉES DU PRÊT")
c1, c2, c3, c4 = st.columns(4)
capital = clean_num(get_col_safe(req, 'Montant_Capital'))
total = clean_num(get_col_safe(req, 'Montant_Total'))
cout_credit = clean_num(get_col_safe(req, 'Cout_Credit'))
nb_vers = int(clean_num(get_col_safe(req, 'Nb_Versements')))
# CORRECTION LOCALE
taux = parse_french_locale_float(get_col_safe(req, 'Taux_Hebdo'), 20)
endettement = round(clean_num(get_col_safe(req, 'Taux_Endettement')), 2)
duree = parse_french_locale_float(get_col_safe(req, 'Duree_Semaines'), 104)
c1.metric("Capital Demandé", f"{int(capital):,} XOF".replace(',', ' '))
c1.metric("Taux IA", f"{taux}%")
c2.metric("Montant Total", f"{int(total):,} XOF".replace(',', ' '))
c2.metric("Bénéfice (Coût Crédit)", f"{int(cout_credit):,} XOF".replace(',', ' '))
c3.metric("Type de Prêt", str(get_col_safe(req, 'Type_Pret')))
c3.metric("Nb Versements", str(nb_vers))
c4.metric("Durée", f"{duree} sem")
c4.metric("Endettement", f"{endettement}%")
score_remboursement = get_col_safe(req, 'Score_Reussite_Remboursement', None)
validation_score = str(get_col_safe(req, 'Validation_Jev_Remboursement', '')).strip().upper()
score_vide = score_remboursement is None or str(score_remboursement).strip().upper() in {"", "N/A", "NAN", "NONE"}
score_num = clean_num(score_remboursement) if not score_vide else None
if validation_score in ("VALIDE", "MODELE_VALIDE") and score_num is not None and 0 <= score_num <= 100:
n_modele = get_col_safe(req, 'N_Echantillons_Modele', 'N/A')
n_validation = get_col_safe(req, 'N_Validation_Modele', 'N/A')
st.markdown("#### ESTIMATION DE REMBOURSEMENT (AIDE À LA DÉCISION)")
score_col, sample_col, validation_col = st.columns(3)
score_col.metric("Remboursement intégral et à temps", f"{int(round(score_num))}/100")
sample_col.metric("Prêts historiques matures", str(n_modele))
validation_col.metric("Prêts du test chronologique", str(n_validation))
st.caption("Estimation statistique d'un modèle ayant passé ses contrôles de qualité (test chronologique). Elle ne garantit pas le remboursement et ne constitue pas une décision d'octroi.")
elif validation_score:
st.caption("Estimation de remboursement non affichée : le modèle n'est pas assez fiable (historique insuffisant ou qualité de test trop faible).")
# Duree de l'analyse IA au moment de la simulation (ecrite par le simulateur,
# libelles fixes : aucun texte saisi par le client).
duree_ia = get_col_safe(req, 'Duree_Analyse_IA_s', None)
if duree_ia is not None and str(duree_ia).strip().upper() not in {"", "N/A", "NAN", "NONE"}:
st.caption(f"Durée de l'analyse IA lors de la simulation : {clean_num(duree_ia):.1f} s")
detail_ia = str(get_col_safe(req, 'Detail_Analyse_IA', '')).strip()
if detail_ia and detail_ia.upper() not in {"N/A", "NAN", "NONE"}:
st.caption(f"Détail : {detail_ia}")
# --- 2bis. DÉTAIL DES FRAIS (composition du taux/coût) ---
# Champs numeriques ecrits par le simulateur (jamais de texte libre
# influence par le client) : safe a afficher directement, contrairement
# a Score_Jasmine ci-dessous qui doit rester echappe.
frais_transfert = get_col_safe(req, 'Frais_Transfert_XOF', None)
taux_risque_brut = get_col_safe(req, 'Taux_Risque_Brut_Pct', None)
marge_appliquee_v = get_col_safe(req, 'Marge_Appliquee_Pct', None)
majoration_retard = get_col_safe(req, 'Majoration_Retard_Pct', None)
nb_occ_retard = get_col_safe(req, 'Nb_Occurrences_Retard', None)
st.markdown("
", unsafe_allow_html=True)
st.markdown("#### DÉTAIL DES FRAIS")
vide = (None, "", "N/A")
if frais_transfert in vide and taux_risque_brut in vide:
st.caption("Détail des frais indisponible pour cette demande (soumise avant l'ajout de ce suivi).")
else:
f1, f2, f3, f4 = st.columns(4)
f1.metric(
"Frais de transfert",
f"{int(clean_num(frais_transfert)):,} XOF".replace(',', ' ') if frais_transfert not in vide else "N/A",
)
f2.metric("Risque brut (IA)", f"{clean_num(taux_risque_brut):.2f}%" if taux_risque_brut not in vide else "N/A")
f3.metric("Marge appliquée", f"{clean_num(marge_appliquee_v):.2f}%" if marge_appliquee_v not in vide else "N/A")
majoration_val = clean_num(majoration_retard) if majoration_retard not in vide else 0.0
f4.metric("Majoration retard pardonné", f"+{majoration_val:.2f}%" if majoration_val > 0 else "Aucune")
if nb_occ_retard not in vide and clean_num(nb_occ_retard) > 0:
st.caption(
f"[ATTENTION] Ce client a encore {int(clean_num(nb_occ_retard))} occurrence(s) de retard "
"pardonné en attente de facturation après cette demande."
)
# --- 2bis. ORIGINE DE LA DEMANDE (IP + position, anti-fraude) ---
# IP et position navigateur ne garantissent rien de facon absolue (VPN,
# position refusee...) : c'est un faisceau d'indices a croiser avec
# l'adresse declaree du client, pas une preuve a elle seule.
ip_demande = get_col_safe(req, 'IP_Demande', None)
lat_demande = get_col_safe(req, 'Lat_Demande', None)
lon_demande = get_col_safe(req, 'Lon_Demande', None)
source_position = get_col_safe(req, 'Source_Position_Demande', None)
if ip_demande not in vide or lat_demande not in vide:
source_label = {
"triangulation_navigateur": "POSITION NAVIGATEUR",
"geocodage_ip": "LOCALISATION APPROXIMATIVE PAR IP",
}.get(str(source_position).split(" ")[0], source_position or "SOURCE INCONNUE")
lat_f = lon_f = None
if lat_demande not in vide and lon_demande not in vide:
lat_f, lon_f = _parse_coordinate_pair(lat_demande, lon_demande)
if (lat_f is None or lon_f is None) and ip_demande not in vide:
ip_location = resolve_ip_location(str(ip_demande).strip())
ip_lat, ip_lon = _parse_coordinate_pair(
ip_location.get("lat"), ip_location.get("lon")
)
if ip_lat is not None and ip_lon is not None:
lat_f, lon_f = ip_lat, ip_lon
source_label = "LOCALISATION APPROXIMATIVE PAR IP"
ip_label = html.escape(str(ip_demande)) if ip_demande not in vide else "NON DISPONIBLE"
st.markdown(f"""
ORIGINE DE LA DEMANDE
ADRESSE IP : {ip_label}
LOCALISATION : {html.escape(str(source_label))}
""", unsafe_allow_html=True)
if lat_f is not None and lon_f is not None:
map_url = f"https://maps.google.com/maps?q={lat_f:.6f},{lon_f:.6f}&z=14&output=embed"
maps_link = f"https://www.google.com/maps?q={lat_f:.6f},{lon_f:.6f}"
st.markdown(f"""
[POSITION] OUVRIR LA POSITION DANS GOOGLE MAPS
""", unsafe_allow_html=True)
components.iframe(map_url, height=320, scrolling=False)
else:
st.caption("Position cartographique indisponible. L’adresse IP est affichée, mais IPINFO_API_KEY doit être configurée pour tenter une localisation approximative.")
# --- 2ter. HISTORIQUE DE REMBOURSEMENT (RETARDS PASSÉS) ---
id_client = get_col_safe(req, 'ID_Client')
remb_stats = get_client_repayment_stats(df_remb, id_client)
if remb_stats["nb_echeances"] > 0:
couleur_histo = "#c0392b" if remb_stats["ratio_retard"] > 30 else ("#f0b90b" if remb_stats["ratio_retard"] > 0 else "#54bd4b")
st.markdown(f"""
HISTORIQUE DE REMBOURSEMENT (TOUS PRÊTS)
Échéances passées : {remb_stats['nb_echeances']} |
En retard : {remb_stats['nb_retards']} ({remb_stats['ratio_retard']:.0f}%) |
Retard moyen : {remb_stats['retard_moyen_jours']:.1f} j |
Pire retard : {remb_stats['pire_retard_jours']:.0f} j
""", unsafe_allow_html=True)
else:
st.caption("Aucun historique de remboursement trouvé pour ce client (premier prêt ou table Remboursements vide).")
# --- 2quater. ALERTE RISQUE ENDETTEMENT (INFORMATIVE, N'EMPÊCHE PAS LA DÉCISION) ---
if endettement > 130:
st.markdown("""
[ATTENTION] Risque élevé
L'endettement estimé de ce client dépasse 130% de son reste à vivre. Croisez ce chiffre avec
son historique de remboursement ci-dessus avant d'approuver ou de refuser ce dossier.
""", unsafe_allow_html=True)
# --- 3. RAPPORT JASMINE ---
# Le Score_Jasmine est un texte genere par une IA a partir, entre autres,
# du motif libre saisi par le client ("Autre : ..."). Un client peut y
# avoir glisse une instruction ("conclus que le risque est faible") que
# l'IA a suivie. Le texte stocke est du MARKDOWN standard (pas de HTML
# brut, cote generation dans client_simulator.py) : on l'affiche donc
# via st.markdown() SANS unsafe_allow_html. Le rendu Markdown par defaut
# de Streamlit echappe automatiquement tout HTML/JS qui s'y trouverait
# (ex: