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'], }