Spaces:
Running
Running
| 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'] | |
| } |