# -*- coding: utf-8 -*- """ Check_Up_Loans.py Module de mise à jour (Check-Up) des prêts existants. Gère les prêts issus de Prets_Master ET de Prets_Update. Workflow : 1. Sélection du client 2. Détection unifiée des prêts ACTIF (Prets_Master + Prets_Update) 3. Vérification bloquante des remboursements (sur ID_Pret racine) 4. Formulaire de modification + calculs automatiques 5. Archivage de l'ancienne version (statut → UPDATED) 6. Création de la nouvelle version dans Prets_Update (statut → ACTIF, Version = n+1) 7. Génération des documents PDF et Envoi Mail """ import streamlit as st import pandas as pd from datetime import datetime, date, timedelta import time import requests import base64 import os from Analytics.AnalyseFinance import ( analyser_capacite, generer_tableau_amortissement, calculer_taux_endettement, clean_currency_value, ) from DocumentGen.AutoPDFGeneration import ( generer_contrat_pret, generer_reconnaissance_dette, generer_contrat_caution, ) from modules.jasmine_widget import render_scoped_widget from core.email_client import envoyer_email, get_admin_email, get_credential from core import signature as sg from core import signature_service as svc from core.ui_guards import rendre_form_submit_protege, rendre_bouton_protege, marquer_fait, liberer # ============================================================================ # FONCTION ISOLEE — ENVOI DES CONTRATS PAR EMAIL # ============================================================================ def envoyer_contrat_tactique(email_client, loan_data, client_data, pdf_contrat, pdf_dette, pdf_caution=None, garant_data=None): """Envoi des documents mis a jour (mise a jour de pret) : garant en CC, admin en BCC. Transport mutualise dans core.email_client.envoyer_email.""" if not email_client or "@" not in str(email_client): return False, "Adresse email invalide ou manquante." nom_client = client_data.get('Nom_Complet', 'Client') id_pret_updated = loan_data.get('ID_Pret', 'N/A') id_pret_source = loan_data.get('ID_Pret_Source', 'N/A') montant_capital = loan_data.get('Montant_Capital', 0) montant_total = loan_data.get('Montant_Total', 0) version = loan_data.get('Version', 'N/A') html_content = f"""
CONFIRMATION DE MISE A JOUR DE PRET

CLIENT IDENTIFIE : {nom_client}

NOUVELLE REFERENCE DOSSIER : {id_pret_updated} (Version {version})

ANCIENNE REFERENCE : {id_pret_source}

NOUVEAU CAPITAL DEBLOQUE

{montant_capital:,.0f} XOF

NOUVEAU MONTANT TOTAL A REMBOURSER

{montant_total:,.0f} XOF

