# -*- 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)