Spaces:
Running
Running
| 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) |