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(""" """, unsafe_allow_html=True) def render_gotham_card(option_num, label, badge, data): """Génère une carte style Gotham""" html = f"""

Option {option_num}

{badge}
Durée de retard {data['jours_retard']} jours ({data['semaines_retard']} semaine{'s' if data['semaines_retard'] > 1 else ''})
Montant échéance {data['montant_echeance']:,.0f} XOF
Pénalités ({data['taux']}%) + {data['montant_penalites']:,.0f} XOF
TOTAL À ENCAISSER
{data['total_a_encaisser']:,.0f} XOF
{data['impact']}
""" 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('
', 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('
', unsafe_allow_html=True) return except Exception as e: st.error(f"❌ Erreur de connexion : {e}") st.markdown('', 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('', 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('', 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"{version_info if version_info else 'UPDATED'}", 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"""

✅ DOSSIER {loan_id} AUTOMATIQUEMENT CLÔTURÉ

Motif : {critere_messages.get(analyse_complete['critere_cloture'], 'Prêt terminé')}
Paiement de {montant_verse:,.0f} XOF enregistré | Ref: {trans_id}

""", 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('', unsafe_allow_html=True)