Spaces:
Running
Running
Download src/Analytics/AnalyseRepayment.py from klydekushy/Vortex-Flux: direct link, hf CLI and curl.
- Browser
- Download file 33.8 kB
-
https://huggingface.co/spaces/klydekushy/Vortex-Flux/resolve/main/src/Analytics/AnalyseRepayment.py
- Command line
-
hf download hf://spaces/klydekushy/Vortex-Flux/src/Analytics/AnalyseRepayment.py
-
curl -L -o AnalyseRepayment.py https://huggingface.co/spaces/klydekushy/Vortex-Flux/resolve/main/src/Analytics/AnalyseRepayment.py
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 | |
| 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'], | |
| } |