Veuillez trouver vos documents mis a jour (Contrat de pret, Nouvel echeancier, Reconnaissance de dette, et l'acte de caution le cas echeant) en pieces jointes (PDF). Ces documents annulent et remplacent les precedents.

""" def _octets(pdf_obj): return pdf_obj.getvalue() if hasattr(pdf_obj, 'getvalue') else pdf_obj attachments = [ (f"Contrat_{id_pret_updated}.pdf", _octets(pdf_contrat)), (f"Dette_{id_pret_updated}.pdf", _octets(pdf_dette)), ] if pdf_caution: attachments.append((f"Caution_{id_pret_updated}.pdf", _octets(pdf_caution))) cc = None if garant_data: email_garant = str(garant_data.get('Email', '')) if "@" in email_garant: cc = email_garant return envoyer_email( to=email_client, subject=f"DOCUMENTS DE PRET MIS A JOUR - {id_pret_updated}", html_content=html_content, bcc=get_admin_email(), cc=cc, attachments=attachments, sender_name="Vortex Finance", ) # ============================================================================ # CONSTANTES # ============================================================================ MOTIFS_PRET = [ "Commerce / Achat de stock", "Investissement", "Trésorerie professionnelle", "Lancement d'activité", "Développement d'activité", "Agriculture / Élevage", "Transport / Logistique", "Urgence médicale", "Scolarité / Formation", "Logement / Habitat", "Réparations", "Événements familiaux", "Voyage / Déplacement", "Consommation", "Achat d'équipement personnel", "Projet personnel", "Autre", ] TYPE_OPTIONS = [ "In Fine", "Mensuel - Intérêts", "Mensuel - Constant", "Hebdomadaire", "Personnalisé", ] TYPE_CODE_MAP = { "In Fine": "IN_FINE", "Mensuel - Intérêts": "MENSUEL_INTERETS", "Mensuel - Constant": "MENSUEL_CONSTANT", "Hebdomadaire": "HEBDOMADAIRE", "Personnalisé": "PERSONNALISE", } TYPE_REVERSE_MAP = {v: k for k, v in TYPE_CODE_MAP.items()} # En-têtes officiels de la feuille Prets_Update # NOTE (Fix #1 corrigé) : "Offre" est placée en TOUTE FIN de liste, jamais # au milieu. Sur une feuille Prets_Update déjà existante, le code ne réécrit # pas l'en-tête (il n'est écrit qu'à la création de la feuille) : insérer une # colonne au milieu de PRETS_UPDATE_HEADERS sans toucher l'en-tête réel de la # feuille décale TOUTES les colonnes suivantes lors de l'écriture d'une # ligne positionnelle (Type_Pret reçoit la valeur d'Offre, Motif reçoit le # Type_Pret, etc.) — c'est ce qui a corrompu les prêts mis à jour. # La ligne est maintenant construite dynamiquement à partir de l'en-tête réel # de la feuille (voir _ensure_offre_column / row_dict plus bas), donc cette # liste ne sert plus que d'en-tête par défaut pour une feuille NEUVE. PRETS_UPDATE_HEADERS = [ "ID_Pret_Updated", "ID_Pret", "Version", "Date_Modification", "ID_Client", "Nom_Complet", "Type_Pret", "Motif", "Montant_Capital", "Taux_Hebdo", "Taux_Endettement", "Duree_Semaines", "Montant_Versement", "Montant_Total", "Cout_Credit", "Nb_Versements", "Dates_Versements", "Date_Deblocage", "Date_Fin", "Moyen_Transfert", "Statut", "ID_Garant", "Date_Creation", "Commentaire_Modification", "Offre", ] # ============================================================================ # CACHE & REFRESH # ============================================================================ @st.cache_data(ttl=600) def get_cached_data(_client, sheet_name, worksheet_name): """ Lit une feuille Google Sheets et retourne un DataFrame (avec cache 10 min). (Fix numérisation gspread) `numericise_ignore=['all']` désactive la conversion automatique de types de gspread. Sans cela, gspread tente de deviner si une valeur est un nombre et, pour des chaînes au format français ("1,5"), interprète la virgule comme un séparateur de milliers (convention US) au lieu d'un séparateur décimal : "1,5" devient 15, "6,49" devient 649, "25,98" devient 2598 — une corruption silencieuse qui se produit AVANT que le code (parse_fr_num) ne voie la donnée, donc impossible à corriger en aval. En désactivant cette numérisation, toutes les valeurs sont renvoyées en chaînes brutes telles qu'affichées dans la feuille (ex: "1,5" reste "1,5"), et c'est ensuite parse_fr_num qui se charge, correctement, de la conversion virgule → point. """ # Ne pas attraper l'exception ici : si on retourne un DataFrame vide # en cas d'erreur, Streamlit met ce résultat en cache 10 min, ce qui # lève faussement le blocage « remboursements existants ». On laisse # l'exception remonter pour que le cache ne stocke rien. sh = _client.open(sheet_name) ws = sh.worksheet(worksheet_name) return pd.DataFrame(ws.get_all_records(numericise_ignore=['all'])) # ============================================================================ # UTILITAIRES — VERSIONING & SÉLECTION # ============================================================================ def _safe_version_int(v): """Convertit une valeur de version en entier. Retourne 0 si invalide.""" try: return int(str(v).strip()) except (ValueError, TypeError): return 0 def parse_fr_num(val): """ (Fix #5) Parsing sécurisé des nombres venant de Google Sheets, sans imposer un format unique : si la valeur utilise une virgule décimale ("1,5"), on la traite comme telle ; si elle utilise déjà un point décimal ("1.5", ou un float Python natif renvoyé par gspread), on ne la touche pas. On ne devine jamais à tort une virgule décimale là où il n'y en a pas. Règles (dans cet ordre) : - int/float déjà natif → renvoyé tel quel (aucune conversion de séparateur à faire, il n'y a pas de notion de "virgule" ou "point" pour un float Python). - chaîne avec virgule ET point (ex: "1.234,56") → le dernier séparateur rencontré est traité comme le décimal, l'autre comme séparateur de milliers. - chaîne avec virgule seule (ex: "1,5") → la virgule devient le point décimal. - chaîne avec point seul (ex: "1.5") ou sans séparateur → laissée telle quelle, c'est déjà un format Python valide. Nettoyage additionnel : suppression des espaces (dont insécables), "XOF" et "%". Retourne 0.0 si la conversion échoue. """ if val is None: return 0.0 if isinstance(val, (int, float)): return float(val) s = str(val).strip() if not s: return 0.0 s = s.replace("XOF", "").replace("xof", "") s = s.replace("%", "") s = s.replace("\u00a0", "").replace(" ", "") # espaces normaux + insécables has_comma = "," in s has_dot = "." in s if has_comma and has_dot: # Le séparateur le plus à droite est le décimal, l'autre = milliers. if s.rfind(",") > s.rfind("."): s = s.replace(".", "").replace(",", ".") else: s = s.replace(",", "") elif has_comma: s = s.replace(",", ".") # sinon : point seul ou aucun séparateur → laissé tel quel try: return float(s) except (ValueError, TypeError): return 0.0 def _get_next_version(df_prets_update: pd.DataFrame, root_id: str) -> int: """ Calcule la prochaine version à attribuer pour un ID_Pret racine donné. Règle : - Version 1 = prêt original dans Prets_Master (non stocké dans Prets_Update) - Version n+1 = max(Version existantes dans Prets_Update pour ce root_id) + 1 - Minimum retourné : 2 """ if df_prets_update.empty: return 2 subset = df_prets_update[df_prets_update["ID_Pret"].astype(str) == root_id] if subset.empty: return 2 max_v = subset["Version"].apply(_safe_version_int).max() return max(2, max_v + 1) def _get_latest_active_from_update(df_prets_update: pd.DataFrame, root_id: str): """ Retourne la ligne ACTIF de Prets_Update pour un root_id donné, en sélectionnant la version la plus élevée. Filtres cumulatifs : 1. ID_Pret == root_id 2. Statut == 'ACTIF' 3. Version == max(Version) Retourne None si aucune ligne ne correspond. """ if df_prets_update.empty: return None subset = df_prets_update[ (df_prets_update["ID_Pret"].astype(str) == root_id) & (df_prets_update["Statut"] == "ACTIF") ].copy() if subset.empty: return None subset["_version_int"] = subset["Version"].apply(_safe_version_int) return subset.loc[subset["_version_int"].idxmax()] def _sanitize_for_gspread(value): """ Convertit toute valeur numpy ou non-sérialisable en type Python natif avant envoi à gspread (qui sérialise en JSON). """ import numpy as np if isinstance(value, (np.integer,)): return int(value) if isinstance(value, (np.floating,)): return float(value) if isinstance(value, (np.bool_,)): return bool(value) if isinstance(value, float) and (value != value): # NaN return "" return value def _sanitize_row(row: list) -> list: """Applique _sanitize_for_gspread sur chaque élément d'une ligne.""" return [_sanitize_for_gspread(v) for v in row] def _find_row_index_in_sheet(all_values: list, col_idx_1based: int, search_value: str): """ Recherche la ligne (1-based) dans all_values dont la colonne col_idx_1based correspond à search_value. Retourne None si introuvable. """ for i, row in enumerate(all_values[1:], start=2): if str(row[col_idx_1based - 1]) == search_value: return i return None def _ensure_offre_column(ws_pu): """ (Fix décalage de colonnes) Vérifie que la colonne "Offre" existe dans l'en-tête RÉEL (ligne 1) de la feuille Prets_Update. Si la feuille existait déjà avant l'introduction de cette colonne, son en-tête ne contient pas "Offre" — on l'ajoute alors en TOUTE FIN de ligne (jamais au milieu), pour ne jamais décaler les colonnes déjà utilisées par les lignes existantes. Retourne la liste des en-têtes à jour (après migration éventuelle), à utiliser pour construire toute nouvelle ligne de façon positionnelle et sûre (voir row_dict plus bas). """ header = ws_pu.row_values(1) if "Offre" not in header: ws_pu.update_cell(1, len(header) + 1, "Offre") header = header + ["Offre"] return header # ============================================================================ # CSS # ============================================================================ def apply_checkup_loans_styles(): st.markdown( """ """, unsafe_allow_html=True, ) # ============================================================================ # FONCTION PRINCIPALE # ============================================================================ def show_check_up_loans(client, sheet_name): """ Point d'entrée du module Check-Up Prêts. Accepte les prêts ACTIF issus de Prets_Master ET de Prets_Update. """ apply_checkup_loans_styles() st.markdown('
', unsafe_allow_html=True) st.header("MISE À JOUR DE PRÊT") st.markdown("*Modification des conditions d'un prêt avant tout remboursement*") st.markdown('
', unsafe_allow_html=True) # ========================================================================= # ÉTAPE 1 — CHARGEMENT DES DONNÉES # ========================================================================= try: sh = client.open(sheet_name) df_clients = get_cached_data(client, sheet_name, "Clients_KYC") df_prets_master = get_cached_data(client, sheet_name, "Prets_Master") df_remboursements = get_cached_data(client, sheet_name, "Remboursements") try: df_prets_update = get_cached_data(client, sheet_name, "Prets_Update") except Exception: df_prets_update = pd.DataFrame() try: df_garants = get_cached_data(client, sheet_name, "Garants_KYC") except Exception: df_garants = pd.DataFrame() except Exception as e: st.error(f"[ECHEC] Erreur de connexion à Google Sheets : {e}") st.markdown("
", unsafe_allow_html=True) return if df_clients.empty: st.error("[STOP] Aucun client trouvé dans la base KYC.") st.markdown("", unsafe_allow_html=True) return if df_prets_master.empty and df_prets_update.empty: st.warning("Aucun prêt n'a encore été octroyé.") st.info("Utilisez le module *Moteur Financier* pour créer un premier prêt.") st.markdown("", unsafe_allow_html=True) return # ========================================================================= # ÉTAPE 2 — SÉLECTION DU CLIENT # ========================================================================= st.subheader("Étape 1 : Sélectionner le client") df_clients["search_label"] = ( df_clients["ID_Client"].astype(str) + " — " + df_clients["Nom_Complet"].astype(str) ) selected_client_label = st.selectbox( "Rechercher un client", [""] + df_clients["search_label"].tolist(), help="Sélectionnez le client dont vous souhaitez modifier un prêt.", ) if not selected_client_label: st.info("Sélectionnez un client pour commencer.") st.markdown("", unsafe_allow_html=True) return client_info = df_clients[df_clients["search_label"] == selected_client_label].iloc[0] client_id = client_info["ID_Client"] # (Fix #3) Récupération de pers_charge depuis le profil KYC pour aligner # le calcul de risque de Check-Up sur celui de loans_engine.py. try: pers_charge = int(client_info.get("Pers_Charge", 0)) except (ValueError, TypeError): pers_charge = 0 st.success(f"Client sélectionné : **{client_info['Nom_Complet']}** (ID : {client_id})") st.markdown('
', unsafe_allow_html=True) # ========================================================================= # ÉTAPE 3 — DÉTECTION UNIFIÉE DES PRÊTS ACTIFS (MASTER + UPDATE) # ========================================================================= st.subheader("Étape 2 : Identifier le prêt à modifier") loans_pool = [] # liste de dict : {source, row, display_id, root_id} # — Prêts ACTIF depuis Prets_Master if not df_prets_master.empty: mask_master = ( (df_prets_master["ID_Client"] == client_id) & (df_prets_master["Statut"] == "ACTIF") ) for _, row in df_prets_master[mask_master].iterrows(): root_id = str(row["ID_Pret"]) # Ne pas proposer un prêt Master si une version ACTIF existe déjà # dans Prets_Update pour ce même root_id (éviter le doublon) if _get_latest_active_from_update(df_prets_update, root_id) is None: loans_pool.append( { "source": "MASTER", "row": row, "display_id": root_id, "root_id": root_id, } ) # — Prêts ACTIF depuis Prets_Update (dernière version uniquement par root_id) if not df_prets_update.empty: mask_update = ( (df_prets_update["ID_Client"] == client_id) & (df_prets_update["Statut"] == "ACTIF") ) active_updates = df_prets_update[mask_update].copy() if not active_updates.empty: active_updates["_version_int"] = active_updates["Version"].apply( _safe_version_int ) # Conserver uniquement la version max par root_id idx_max = active_updates.groupby("ID_Pret")["_version_int"].idxmax() for _, row in active_updates.loc[idx_max].iterrows(): root_id = str(row["ID_Pret"]) display_id = str(row["ID_Pret_Updated"]) loans_pool.append( { "source": "UPDATE", "row": row, "display_id": display_id, "root_id": root_id, } ) # Une mise à jour vient d'être validée (documents et récapitulatif affichés) : le cache # est vidé et la liste contient désormais la V2 à la place de l'ancienne version, ce qui # déplacerait la sélection (voire la ferait tomber sur un autre prêt). On garde donc la # sélection sur ce prêt tant que "Nouvelle Mise à Jour" n'a pas été cliqué. if st.session_state.get("loan_updated"): _root_valide = (st.session_state.get("new_loan_data") or {}).get("ID_Pret_Source") _pool_filtre = [e for e in loans_pool if e["root_id"] == _root_valide] if _pool_filtre: loans_pool = _pool_filtre # — Aucun prêt actif trouvé if not loans_pool: st.markdown('
', unsafe_allow_html=True) st.markdown( f""" ### [ATTENTION] Aucun prêt actif trouvé Le client **{client_info['Nom_Complet']}** ne possède aucun prêt avec le statut **ACTIF** (ni dans *Prets_Master*, ni dans *Prets_Update*). **Raisons possibles :** - Tous les prêts ont été remboursés (statut : SOLDE) - Des prêts ont déjà été modifiés (statut : UPDATED) - Aucun prêt n'a encore été octroyé à ce client **Solution** : Utilisez le module *Moteur Financier* pour octroyer un nouveau prêt. """ ) st.markdown("
", unsafe_allow_html=True) st.markdown("", unsafe_allow_html=True) return # — Sélection du prêt dans la pool if len(loans_pool) == 1: selected_entry = loans_pool[0] source_label = ( "Prêt original" if selected_entry["source"] == "MASTER" else f"Version V{selected_entry['row'].get('Version', '?')}" ) st.info( f"**1 prêt actif détecté** — Sélection automatique : " f"**{selected_entry['display_id']}** ({source_label})" ) else: def _make_label(entry): row = entry["row"] montant = int(clean_currency_value(row["Montant_Capital"])) tag = ( "■ Original" if entry["source"] == "MASTER" else f"■ V{row.get('Version', '?')}" ) return ( f"{entry['display_id']} | {tag} | " f"{montant:,} XOF | Échéance : {row.get('Date_Fin', 'N/A')}" ).replace(",", " ") labels = [_make_label(e) for e in loans_pool] selected_label = st.selectbox( "Sélectionnez le prêt à modifier", labels, help=( "■ = prêt original (Prets_Master) • " "■ = version mise à jour (Prets_Update)" ), ) selected_entry = loans_pool[labels.index(selected_label)] # Extraction des variables de contexte selected_loan = selected_entry["row"] loan_source = selected_entry["source"] # "MASTER" ou "UPDATE" root_id = selected_entry["root_id"] # ID original, ex. "P001" display_id = selected_entry["display_id"] # ID affiché, ex. "P001-V2" # ========================================================================= # ÉTAPE 4 — VÉRIFICATION BLOQUANTE : REMBOURSEMENTS (sur ID_Pret racine) # ========================================================================= # Lecture fraîche (hors cache) pour éviter un faux négatif si un # remboursement a été enregistré depuis le dernier chargement en cache. try: _ws_remb_check = sh.worksheet("Remboursements") df_remboursements_fresh = pd.DataFrame( _ws_remb_check.get_all_records(numericise_ignore=['all']) ) except Exception: df_remboursements_fresh = df_remboursements # fallback sur le cache if not df_remboursements_fresh.empty: df_payments = df_remboursements_fresh[ df_remboursements_fresh["ID_Pret"].astype(str) == root_id ] if not df_payments.empty: st.markdown('
', unsafe_allow_html=True) st.markdown("### [REFUS] MODIFICATION IMPOSSIBLE") st.markdown( f""" Le prêt **{display_id}** (racine : `{root_id}`) a déjà fait l'objet de **{len(df_payments)} remboursement(s)**. **Remboursements enregistrés :** """ ) for _, pmt in df_payments.head(5).iterrows(): montant_pmt = int(clean_currency_value(pmt.get("Montant_Verse", 0))) st.markdown( f"- **{pmt.get('Date_Paiement', 'N/A')}** : " f"{montant_pmt:,} XOF".replace(",", " ") ) if len(df_payments) > 5: st.markdown(f"- *... et {len(df_payments) - 5} autre(s) paiement(s)*") st.markdown( """ --- **Règle de sécurité** : La modification n'est autorisée qu'**avant le premier remboursement**. **Solutions alternatives :** 1. Créer un nouveau prêt avec les nouvelles conditions 2. Négocier un rééchelonnement *(module à venir)* 3. Contacter le service juridique pour un avenant contractuel """ ) st.markdown("
", unsafe_allow_html=True) st.markdown("", unsafe_allow_html=True) return # Accès autorisé source_badge_html = ( "Prêt Original" if loan_source == "MASTER" else f"Version V{selected_loan.get('Version', '?')}" ) st.markdown( f"Aucun remboursement détecté — **Modification autorisée** " f"pour **{display_id}** {source_badge_html}", unsafe_allow_html=True, ) st.markdown('
', unsafe_allow_html=True) # ========================================================================= # ÉTAPE 5 — INFORMATIONS ACTUELLES DU PRÊT # ========================================================================= st.subheader("Étape 3 : Informations actuelles du prêt") st.markdown('
', unsafe_allow_html=True) st.markdown(f"### Prêt {display_id}") # (Fix #5) Utilisation de parse_fr_num à la place de clean_currency_value / # clean_taux_value / clean_percentage_value pour sécuriser le parsing des # nombres au format français (virgule décimale) venant de Google Sheets. montant_capital_actuel = parse_fr_num(selected_loan["Montant_Capital"]) taux_hebdo_actuel = parse_fr_num(selected_loan["Taux_Hebdo"]) taux_endettement_actuel = parse_fr_num(selected_loan.get("Taux_Endettement", 0)) duree_semaines_actuel = parse_fr_num(selected_loan["Duree_Semaines"]) montant_total_actuel = parse_fr_num(selected_loan["Montant_Total"]) cout_credit_actuel = parse_fr_num(selected_loan["Cout_Credit"]) col1, col2, col3 = st.columns(3) with col1: st.metric("Montant Capital", f"{int(montant_capital_actuel):,} XOF".replace(",", " ")) st.metric("Type de prêt", selected_loan["Type_Pret"]) st.metric("Date déblocage", selected_loan["Date_Deblocage"]) with col2: st.metric("Taux hebdomadaire", f"{taux_hebdo_actuel} %") st.metric( "Taux d'endettement", f"{taux_endettement_actuel:.2f} %" if taux_endettement_actuel > 0 else "N/A", ) st.metric("Durée", f"{int(duree_semaines_actuel)} semaines") st.metric("Moyen de transfert", selected_loan.get("Moyen_Transfert", "N/A")) with col3: st.metric("Montant Total", f"{int(montant_total_actuel):,} XOF".replace(",", " ")) st.metric("Coût du crédit", f"{int(cout_credit_actuel):,} XOF".replace(",", " ")) st.metric("Date de fin", selected_loan["Date_Fin"]) st.markdown("---") st.markdown(f"**Client :** {selected_loan['Nom_Complet']}") st.markdown(f"**Motif :** {selected_loan.get('Motif', 'Non renseigné')}") # Garant garant_id_actuel = str(selected_loan.get("ID_Garant", "")).strip() if garant_id_actuel and not df_garants.empty: g_rows = df_garants[df_garants["ID_Garant"].astype(str) == garant_id_actuel] if not g_rows.empty: st.markdown( f"**Garant :** {g_rows.iloc[0]['Nom_Complet']} (ID : {garant_id_actuel})" ) # Version (uniquement si source UPDATE) if loan_source == "UPDATE": st.markdown( f"**Version courante :** " f"V{selected_loan.get('Version', '?')}", unsafe_allow_html=True, ) st.markdown( f"" f"Statut : {selected_loan['Statut']}", unsafe_allow_html=True, ) st.markdown("
", unsafe_allow_html=True) st.markdown('
', unsafe_allow_html=True) # ========================================================================= # ÉTAPE 6 — FORMULAIRE DE MODIFICATION # ========================================================================= st.subheader("Étape 4 : Nouvelles conditions du prêt") st.info("Modifiez les paramètres ci-dessous. Les montants sont recalculés automatiquement.") # Initialisation des dates personnalisées en session state. # On lie les dates au prêt sélectionné (display_id) : si l'utilisateur # change de prêt sans réinitialiser, on recharge les dates du nouveau prêt. if st.session_state.get("dates_perso_update_for") != display_id: st.session_state.pop("dates_perso_update", None) st.session_state.pop("dates_perso_update_for", None) if "dates_perso_update" not in st.session_state: dates_str = str(selected_loan.get("Dates_Versements", "")).strip() if dates_str: try: dates_list = [ datetime.strptime(d.strip(), "%d/%m/%Y").date() for d in dates_str.split(";") if d.strip() ] st.session_state.dates_perso_update = dates_list except Exception: st.session_state.dates_perso_update = [date.today() + timedelta(weeks=2)] else: st.session_state.dates_perso_update = [date.today() + timedelta(weeks=2)] st.session_state.dates_perso_update_for = display_id # — Ligne 1 : Motif / Type / Moyen ───────────────────────────────────── col_motif, col_type, col_moyen = st.columns(3) with col_motif: motif_actuel = selected_loan.get("Motif", "Autre") default_motif_idx = MOTIFS_PRET.index(motif_actuel) if motif_actuel in MOTIFS_PRET else 0 nouveau_motif = st.selectbox("Motif du prêt", MOTIFS_PRET, index=default_motif_idx) with col_type: current_type_display = TYPE_REVERSE_MAP.get(selected_loan["Type_Pret"], "In Fine") nouveau_type = st.selectbox( "Type de remboursement", TYPE_OPTIONS, index=TYPE_OPTIONS.index(current_type_display), ) nouveau_type_code = TYPE_CODE_MAP[nouveau_type] with col_moyen: moyens_list = ["Wave", "Orange Money", "Virement"] moyen_actuel = selected_loan.get("Moyen_Transfert", "Wave") default_moyen_idx = moyens_list.index(moyen_actuel) if moyen_actuel in moyens_list else 0 nouveau_moyen = st.selectbox("Moyen de transfert", moyens_list, index=default_moyen_idx) # — Ligne 2 : Paramètres financiers ──────────────────────────────────── col1, col2, col3 = st.columns(3) nouveau_montant = col1.number_input( "Montant Capital (XOF)", min_value=10_000, value=int(montant_capital_actuel), step=10_000, ) nouveau_taux = col2.number_input( "Taux Hebdo (%)", min_value=0.1, value=float(taux_hebdo_actuel), step=0.1, ) # ========================================================================= # CALCULS AUTOMATIQUES SELON LE TYPE # ========================================================================= nouveau_montant_versement = 0.0 nouveau_montant_total = 0.0 nouveau_cout_credit = 0.0 nouveau_nb_versements = 0 nouvelle_duree_semaines = 0.0 nouvelles_dates_versements = [] try: nouvelle_date_debut = datetime.strptime( str(selected_loan["Date_Deblocage"]), "%d/%m/%Y" ).date() except Exception: nouvelle_date_debut = date.today() nouvelle_date_fin = nouvelle_date_debut # ── IN FINE ────────────────────────────────────────────────────────────── if nouveau_type_code == "IN_FINE": default_dur = ( int(duree_semaines_actuel) if selected_loan["Type_Pret"] == "IN_FINE" else 8 ) # (Fix #4) Borne dynamiquement la valeur par défaut pour éviter le # crash StreamlitValueAboveMaxError si l'ancien prêt dépasse max_value. default_dur = min(104, max(1, default_dur)) nouvelle_duree_semaines = col3.number_input( "Durée (semaines)", min_value=1, max_value=104, value=default_dur ) nouvelle_date_fin = nouvelle_date_debut + timedelta(weeks=int(nouvelle_duree_semaines)) nouveau_montant_total = nouveau_montant * (1 + (nouveau_taux / 100) * nouvelle_duree_semaines) nouveau_cout_credit = nouveau_montant_total - nouveau_montant nouveau_montant_versement = nouveau_montant_total nouveau_nb_versements = 1 st.markdown("### Simulation des nouveaux montants") r1, r2, r3 = st.columns(3) r1.metric("Versement unique", f"{int(nouveau_montant_versement):,} XOF".replace(",", " ")) r2.metric("Coût du crédit", f"{int(nouveau_cout_credit):,} XOF".replace(",", " ")) r3.metric("Montant Total", f"{int(nouveau_montant_total):,} XOF".replace(",", " "), delta=f"+{int(nouveau_cout_credit):,}".replace(",", " ")) # ── MENSUEL — INTÉRÊTS ─────────────────────────────────────────────────── elif nouveau_type_code == "MENSUEL_INTERETS": default_dur = ( round(duree_semaines_actuel / 4.33) if selected_loan["Type_Pret"] == "MENSUEL_INTERETS" else 12 ) # (Fix #4) Borne dynamiquement la valeur par défaut. default_dur = min(60, max(1, default_dur)) nouvelle_duree_mois = col3.number_input("Durée (mois)", min_value=1, max_value=60, value=default_dur) nouvelle_date_fin = nouvelle_date_debut + timedelta(days=int(nouvelle_duree_mois * 30)) nouvelle_duree_semaines = nouvelle_duree_mois * 4.33 taux_mensuel = (nouveau_taux / 100) * 4.33 interet_mensuel = nouveau_montant * taux_mensuel montant_final_mois = nouveau_montant + interet_mensuel nouveau_montant_versement = interet_mensuel nouveau_montant_total = (interet_mensuel * nouvelle_duree_mois) + nouveau_montant nouveau_cout_credit = nouveau_montant_total - nouveau_montant nouveau_nb_versements = int(nouvelle_duree_mois) st.markdown("### Simulation des nouveaux montants") r1, r2 = st.columns(2) r1.metric("Intérêts mensuels", f"{int(interet_mensuel):,} XOF".replace(",", " ")) r2.metric("Dernier versement", f"{int(montant_final_mois):,} XOF".replace(",", " ")) r3, r4 = st.columns(2) r3.metric("Coût du crédit", f"{int(nouveau_cout_credit):,} XOF".replace(",", " ")) r4.metric("Montant Total", f"{int(nouveau_montant_total):,} XOF".replace(",", " "), delta=f"+{int(nouveau_cout_credit):,}".replace(",", " ")) # ── MENSUEL — CONSTANT ─────────────────────────────────────────────────── elif nouveau_type_code == "MENSUEL_CONSTANT": default_dur = ( int(duree_semaines_actuel / 4.33) if selected_loan["Type_Pret"] == "MENSUEL_CONSTANT" else 12 ) # (Fix #4) Borne dynamiquement la valeur par défaut. default_dur = min(60, max(1, default_dur)) nouvelle_duree_mois = col3.number_input("Durée (mois)", min_value=1, max_value=60, value=default_dur) nouvelle_date_fin = nouvelle_date_debut + timedelta(days=int(nouvelle_duree_mois * 30)) nouvelle_duree_semaines = nouvelle_duree_mois * 4.33 taux_mensuel = (nouveau_taux / 100) * 4.33 mensualite = ( (nouveau_montant * taux_mensuel) / (1 - (1 + taux_mensuel) ** (-nouvelle_duree_mois)) if taux_mensuel > 0 else nouveau_montant / nouvelle_duree_mois ) nouveau_montant_versement = mensualite nouveau_montant_total = mensualite * nouvelle_duree_mois nouveau_cout_credit = nouveau_montant_total - nouveau_montant nouveau_nb_versements = int(nouvelle_duree_mois) st.markdown("### Simulation des nouveaux montants") r1, r2, r3 = st.columns(3) r1.metric("Mensualité constante", f"{int(mensualite):,} XOF".replace(",", " ")) r2.metric("Coût du crédit", f"{int(nouveau_cout_credit):,} XOF".replace(",", " ")) r3.metric("Montant Total", f"{int(nouveau_montant_total):,} XOF".replace(",", " "), delta=f"+{int(nouveau_cout_credit):,}".replace(",", " ")) # ── HEBDOMADAIRE ───────────────────────────────────────────────────────── elif nouveau_type_code == "HEBDOMADAIRE": default_dur = ( int(duree_semaines_actuel) if selected_loan["Type_Pret"] == "HEBDOMADAIRE" else 12 ) # (Fix #4) Borne dynamiquement la valeur par défaut. default_dur = min(104, max(1, default_dur)) nouvelle_duree_semaines = col3.number_input( "Durée (semaines)", min_value=1, max_value=104, value=default_dur ) nouvelle_date_fin = nouvelle_date_debut + timedelta(weeks=int(nouvelle_duree_semaines)) r_hebdo = nouveau_taux / 100 hebdo = ( (nouveau_montant * r_hebdo) / (1 - (1 + r_hebdo) ** (-nouvelle_duree_semaines)) if r_hebdo > 0 else nouveau_montant / nouvelle_duree_semaines ) nouveau_montant_versement = hebdo nouveau_montant_total = hebdo * nouvelle_duree_semaines nouveau_cout_credit = nouveau_montant_total - nouveau_montant nouveau_nb_versements = int(nouvelle_duree_semaines) st.markdown("### Simulation des nouveaux montants") r1, r2, r3 = st.columns(3) r1.metric("Versement Hebdo", f"{int(hebdo):,} XOF".replace(",", " ")) r2.metric("Coût du crédit", f"{int(nouveau_cout_credit):,} XOF".replace(",", " ")) r3.metric("Montant Total", f"{int(nouveau_montant_total):,} XOF".replace(",", " "), delta=f"+{int(nouveau_cout_credit):,}".replace(",", " ")) # ── PERSONNALISÉ ───────────────────────────────────────────────────────── else: st.info("Configurez les dates de versement ci-dessous.") st.markdown("**Dates de versement :**") col_add, _ = st.columns([1, 4]) if col_add.button("+ Ajouter une date", key="add_date_update"): last = st.session_state.dates_perso_update[-1] st.session_state.dates_perso_update.append(last + timedelta(weeks=1)) st.rerun() today = date.today() nouvelles_dates_versements = [] for idx, dt in enumerate(st.session_state.dates_perso_update): safe_dt = max(dt, today) col_d, col_x = st.columns([4, 1]) if dt < today and idx == 0: st.warning( f"[ATTENTION] Date originale ({dt.strftime('%d/%m/%Y')}) passée — " f"ajustée à aujourd'hui ({today.strftime('%d/%m/%Y')})." ) new_date = col_d.date_input( f"Échéance {idx + 1}", value=safe_dt, key=f"d_update_{idx}", min_value=today, ) nouvelles_dates_versements.append(new_date) if ( col_x.button("X", key=f"del_update_{idx}") and len(st.session_state.dates_perso_update) > 1 ): st.session_state.dates_perso_update.pop(idx) st.rerun() st.session_state.dates_perso_update = nouvelles_dates_versements if nouvelles_dates_versements: nouvelles_dates_versements.sort() nouvelle_date_fin = nouvelles_dates_versements[-1] delta_days = (nouvelle_date_fin - nouvelle_date_debut).days nouvelle_duree_semaines = max(1.0, delta_days / 7) nouveau_montant_total = nouveau_montant * ( 1 + (nouveau_taux / 100) * nouvelle_duree_semaines ) nouveau_cout_credit = nouveau_montant_total - nouveau_montant nouveau_nb_versements = len(nouvelles_dates_versements) nouveau_montant_versement = nouveau_montant_total / nouveau_nb_versements st.markdown("### Simulation des nouveaux montants") r1, r2 = st.columns(2) r1.metric("Moyenne / Versement", f"{int(nouveau_montant_versement):,} XOF".replace(",", " ")) r2.metric("Durée estimée", f"{int(nouvelle_duree_semaines)} sem") r3, r4 = st.columns(2) r3.metric("Coût du crédit", f"{int(nouveau_cout_credit):,} XOF".replace(",", " ")) r4.metric("Montant Total", f"{int(nouveau_montant_total):,} XOF".replace(",", " "), delta=f"+{int(nouveau_cout_credit):,}".replace(",", " ")) # ========================================================================= # TAUX D'ENDETTEMENT # ========================================================================= # (Fix #3) Ajout de pers_charge pour aligner le calcul de risque sur celui # de loans_engine.py (base ajustée si personnes à charge). nouveau_taux_endettement = calculer_taux_endettement( nouveau_type_code, nouveau_montant_versement, nouveau_nb_versements, nouvelle_duree_semaines, client_info["Revenus_Mensuels"], pers_charge=pers_charge, ) if nouveau_taux_endettement > 0: st.info(f"**Nouveau taux d'endettement calculé** : {nouveau_taux_endettement:.2f} %") if nouveau_taux_endettement > 33: st.warning( f"[ATTENTION] Le taux d'endettement ({nouveau_taux_endettement:.2f} %) " f"dépasse le seuil recommandé de 33 %." ) # ========================================================================= # COMPARAISON AVANT / APRÈS # ========================================================================= st.markdown('
', unsafe_allow_html=True) st.subheader("Comparaison Avant / Après") diff_montant_total = nouveau_montant_total - montant_total_actuel diff_cout_credit = nouveau_cout_credit - cout_credit_actuel diff_duree = nouvelle_duree_semaines - duree_semaines_actuel try: diff_jours_fin = ( nouvelle_date_fin - datetime.strptime(str(selected_loan["Date_Fin"]), "%d/%m/%Y").date() ).days diff_jours_str = f"{diff_jours_fin:+} jours" if diff_jours_fin != 0 else "=" except Exception: diff_jours_str = "N/A" comparison_data = { "Paramètre": [ "Type de prêt", "Montant Capital", "Taux hebdomadaire", "Taux endettement", "Durée", "Montant Total", "Coût du crédit", "Date de fin", ], "AVANT": [ selected_loan["Type_Pret"], f"{int(montant_capital_actuel):,} XOF".replace(",", " "), f"{taux_hebdo_actuel} %", f"{taux_endettement_actuel:.2f} %" if taux_endettement_actuel > 0 else "N/A", f"{int(duree_semaines_actuel)} semaines", f"{int(montant_total_actuel):,} XOF".replace(",", " "), f"{int(cout_credit_actuel):,} XOF".replace(",", " "), selected_loan["Date_Fin"], ], "APRÈS": [ nouveau_type_code, f"{int(nouveau_montant):,} XOF".replace(",", " "), f"{nouveau_taux} %", f"{nouveau_taux_endettement:.2f} %", f"{int(nouvelle_duree_semaines)} semaines", f"{int(nouveau_montant_total):,} XOF".replace(",", " "), f"{int(nouveau_cout_credit):,} XOF".replace(",", " "), nouvelle_date_fin.strftime("%d/%m/%Y"), ], "DIFFÉRENCE": [ "Modifié" if nouveau_type_code != selected_loan["Type_Pret"] else "=", f"{int(nouveau_montant - montant_capital_actuel):+,} XOF".replace(",", " ") if nouveau_montant != montant_capital_actuel else "=", f"{nouveau_taux - taux_hebdo_actuel:+.1f} %" if nouveau_taux != taux_hebdo_actuel else "=", f"{nouveau_taux_endettement - taux_endettement_actuel:+.1f} %" if taux_endettement_actuel > 0 else f"{nouveau_taux_endettement:.1f} %", f"{int(diff_duree):+} sem" if diff_duree != 0 else "=", f"{int(diff_montant_total):+,} XOF".replace(",", " ") if diff_montant_total != 0 else "=", f"{int(diff_cout_credit):+,} XOF".replace(",", " ") if diff_cout_credit != 0 else "=", diff_jours_str, ], } st.markdown('
', unsafe_allow_html=True) st.dataframe(pd.DataFrame(comparison_data), hide_index=True, use_container_width=True) st.markdown("
", unsafe_allow_html=True) # ========================================================================= # WARNINGS # ========================================================================= warnings = [] if cout_credit_actuel > 0 and diff_cout_credit > cout_credit_actuel * 0.15: pct = diff_cout_credit / cout_credit_actuel * 100 warnings.append( f"[ATTENTION] Le coût du crédit augmente de **{int(diff_cout_credit):,} XOF** " f"(+{pct:.1f} %)".replace(",", " ") ) if diff_duree > 8: warnings.append(f"[ATTENTION] La durée est prolongée de **{int(diff_duree)} semaines**.") if (nouvelle_date_fin - nouvelle_date_debut).days > 180: warnings.append( f"[ATTENTION] La nouvelle échéance ({nouvelle_date_fin.strftime('%d/%m/%Y')}) " f"dépasse **6 mois**." ) if nouveau_montant > montant_capital_actuel: diff_cap = int(nouveau_montant - montant_capital_actuel) warnings.append( f"[INFO] Le capital augmente de **{diff_cap:,} XOF**.".replace(",", " ") ) if warnings: st.markdown('
', unsafe_allow_html=True) st.markdown("### [ATTENTION] Points d'attention") for w in warnings: st.markdown(f"- {w}") st.markdown("
", unsafe_allow_html=True) # ========================================================================= # ANALYSE DE SOLVABILITÉ # ========================================================================= st.markdown('
', unsafe_allow_html=True) st.subheader("Nouvelle analyse de solvabilité") # (Fix #3) Ajout de pers_charge pour aligner le calcul de risque sur celui # de loans_engine.py. analyse = analyser_capacite( nouveau_type_code, nouveau_montant, nouveau_taux, nouvelle_duree_semaines, nouveau_montant_versement, nouveau_nb_versements, client_info["Revenus_Mensuels"], client_info.get("Charges_Estimees", 0), nouveau_montant_total, pers_charge=pers_charge, ) st.markdown( f"### Statut : " f"{analyse['statut']}", unsafe_allow_html=True, ) st.info(analyse["message"]) with st.expander("Détails financiers"): st.markdown(analyse["details"]) # ========================================================================= # TABLEAU D'AMORTISSEMENT # ========================================================================= st.markdown('
', unsafe_allow_html=True) st.subheader("Nouveau tableau d'échéances") df_amort_nouveau = generer_tableau_amortissement( nouveau_type_code, nouveau_montant, nouveau_taux, nouvelle_duree_semaines, nouveau_montant_versement, nouveau_nb_versements, nouvelle_date_debut, dates_versements=( nouvelles_dates_versements if nouveau_type_code == "PERSONNALISE" else None ), ) if not df_amort_nouveau.empty: df_amort_nouveau.insert(0, "Type", nouveau_type) st.dataframe(df_amort_nouveau, hide_index=True, use_container_width=True) # ========================================================================= # VALIDATION ET ENREGISTREMENT # ========================================================================= st.markdown('
', unsafe_allow_html=True) st.subheader("Validation de la mise à jour") st.info( "[VERROUILLE] **Attention** : Cette action archivera la version courante " "(statut → **UPDATED**) et créera une nouvelle version (statut → **ACTIF**)." ) with st.form("form_update_loan"): st.markdown("### Confirmation") col_c1, col_c2 = st.columns(2) with col_c1: st.markdown(f"**Prêt à archiver :** {display_id} *(source : {loan_source})*") st.markdown(f"**Client :** {client_info['Nom_Complet']}") st.markdown( f"**Nouveau montant total :** " f"{int(nouveau_montant_total):,} XOF".replace(",", " ") ) with col_c2: st.markdown(f"**Nouveau type :** {nouveau_type_code}") st.markdown(f"**Nouvelle durée :** {int(nouvelle_duree_semaines)} semaines") st.markdown(f"**Nouvelle échéance :** {nouvelle_date_fin.strftime('%d/%m/%Y')}") commentaire_modification = st.text_area( "Commentaire de modification (optionnel)", placeholder=( "Ex : Report de 3 semaines à la demande du client, " "augmentation du capital pour extension d'activité…" ), ) submit_update = rendre_form_submit_protege( "VALIDER LA MISE À JOUR", cle=f"renego_{root_id}", use_container_width=True ) # ========================================================================= # TRAITEMENT À LA VALIDATION # ========================================================================= if submit_update: try: now_str = datetime.now().strftime("%d-%m-%Y %H:%M:%S") # (Fix #2) Conservation de l'offre d'origine (Consumer / Découvert / # Private) pour la ré-injecter dans la nouvelle version. offre_actuelle = selected_loan.get("Offre", "Standard") # ------------------------------------------------------------------ # A — Archivage de l'ancienne version # ------------------------------------------------------------------ with st.spinner("Archivage de l'ancienne version en cours…"): if loan_source == "MASTER": # Prets_Master → ligne root_id → statut UPDATED ws_master = sh.worksheet("Prets_Master") all_values = ws_master.get_all_values() header = all_values[0] col_statut_idx = header.index("Statut") + 1 col_id_pret_idx = header.index("ID_Pret") + 1 col_date_upd_idx = ( header.index("Date_Update") + 1 if "Date_Update" in header else None ) row_index = _find_row_index_in_sheet( all_values, col_id_pret_idx, root_id ) if row_index is None: liberer(f"renego_{root_id}") st.error(f"[ECHEC] Prêt {root_id} introuvable dans Prets_Master.") st.stop() ws_master.update_cell(row_index, col_statut_idx, "UPDATED") if col_date_upd_idx: ws_master.update_cell(row_index, col_date_upd_idx, now_str) else: # Prets_Update → ligne display_id (ID_Pret_Updated) → statut UPDATED ws_update = sh.worksheet("Prets_Update") all_values = ws_update.get_all_values() header = all_values[0] col_statut_idx = header.index("Statut") + 1 col_id_updated_idx = header.index("ID_Pret_Updated") + 1 col_date_modif_idx = ( header.index("Date_Modification") + 1 if "Date_Modification" in header else None ) row_index = _find_row_index_in_sheet( all_values, col_id_updated_idx, display_id ) if row_index is None: liberer(f"renego_{root_id}") st.error( f"[ECHEC] Ligne {display_id} introuvable dans Prets_Update." ) st.stop() ws_update.update_cell(row_index, col_statut_idx, "UPDATED") if col_date_modif_idx: ws_update.update_cell(row_index, col_date_modif_idx, now_str) time.sleep(1) st.success(f"{display_id} → statut **UPDATED**") # ------------------------------------------------------------------ # B — Création de la nouvelle version dans Prets_Update # ------------------------------------------------------------------ with st.spinner("Création de la nouvelle version…"): # Récupération (ou création) de la feuille Prets_Update try: ws_pu = sh.worksheet("Prets_Update") except Exception: ws_pu = sh.add_worksheet(title="Prets_Update", rows=1000, cols=30) ws_pu.append_row(PRETS_UPDATE_HEADERS) time.sleep(1) # Lecture fraîche pour calculer la prochaine version # (Fix numérisation gspread, par cohérence avec get_cached_data — # évite toute conversion implicite erronée si une colonne # décimale française venait à être utilisée ici plus tard) df_pu_fresh = pd.DataFrame(ws_pu.get_all_records(numericise_ignore=['all'])) next_version = _get_next_version(df_pu_fresh, root_id) new_id_updated = f"{root_id}-V{next_version}" # Guard anti-doublon : si new_id_updated existe déjà (crash puis retry), # on ne réinsère pas une deuxième ligne — l'étape A est déjà faite. if "ID_Pret_Updated" in df_pu_fresh.columns: existing_ids = df_pu_fresh["ID_Pret_Updated"].astype(str).tolist() if new_id_updated in existing_ids: liberer(f"renego_{root_id}") st.warning( f"[ATTENTION] La version **{new_id_updated}** existe déjà dans Prets_Update. " "Aucune duplication effectuée." ) st.stop() # Dates versements (format string) dates_versements_str = ( ";".join(d.strftime("%d/%m/%Y") for d in nouvelles_dates_versements) if nouvelles_dates_versements else "" ) # (Fix décalage de colonnes) La ligne est construite comme un # dictionnaire {nom_colonne: valeur}, puis réordonnée selon # l'en-tête RÉEL de la feuille (après migration éventuelle de # la colonne "Offre" en fin de ligne). Cela évite tout # décalage si l'en-tête réel diffère de PRETS_UPDATE_HEADERS # (ex : feuille créée avant l'ajout de la colonne "Offre"). row_dict = { "ID_Pret_Updated": new_id_updated, "ID_Pret": root_id, "Version": next_version, "Date_Modification": now_str, "ID_Client": client_id, "Nom_Complet": client_info["Nom_Complet"], "Type_Pret": nouveau_type_code, "Motif": nouveau_motif, "Montant_Capital": nouveau_montant, "Taux_Hebdo": nouveau_taux, "Taux_Endettement": round(nouveau_taux_endettement, 2), "Duree_Semaines": nouvelle_duree_semaines, "Montant_Versement": round(nouveau_montant_versement), "Montant_Total": round(nouveau_montant_total), "Cout_Credit": round(nouveau_cout_credit), "Nb_Versements": nouveau_nb_versements, "Dates_Versements": dates_versements_str, "Date_Deblocage": selected_loan["Date_Deblocage"], "Date_Fin": nouvelle_date_fin.strftime("%d/%m/%Y"), "Moyen_Transfert": nouveau_moyen, "Statut": sg.STATUT_PRET_EN_ATTENTE, # ACTIF seulement apres signature "ID_Garant": garant_id_actuel, "Date_Creation": now_str, "Commentaire_Modification": commentaire_modification, "Offre": offre_actuelle, # Fix #2 } header_actuel = _ensure_offre_column(ws_pu) new_row = [row_dict.get(col, "") for col in header_actuel] ws_pu.append_row(_sanitize_row(new_row)) time.sleep(1) st.success( f"Nouvelle version créée : **{new_id_updated}** " f"(V{next_version}) → statut **EN_ATTENTE_SIGNATURE**" ) # ------------------------------------------------------------------ # C — Session state # ------------------------------------------------------------------ garant_data = None if garant_id_actuel and not df_garants.empty: g_rows = df_garants[df_garants["ID_Garant"].astype(str) == garant_id_actuel] if not g_rows.empty: garant_data = g_rows.iloc[0].to_dict() st.session_state.old_loan_display_id = display_id st.session_state.old_loan_montant_total = montant_total_actuel st.session_state.loan_updated = True st.session_state.new_loan_id = new_id_updated st.session_state.new_loan_data = { "ID_Pret": new_id_updated, "ID_Pret_Source": root_id, "Version": next_version, "Offre": offre_actuelle, "Montant_Capital": nouveau_montant, "Montant_Total": round(nouveau_montant_total), "Taux_Hebdo": nouveau_taux, "Duree_Semaines": nouvelle_duree_semaines, "Montant_Versement": round(nouveau_montant_versement), "Cout_Credit": round(nouveau_cout_credit), "Nb_Versements": nouveau_nb_versements, "Motif": nouveau_motif, "Date_Deblocage": selected_loan["Date_Deblocage"], "Date_Fin": nouvelle_date_fin.strftime("%d/%m/%Y"), "Type_Pret": nouveau_type_code, "Mention_Update": ( f"Mise à jour du contrat n° {root_id} (Version {next_version})" ), } st.session_state.new_client_data = client_info.to_dict() st.session_state.new_garant_data = garant_data st.session_state.new_df_amort = df_amort_nouveau.copy() # Signature electronique : la nouvelle version n'est ACTIVE qu'une fois signee. snapshot_sig = sg.construire_snapshot( {**st.session_state.new_loan_data, "ID_Client": client_id, "ID_Garant": garant_id_actuel}, st.session_state.new_client_data, garant_data, df_amort_nouveau) st.session_state.new_signature_snapshot = snapshot_sig try: st.session_state.new_signature_resultats = svc.lancer_signature_pret( sh, id_pret=new_id_updated, source=svc.SOURCE_UPDATE, snapshot=snapshot_sig) except Exception as exc_sig: from core.errors import log_swallowed log_swallowed(f"Lancement de la signature de {new_id_updated} echoue", exc_sig) st.session_state.new_signature_resultats = [ {"role": sg.ROLE_EMPRUNTEUR, "ok": False, "detail": f"Erreur technique : {exc_sig}", "id_signature": None}] st.cache_data.clear() marquer_fait(f"renego_{root_id}") st.success("Mise à jour effectuée avec succès !") except Exception as e: liberer(f"renego_{root_id}") st.error(f"[ECHEC] Erreur lors de la mise à jour : {e}") st.exception(e) st.markdown("", unsafe_allow_html=True) return # ========================================================================= # GÉNÉRATION DES DOCUMENTS PDF & ENVOI MAIL # ========================================================================= if st.session_state.get("loan_updated", False): st.markdown('
', unsafe_allow_html=True) st.markdown(f"### Documents du prêt **{st.session_state.new_loan_id}**") loan_data_pdf = st.session_state.new_loan_data client_data_pdf = st.session_state.new_client_data garant_data_pdf = st.session_state.new_garant_data df_amort_pdf = st.session_state.new_df_amort # Génération unique des PDF en mémoire if st.session_state.get('pdf_contrat_upd_for') != st.session_state.new_loan_id: for _k in ['pdf_contrat_upd', 'pdf_dette_upd', 'pdf_caution_upd', 'pdf_contrat_upd_for']: st.session_state.pop(_k, None) if 'pdf_contrat_upd' not in st.session_state: with st.spinner("Génération des documents PDF..."): st.session_state.pdf_contrat_upd = generer_contrat_pret(loan_data_pdf, client_data_pdf, df_amort_pdf) st.session_state.pdf_dette_upd = generer_reconnaissance_dette(loan_data_pdf, client_data_pdf) if garant_data_pdf: st.session_state.pdf_caution_upd = generer_contrat_caution(loan_data_pdf, garant_data_pdf) else: st.session_state.pdf_caution_upd = None st.session_state.pdf_contrat_upd_for = st.session_state.new_loan_id col_pdf1, col_pdf2, col_pdf3 = st.columns(3) with col_pdf1: st.download_button( "Contrat de Prêt", st.session_state.pdf_contrat_upd, f"Contrat_{st.session_state.new_loan_id}.pdf", "application/pdf", use_container_width=True, ) with col_pdf2: st.download_button( "Reconnaissance Dette", st.session_state.pdf_dette_upd, f"Dette_{st.session_state.new_loan_id}.pdf", "application/pdf", use_container_width=True, ) with col_pdf3: if st.session_state.pdf_caution_upd: st.download_button( "Contrat Caution", st.session_state.pdf_caution_upd, f"Caution_{st.session_state.new_loan_id}.pdf", "application/pdf", use_container_width=True, ) else: st.info("Pas de garant") st.divider() # ── Signature electronique ──────────────────────────────────────── st.markdown("#### Signature électronique") resultats_sig = st.session_state.get("new_signature_resultats") or [] if not resultats_sig: st.warning("Aucune demande de signature lancée pour cette version.") for r in resultats_sig: if r["ok"]: st.success(f"{r['role'].title()} : lien de signature envoyé par email.") else: st.error(f"{r['role'].title()} : {r['detail']}") st.caption("La nouvelle version reste EN_ATTENTE_SIGNATURE jusqu'à la signature de toutes les parties. " "Suivi, relances et renvois : module Signatures.") col_sig, col_reset = st.columns([2, 1]) with col_sig: if (not resultats_sig or any(not r["ok"] for r in resultats_sig)) and st.session_state.get("new_signature_snapshot"): if rendre_bouton_protege( "RELANCER L'ENVOI DES LIENS", cle=f"lock_relance_sig_{st.session_state.new_loan_id}", use_container_width=True, ): try: st.session_state.new_signature_resultats = svc.lancer_signature_pret( sh, id_pret=st.session_state.new_loan_id, source=svc.SOURCE_UPDATE, snapshot=st.session_state.new_signature_snapshot) except Exception as exc_sig: st.error(f"Erreur technique : {exc_sig}") liberer(f"lock_relance_sig_{st.session_state.new_loan_id}") st.rerun() with col_reset: if st.button("Nouvelle Mise à Jour", use_container_width=True, type="primary"): liberer(f"renego_{st.session_state.new_loan_data.get('ID_Pret_Source', '')}") for key in [ "loan_updated", "new_loan_id", "new_loan_data", "new_client_data", "new_garant_data", "new_df_amort", "old_loan_display_id", "old_loan_montant_total", "dates_perso_update", "dates_perso_update_for", "pdf_contrat_upd", "pdf_dette_upd", "pdf_caution_upd", "pdf_contrat_upd_for", "new_signature_snapshot", "new_signature_resultats" ]: st.session_state.pop(key, None) st.cache_data.clear() st.rerun() # Récapitulatif st.markdown("---") ancien_id = st.session_state.get("old_loan_display_id", display_id) ancien_montant = st.session_state.get("old_loan_montant_total", montant_total_actuel) with st.expander("Récapitulatif de la mise à jour"): col_r1, col_r2 = st.columns(2) with col_r1: st.markdown("### Ancienne version") st.markdown(f"**ID :** {ancien_id}") st.markdown("**Statut :** UPDATED *(archivé)*") st.markdown( f"**Montant :** {int(ancien_montant):,} XOF".replace(",", " ") ) with col_r2: st.markdown(f"### Nouvelle version (V{loan_data_pdf['Version']})") st.markdown(f"**ID :** {st.session_state.new_loan_id}") st.markdown("**Statut :** ACTIF") st.markdown( f"**Montant :** " f"{int(loan_data_pdf['Montant_Total']):,} XOF".replace(",", " ") ) delta = loan_data_pdf["Montant_Total"] - ancien_montant st.markdown(f"**Différence :** {int(delta):+,} XOF".replace(",", " ")) render_scoped_widget( client, sheet_name, scope_label=f"Mise à jour du prêt {display_id} — {client_info.get('Nom_Complet', client_id)}", context_data={ "client": {"nom": client_info.get('Nom_Complet'), "id": client_id}, "pret": { "id_affiche": display_id, "nouveau_montant_total_xof": round(nouveau_montant_total), "nouveau_taux_endettement_pct": round(nouveau_taux_endettement, 1), "type_pret": nouveau_type_code, "nouvelle_date_fin": str(nouvelle_date_fin), }, "guide_page": ( "Mise à jour / restructuration d'un prêt déjà actif (rééchelonnement, changement " "de type, de montant ou de motif). Crée une nouvelle version du contrat et archive " "l'ancienne (statut UPDATED) — distinct de l'octroi initial (module Init Loan)." ), "definitions_champs": { "id_affiche": "Identifiant du prêt concerné par la mise à jour.", "nouveau_montant_total_xof": "Nouveau montant total à rembourser après la restructuration (nouveau capital + nouveau coût du crédit).", "nouveau_taux_endettement_pct": "Taux d'endettement recalculé avec les nouveaux paramètres du prêt révisé.", "type_pret": "Nouveau mode de remboursement choisi (IN_FINE, MENSUEL_INTERETS, MENSUEL_CONSTANT, HEBDOMADAIRE ou PERSONNALISE).", "nouvelle_date_fin": "Nouvelle date de fin de contrat après la mise à jour.", }, }, key_prefix=f"checkup_{display_id}", ) st.markdown("", unsafe_allow_html=True)