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"""

VOTRE PRET EST APPROUVE

Dossier officiel : {prt_id}

Capital validé : {req_data['Montant_Capital']:,} XOF

Veuillez trouver vos documents contractuels en pièces jointes. Les fonds vous seront transférés sous peu.

""" 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"""

MISE À JOUR DE VOTRE DEMANDE DE PRÊT

Dossier : {req_id}

Nous regrettons de vous informer qu'après analyse par la direction, votre demande de financement n'a pas pu être retenue.

Motif de la décision :

{motif_refus}

Pour toute question, veuillez contacter le support Vortex Finance.

""" 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("""""", 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("""
[ AVERTISSEMENT TACTIQUE : CODE 429 ]
SATURATION TEMPORAIRE DES TRANSMISSIONS VERS LE CENTRE DE DONNEES.
Le systeme Google limite actuellement les requetes pour eviter la surcharge.
> ACTION REQUISE : Maintenez la position et patientez environ 60 secondes avant de relancer.
""", unsafe_allow_html=True) else: st.markdown(f"""
[ ERREUR SYSTEME CRITIQUE ]
ECHEC DE CONNEXION : {error_str}
""", unsafe_allow_html=True) return # ============================================================================ for idx, req in df_attente.iterrows(): with st.container(): st.markdown(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: