Vortex-Flux / src /modules /repayments.py
klydekushy's picture
Update src/modules/repayments.py
cd627cd verified
import streamlit as st
import pandas as pd
from datetime import datetime, date
import sys
import os
# Import du module d'analyse
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
from Analytics.AnalyseRepayment import AnalyseRepayment
from DocumentGen.InvoiceRepayment import generer_recu
# === CSS SPÉCIFIQUE REPAYMENTS MODULE ===
def apply_repayments_styles():
st.markdown("""
<style>
/* === STYLES SPÉCIFIQUES MODULE REPAYMENTS === */
/* Wrapper pour isolation */
#repayments-module {
font-family: 'Space Grotesk', sans-serif;
}
/* Headers spécifiques */
#repayments-module h2 {
font-size: 1.4rem !important;
margin-bottom: 16px !important;
}
#repayments-module h3 {
font-size: 1.1rem !important;
margin-bottom: 12px !important;
color: #8b949e !important;
}
/* Metrics cards pour montants */
#repayments-module [data-testid="stMetric"] {
background: rgba(22, 27, 34, 0.7);
border: 1px solid rgba(88, 166, 255, 0.4);
padding: 12px !important;
}
#repayments-module [data-testid="stMetric"] label {
color: #8b949e !important;
font-size: 0.75rem !important;
font-weight: 600 !important;
}
#repayments-module [data-testid="stMetric"] [data-testid="stMetricValue"] {
color: #58a6ff !important;
font-size: 1.4rem !important;
font-weight: 700 !important;
}
/* Cartes Palantir pour scénarios - MINIMALISTE */
.gotham-card {
background: rgba(22, 27, 34, 0.4);
border: 1px solid rgba(88, 166, 255, 0.2);
border-radius: 4px;
padding: 16px;
margin: 10px 0;
transition: border-color 0.2s ease;
}
.gotham-card:hover {
border-color: rgba(88, 166, 255, 0.4);
}
.gotham-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(88, 166, 255, 0.15);
}
.gotham-card-title {
font-size: 1.1rem;
font-weight: 500;
color: #58a6ff;
margin: 0;
}
.gotham-card-badge {
background: transparent;
color: #58a6ff;
padding: 2px 8px;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 500;
border: 1px solid rgba(88, 166, 255, 0.3);
}
.gotham-card-body {
color: #c9d1d9;
}
.gotham-card-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid rgba(48, 54, 61, 0.2);
}
.gotham-card-row:last-child {
border-bottom: none;
}
.gotham-card-label {
color: #8b949e;
font-size: 0.85rem;
font-weight: 400;
}
.gotham-card-value {
color: #c9d1d9;
font-weight: 500;
font-size: 0.9rem;
}
.gotham-card-total {
background: rgba(88, 166, 255, 0.05);
padding: 10px;
border-radius: 4px;
margin-top: 10px;
text-align: center;
border: 1px solid rgba(88, 166, 255, 0.15);
}
.gotham-card-total-label {
color: #8b949e;
font-size: 0.75rem;
margin-bottom: 4px;
font-weight: 500;
}
.gotham-card-total-value {
color: #58a6ff;
font-size: 1.4rem;
font-weight: 600;
}
.gotham-card-impact {
margin-top: 10px;
padding: 8px;
background: rgba(84, 189, 75, 0.05);
border-left: 2px solid #54bd4b;
border-radius: 2px;
}
.gotham-card-impact.negative {
background: rgba(243, 156, 18, 0.05);
border-left-color: #f39c12;
}
/* Badge UPDATED */
.badge-updated {
display: inline-block;
background: rgba(243, 156, 18, 0.1);
border: 1px solid #f39c12;
color: #f39c12;
padding: 3px 8px;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 600;
margin-left: 8px;
}
/* Expanders pour détails contrat */
#repayments-module .streamlit-expanderHeader {
background: rgba(22, 27, 34, 0.7) !important;
border-left: 3px solid rgba(88, 166, 255, 0.6) !important;
padding: 10px 14px !important;
font-weight: 600 !important;
}
#repayments-module .streamlit-expanderHeader:hover {
background: rgba(33, 38, 45, 0.9) !important;
border-left-color: rgba(88, 166, 255, 0.9) !important;
}
#repayments-module .streamlit-expanderContent {
background: rgba(13, 17, 23, 0.7);
border: 1px solid rgba(48, 54, 61, 0.6);
padding: 12px !important;
}
/* Formulaire de paiement */
#repayments-module [data-testid="stForm"] {
background: rgba(22, 27, 34, 0.5);
border: 1px solid rgba(88, 166, 255, 0.3);
border-radius: 6px;
padding: 16px;
backdrop-filter: blur(10px);
}
/* Inputs dans le formulaire */
#repayments-module .stDateInput > div > div > input,
#repayments-module .stNumberInput > div > div > input,
#repayments-module .stSelectbox > div > div > div,
#repayments-module .stTextInput > div > div > input {
background: rgba(13, 17, 23, 0.9) !important;
border: 1px solid rgba(48, 54, 61, 0.8) !important;
color: #c9d1d9 !important;
}
#repayments-module .stDateInput > div > div > input:focus,
#repayments-module .stNumberInput > div > div > input:focus,
#repayments-module .stSelectbox > div > div > div:focus,
#repayments-module .stTextInput > div > div > input:focus {
border-color: rgba(88, 166, 255, 0.6) !important;
}
/* Labels des inputs */
#repayments-module label {
color: #8b949e !important;
font-weight: 500 !important;
font-size: 0.85rem !important;
}
/* Bouton de validation */
#repayments-module [data-testid="stForm"] .stButton > button {
background: linear-gradient(135deg, rgba(88, 166, 255, 0.2) 0%, rgba(84, 189, 75, 0.2) 100%) !important;
border: 2px solid #54bd4b !important;
color: #54bd4b !important;
font-weight: 700 !important;
font-size: 0.95rem !important;
padding: 12px 24px !important;
text-transform: uppercase;
letter-spacing: 1px;
margin-top: 16px;
}
#repayments-module [data-testid="stForm"] .stButton > button:hover {
background: linear-gradient(135deg, rgba(88, 166, 255, 0.3) 0%, rgba(84, 189, 75, 0.3) 100%) !important;
border-color: #54bd4b !important;
box-shadow: 0 0 20px rgba(84, 189, 75, 0.4) !important;
transform: translateY(-2px);
}
/* Divider */
#repayments-module hr {
background: rgba(88, 166, 255, 0.3);
height: 2px;
margin: 20px 0;
}
/* Alert boxes */
#repayments-module .stAlert {
border-radius: 6px;
padding: 12px 16px !important;
}
#repayments-module .stAlert[kind="success"] {
background: rgba(84, 189, 75, 0.1) !important;
border-left: 4px solid #54bd4b !important;
}
#repayments-module .stAlert[kind="warning"] {
background: rgba(243, 156, 18, 0.1) !important;
border-left: 4px solid #f39c12 !important;
}
#repayments-module .stAlert[kind="error"] {
background: rgba(231, 76, 60, 0.1) !important;
border-left: 4px solid #e74c3c !important;
}
/* Success badge style - sobre */
.repayment-success-badge {
background: rgba(84, 189, 75, 0.08);
border: 1px solid #54bd4b;
border-radius: 4px;
padding: 14px;
text-align: center;
margin: 14px 0;
}
.repayment-success-badge h3 {
color: #54bd4b !important;
margin: 0 !important;
font-size: 1.1rem !important;
font-weight: 500 !important;
}
</style>
""", unsafe_allow_html=True)
def render_gotham_card(option_num, label, badge, data):
"""Génère une carte style Gotham"""
html = f"""
<div class="gotham-card">
<div class="gotham-card-header">
<h4 class="gotham-card-title">Option {option_num}</h4>
<span class="gotham-card-badge">{badge}</span>
</div>
<div class="gotham-card-body">
<div class="gotham-card-row">
<span class="gotham-card-label">Durée de retard</span>
<span class="gotham-card-value">{data['jours_retard']} jours ({data['semaines_retard']} semaine{'s' if data['semaines_retard'] > 1 else ''})</span>
</div>
<div class="gotham-card-row">
<span class="gotham-card-label">Montant échéance</span>
<span class="gotham-card-value">{data['montant_echeance']:,.0f} XOF</span>
</div>
<div class="gotham-card-row">
<span class="gotham-card-label">Pénalités ({data['taux']}%)</span>
<span class="gotham-card-value">+ {data['montant_penalites']:,.0f} XOF</span>
</div>
</div>
<div class="gotham-card-total">
<div class="gotham-card-total-label">TOTAL À ENCAISSER</div>
<div class="gotham-card-total-value">{data['total_a_encaisser']:,.0f} XOF</div>
</div>
<div class="gotham-card-impact {'negative' if data['montant_penalites'] == 0 else ''}">
<span style="font-size: 0.85rem; color: #c9d1d9;">
{data['impact']}
</span>
</div>
</div>
"""
return html
def show_repayments_module(client, sheet_name):
"""Fonction principale du module de remboursement"""
# Appliquer les styles spécifiques
apply_repayments_styles()
# Wrapper pour isolation
st.markdown('<div id="repayments-module">', unsafe_allow_html=True)
st.header("GUICHET DE RECOUVREMENT (CASHFLOW IN)")
try:
sh = client.open(sheet_name)
ws_prets = sh.worksheet("Prets_Master")
ws_remb = sh.worksheet("Remboursements")
ws_clients = sh.worksheet("Clients_KYC")
# Tentative de charger Ajustements_Echeances
try:
ws_ajust = sh.worksheet("Ajustements_Echeances")
df_ajust = pd.DataFrame(ws_ajust.get_all_records())
except:
st.warning("⚠️ Table Ajustements_Echeances non trouvée. Création nécessaire.")
df_ajust = pd.DataFrame()
# Chargement des données
df_prets = pd.DataFrame(ws_prets.get_all_records())
df_remb = pd.DataFrame(ws_remb.get_all_records())
df_clients = pd.DataFrame(ws_clients.get_all_records())
# === NOUVEAU : Charger Prets_Update ===
try:
ws_prets_update = sh.worksheet("Prets_Update")
df_prets_update = pd.DataFrame(ws_prets_update.get_all_records())
print(f"✅ Table Prets_Update chargée : {len(df_prets_update)} lignes")
except:
st.info("ℹ️ Table Prets_Update non trouvée. Seuls les prêts non mis à jour seront accessibles.")
df_prets_update = pd.DataFrame()
ws_prets_update = None
# Initialisation de l'analyseur avec Prets_Update
analyser = AnalyseRepayment(df_prets, df_remb, df_ajust, df_prets_update)
# Récupération des prêts actifs (inclure UPDATED)
if not df_prets.empty and 'ID_Pret' in df_prets.columns:
active_loans = df_prets[df_prets['Statut'].isin(["ACTIF", "EN_RETARD", "LITIGE", "UPDATED"])]
else:
st.warning("⚠️ Aucun prêt actif trouvé.")
st.markdown('</div>', unsafe_allow_html=True)
return
except Exception as e:
st.error(f"❌ Erreur de connexion : {e}")
st.markdown('</div>', unsafe_allow_html=True)
return
# --- 1. SÉLECTION DU CLIENT ---
st.subheader("1. Identifier le Client")
# Création liste de clients avec prêts actifs
clients_with_active_loans = active_loans['ID_Client'].unique()
clients_choices = df_clients[df_clients['ID_Client'].isin(clients_with_active_loans)].apply(
lambda x: f"{x['ID_Client']} - {x['Nom_Complet']} - {x.get('Telephone', 'N/A')}",
axis=1
).tolist()
selected_client = st.selectbox(
"Sélectionnez le client",
["Choisir un client..."] + clients_choices,
help="Recherchez par nom, ID ou numéro de téléphone"
)
if selected_client and selected_client != "Choisir un client...":
# Extraction de l'ID Client
client_id = selected_client.split(" - ")[0]
client_data = df_clients[df_clients['ID_Client'] == client_id].iloc[0]
# Filtrer les prêts actifs de ce client
client_active_loans = active_loans[active_loans['ID_Client'] == client_id]
if client_active_loans.empty:
st.warning(f"Aucun prêt actif trouvé pour {client_data['Nom_Complet']}")
st.markdown('</div>', unsafe_allow_html=True)
return
# --- 2. SÉLECTION DU PRÊT ---
st.divider()
st.subheader("2. Sélectionner le Prêt")
# Affichage rapide des infos client
st.caption(f"**Client sélectionné :** {client_data['Nom_Complet']} | **Téléphone :** {client_data.get('Telephone', 'N/A')}")
# === NOUVEAU : Badge UPDATED dans la liste ===
loan_choices = []
for _, loan in client_active_loans.iterrows():
updated_badge = " 🔄 UPDATED" if loan['Statut'] == 'UPDATED' else ""
choice = f"{loan['ID_Pret']} - {loan['Type_Pret']} (Total dû: {loan['Montant_Total']:,.0f} XOF){updated_badge}"
loan_choices.append(choice)
if len(loan_choices) == 1:
# Si un seul prêt, sélection automatique
selected_loan_choice = loan_choices[0]
st.info(f"Prêt sélectionné automatiquement : {selected_loan_choice}")
else:
# Si plusieurs prêts, laisser le choix
selected_loan_choice = st.selectbox(
"Sélectionnez le prêt à rembourser",
loan_choices,
help="Le client a plusieurs prêts actifs. Les prêts marqués 🔄 UPDATED ont été modifiés."
)
if selected_loan_choice:
# Extraction de l'ID Prêt
loan_id = selected_loan_choice.split(" - ")[0]
# === NOUVEAU : Récupération via l'analyseur (gère automatiquement UPDATED) ===
loan_data = analyser.get_loan_data(loan_id)
if loan_data is None:
st.error(f"❌ Impossible de récupérer les données du prêt {loan_id}")
st.markdown('</div>', unsafe_allow_html=True)
return
# Vérifier si c'est un prêt UPDATED
original_loan = df_prets[df_prets['ID_Pret'] == loan_id]
is_updated = False
if not original_loan.empty:
is_updated = original_loan.iloc[0]['Statut'] == 'UPDATED'
version_info = ""
if is_updated and 'Version' in loan_data.index:
version_info = f"Version {loan_data['Version']}"
# Affichage du contexte
st.divider()
with st.expander("Détails du contrat", expanded=True):
if is_updated:
st.markdown(f"<span class='badge-updated'>{version_info if version_info else 'UPDATED'}</span>", unsafe_allow_html=True)
if 'Commentaire_Modification' in loan_data.index and loan_data.get('Commentaire_Modification'):
st.caption(f"💬 **Motif de modification :** {loan_data['Commentaire_Modification']}")
c1, c2, c3 = st.columns(3)
with c1:
st.metric("Capital Prêté", f"{loan_data['Montant_Capital']:,.0f} XOF")
with c2:
st.metric("Montant Total Dû", f"{loan_data['Montant_Total']:,.0f} XOF")
with c3:
st.metric("Échéance Prévue", f"{loan_data['Montant_Versement']:,.0f} XOF")
st.caption(f"**Client :** {loan_data['Nom_Complet']}")
st.caption(f"**Date de début :** {loan_data.get('Date_Deblocage', 'N/A')}")
st.caption(f"**Statut actuel :** {loan_data['Statut']}")
# --- 3. ANALYSE DE L'ÉCHÉANCE ---
st.divider()
st.subheader("3. Analyse de l'Échéance en Cours")
# Date du paiement
date_paiement = st.date_input("Date du paiement", value=date.today())
# Analyse automatique
echeance_info = analyser.detecter_echeance_attendue(loan_id, date_paiement)
if echeance_info:
col_a, col_b, col_c = st.columns(3)
with col_a:
st.metric("Échéance Attendue", f"{echeance_info['numero']}/{echeance_info['total']}")
with col_b:
st.metric("Date Prévue", echeance_info['date_prevue'].strftime("%d/%m/%Y"))
with col_c:
st.metric("Montant Échéance", f"{echeance_info['montant_ajuste']:,.0f} XOF")
# Calcul du retard
penalites_base = analyser.calculer_penalites(
echeance_info['montant_ajuste'],
echeance_info['date_prevue'],
date_paiement
)
jours_retard = penalites_base['jours_retard']
# --- Suite dans Partie 3 ---
# --- 4. SCÉNARIOS DE PÉNALITÉS (si retard détecté) ---
if jours_retard >= 1:
st.divider()
st.warning(f"⚠️ RETARD DÉTECTÉ : {jours_retard} jours")
st.subheader("4. Choix du Scénario de Pénalités")
# Génération des 3 scénarios
scenarios = analyser.generer_scenarios_penalites(
echeance_info['montant_ajuste'],
echeance_info['date_prevue'],
date_paiement
)
# Ajout de l'impact business
scenarios[0]['impact'] = f"Gain structure : +{scenarios[0]['montant_penalites']:,.0f} XOF | Applique pénalités réglementaires"
scenarios[1]['impact'] = f"Manque à gagner : -{scenarios[0]['montant_penalites']:,.0f} XOF | Geste de fidélisation client"
scenarios[2]['impact'] = f"Gain structure : +{scenarios[2]['montant_penalites']:,.0f} XOF | Compromis équitable"
# Enrichir avec les données nécessaires
for s in scenarios:
s['montant_echeance'] = echeance_info['montant_ajuste']
s['semaines_retard'] = s.get('semaines_retard', penalites_base['semaines_retard'])
# Affichage des cartes
col1, col2, col3 = st.columns(3)
with col1:
st.markdown(render_gotham_card(1, "Règlement Standard", "5%", scenarios[0]), unsafe_allow_html=True)
if st.button("Sélectionner Option 1", key="opt1", use_container_width=True):
st.session_state['selected_scenario'] = scenarios[0]
with col2:
st.markdown(render_gotham_card(2, "Geste Commercial", "0%", scenarios[1]), unsafe_allow_html=True)
if st.button("Sélectionner Option 2", key="opt2", use_container_width=True):
st.session_state['selected_scenario'] = scenarios[1]
with col3:
st.markdown(render_gotham_card(3, "Taux Personnalisé", "Custom", scenarios[2]), unsafe_allow_html=True)
# Slider pour taux personnalisé
taux_custom = st.slider(
"Taux de pénalité (%)",
min_value=0.0,
max_value=5.0,
value=2.5,
step=0.5,
key="taux_slider"
)
# Recalcul automatique
if taux_custom != 2.5:
scenario_custom = analyser.calculer_penalites(
echeance_info['montant_ajuste'],
echeance_info['date_prevue'],
date_paiement,
taux_hebdo=taux_custom / 100
)
scenario_custom['label'] = "Taux Personnalisé"
scenario_custom['taux'] = taux_custom
scenario_custom['total_a_encaisser'] = echeance_info['montant_ajuste'] + scenario_custom['montant_penalites']
scenario_custom['impact'] = f"Gain structure : +{scenario_custom['montant_penalites']:,.0f} XOF | Taux ajusté"
scenario_custom['montant_echeance'] = echeance_info['montant_ajuste']
scenario_custom['semaines_retard'] = scenario_custom.get('semaines_retard', penalites_base['semaines_retard'])
st.markdown(render_gotham_card(3, "Taux Personnalisé", f"{taux_custom}%", scenario_custom), unsafe_allow_html=True)
if st.button("Sélectionner Option 3", key="opt3", use_container_width=True):
if taux_custom != 2.5:
st.session_state['selected_scenario'] = scenario_custom
else:
st.session_state['selected_scenario'] = scenarios[2]
# Affichage du scénario sélectionné
if 'selected_scenario' in st.session_state:
selected = st.session_state['selected_scenario']
st.success(f"✅ Scénario sélectionné : {selected['label']} ({selected['taux']}% de pénalités)")
st.info(f"Montant total à encaisser : **{selected['total_a_encaisser']:,.0f} XOF**")
else:
# Pas de retard
if jours_retard < 0:
st.success(f"✅ Paiement anticipé de {abs(jours_retard)} jours")
else:
st.success("✅ Paiement dans les délais")
st.session_state['selected_scenario'] = {
'taux': 0.0,
'montant_penalites': 0,
'total_a_encaisser': echeance_info['montant_ajuste']
}
# --- 5. SAISIE DU PAIEMENT ---
st.divider()
st.subheader("5. Enregistrement du Paiement")
with st.form("repayment_form", clear_on_submit=True):
col_a, col_b = st.columns(2)
with col_a:
montant_verse = st.number_input(
"Montant Versé (XOF)",
min_value=1000,
step=5000,
value=int(st.session_state.get('selected_scenario', {}).get('total_a_encaisser', echeance_info['montant_ajuste'])),
help="Montant exact reçu"
)
with col_b:
moyen = st.selectbox(
"Moyen de Paiement",
["Espèces", "Mobile Money (Wave)", "Mobile Money (Orange Money)", "Virement Bancaire", "Chèque"],
help="Canal de réception des fonds"
)
reference_externe = st.text_input(
"Référence Transaction",
placeholder="Ex: WAVE-TXN-123456, Numéro de chèque...",
help="Référence externe pour traçabilité"
)
commentaire = st.text_area(
"Commentaire",
placeholder="Notes additionnelles sur la transaction...",
help="Informations complémentaires"
)
submit = st.form_submit_button("✅ VALIDER L'ENCAISSEMENT", use_container_width=True)
if submit:
if montant_verse <= 0:
st.error("❌ Le montant versé doit être supérieur à 0 XOF")
else:
try:
# Récupération du taux de pénalité sélectionné
taux_penalite = st.session_state.get('selected_scenario', {}).get('taux', 0.0) / 100
# Analyse complète
analyse_complete = analyser.analyser_remboursement_complet(
loan_id,
date_paiement,
montant_verse,
taux_penalite=taux_penalite
)
if 'error' in analyse_complete:
st.error(f"❌ {analyse_complete['error']}")
else:
# Génération ID Transaction
existing_remb = ws_remb.get_all_values()
next_id = len(existing_remb)
trans_id = f"TRX-2026-{next_id:04d}"
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Génération Numéro Reçu
annee_actuelle = datetime.now().year
count_annee = len([r for r in existing_remb if f"REC-{annee_actuelle}" in str(r)])
numero_recu = f"REC-{annee_actuelle}-{count_annee + 1:04d}"
# 1. Écriture dans Remboursements (avec conversions explicites)
new_row = [
str(trans_id),
str(loan_id),
str(analyse_complete['id_client']),
str(date_paiement),
int(montant_verse),
int(analyse_complete['montant_principal']),
int(analyse_complete['montant_interets']),
int(analyse_complete['penalites_retard']),
int(analyse_complete['solde_avant']),
int(analyse_complete['solde_apres']),
str(analyse_complete['numero_echeance']),
str(analyse_complete['date_echeance_prevue']),
int(analyse_complete['jours_retard']),
str(analyse_complete['statut_paiement']),
str(moyen),
str(reference_externe if reference_externe else "N/A"),
str(commentaire if commentaire else "N/A"),
"NON",
str(numero_recu),
str(timestamp)
]
ws_remb.append_row(new_row)
# 2. Gestion des ajustements si PARTIEL
if analyse_complete['statut_paiement'] == "PARTIEL" and analyse_complete['montant_a_reporter'] > 0:
if analyse_complete['prochaine_echeance']:
try:
existing_ajust = ws_ajust.get_all_values()
next_ajust_id = len(existing_ajust)
except:
ws_ajust = sh.add_worksheet(title="Ajustements_Echeances", rows="1000", cols="7")
ws_ajust.append_row(["ID_Ajustement", "ID_Pret", "Numero_Echeance", "Montant_Additionnel", "Raison", "Date_Creation", "Timestamp"])
next_ajust_id = 1
ajust_id = f"ADJ-{annee_actuelle}-{next_ajust_id:04d}"
ajust_row = [
str(ajust_id),
str(loan_id),
int(analyse_complete['prochaine_echeance']),
int(analyse_complete['montant_a_reporter']),
"PAIEMENT_PARTIEL",
str(date.today()),
str(timestamp)
]
ws_ajust.append_row(ajust_row)
st.warning(f"⚠️ Paiement PARTIEL détecté. {analyse_complete['montant_a_reporter']:,.0f} XOF reportés sur l'échéance #{analyse_complete['prochaine_echeance']}")
# 3. CLÔTURE AUTOMATIQUE (GESTION UPDATED)
if analyse_complete['doit_cloturer']:
if is_updated and ws_prets_update is not None:
try:
cell = ws_prets_update.find(loan_id)
header_update = ws_prets_update.row_values(1)
col_statut_idx = header_update.index("Statut") + 1
ws_prets_update.update_cell(cell.row, col_statut_idx, "TERMINE")
if "Date_Fin" in header_update:
col_fin_idx = header_update.index("Date_Fin") + 1
ws_prets_update.update_cell(cell.row, col_fin_idx, str(date.today()))
st.info(f"📝 Statut mis à jour dans Prets_Update (Version {version_info})")
except Exception as e:
st.warning(f"⚠️ Impossible de mettre à jour Prets_Update : {e}")
else:
try:
cell = ws_prets.find(loan_id)
header = ws_prets.row_values(1)
col_statut_idx = header.index("Statut") + 1
ws_prets.update_cell(cell.row, col_statut_idx, "TERMINE")
if "Date_Cloture" in header:
col_cloture_idx = header.index("Date_Cloture") + 1
ws_prets.update_cell(cell.row, col_cloture_idx, str(date.today()))
st.info(f"📝 Statut mis à jour dans Prets_Master")
except Exception as e:
st.warning(f"⚠️ Impossible de mettre à jour Prets_Master : {e}")
critere_messages = {
'SOLDE_ZERO': 'Solde intégralement remboursé',
'TOUTES_ECHEANCES': 'Toutes les échéances payées',
'MONTANT_TOTAL': 'Montant total du prêt remboursé'
}
st.markdown(f"""
<div class="repayment-success-badge">
<h3>✅ DOSSIER {loan_id} AUTOMATIQUEMENT CLÔTURÉ</h3>
<p style="color: #8b949e; margin: 8px 0 0 0;">
Motif : {critere_messages.get(analyse_complete['critere_cloture'], 'Prêt terminé')}<br>
Paiement de {montant_verse:,.0f} XOF enregistré | Ref: {trans_id}
</p>
</div>
""", unsafe_allow_html=True)
st.info(f"Détails : {analyse_complete['message_cloture']}")
else:
st.success(f"✅ Paiement de **{montant_verse:,.0f} XOF** enregistré avec succès")
st.info(f"Référence de transaction : **{trans_id}**")
if analyse_complete['solde_apres'] > 0:
st.info(f"Solde restant à rembourser : **{analyse_complete['solde_apres']:,.0f} XOF**")
# Préparer les données pour le reçu
client_data_dict = df_clients[df_clients['ID_Client'] == analyse_complete['id_client']].iloc[0].to_dict()
if hasattr(loan_data, 'to_dict'):
loan_data_dict = loan_data.to_dict()
else:
loan_data_dict = dict(loan_data)
# Conversion des types pandas
def convert_to_native(obj):
import numpy as np
if isinstance(obj, dict):
return {key: convert_to_native(value) for key, value in obj.items()}
elif isinstance(obj, list):
return [convert_to_native(item) for item in obj]
elif isinstance(obj, (np.integer, np.int64)):
return int(obj)
elif isinstance(obj, (np.floating, np.float64)):
return float(obj)
elif isinstance(obj, np.ndarray):
return obj.tolist()
elif pd.isna(obj):
return None
else:
return obj
client_data_dict = convert_to_native(client_data_dict)
loan_data_dict = convert_to_native(loan_data_dict)
analyse_complete_clean = convert_to_native(analyse_complete)
recu_data = {
'numero_recu': numero_recu,
'trans_id': trans_id,
'date_paiement': date_paiement,
'client': client_data_dict,
'loan': loan_data_dict,
'paiement': analyse_complete_clean,
'moyen': moyen,
'reference': reference_externe if reference_externe else "N/A"
}
# Sauvegarder dans session_state
st.session_state['derniere_transaction'] = {
'recu_data': recu_data,
'trans_id': trans_id,
'numero_recu': numero_recu,
'loan_id': loan_id
}
# Récapitulatif détaillé
with st.expander("📋 Récapitulatif de la transaction", expanded=True):
recap_col1, recap_col2, recap_col3 = st.columns(3)
with recap_col1:
st.write(f"**ID Transaction :** {trans_id}")
st.write(f"**ID Prêt :** {loan_id}")
st.write(f"**Date :** {date_paiement}")
st.write(f"**Échéance :** {analyse_complete['numero_echeance']}")
with recap_col2:
st.write(f"**Montant Versé :** {montant_verse:,.0f} XOF")
st.write(f"**Principal :** {analyse_complete['montant_principal']:,.0f} XOF")
st.write(f"**Intérêts :** {analyse_complete['montant_interets']:,.0f} XOF")
st.write(f"**Pénalités :** {analyse_complete['penalites_retard']:,.0f} XOF")
with recap_col3:
st.write(f"**Solde Avant :** {analyse_complete['solde_avant']:,.0f} XOF")
st.write(f"**Solde Après :** {analyse_complete['solde_apres']:,.0f} XOF")
st.write(f"**Moyen :** {moyen}")
st.write(f"**Statut :** {analyse_complete['statut_paiement']}")
except Exception as e:
st.error(f"❌ Erreur lors de l'enregistrement : {e}")
st.exception(e)
else:
st.error("❌ Impossible d'analyser l'échéance pour ce prêt")
# === GÉNÉRATION DU REÇU (EN DEHORS DES BLOCS CONDITIONNELS) ===
if 'derniere_transaction' in st.session_state:
st.divider()
st.subheader("📄 Reçu de Remboursement")
trans_data = st.session_state['derniere_transaction']
st.success(f"✅ Transaction **{trans_data['trans_id']}** enregistrée avec succès")
st.caption(f"Reçu N° : **{trans_data['numero_recu']}**")
# Générer le PDF automatiquement si pas encore fait
if 'pdf_generated' not in st.session_state:
try:
with st.spinner("Génération du reçu en cours..."):
pdf_bytes = generer_recu(trans_data['recu_data'])
if pdf_bytes:
# Stocker le PDF dans session_state
st.session_state['pdf_generated'] = pdf_bytes
# Mise à jour du statut dans Google Sheets
try:
cell_trans = ws_remb.find(trans_data['trans_id'])
header_remb = ws_remb.row_values(1)
col_recu_idx = header_remb.index("Recu_Emis") + 1
ws_remb.update_cell(cell_trans.row, col_recu_idx, "OUI")
except Exception as e_update:
st.warning(f"⚠️ Impossible de mettre à jour le statut : {e_update}")
else:
st.error("❌ Erreur lors de la génération du PDF")
except Exception as e_pdf:
st.error(f"❌ Erreur lors de la génération : {e_pdf}")
import traceback
st.code(traceback.format_exc())
# Afficher le bouton de téléchargement si le PDF existe
if 'pdf_generated' in st.session_state:
st.info("✅ Reçu prêt ! Vous pouvez le télécharger autant de fois que nécessaire.")
col_btn1, col_btn2 = st.columns(2)
with col_btn1:
st.download_button(
label="📥 Télécharger le Reçu PDF",
data=st.session_state['pdf_generated'],
file_name=f"{trans_data['numero_recu']}_{trans_data['loan_id']}.pdf",
mime="application/pdf",
type="primary",
use_container_width=True,
key=f"download_{trans_data['trans_id']}"
)
with col_btn2:
if st.button("🔄 Nouvelle Transaction", use_container_width=True):
# Nettoyer le session_state
keys_to_delete = ['derniere_transaction', 'selected_scenario', 'pdf_generated']
for key in keys_to_delete:
if key in st.session_state:
del st.session_state[key]
st.rerun()
st.caption("💡 Cliquez sur '🔄 Nouvelle Transaction' uniquement quand vous avez terminé avec ce reçu.")
# Affichage des instructions si aucun client sélectionné et aucune transaction en cours d'affichage
elif not selected_client or selected_client == "Choisir un client...":
st.info("ℹ️ Veuillez sélectionner un client dans la liste ci-dessus pour commencer")
if not active_loans.empty:
st.markdown("---")
st.subheader("Vue d'ensemble des clients et prêts actifs")
stat_col1, stat_col2, stat_col3 = st.columns(3)
with stat_col1:
nb_clients = active_loans['ID_Client'].nunique()
st.metric("Clients avec Prêts Actifs", nb_clients)
with stat_col2:
total_encours = active_loans['Montant_Total'].sum() if 'Montant_Total' in active_loans.columns else 0
st.metric("Encours Total", f"{total_encours:,.0f} XOF")
with stat_col3:
avg_loan = active_loans['Montant_Capital'].mean() if 'Montant_Capital' in active_loans.columns else 0
st.metric("Prêt Moyen", f"{avg_loan:,.0f} XOF")
st.markdown('</div>', unsafe_allow_html=True)