Vortex-Flux / src /Analytics /AnalyseRepayment.py
klydekushy's picture
Update src/Analytics/AnalyseRepayment.py
8acf794 verified
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']
}