Vortex-Flux / src /modules /request_loans.py
jaimalklyde's picture
Claude Sonnet 5.5
feat(simulateur): score de remboursement sans Jev, selon la qualite du modele
2278cb0
Raw History Blame Contribute Delete
48.1 kB
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"""
<html><body style="font-family: 'Courier New', monospace; background-color: #0a0e12; color: #c9d1d9; padding: 20px;">
<div style="border: 1px solid #54bd4b; padding: 20px; background-color: rgba(10, 14, 18, 0.8);">
<h2 style="color: #54bd4b;">VOTRE PRET EST APPROUVE</h2>
<p>Dossier officiel : <strong>{prt_id}</strong></p>
<p>Capital validé : {req_data['Montant_Capital']:,} XOF</p>
<p>Veuillez trouver vos documents contractuels en pièces jointes. Les fonds vous seront transférés sous peu.</p>
</div>
</body></html>
"""
url = "https://api.brevo.com/v3/smtp/email"
headers = {"accept": "application/json", "api-key": api_key, "content-type": "application/json"}
payload_client = {
"sender": {"email": sender_email, "name": "Vortex Finance"},
"to": [{"email": client_data.get('Email',''), "name": client_data.get('Nom_Complet','')}],
"bcc": [{"email": get_admin_email(), "name": "Klyde (Admin Vortex)"}],
"subject": f"CONTRAT DE PRET APPROUVE - {prt_id}",
"htmlContent": html, "attachment": []
}
if garant_data and "@" in str(garant_data.get('Email', '')):
payload_client["cc"] = [{"email": garant_data['Email'], "name": garant_data['Nom_Complet']}]
payload_admin = {
"sender": {"email": sender_email, "name": "Vortex Finance"},
"to": [{"email": get_admin_email(), "name": "Admin Vortex"}],
"subject": f"[COPIE SECRETE ADMIN] OCTROI - {prt_id}",
"htmlContent": html, "attachment": []
}
def attach(pdf_obj, name):
if not pdf_obj: return
raw = pdf_obj.getvalue() if hasattr(pdf_obj, 'getvalue') else pdf_obj
b64 = base64.b64encode(raw).decode('utf-8')
payload_client["attachment"].append({"name": name, "content": b64})
payload_admin["attachment"].append({"name": name, "content": b64})
attach(pdf_contrat, f"Contrat_{prt_id}.pdf")
attach(pdf_dette, f"Dette_{prt_id}.pdf")
attach(pdf_caution, f"Caution_{prt_id}.pdf")
try:
requests.post(url, json=payload_client, headers=headers, timeout=15)
requests.post(url, json=payload_admin, headers=headers, timeout=15)
return True
except Exception as e:
from core.errors import log_swallowed
log_swallowed(f"Envoi de l'email d'octroi pour {prt_id} echoue", e)
return False
def refuser_et_envoyer(req_data, client_data, garant_data, motif_refus):
"""Envoie un e-mail de refus au client, avec copie au garant et BCC à l'admin"""
api_key = _get_credential("BREVO_API_KEY")
sender_email = _get_credential("SENDER_EMAIL")
if not api_key or not sender_email: return False
req_id = req_data.get('ID_Pret', 'DOSSIER')
html = f"""
<html><body style="font-family: 'Courier New', monospace; background-color: #0a0e12; color: #c9d1d9; padding: 20px;">
<div style="border: 1px solid #e74c3c; padding: 20px; background-color: rgba(10, 14, 18, 0.8);">
<h2 style="color: #e74c3c;">MISE À JOUR DE VOTRE DEMANDE DE PRÊT</h2>
<p>Dossier : <strong>{req_id}</strong></p>
<p>Nous regrettons de vous informer qu'après analyse par la direction, votre demande de financement n'a pas pu être retenue.</p>
<div style="background: rgba(255,255,255,0.05); padding: 15px; border-left: 3px solid #e74c3c; margin-top: 20px;">
<p><strong>Motif de la décision :</strong></p>
<p>{motif_refus}</p>
</div>
<p style="font-size: 0.8rem; color: #7a8a9a; margin-top: 20px;">Pour toute question, veuillez contacter le support Vortex Finance.</p>
</div>
</body></html>
"""
url = "https://api.brevo.com/v3/smtp/email"
headers = {"accept": "application/json", "api-key": api_key, "content-type": "application/json"}
payload_client = {
"sender": {"email": sender_email, "name": "Vortex Finance"},
"to": [{"email": client_data.get('Email',''), "name": client_data.get('Nom_Complet','')}],
"bcc": [{"email": get_admin_email(), "name": "Klyde (Admin Vortex)"}],
"subject": f"Mise à jour concernant votre demande - {req_id}",
"htmlContent": html
}
if garant_data and "@" in str(garant_data.get('Email', '')):
payload_client["cc"] = [{"email": garant_data['Email'], "name": garant_data['Nom_Complet']}]
try:
requests.post(url, json=payload_client, headers=headers, timeout=15)
return True
except Exception as e:
from core.errors import log_swallowed
log_swallowed(f"Envoi de l'email de refus pour {req_id} echoue", e)
return False
def show_request_loans(client_gspread, sheet_name):
st.markdown("""<style>
.req-card { background: rgba(15,20,26,0.9); border-left: 4px solid #f39c12; padding: 15px; margin-bottom: 15px; font-family: 'JetBrains Mono', monospace;}
.jasmine-box { background: rgba(88,166,255,0.05); border: 1px solid rgba(88,166,255,0.3); padding: 15px; font-size: 0.8rem; margin-top: 15px; line-height:1.6;}
.kyc-box { border: 1px solid rgba(100,120,140,0.3); padding: 10px; font-size: 0.75rem; background: rgba(10,14,18,0.5); }
</style>""", unsafe_allow_html=True)
st.header("VALIDATION DES DEMANDES (BACK-OFFICE)")
# ============================================================================
# BLOC D'INTERCEPTION 429 (CHARGEMENT)
# ============================================================================
try:
sh = client_gspread.open(sheet_name)
ws_req = sh.worksheet("Prets_Requests")
# Les 4 feuilles necessaires a cette page sont lues en UN SEUL appel API
# (values_batch_get) au lieu de 4 appels separes, pour diviser par 4 la
# consommation de quota de cette page a chaque rerun (clic, expander...).
try:
_dfs = get_cached_dfs_batch(
sh, sheet_name, ["Prets_Requests", "Clients_KYC", "Garants_KYC", "Remboursements"],
numericise_ignore_columns={"Prets_Requests": {"Taux_Endettement"}},
)
except Exception as inner_e:
if "429" in str(inner_e) or "Quota" in str(inner_e):
raise inner_e # On remonte l'erreur 429 au gestionnaire principal
st.info("La file d'attente est vide.")
return
df_req = _dfs["Prets_Requests"]
if df_req.empty or 'Statut' not in df_req.columns:
st.info("Aucune demande en attente actuellement.")
return
df_attente = df_req[df_req['Statut'] == 'EN_ATTENTE']
if df_attente.empty:
st.info("Aucune nouvelle demande en attente de validation.")
return
df_clients = _dfs["Clients_KYC"]
df_garants = _dfs["Garants_KYC"]
df_remb = _dfs["Remboursements"]
except Exception as e:
error_str = str(e)
if "429" in error_str or "Quota exceeded" in error_str:
st.markdown("""
<div style="background: rgba(243, 156, 18, 0.08); border: 1px solid rgba(243, 156, 18, 0.3); border-left: 4px solid #f39c12; padding: 14px 18px; margin-bottom: 16px; font-family: 'JetBrains Mono', monospace; border-radius: 0;">
<div style="color: #f39c12; font-weight: 800; font-size: 0.75rem; letter-spacing: 2px; text-transform: uppercase; margin-bottom: 6px;">
[ AVERTISSEMENT TACTIQUE : CODE 429 ]
</div>
<div style="color: #a8b8c8; font-size: 0.75rem; line-height: 1.6;">
SATURATION TEMPORAIRE DES TRANSMISSIONS VERS LE CENTRE DE DONNEES.<br>
Le systeme Google limite actuellement les requetes pour eviter la surcharge.<br>
<span style="color:#f39c12; font-weight: 700;">> ACTION REQUISE :</span> Maintenez la position et patientez environ 60 secondes avant de relancer.
</div>
</div>
""", unsafe_allow_html=True)
else:
st.markdown(f"""
<div style="background: rgba(192, 57, 43, 0.08); border: 1px solid rgba(192, 57, 43, 0.3); border-left: 4px solid #c0392b; padding: 14px 18px; margin-bottom: 16px; font-family: 'JetBrains Mono', monospace; border-radius: 0;">
<div style="color: #c0392b; font-weight: 800; font-size: 0.75rem; letter-spacing: 2px; text-transform: uppercase; margin-bottom: 6px;">
[ ERREUR SYSTEME CRITIQUE ]
</div>
<div style="color: #a8b8c8; font-size: 0.75rem; line-height: 1.6;">
ECHEC DE CONNEXION : {error_str}
</div>
</div>
""", unsafe_allow_html=True)
return
# ============================================================================
for idx, req in df_attente.iterrows():
with st.container():
st.markdown(f'<div class="req-card">', 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"""
<div class="kyc-box">
<b>Profession :</b> {html.escape(str(c_data.get('Statut_Pro', 'N/A')))} | <b>Secteur :</b> {html.escape(str(c_data.get('Secteur_Activite', 'N/A')))} <br>
<b>Revenus :</b> {rev_str} XOF | <b>Charges :</b> {chg_str} XOF | <b>Reste à vivre :</b> <span style="color:#54bd4b">{rav_str} XOF</span>
</div>
""", unsafe_allow_html=True)
else:
c_data = {}
st.warning("Données KYC du client introuvables.")
# --- 2. CONFIGURATION DU PRET ---
st.markdown("<br>", 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("<br>", 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"""
<div style="background:rgba(10,14,18,0.92); border:1px solid rgba(255,79,160,0.35); border-left:4px solid #ff4f9a; padding:14px 18px; margin:12px 0 0; font-family:'JetBrains Mono',monospace;">
<div style="color:#ff4f9a; font-weight:800; letter-spacing:1.5px; text-transform:uppercase; font-size:0.78rem; margin-bottom:10px;">ORIGINE DE LA DEMANDE</div>
<div style="color:#a8b8c8; font-size:0.82rem; line-height:1.8;">
ADRESSE IP : <b style="color:#e6edf3;">{ip_label}</b><br>
LOCALISATION : <b style="color:#ff8cbd;">{html.escape(str(source_label))}</b>
</div>
</div>
""", 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"""
<a href="{maps_link}" target="_blank" rel="noopener noreferrer" style="display:inline-block; margin:10px 0 6px; padding:8px 12px; border:1px solid #ff4f9a; color:#ff8cbd; font-family:'JetBrains Mono',monospace; font-size:0.72rem; letter-spacing:1px; text-decoration:none;">[POSITION] OUVRIR LA POSITION DANS GOOGLE MAPS</a>
""", 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"""
<div style="background: rgba(10, 14, 18, 0.9); border: 1px solid rgba(80, 100, 120, 0.3); border-left: 4px solid {couleur_histo}; padding: 14px 18px; margin-top: 10px; font-family: 'JetBrains Mono', monospace;">
<div style="color:{couleur_histo}; font-weight: 800; letter-spacing: 1px; text-transform: uppercase; font-size: 0.75rem; margin-bottom: 6px;">HISTORIQUE DE REMBOURSEMENT (TOUS PRÊTS)</div>
<div style="color:#a8b8c8; font-size: 0.85rem; line-height: 1.6;">
Échéances passées : <b style="color:#e6edf3;">{remb_stats['nb_echeances']}</b> &nbsp;|&nbsp;
En retard : <b style="color:{couleur_histo};">{remb_stats['nb_retards']} ({remb_stats['ratio_retard']:.0f}%)</b> &nbsp;|&nbsp;
Retard moyen : <b style="color:#e6edf3;">{remb_stats['retard_moyen_jours']:.1f} j</b> &nbsp;|&nbsp;
Pire retard : <b style="color:#e6edf3;">{remb_stats['pire_retard_jours']:.0f} j</b>
</div>
</div>
""", 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("""
<div style="background: rgba(240, 185, 11, 0.06); border: 1px solid rgba(240, 185, 11, 0.4); border-left: 4px solid #f0b90b; padding: 14px 18px; margin-top: 10px; font-family: 'JetBrains Mono', monospace;">
<div style="display:inline-block; background: rgba(240, 185, 11, 0.15); border: 1px solid #f0b90b; color: #f0b90b; font-weight: 800; letter-spacing: 2px; text-transform: uppercase; font-size: 0.7rem; padding: 5px 12px; border-radius: 4px; margin-bottom: 8px;">[ATTENTION] Risque élevé</div>
<div style="color:#c9d1d9; font-size: 0.85rem; line-height: 1.6;">
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.
</div>
</div>
""", 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: <script>, un faux encart imitant "RISQUE FAIBLE — VALIDER"), donc
# pas besoin d'echappement manuel — et ca evite l'ecueil inverse (un
# echappement manuel qui casse aussi le markdown legitime de l'audit,
# comme le **gras** ou les listes).
st.markdown(f"**Commentaire Client :** {get_col_safe(req, 'Commentaire_Client')}")
st.markdown('<div class="jasmine-box"><h4 style="color:#58a6ff; margin-top:0;">[IA] ANALYSE JASMINE</h4>', unsafe_allow_html=True)
st.markdown(str(get_col_safe(req, 'Score_Jasmine')))
st.markdown('</div>', unsafe_allow_html=True)
st.caption("[ATTENTION] Rapport genere par IA a partir de donnees saisies par le client (dont le motif libre) — a verifier, ne pas prendre comme unique base de decision.")
# --- 4. TABLEAU D'AMORTISSEMENT ---
st.markdown("<br>", unsafe_allow_html=True)
st.markdown("#### TABLEAU D'AMORTISSEMENT")
type_pret = get_col_safe(req, 'Type_Pret')
dates_str = str(get_col_safe(req, 'Dates_Versements'))
custom_dates = None
if dates_str and dates_str != "nan" and dates_str != "N/A":
try:
custom_dates = [datetime.strptime(d.strip(), "%d/%m/%Y").date() for d in dates_str.split(';') if d.strip()]
except ValueError as e:
from core.errors import log_swallowed
log_swallowed(f"Dates_Versements illisibles pour la demande (valeur : {dates_str!r})", e)
raw_date_debloc = get_col_safe(req, 'Date_Deblocage', date.today().strftime("%d/%m/%Y"))
try:
date_debloc_parsed = datetime.strptime(raw_date_debloc, "%d/%m/%Y").date()
except (ValueError, TypeError) as e:
from core.errors import log_swallowed
log_swallowed(f"Date_Deblocage illisible pour la demande (valeur : {raw_date_debloc!r}), aujourd'hui utilise en repli", e)
date_debloc_parsed = date.today()
df_amort = generer_tableau_amortissement(
type_pret,
capital,
taux,
duree,
clean_num(get_col_safe(req, 'Montant_Versement')),
nb_vers,
date_debloc_parsed,
dates_versements=custom_dates,
frais_transfert=(
clean_num(frais_transfert) if frais_transfert not in vide
else capital * (TAXE_TRANSFERT - 1)
),
)
st.dataframe(df_amort, hide_index=True, use_container_width=True)
render_scoped_widget(
client_gspread, sheet_name,
scope_label=f"Demande de prêt en attente {id_pret} — {nom_client}",
context_data={
"client": {"nom": nom_client, "id": get_col_safe(req, 'ID_Client')},
"demande": {
"id_pret": id_pret,
"capital_xof": capital,
"montant_total_xof": total,
"cout_credit_xof": cout_credit,
"taux_pct": taux,
"endettement_pct": endettement,
"duree_semaines": duree,
"motif": get_col_safe(req, 'Motif'),
},
"guide_page": (
"Validation des demandes de prêt en attente (back-office). L'agent examine "
"le profil KYC du client et les paramètres proposés, puis approuve (transfert "
"vers Prets_Master, statut ACTIF) ou refuse la demande. Étape avant l'octroi "
"effectif (module Init Loan)."
),
"definitions_champs": {
"capital_xof": "Montant du capital demandé par le client, avant intérêts.",
"montant_total_xof": "Capital + coût du crédit — montant total que le client devra rembourser.",
"cout_credit_xof": "Montant_Total - Capital — la marge/bénéfice de l'IMF sur ce prêt.",
"taux_pct": "Taux d'intérêt hebdomadaire proposé (par l'IA) pour cette demande.",
"endettement_pct": "Taux d'endettement mensuel estimé du client pour ce prêt (charge mensuelle / revenus).",
"duree_semaines": "Durée du prêt en semaines.",
"motif": "Raison du prêt renseignée par le client.",
},
},
key_prefix=f"reqloan_{id_pret}",
)
# --- 5. ZONE DE DÉCISION (APPROUVER OU REFUSER) ---
st.markdown("<br>", unsafe_allow_html=True)
# Bouton Approuver
if rendre_bouton_protege("[OK] APPROUVER ET OCTROYER CE PRET", cle=f"octroi_{id_pret}", type="primary", use_container_width=True):
with st.spinner("Transfert vers Prets_Master et envoi des contrats..."):
try:
ws_master = sh.worksheet("Prets_Master")
# --- GARDE ANTI-DOUBLON ---
# Si un octroi precedent a deja ecrit le pret dans Prets_Master
# mais a echoue AVANT de marquer la demande "APPROUVE" (ex: 429
# sur l'update_cell suivant), recliquer sur ce bouton reprend
# l'octroi la ou il s'est arrete au lieu de creer un 2e pret.
# Le pret deja octroye pour CETTE demande precise est identifie
# par (ID_Client, Date_Creation), stables sur toute nouvelle
# tentative pour la meme ligne de Prets_Requests.
id_client_req = get_col_safe(req, 'ID_Client')
date_creation_req = get_col_safe(req, 'Date_Creation')
all_master_rows = ws_master.get_all_values()
# --- RECALAGE DE L'ECHEANCIER SUR LA DATE D'OCTROI ---
# Date_Deblocage/Date_Fin/Dates_Versements sur la demande sont
# figes a la date de SOUMISSION de la demande par le client. Si
# l'agent valide plus tard (ex: 5 jours apres), utiliser ces dates
# telles quelles decale tout l'echeancier vers le passe et rend le
# client "en retard" des le premier jour. On recalcule donc le
# tableau d'amortissement a partir de la date d'octroi reelle
# (aujourd'hui), en conservant l'espacement original entre echeances
# pour les prets a dates personnalisees.
octroi_date = date.today()
delta_octroi = (octroi_date - date_debloc_parsed).days
custom_dates_octroi = (
[d + timedelta(days=delta_octroi) for d in custom_dates]
if custom_dates else None
)
df_amort_octroi = generer_tableau_amortissement(
type_pret,
capital,
taux,
duree,
clean_num(get_col_safe(req, 'Montant_Versement')),
nb_vers,
octroi_date,
dates_versements=custom_dates_octroi,
frais_transfert=(
clean_num(frais_transfert) if frais_transfert not in vide
else capital * (TAXE_TRANSFERT - 1)
),
)
date_fin_octroi = (
df_amort_octroi.iloc[-1]['Date']
if not df_amort_octroi.empty
else (octroi_date + timedelta(weeks=duree)).strftime("%d/%m/%Y")
)
dates_str_octroi = (
";".join(d.strftime("%d/%m/%Y") for d in custom_dates_octroi)
if custom_dates_octroi else dates_str
)
new_prt_id = None
if len(all_master_rows) > 1:
header_master = all_master_rows[0]
try:
col_id_idx = header_master.index('ID_Pret')
col_client_idx = header_master.index('ID_Client')
col_date_idx = header_master.index('Date_Creation')
for row in all_master_rows[1:]:
if (
len(row) > max(col_id_idx, col_client_idx, col_date_idx)
and row[col_client_idx] == id_client_req
and row[col_date_idx] == date_creation_req
):
new_prt_id = row[col_id_idx]
break
except ValueError:
pass # colonne attendue absente : pas de garde possible, on octroie normalement
if new_prt_id:
st.info(f"Ce prêt avait déjà été octroyé sous l'ID {new_prt_id} lors d'une tentative précédente — reprise sans créer de doublon.")
else:
new_prt_id = generate_next_id(ws_master, "PRT")
row_master = [
new_prt_id,
get_col_safe(req, 'ID_Client'),
nom_client,
get_col_safe(req, 'Offre'),
type_pret,
get_col_safe(req, 'Motif'),
capital, taux, endettement,
duree,
clean_num(get_col_safe(req, 'Montant_Versement')),
total,
cout_credit,
nb_vers,
dates_str_octroi,
octroi_date.strftime("%d/%m/%Y"),
date_fin_octroi,
get_col_safe(req, 'Moyen_Transfert'),
sg.STATUT_PRET_EN_ATTENTE, # ACTIF seulement apres signature
get_col_safe(req, 'ID_Garant', ""),
get_col_safe(req, 'Date_Creation'),
""
]
ws_master.append_row(row_master)
col_statut_idx = df_req.columns.get_loc('Statut') + 1
ws_req.update_cell(idx + 2, col_statut_idx, "APPROUVE")
clear_sheets_cache()
# Consommation d'une occurrence de retard pardonne,
# UNIQUEMENT a l'octroi et seulement si cette demande
# porte reellement une majoration. Idempotent par
# reference (ID de la demande) : un nouveau clic apres
# une erreur ne consomme pas une 2e occurrence.
maj_pct = clean_num(majoration_retard) if majoration_retard not in vide else 0.0
if maj_pct > 0:
if not penalites_retard.consommer_occurrences(
client_gspread, sheet_name, id_client_req,
1, reference=id_pret,
raison=f"Majoration appliquee a l'octroi de la demande {id_pret} ({new_prt_id})",
):
st.warning("Prêt octroyé, mais le solde de retards pardonnés n'a pas pu être mis à jour (Suivi_Penalites_Retard). À corriger manuellement.")
id_g = get_col_safe(req, 'ID_Garant')
g_data = None
if id_g and not df_garants.empty:
g_match = df_garants[df_garants['ID_Garant'] == id_g]
if not g_match.empty: g_data = g_match.iloc[0].to_dict()
req_dict = req.to_dict()
req_dict['ID_Pret'] = new_prt_id
req_dict['Montant_Capital'] = capital
req_dict['Montant_Total'] = total
req_dict['Taux_Hebdo'] = taux
req_dict['Date_Deblocage'] = octroi_date.strftime("%d/%m/%Y")
req_dict['Date_Fin'] = date_fin_octroi
ok_sig, detail_sig = lancer_signature_octroi(
client_gspread, sheet_name, req_dict, c_data, g_data, df_amort_octroi,
new_prt_id, id_client_req)
if ok_sig:
marquer_fait(f"octroi_{id_pret}")
st.success("[OK] Octroi enregistré : liens de signature envoyés. "
"Le prêt devient ACTIF dès que toutes les parties ont signé.")
st.rerun()
else:
# Pret deja enregistre (garde anti-doublon ci-dessus) : on
# libere le bouton pour permettre de reessayer UNIQUEMENT
# l'envoi des liens, sans risque de recreer le pret.
liberer(f"octroi_{id_pret}")
st.error(f"Prêt enregistré (EN_ATTENTE_SIGNATURE) mais lien de signature non envoyé : "
f"{detail_sig}. Réessayez, ou utilisez le module Signatures.")
# --- Protection 429 lors de l'écriture Google Sheets ---
except Exception as btn_e:
liberer(f"octroi_{id_pret}")
if "429" in str(btn_e) or "Quota" in str(btn_e):
st.error("[ATTENTION] CODE 429 : Saturation de la base de données. Attendez 60 secondes puis réessayez l'octroi.")
else:
st.error(f"Erreur technique lors de l'octroi : {btn_e}")
st.markdown("<hr style='border:1px dashed rgba(255,255,255,0.2);'>", unsafe_allow_html=True)
# Zone de Refus avec Commentaire
motif_refus = st.text_area("Motif du refus (communiqué au client) :", key=f"motif_ref_{id_pret}", placeholder="Ex: Capacité de remboursement insuffisante au vu de vos charges...")
if rendre_bouton_protege("[ECHEC] REFUSER CETTE DEMANDE", cle=f"refus_{id_pret}", use_container_width=True):
if not motif_refus.strip():
liberer(f"refus_{id_pret}")
st.error("Veuillez saisir un motif de refus avant de rejeter la demande.")
else:
with st.spinner("Mise à jour du dossier et notification du client..."):
try:
col_statut_idx = df_req.columns.get_loc('Statut') + 1
ws_req.update_cell(idx + 2, col_statut_idx, "REFUSE")
# Ecriture du Commentaire_Refus dans Google Sheets
try:
col_refus_idx = df_req.columns.get_loc('Commentaire_Refus') + 1
ws_req.update_cell(idx + 2, col_refus_idx, str(motif_refus))
except KeyError:
st.warning("[ATTENTION] La colonne 'Commentaire_Refus' n'existe pas dans Google Sheets. Le statut est refusé, mais le motif n'y est pas enregistré. Créez la colonne juste après Commentaire_Client.")
clear_sheets_cache()
id_g = get_col_safe(req, 'ID_Garant')
g_data = None
if id_g and not df_garants.empty:
g_match = df_garants[df_garants['ID_Garant'] == id_g]
if not g_match.empty: g_data = g_match.iloc[0].to_dict()
req_dict = req.to_dict()
if refuser_et_envoyer(req_dict, c_data, g_data, motif_refus):
marquer_fait(f"refus_{id_pret}")
st.success("[ECHEC] Demande refusée. Le client a été notifié par e-mail.")
st.rerun()
else:
liberer(f"refus_{id_pret}")
st.error("Le statut a été mis à jour dans le sheet, mais l'e-mail n'a pas pu être envoyé.")
# --- Protection 429 lors de l'écriture Google Sheets ---
except Exception as btn_e:
liberer(f"refus_{id_pret}")
if "429" in str(btn_e) or "Quota" in str(btn_e):
st.error("[ATTENTION] CODE 429 : Saturation de la base de données. Attendez 60 secondes puis réessayez de refuser.")
else:
st.error(f"Erreur technique lors du refus : {btn_e}")
st.markdown('</div>', unsafe_allow_html=True)