Vortex-Flux / src /Analytics /AnalyseRepayment.py
jaimalklyde's picture
Claude Haiku 5.5
fix: journalisation des refus Jev et ajustements du remboursement
238c4a3
Raw History Blame Contribute Delete
33.8 kB
import pandas as pd
from datetime import datetime, date as _date_cls
import math
def get_client_repayment_stats(df_remboursements, id_client):
"""
Historique de remboursement d'un client, tous prêts confondus.
Utilisé pour donner de la visibilité (client + gérant) sur les retards passés,
sans jamais bloquer la simulation/soumission.
"""
stats = {
"nb_echeances": 0,
"nb_retards": 0,
"ratio_retard": 0.0,
"retard_moyen_jours": 0.0,
"pire_retard_jours": 0.0,
}
if df_remboursements is None or df_remboursements.empty:
return stats
if 'ID_Client' not in df_remboursements.columns or 'Jours_Retard' not in df_remboursements.columns:
return stats
client_remb = df_remboursements[df_remboursements['ID_Client'] == id_client].copy()
if client_remb.empty:
return stats
def _to_float(x):
try:
s = str(x).strip().replace(' ', '').replace(',', '.')
return float(s) if s not in ('', 'nan') else 0.0
except (ValueError, TypeError):
return 0.0
client_remb['_retard_num'] = client_remb['Jours_Retard'].apply(_to_float)
stats["nb_echeances"] = int(len(client_remb))
# Seules les echeances reellement en retard (jours > 0) comptent ici.
# Un paiement en avance (jours negatifs) n'est pas un "retard negatif" :
# l'inclure dans la moyenne fausserait le chiffre (ex: moyenne negative,
# incoherente avec l'intitule "Retard moyen").
en_retard = client_remb[client_remb['_retard_num'] > 0]
stats["nb_retards"] = int(len(en_retard))
stats["ratio_retard"] = float((stats["nb_retards"] / stats["nb_echeances"]) * 100)
stats["retard_moyen_jours"] = float(en_retard['_retard_num'].mean()) if not en_retard.empty else 0.0
stats["pire_retard_jours"] = float(en_retard['_retard_num'].max()) if not en_retard.empty else 0.0
return stats
class AnalyseRepayment:
"""
Classe pour gerer toute la logique metier des remboursements.
Gestion des prets UPDATED via Prets_Update.
"""
def __init__(self, df_prets, df_remboursements, df_ajustements=None, df_prets_update=None):
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()
# =========================================================================
# RESOLUTION DU PRET
# =========================================================================
def _safe_version_int(self, v):
try:
return int(str(v).strip())
except (ValueError, TypeError):
return 0
def get_loan_data(self, id_pret):
"""
Recupere les donnees effectives d'un pret.
Si statut UPDATED -> cherche la version ACTIF la plus recente dans Prets_Update.
"""
loan = self.df_prets[self.df_prets['ID_Pret'] == id_pret]
if loan.empty:
return None
loan_data = loan.iloc[0]
if loan_data.get('Statut') == 'UPDATED' and not self.df_prets_update.empty:
subset = self.df_prets_update[
(self.df_prets_update['ID_Pret'].astype(str) == str(id_pret))
& (self.df_prets_update['Statut'] == 'ACTIF')
].copy()
if not subset.empty:
subset['_version_int'] = subset['Version'].apply(self._safe_version_int)
latest_row = subset.loc[subset['_version_int'].idxmax()]
return latest_row
return loan_data
# =========================================================================
# PAIEMENTS ANTERIEURS
# =========================================================================
def get_previous_payments(self, id_pret):
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 []
# =========================================================================
# PARSING DES DATES
# =========================================================================
def parse_echeances(self, dates_versements_str):
if not dates_versements_str or pd.isna(dates_versements_str):
return []
dates_versements_str = str(dates_versements_str)
separator = ';' if ';' in dates_versements_str else ','
dates_str = dates_versements_str.split(separator)
echeances = []
for d in dates_str:
d = d.strip()
if d:
try:
echeances.append(datetime.strptime(d, "%d/%m/%Y").date())
except ValueError:
continue
return echeances
# =========================================================================
# DETECTION DE L'ECHEANCE EN COURS
# =========================================================================
def detecter_echeance_attendue(self, id_pret, date_paiement):
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)
# 1. On compte UNIQUEMENT les paiements complets pour avancer d'échéance
paiements_complets = [p for p in previous_payments if 'PARTIEL' not in str(p.get('Statut_Paiement', '')).upper()]
nb_echeances_cloturees = len(paiements_complets)
numero_echeance = nb_echeances_cloturees + 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_echeances_cloturees == 0 and date_paiement < premiere_echeance:
numero_echeance = 1
date_prevue = echeances[numero_echeance - 1]
montant_echeance_base = loan_data['Montant_Versement']
# Ajustements_Echeances n'est plus ECRITE par aucun code actuel : le
# seul mecanisme qui y ajoutait des lignes (report manuel du
# reliquat d'un paiement partiel sur l'echeance suivante) a ete
# retire de repayments.py (cf. commentaire "ANCIEN COMPORTEMENT
# (retire)" a l'encaissement) car il doublait le montant du a payer
# des que le reliquat etait deja redemande automatiquement par
# detecter_echeance_attendue. Toute ligne que cette feuille contient
# encore est une relique de cet ancien systeme (ex: report cree sur
# une echeance ensuite soldee normalement sans jamais consommer
# cette ligne) : on ne l'applique donc plus jamais, pour ne pas
# fausser une echeance avec un montant qui ne lui correspond plus.
montant_ajuste = montant_echeance_base
# 2. Calcul du montant déjà versé (reliquat) sur CETTE tranche précise
prefix_echeance = f"{numero_echeance}/"
deja_verse_tranche = sum(
float(p.get('Montant_Principal', 0)) + float(p.get('Montant_Interets', 0))
for p in previous_payments
if str(p.get('Numero_Echeance', '')).startswith(prefix_echeance)
)
reliquat = montant_ajuste - deja_verse_tranche
est_partiel = deja_verse_tranche > 0 and reliquat > 0
return {
'numero': numero_echeance,
'total': len(echeances),
'date_prevue': date_prevue,
'montant_echeance': montant_echeance_base,
'montant_ajuste': montant_ajuste,
'deja_verse': deja_verse_tranche,
'reliquat': max(0, reliquat),
'est_partiel': est_partiel
}
# =========================================================================
# CALCUL DES PENALITES
# =========================================================================
def calculer_penalites(self, montant_echeance, date_echeance_prevue, date_paiement, taux_hebdo=0.05):
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),
}
# =========================================================================
# SCENARIOS DE PENALITES
# =========================================================================
def penalites_deja_encaissees(self, previous_payments, numero_echeance):
"""Penalites deja prelevees sur les paiements anterieurs de CETTE echeance."""
prefix = f"{numero_echeance}/"
total = 0.0
for p in previous_payments:
if str(p.get('Numero_Echeance', '')).startswith(prefix):
try:
total += float(p.get('Penalites_Retard', 0) or 0)
except (TypeError, ValueError):
continue
return total
@staticmethod
def appliquer_deja_paye(scenario, montant_echeance, deja_verse=0.0, penalites_deja=0.0):
"""Ramene un scenario a ce qui reste reellement a encaisser sur l'echeance :
les penalites sont dues une seule fois par echeance (on retire celles deja
prelevees) et la base est le reliquat (echeance - deja verse)."""
s = dict(scenario)
reliquat = max(0.0, montant_echeance - deja_verse)
s['montant_echeance'] = reliquat
s['montant_penalites'] = max(0.0, s.get('montant_penalites', 0) - penalites_deja)
s['total_a_encaisser'] = reliquat + s['montant_penalites']
return s
def projection_penalites_rappel(self, r, today=None):
"""Penalites annoncees dans un rappel (taux standard 5 %/semaine), avec la MEME
regle que l'encaissement au guichet : calculees sur l'echeance entiere, moins
les penalites deja prelevees sur les paiements partiels de cette echeance.
`r` est une ligne du radar (get_echeances_imminentes). Retourne None si
l'echeance n'est pas encore echue ; echeance du jour : projection a demain."""
from datetime import timedelta
if r['delta'] > 0:
return None
today = today or _date_cls.today()
date_ref = today if r['delta'] < 0 else today + timedelta(days=1)
loan_data = self.get_loan_data(r['root_id'])
# Ajustements_Echeances n'est plus lue ici : voir le commentaire dans
# detecter_echeance_attendue (feuille plus jamais ecrite depuis le
# retrait de l'ancien report manuel de reliquat).
base = float(loan_data['Montant_Versement'])
brut = self.calculer_penalites(base, r['next_date'], date_ref, taux_hebdo=0.05)
deja = self.penalites_deja_encaissees(self.get_previous_payments(r['root_id']), r['numero_echeance'])
return dict(brut, montant_penalites=max(0.0, brut['montant_penalites'] - deja))
def generer_scenarios_penalites(self, montant_echeance, date_echeance_prevue, date_paiement,
deja_verse=0.0, penalites_deja=0.0):
scenario1 = self.calculer_penalites(montant_echeance, date_echeance_prevue, date_paiement, taux_hebdo=0.05)
scenario1['label'] = "Reglement Standard"
scenario1['taux'] = 5.0
scenario2 = {
'label': "Geste Commercial",
'taux': 0.0,
'jours_retard': scenario1['jours_retard'],
'semaines_retard': 0,
'montant_penalites': 0,
}
scenario3 = self.calculer_penalites(montant_echeance, date_echeance_prevue, date_paiement, taux_hebdo=0.025)
scenario3['label'] = "Taux Personnalise"
scenario3['taux'] = 2.5
return [
self.appliquer_deja_paye(sc, montant_echeance, deja_verse, penalites_deja)
for sc in (scenario1, scenario2, scenario3)
]
# =========================================================================
# DECOMPOSITION DU VERSEMENT (Conscient du reliquat)
# =========================================================================
def decomposer_versement(self, loan_data, numero_echeance, montant_verse, penalites=0, interets_deja_payes=0, principal_deja_paye=0, principal_supplementaire=0):
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
# On soustrait ce qui a déjà été payé lors de précédents partiels
interets_restants = max(0, interets_par_echeance - interets_deja_payes)
principal_restant = max(0, principal_par_echeance - principal_deja_paye) + principal_supplementaire
montant_pour_dette = montant_verse - penalites
if montant_pour_dette >= (interets_restants + principal_restant):
return {
'montant_principal': round(principal_restant, 0),
'montant_interets': round(interets_restants, 0),
'montant_penalites': round(penalites, 0),
'montant_pour_dette': round(montant_pour_dette, 0),
}
if montant_pour_dette >= interets_restants:
montant_interets = interets_restants
montant_principal = montant_pour_dette - interets_restants
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),
}
# =========================================================================
# CALCUL DES SOLDES
# =========================================================================
def calculer_soldes(self, loan_data, previous_payments, montant_pour_dette):
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),
}
# =========================================================================
# STATUT DU PAIEMENT (Combinaison Temps + Montant)
# =========================================================================
def determiner_statut_paiement(self, jours_retard, montant_verse, montant_echeance_ajuste, deja_verse_tranche=0):
reliquat = montant_echeance_ajuste - deja_verse_tranche
tolerance = montant_echeance_ajuste * 0.01
montant_min = reliquat - tolerance
# 1. Évaluation du Timing
timing = "PONCTUEL"
if jours_retard > 0:
timing = "EN_RETARD"
elif jours_retard < 0:
timing = "ANTICIPE"
# 2. Évaluation de la Complétion
if montant_verse < montant_min:
# Concaténation : ex -> "ANTICIPE; PARTIEL" ou juste "PARTIEL"
return f"{timing}; PARTIEL" if timing != "PONCTUEL" else "PARTIEL"
return timing
def calculer_montant_a_reporter(self, montant_echeance_ajuste, montant_verse, penalites, deja_verse=0):
montant_pour_dette = montant_verse - penalites
return max(0, montant_echeance_ajuste - (deja_verse + montant_pour_dette))
# =========================================================================
# VERIFICATION DE CLOTURE (Protection Partiel)
# =========================================================================
def verifier_si_pret_termine(self, id_pret, solde_apres, montant_principal_paye, montant_interets_paye, statut_paiement_actuel=""):
loan_data = self.get_loan_data(id_pret)
if loan_data is None:
return {'est_termine': False, 'critere': None, 'message': 'Pret non trouve'}
if solde_apres <= 0:
return {
'est_termine': True,
'critere': 'SOLDE_ZERO',
'message': f'Solde integralement rembourse (Solde final : {solde_apres:,.0f} XOF)',
}
echeances = self.parse_echeances(loan_data['Dates_Versements'])
nb_echeances_prevus = len(echeances)
previous_payments = self.get_previous_payments(id_pret)
paiements_complets = [p for p in previous_payments if 'PARTIEL' not in str(p.get('Statut_Paiement', '')).upper()]
# Si le paiement ACTUEL est partiel, il ne clôture pas l'échéance finale
est_actuel_partiel = 'PARTIEL' in statut_paiement_actuel.upper()
nb_paiements_valides = len(paiements_complets) + (0 if est_actuel_partiel else 1)
# Ajustements_Echeances n'est plus lue ici : voir le commentaire dans
# detecter_echeance_attendue (feuille plus jamais ecrite depuis le
# retrait de l'ancien report manuel de reliquat) — une ligne morte
# ne doit plus pouvoir bloquer la cloture automatique d'un pret
# dont toutes les echeances prevues sont reellement payees.
if nb_paiements_valides >= nb_echeances_prevus:
# Garde-fou : ne jamais clore un pret qui a encore un solde du (au-dela
# de la tolerance de 1 % d'une echeance, la meme que le statut PARTIEL).
try:
tolerance_solde = max(1.0, 0.01 * float(loan_data['Montant_Versement']))
except (KeyError, TypeError, ValueError):
tolerance_solde = 1.0
if solde_apres > tolerance_solde:
return {
'est_termine': False,
'critere': None,
'message': (
f'Toutes les echeances sont passees mais il reste {solde_apres:,.0f} XOF du : '
f'pret maintenu actif ({nb_paiements_valides}/{nb_echeances_prevus})'
),
}
return {
'est_termine': True,
'critere': 'TOUTES_ECHEANCES',
'message': f'Toutes les echeances payees ({nb_paiements_valides}/{nb_echeances_prevus})',
}
total_principal = sum(p.get('Montant_Principal', 0) for p in previous_payments) + montant_principal_paye
total_interets = sum(p.get('Montant_Interets', 0) for p in previous_payments) + montant_interets_paye
total_rembourse = total_principal + total_interets
montant_total_du = loan_data['Montant_Total']
if total_rembourse >= montant_total_du:
return {
'est_termine': True,
'critere': 'MONTANT_TOTAL',
'message': f'Montant total rembourse ({total_rembourse:,.0f} >= {montant_total_du:,.0f} XOF)',
}
return {
'est_termine': False,
'critere': None,
'message': (
f'Pret encore actif '
f'(Solde : {solde_apres:,.0f} XOF, '
f'Echeances : {nb_paiements_valides}/{nb_echeances_prevus})'
),
}
# =========================================================================
# ECHEANCES IMMINENTES (Radar)
# =========================================================================
def get_echeances_imminentes(self, eligible_pool: list, today=None, n: int = 10) -> list:
from datetime import date as _date
if today is None:
today = _date.today()
results = []
for entry in eligible_pool:
root_id = entry.get('root_id', '')
cid = entry.get('id_client', '')
row = entry.get('row')
type_prt = entry.get('type_pret', '')
if row is None:
continue
rem_pret = pd.DataFrame()
est_partiel = False
statut_dernier = None
if not self.df_remboursements.empty and 'ID_Pret' in self.df_remboursements.columns:
rem_pret = self.df_remboursements[
self.df_remboursements['ID_Pret'].astype(str) == str(root_id)
].copy()
# ── Identifier le dernier paiement et verifier s'il est PARTIEL ──
if not rem_pret.empty:
try:
rem_pret['_ts'] = pd.to_datetime(
rem_pret['Timestamp'] if 'Timestamp' in rem_pret.columns
else rem_pret.get('Date_Paiement', pd.Series(dtype=str)),
errors='coerce'
)
dernier = rem_pret.sort_values('_ts').iloc[-1]
except Exception:
dernier = rem_pret.iloc[-1]
statut_dernier = str(dernier.get('Statut_Paiement', '')).upper()
if 'PARTIEL' in statut_dernier:
est_partiel = True
# ── Calcul de nb_done : NE PAS compter les partiels comme terminés ─
# Chaque paiement PARTIEL laisse son echeance due : on compte seulement
# les paiements non partiels. len-1 ne suffit pas quand plusieurs
# partiels s'enchainent sur la meme echeance (saut a une echeance trop loin).
if not rem_pret.empty:
statuts_rem = rem_pret['Statut_Paiement'].astype(str).str.upper()
nb_done = int((~statuts_rem.str.contains('PARTIEL')).sum())
else:
nb_done = 0
# ── Dates d'echeances ─────────────────────────────────────────────
try:
dates_str = (
row.get('Dates_Versements', '')
if hasattr(row, 'get')
else getattr(row, 'Dates_Versements', '')
)
except Exception:
continue
echeances = self.parse_echeances(dates_str)
if not echeances:
continue
idx_next = min(nb_done, len(echeances) - 1)
next_date = echeances[idx_next]
delta = (next_date - today).days
# ── Montant de base de la tranche ─────────────────────────────────
try:
montant_base = int(
row.get('Montant_Versement', 0)
if hasattr(row, 'get')
else getattr(row, 'Montant_Versement', 0)
)
except Exception:
montant_base = 0
# Ajustements_Echeances n'est plus lue ici : voir le commentaire
# dans detecter_echeance_attendue (feuille plus jamais ecrite
# depuis le retrait de l'ancien report manuel de reliquat).
montant = montant_base
# ── Calcul mathématique du Reliquat pour le Radar ─────────────────
# Si le dernier paiement est partiel, on calcule exactement ce qui a été versé
# sur cette échéance, et on met à jour le montant affiché.
if est_partiel:
prefix_echeance = f"{idx_next + 1}/"
deja_verse_tranche = 0
if not rem_pret.empty:
for _, p in rem_pret.iterrows():
if str(p.get('Numero_Echeance', '')).startswith(prefix_echeance):
deja_verse_tranche += float(p.get('Montant_Principal', 0)) + float(p.get('Montant_Interets', 0))
reliquat = montant - deja_verse_tranche
if reliquat > 0:
montant = reliquat
# Statut de l'echeance
if delta < 0:
statut_echeance = 'EN_RETARD'
elif delta <= 3:
statut_echeance = 'CRITIQUE'
elif delta <= 7:
statut_echeance = 'IMMINENT'
else:
statut_echeance = 'A_VENIR'
# Si le paiement est partiel et en retard, on combine les statuts pour la couleur du radar
if est_partiel and statut_echeance == 'EN_RETARD':
statut_echeance = 'EN_RETARD; PARTIEL'
results.append({
'root_id': root_id,
'id_client': cid,
'type_pret': type_prt,
'source': entry.get('source', 'MASTER'),
'next_date': next_date,
'delta': delta,
'montant': montant,
'est_partiel': est_partiel,
'statut_dernier': statut_dernier,
'numero_echeance': idx_next + 1,
'total_echeances': len(echeances),
'statut_echeance': statut_echeance,
})
results.sort(key=lambda x: x['delta'])
return results[:n]
# =========================================================================
# ANALYSE COMPLETE D'UN REMBOURSEMENT (Orchestration Globale)
# =========================================================================
def analyser_remboursement_complet(self, id_pret, date_paiement, montant_verse, taux_penalite=0.05):
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': 'Pret non trouve'}
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 determiner l'echeance"}
penalites_info = self.calculer_penalites(
echeance_info['montant_ajuste'],
echeance_info['date_prevue'],
date_paiement,
taux_hebdo=taux_penalite,
)
# Les penalites sont dues UNE SEULE FOIS par echeance : on retire celles deja
# prelevees sur les paiements partiels precedents de cette echeance.
deja_verse_tranche = echeance_info.get('deja_verse', 0)
penalites_deja = self.penalites_deja_encaissees(previous_payments, echeance_info['numero'])
penalites_dues = max(0.0, penalites_info['montant_penalites'] - penalites_deja)
# Refus du trop-percu : l'excedent n'est ventile nulle part (ni capital, ni
# interets, ni penalites) et fausse les soldes. A enregistrer en paiement
# distinct de l'echeance suivante.
reliquat_echeance = max(0.0, echeance_info['montant_ajuste'] - deja_verse_tranche)
# Derniere echeance : elle absorbe aussi le solde restant des echeances
# precedentes soldees dans la tolerance de 1 % (sinon ce reliquat cumule ne
# pourrait plus jamais etre encaisse et le pret resterait ouvert).
arriere_dernier = 0.0
if echeance_info['numero'] >= echeance_info['total']:
deja_rembourse = sum(
float(p.get('Montant_Principal', 0) or 0) + float(p.get('Montant_Interets', 0) or 0)
for p in previous_payments
)
solde_avant_pret = max(0.0, float(loan_data['Montant_Total']) - deja_rembourse)
arriere_dernier = max(0.0, solde_avant_pret - reliquat_echeance)
total_du_echeance = reliquat_echeance + arriere_dernier + penalites_dues
if montant_verse > total_du_echeance + 1:
return {'error': (
f"Montant superieur au du sur l'echeance {echeance_info['numero']}/{echeance_info['total']} "
f"({total_du_echeance:,.0f} XOF, penalites comprises). Encaissez ce montant, puis enregistrez "
f"le surplus ({montant_verse - total_du_echeance:,.0f} XOF) comme paiement de l'echeance suivante."
)}
# Les penalites sont prelevees en premier, dans la limite de ce qui est verse :
# un paiement inferieur aux penalites ne produit jamais d'interets negatifs.
penalites_prelevees = min(penalites_dues, max(0.0, float(montant_verse)))
penalites_info = dict(penalites_info, montant_penalites=penalites_prelevees)
# Récupération de l'historique financier propre à cette tranche pour la décomposition
prefix_echeance = f"{echeance_info['numero']}/"
interets_deja_payes = sum(float(p.get('Montant_Interets', 0)) for p in previous_payments if str(p.get('Numero_Echeance', '')).startswith(prefix_echeance))
principal_deja_paye = sum(float(p.get('Montant_Principal', 0)) for p in previous_payments if str(p.get('Numero_Echeance', '')).startswith(prefix_echeance))
decomposition = self.decomposer_versement(
loan_data,
echeance_info['numero'],
montant_verse,
penalites=penalites_info['montant_penalites'],
interets_deja_payes=interets_deja_payes,
principal_deja_paye=principal_deja_paye,
principal_supplementaire=arriere_dernier,
)
soldes = self.calculer_soldes(
loan_data,
previous_payments,
decomposition['montant_pour_dette'],
)
# La completion se juge sur ce qui rembourse reellement la dette
# (versement - penalites), pas sur le versement brut : sinon un client
# qui verse exactement l'echeance en retard (penalites incluses dans ce
# versement) est considere a jour alors qu'il manque le montant des penalites.
statut = self.determiner_statut_paiement(
penalites_info['jours_retard'],
decomposition['montant_pour_dette'],
echeance_info['montant_ajuste'],
deja_verse_tranche=deja_verse_tranche
)
montant_a_reporter = 0
if "PARTIEL" in statut:
montant_a_reporter = self.calculer_montant_a_reporter(
echeance_info['montant_ajuste'],
montant_verse,
penalites_info['montant_penalites'],
deja_verse=deja_verse_tranche
)
verification_cloture = self.verifier_si_pret_termine(
id_pret,
soldes['solde_apres'],
decomposition['montant_principal'],
decomposition['montant_interets'],
statut_paiement_actuel=statut
)
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'],
'penalites_impayees': round(penalites_dues - penalites_prelevees, 0),
'total_du_echeance': round(total_du_echeance, 0),
'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'],
}