import pandas as pd from datetime import datetime, timedelta import math class AnalyseRepayment: """ Classe pour gérer toute la logique métier des remboursements Gestion des prêts UPDATED via Prets_Update """ def __init__(self, df_prets, df_remboursements, df_ajustements=None, df_prets_update=None): """ Initialise l'analyse avec les données des prêts et remboursements Args: df_prets: DataFrame de Prets_Master df_remboursements: DataFrame de Remboursements df_ajustements: DataFrame de Ajustements_Echeances (optionnel) df_prets_update: DataFrame de Prets_Update (optionnel) """ self.df_prets = df_prets self.df_remboursements = df_remboursements self.df_ajustements = df_ajustements if df_ajustements is not None else pd.DataFrame() self.df_prets_update = df_prets_update if df_prets_update is not None else pd.DataFrame() def get_loan_data(self, id_pret): """ Récupère les données d'un prêt spécifique Priorise Prets_Update si le statut est UPDATED """ # Chercher d'abord dans Prets_Master loan = self.df_prets[self.df_prets['ID_Pret'] == id_pret] if loan.empty: return None loan_data = loan.iloc[0] # Si le statut est UPDATED, chercher dans Prets_Update if loan_data.get('Statut') == 'UPDATED' and not self.df_prets_update.empty: # Chercher la dernière version dans Prets_Update updated_loans = self.df_prets_update[self.df_prets_update['ID_Pret'] == id_pret] if not updated_loans.empty: # Trier par Version décroissante pour prendre la plus récente if 'Version' in updated_loans.columns: updated_loans = updated_loans.sort_values('Version', ascending=False) # Prendre la première ligne (version la plus récente) loan_data = updated_loans.iloc[0] print(f"✅ Prêt {id_pret} récupéré depuis Prets_Update (Version {loan_data.get('Version', 'N/A')})") return loan_data def get_previous_payments(self, id_pret): """Récupère tous les paiements antérieurs pour un prêt""" if self.df_remboursements.empty or 'ID_Pret' not in self.df_remboursements.columns: return [] payments = self.df_remboursements[self.df_remboursements['ID_Pret'] == id_pret] return payments.to_dict('records') if not payments.empty else [] def parse_echeances(self, dates_versements_str): """ Parse la chaîne de dates d'échéances Args: dates_versements_str: "04/01/2026,11/01/2026,18/01/2026" ou "04/01/2026;11/01/2026;18/01/2026" Returns: Liste de datetime.date """ if not dates_versements_str or pd.isna(dates_versements_str): return [] dates_versements_str = str(dates_versements_str) if ';' in dates_versements_str: dates_str = dates_versements_str.split(';') else: dates_str = dates_versements_str.split(',') echeances = [] for d in dates_str: d = d.strip() if d: try: date_obj = datetime.strptime(d, "%d/%m/%Y").date() echeances.append(date_obj) except ValueError: continue return echeances def detecter_echeance_attendue(self, id_pret, date_paiement): """ Détecte quelle échéance correspond au paiement Returns: dict: { 'numero': 3, 'total': 10, 'date_prevue': date(2026,1,18), 'montant_echeance': 10600, 'montant_ajuste': 14200 } """ loan_data = self.get_loan_data(id_pret) if loan_data is None: return None echeances = self.parse_echeances(loan_data['Dates_Versements']) if not echeances: return None previous_payments = self.get_previous_payments(id_pret) nb_paiements_faits = len(previous_payments) numero_echeance = nb_paiements_faits + 1 if numero_echeance > len(echeances): numero_echeance = len(echeances) if isinstance(date_paiement, str): date_paiement = datetime.strptime(date_paiement, "%Y-%m-%d").date() premiere_echeance = echeances[0] if nb_paiements_faits == 0 and date_paiement < premiere_echeance: numero_echeance = 1 date_prevue = echeances[numero_echeance - 1] montant_echeance_base = loan_data['Montant_Versement'] montant_ajuste = montant_echeance_base if not self.df_ajustements.empty: ajustements = self.df_ajustements[ (self.df_ajustements['ID_Pret'] == id_pret) & (self.df_ajustements['Numero_Echeance'] == numero_echeance) ] if not ajustements.empty: montant_additionnel = ajustements['Montant_Additionnel'].sum() montant_ajuste = montant_echeance_base + montant_additionnel return { 'numero': numero_echeance, 'total': len(echeances), 'date_prevue': date_prevue, 'montant_echeance': montant_echeance_base, 'montant_ajuste': montant_ajuste } def calculer_penalites(self, montant_echeance, date_echeance_prevue, date_paiement, taux_hebdo=0.05): """ Calcule les pénalités de retard Args: montant_echeance: Montant de l'échéance date_echeance_prevue: Date attendue date_paiement: Date effective taux_hebdo: Taux par semaine (défaut: 5%) Returns: dict: { 'jours_retard': 7, 'semaines_retard': 1, 'montant_penalites': 530 } """ if isinstance(date_paiement, str): date_paiement = datetime.strptime(date_paiement, "%Y-%m-%d").date() if isinstance(date_echeance_prevue, str): date_echeance_prevue = datetime.strptime(date_echeance_prevue, "%Y-%m-%d").date() jours_retard = (date_paiement - date_echeance_prevue).days if jours_retard <= 0: return { 'jours_retard': jours_retard, 'semaines_retard': 0, 'montant_penalites': 0 } semaines_retard = math.ceil(jours_retard / 7) montant_penalites = montant_echeance * taux_hebdo * semaines_retard return { 'jours_retard': jours_retard, 'semaines_retard': semaines_retard, 'montant_penalites': round(montant_penalites, 0) } def generer_scenarios_penalites(self, montant_echeance, date_echeance_prevue, date_paiement): """ Génère 3 scénarios de paiement Returns: list: [scenario1, scenario2, scenario3] """ scenario1 = self.calculer_penalites(montant_echeance, date_echeance_prevue, date_paiement, taux_hebdo=0.05) scenario1['label'] = "Règlement Standard" scenario1['taux'] = 5.0 scenario1['total_a_encaisser'] = montant_echeance + scenario1['montant_penalites'] scenario2 = { 'label': "Geste Commercial", 'taux': 0.0, 'jours_retard': scenario1['jours_retard'], 'semaines_retard': 0, 'montant_penalites': 0, 'total_a_encaisser': montant_echeance } scenario3 = self.calculer_penalites(montant_echeance, date_echeance_prevue, date_paiement, taux_hebdo=0.025) scenario3['label'] = "Taux Personnalisé" scenario3['taux'] = 2.5 scenario3['total_a_encaisser'] = montant_echeance + scenario3['montant_penalites'] return [scenario1, scenario2, scenario3] def decomposer_versement(self, loan_data, numero_echeance, montant_verse, penalites=0): """ Décompose un versement en Principal + Intérêts """ montant_total = loan_data['Montant_Total'] montant_capital = loan_data['Montant_Capital'] nb_versements = loan_data['Nb_Versements'] interets_totaux = montant_total - montant_capital interets_par_echeance = interets_totaux / nb_versements principal_par_echeance = montant_capital / nb_versements montant_pour_dette = montant_verse - penalites montant_echeance_theorique = principal_par_echeance + interets_par_echeance if montant_pour_dette >= montant_echeance_theorique: return { 'montant_principal': round(principal_par_echeance, 0), 'montant_interets': round(interets_par_echeance, 0), 'montant_penalites': round(penalites, 0), 'montant_pour_dette': round(montant_pour_dette, 0) } else: if montant_pour_dette >= interets_par_echeance: montant_interets = interets_par_echeance montant_principal = montant_pour_dette - interets_par_echeance else: montant_interets = montant_pour_dette montant_principal = 0 return { 'montant_principal': round(montant_principal, 0), 'montant_interets': round(montant_interets, 0), 'montant_penalites': round(penalites, 0), 'montant_pour_dette': round(montant_pour_dette, 0) } def calculer_soldes(self, loan_data, previous_payments, montant_pour_dette): """ Calcule le solde avant et après paiement """ montant_total_du = loan_data['Montant_Total'] total_rembourse = sum( p.get('Montant_Principal', 0) + p.get('Montant_Interets', 0) for p in previous_payments ) solde_avant = montant_total_du - total_rembourse solde_apres = solde_avant - montant_pour_dette return { 'solde_avant': round(max(0, solde_avant), 0), 'solde_apres': round(max(0, solde_apres), 0) } def determiner_statut_paiement(self, jours_retard, montant_verse, montant_echeance_ajuste): """ Détermine le statut du paiement """ tolerance = montant_echeance_ajuste * 0.01 montant_min_accepte = montant_echeance_ajuste - tolerance if jours_retard > 0: return "EN_RETARD" if montant_verse < montant_min_accepte: return "PARTIEL" if jours_retard < 0: return "ANTICIPE" return "PONCTUEL" def calculer_montant_a_reporter(self, montant_echeance_ajuste, montant_verse, penalites): """ Calcule le montant à reporter sur l'échéance suivante """ montant_pour_dette = montant_verse - penalites montant_manquant = montant_echeance_ajuste - montant_pour_dette return max(0, montant_manquant) def verifier_si_pret_termine(self, id_pret, solde_apres, montant_principal_paye, montant_interets_paye): """ Vérifie si un prêt doit être automatiquement terminé selon 3 critères """ loan_data = self.get_loan_data(id_pret) if loan_data is None: return {'est_termine': False, 'critere': None, 'message': 'Prêt non trouvé'} if solde_apres <= 0: return { 'est_termine': True, 'critere': 'SOLDE_ZERO', 'message': f'Solde du prêt soldé (Solde final : {solde_apres:,.0f} XOF)' } echeances = self.parse_echeances(loan_data['Dates_Versements']) nb_echeances_prevues = len(echeances) previous_payments = self.get_previous_payments(id_pret) paiements_complets = [p for p in previous_payments if p.get('Statut_Paiement') not in ['PARTIEL', None]] nb_paiements_valides = len(paiements_complets) + 1 ajustements_futurs = False if not self.df_ajustements.empty: for i in range(nb_paiements_valides + 1, nb_echeances_prevues + 1): ajustements = self.df_ajustements[ (self.df_ajustements['ID_Pret'] == id_pret) & (self.df_ajustements['Numero_Echeance'] == i) ] if not ajustements.empty: ajustements_futurs = True break if nb_paiements_valides >= nb_echeances_prevues and not ajustements_futurs: return { 'est_termine': True, 'critere': 'TOUTES_ECHEANCES', 'message': f'Toutes les échéances payées ({nb_paiements_valides}/{nb_echeances_prevues})' } total_principal_rembourse = sum(p.get('Montant_Principal', 0) for p in previous_payments) + montant_principal_paye total_interets_rembourse = sum(p.get('Montant_Interets', 0) for p in previous_payments) + montant_interets_paye total_rembourse = total_principal_rembourse + total_interets_rembourse montant_total_du = loan_data['Montant_Total'] if total_rembourse >= montant_total_du: return { 'est_termine': True, 'critere': 'MONTANT_TOTAL', 'message': f'Montant total remboursé ({total_rembourse:,.0f} XOF >= {montant_total_du:,.0f} XOF)' } return { 'est_termine': False, 'critere': None, 'message': f'Prêt encore actif (Solde: {solde_apres:,.0f} XOF, Échéances: {nb_paiements_valides}/{nb_echeances_prevues})' } def analyser_remboursement_complet(self, id_pret, date_paiement, montant_verse, taux_penalite=0.05): """ Analyse complète d'un remboursement """ if isinstance(date_paiement, str): date_paiement = datetime.strptime(date_paiement, "%Y-%m-%d").date() loan_data = self.get_loan_data(id_pret) if loan_data is None: return {'error': 'Prêt non trouvé'} previous_payments = self.get_previous_payments(id_pret) echeance_info = self.detecter_echeance_attendue(id_pret, date_paiement) if echeance_info is None: return {'error': 'Impossible de déterminer l\'échéance'} penalites_info = self.calculer_penalites( echeance_info['montant_ajuste'], echeance_info['date_prevue'], date_paiement, taux_hebdo=taux_penalite ) decomposition = self.decomposer_versement( loan_data, echeance_info['numero'], montant_verse, penalites=penalites_info['montant_penalites'] ) soldes = self.calculer_soldes( loan_data, previous_payments, decomposition['montant_pour_dette'] ) statut = self.determiner_statut_paiement( penalites_info['jours_retard'], montant_verse, echeance_info['montant_ajuste'] ) montant_a_reporter = 0 if statut == "PARTIEL": montant_a_reporter = self.calculer_montant_a_reporter( echeance_info['montant_ajuste'], montant_verse, penalites_info['montant_penalites'] ) verification_cloture = self.verifier_si_pret_termine( id_pret, soldes['solde_apres'], decomposition['montant_principal'], decomposition['montant_interets'] ) return { 'id_pret': id_pret, 'id_client': loan_data['ID_Client'], 'numero_echeance': f"{echeance_info['numero']}/{echeance_info['total']}", 'date_echeance_prevue': echeance_info['date_prevue'], 'montant_echeance': echeance_info['montant_echeance'], 'montant_ajuste': echeance_info['montant_ajuste'], 'jours_retard': penalites_info['jours_retard'], 'montant_verse': montant_verse, 'montant_principal': decomposition['montant_principal'], 'montant_interets': decomposition['montant_interets'], 'penalites_retard': decomposition['montant_penalites'], 'solde_avant': soldes['solde_avant'], 'solde_apres': soldes['solde_apres'], 'statut_paiement': statut, 'montant_a_reporter': round(montant_a_reporter, 0), 'prochaine_echeance': echeance_info['numero'] + 1 if echeance_info['numero'] < echeance_info['total'] else None, 'doit_cloturer': verification_cloture['est_termine'], 'critere_cloture': verification_cloture['critere'], 'message_cloture': verification_cloture['message'] }