Spaces:
Running
Running
Claude Sonnet 5.5
feat(simulateur): score de remboursement sans Jev, selon la qualite du modele
2278cb0 Download src/modules/request_loans.py from klydekushy/Vortex-Flux: direct link, hf CLI and curl.
- Browser
- Download file 48.1 kB
-
https://huggingface.co/spaces/klydekushy/Vortex-Flux/resolve/main/src/modules/request_loans.py
- Command line
-
hf download hf://spaces/klydekushy/Vortex-Flux/src/modules/request_loans.py
-
curl -L -o request_loans.py https://huggingface.co/spaces/klydekushy/Vortex-Flux/resolve/main/src/modules/request_loans.py
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> | | |
| En retard : <b style="color:{couleur_histo};">{remb_stats['nb_retards']} ({remb_stats['ratio_retard']:.0f}%)</b> | | |
| Retard moyen : <b style="color:#e6edf3;">{remb_stats['retard_moyen_jours']:.1f} j</b> | | |
| 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) |