Spaces:
Running
Running
Download src/modules/Check_Up_Loans.py from klydekushy/Vortex-Flux: direct link, hf CLI and curl.
- Browser
- Download file 78.6 kB
-
https://huggingface.co/spaces/klydekushy/Vortex-Flux/resolve/main/src/modules/Check_Up_Loans.py
- Command line
-
hf download hf://spaces/klydekushy/Vortex-Flux/src/modules/Check_Up_Loans.py
-
curl -L -o Check_Up_Loans.py https://huggingface.co/spaces/klydekushy/Vortex-Flux/resolve/main/src/modules/Check_Up_Loans.py
78.6 kB
| # -*- 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""" | |
| <html><body style="font-family: 'Courier New', monospace; background-color: #0a0e12; color: #c9d1d9; padding: 20px;"> | |
| <div style="border: 1px solid #a371f7; padding: 20px; background-color: rgba(10, 14, 18, 0.8);"> | |
| <div style="border-bottom: 1px solid rgba(163, 113, 247, 0.2); padding-bottom: 10px; margin-bottom: 20px;"> | |
| <span style="color: #a371f7; font-weight: bold; font-size: 16px; letter-spacing: 2px;">CONFIRMATION DE MISE A JOUR DE PRET</span> | |
| </div> | |
| <p>CLIENT IDENTIFIE : <strong>{nom_client}</strong></p> | |
| <p>NOUVELLE REFERENCE DOSSIER : <strong>{id_pret_updated}</strong> (Version {version})</p> | |
| <p>ANCIENNE REFERENCE : <del>{id_pret_source}</del></p> | |
| <div style="background-color: rgba(255, 255, 255, 0.05); padding: 15px; margin: 20px 0; border-left: 3px solid #a371f7;"> | |
| <p style="margin: 0 0 10px 0; color: #8b949e; font-size: 12px; text-transform: uppercase;">NOUVEAU CAPITAL DEBLOQUE</p> | |
| <p style="margin: 0; font-size: 24px; color: #a371f7; font-weight: bold;">{montant_capital:,.0f} XOF</p> | |
| <p style="margin: 15px 0 0 0; color: #8b949e; font-size: 12px; text-transform: uppercase;">NOUVEAU MONTANT TOTAL A REMBOURSER</p> | |
| <p style="margin: 0; font-size: 18px; color: #54bd4b; font-weight: bold;">{montant_total:,.0f} XOF</p> | |
| </div> | |
| <p style="font-size: 12px; color: #8b949e; margin-top: 30px;"> | |
| 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. | |
| </p> | |
| </div> | |
| </body></html> | |
| """ | |
| 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 | |
| # ============================================================================ | |
| 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( | |
| """ | |
| <style> | |
| /* ── Wrapper ─────────────────────────────────────────────────────── */ | |
| #checkup-loans-module { | |
| padding: 1rem; margin: 0 auto; max-width: 100%; | |
| } | |
| /* ── Cartes prêt ──────────────────────────────────────────────────── */ | |
| #checkup-loans-module .checkup-loan-card { | |
| background: rgba(22,27,34,.6); | |
| border: 1px solid rgba(48,54,61,.8); | |
| border-left: 4px solid #58a6ff; | |
| border-radius: 8px; padding: 20px; margin: 1rem 0; | |
| box-shadow: 0 2px 4px rgba(0,0,0,.3); | |
| } | |
| #checkup-loans-module .checkup-loan-card h3 { | |
| color: #58a6ff !important; margin-bottom: 1rem; font-size: 1.2rem; | |
| } | |
| /* ── Tableau comparatif ───────────────────────────────────────────── */ | |
| #checkup-loans-module .checkup-comparison-table { | |
| background: rgba(22,27,34,.4); | |
| border: 1px solid rgba(88,166,255,.3); | |
| border-radius: 8px; padding: 16px; margin: 1.5rem 0; | |
| } | |
| /* ── Alerte blocage ───────────────────────────────────────────────── */ | |
| #checkup-loans-module .checkup-blocked-alert { | |
| background: rgba(231,76,60,.15); | |
| border: 2px solid rgba(231,76,60,.6); | |
| border-left: 6px solid #e74c3c; | |
| border-radius: 8px; padding: 20px; margin: 2rem 0; | |
| } | |
| #checkup-loans-module .checkup-blocked-alert h3 { | |
| color: #e74c3c !important; margin-bottom: 1rem; | |
| } | |
| /* ── Avertissements ───────────────────────────────────────────────── */ | |
| #checkup-loans-module .checkup-warning-box { | |
| background: rgba(243,156,18,.12); | |
| border: 1px solid rgba(243,156,18,.4); | |
| border-left: 4px solid #f39c12; | |
| border-radius: 6px; padding: 16px; margin: 1rem 0; | |
| } | |
| /* ── Badges statut ────────────────────────────────────────────────── */ | |
| #checkup-loans-module .checkup-status-badge { | |
| display: inline-block; padding: 6px 14px; border-radius: 20px; | |
| font-size: .8rem; font-weight: 600; | |
| letter-spacing: .5px; text-transform: uppercase; | |
| } | |
| #checkup-loans-module .checkup-status-actif { | |
| background: rgba(84,189,75,.2); color: #54bd4b; | |
| border: 1px solid rgba(84,189,75,.4); | |
| } | |
| #checkup-loans-module .checkup-status-updated { | |
| background: rgba(243,156,18,.2); color: #f39c12; | |
| border: 1px solid rgba(243,156,18,.4); | |
| } | |
| /* ── Badges source ────────────────────────────────────────────────── */ | |
| #checkup-loans-module .badge-master { | |
| background: rgba(88,166,255,.15); color: #58a6ff; | |
| border: 1px solid rgba(88,166,255,.4); | |
| padding: 3px 10px; border-radius: 12px; | |
| font-size: .75rem; font-weight: 600; | |
| } | |
| #checkup-loans-module .badge-update { | |
| background: rgba(163,113,247,.15); color: #a371f7; | |
| border: 1px solid rgba(163,113,247,.4); | |
| padding: 3px 10px; border-radius: 12px; | |
| font-size: .75rem; font-weight: 600; | |
| } | |
| /* ── Séparateur ───────────────────────────────────────────────────── */ | |
| #checkup-loans-module .checkup-section-divider { | |
| height: 2px; | |
| background: linear-gradient( | |
| 90deg, transparent 0%, | |
| rgba(88,166,255,.4) 50%, | |
| transparent 100% | |
| ); | |
| margin: 2.5rem 0; | |
| } | |
| /* ── Métriques ────────────────────────────────────────────────────── */ | |
| #checkup-loans-module [data-testid="stMetric"] { | |
| background: rgba(22,27,34,.6); | |
| border: 1px solid rgba(48,54,61,.8); | |
| border-radius: 6px; padding: 16px; | |
| box-shadow: 0 1px 3px rgba(0,0,0,.4); | |
| } | |
| #checkup-loans-module [data-testid="stMetric"] label { | |
| color: #8b949e !important; font-size: .75rem !important; | |
| font-weight: 500 !important; | |
| text-transform: uppercase; letter-spacing: .8px; | |
| } | |
| #checkup-loans-module [data-testid="stMetric"] [data-testid="stMetricValue"] { | |
| color: #58a6ff !important; font-size: 1.6rem !important; | |
| font-weight: 600 !important; | |
| } | |
| /* ── Responsive ───────────────────────────────────────────────────── */ | |
| @media (max-width: 768px) { | |
| #checkup-loans-module .checkup-loan-card { padding: 15px; } | |
| #checkup-loans-module [data-testid="stMetric"] | |
| [data-testid="stMetricValue"] { font-size: 1.3rem !important; } | |
| } | |
| </style> | |
| """, | |
| 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('<div id="checkup-loans-module">', 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('<div class="checkup-section-divider"></div>', 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("</div>", unsafe_allow_html=True) | |
| return | |
| if df_clients.empty: | |
| st.error("[STOP] Aucun client trouvé dans la base KYC.") | |
| st.markdown("</div>", 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("</div>", 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("</div>", 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('<div class="checkup-section-divider"></div>', 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('<div class="checkup-blocked-alert">', 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("</div>", unsafe_allow_html=True) | |
| st.markdown("</div>", 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('<div class="checkup-blocked-alert">', 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("</div>", unsafe_allow_html=True) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| return | |
| # Accès autorisé | |
| source_badge_html = ( | |
| "<span class='badge-master'>Prêt Original</span>" | |
| if loan_source == "MASTER" | |
| else f"<span class='badge-update'>Version V{selected_loan.get('Version', '?')}</span>" | |
| ) | |
| st.markdown( | |
| f"Aucun remboursement détecté — **Modification autorisée** " | |
| f"pour **{display_id}** {source_badge_html}", | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown('<div class="checkup-section-divider"></div>', unsafe_allow_html=True) | |
| # ========================================================================= | |
| # ÉTAPE 5 — INFORMATIONS ACTUELLES DU PRÊT | |
| # ========================================================================= | |
| st.subheader("Étape 3 : Informations actuelles du prêt") | |
| st.markdown('<div class="checkup-loan-card">', 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"<span class='badge-update'>V{selected_loan.get('Version', '?')}</span>", | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown( | |
| f"<span class='checkup-status-badge checkup-status-actif'>" | |
| f"Statut : {selected_loan['Statut']}</span>", | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| st.markdown('<div class="checkup-section-divider"></div>', 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('<div class="checkup-section-divider"></div>', 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('<div class="checkup-comparison-table">', unsafe_allow_html=True) | |
| st.dataframe(pd.DataFrame(comparison_data), hide_index=True, use_container_width=True) | |
| st.markdown("</div>", 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('<div class="checkup-warning-box">', unsafe_allow_html=True) | |
| st.markdown("### [ATTENTION] Points d'attention") | |
| for w in warnings: | |
| st.markdown(f"- {w}") | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| # ========================================================================= | |
| # ANALYSE DE SOLVABILITÉ | |
| # ========================================================================= | |
| st.markdown('<div class="checkup-section-divider"></div>', 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 : <span style='color:{analyse['couleur']}'>" | |
| f"{analyse['statut']}</span>", | |
| unsafe_allow_html=True, | |
| ) | |
| st.info(analyse["message"]) | |
| with st.expander("Détails financiers"): | |
| st.markdown(analyse["details"]) | |
| # ========================================================================= | |
| # TABLEAU D'AMORTISSEMENT | |
| # ========================================================================= | |
| st.markdown('<div class="checkup-section-divider"></div>', 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('<div class="checkup-section-divider"></div>', 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("</div>", unsafe_allow_html=True) | |
| return | |
| # ========================================================================= | |
| # GÉNÉRATION DES DOCUMENTS PDF & ENVOI MAIL | |
| # ========================================================================= | |
| if st.session_state.get("loan_updated", False): | |
| st.markdown('<div class="checkup-section-divider"></div>', 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("</div>", unsafe_allow_html=True) |