Spaces:
Running
Running
File size: 20,835 Bytes
5173474 a2e4f45 efaca3a 2f88465 efaca3a 2f88465 3bcf74e 2f88465 c346ea1 10b459d 56dc9b1 c346ea1 56dc9b1 10b459d 56dc9b1 c346ea1 efaca3a 10b459d 56dc9b1 e1ab0e9 56dc9b1 e1ab0e9 56dc9b1 e1ab0e9 56dc9b1 e1ab0e9 56dc9b1 e1ab0e9 56dc9b1 10b459d 5db2ff5 3bcf74e 64719e1 3bcf74e 64719e1 3bcf74e 64719e1 3bcf74e 64719e1 3bcf74e efaca3a 64719e1 3bcf74e 64719e1 3bcf74e 64719e1 3bcf74e 64719e1 3bcf74e 64719e1 3bcf74e 64719e1 3bcf74e efaca3a a2e4f45 efaca3a a2e4f45 efaca3a a2e4f45 ec1fcde 19b6a65 ec1fcde 19b6a65 ec1fcde 19b6a65 ec1fcde 19b6a65 ec1fcde a2e4f45 90546d3 a2e4f45 64719e1 2f88465 64719e1 a2e4f45 64719e1 a2e4f45 64719e1 a2e4f45 64719e1 a2e4f45 64719e1 a2e4f45 64719e1 a2e4f45 64719e1 90546d3 64719e1 90546d3 2f88465 64719e1 90546d3 64719e1 90546d3 a2e4f45 64719e1 a2e4f45 64719e1 a2e4f45 64719e1 a2e4f45 64719e1 a2e4f45 64719e1 90546d3 64719e1 90546d3 64719e1 90546d3 a2e4f45 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 | #Ce fichier est pour les initiations aux prêts et pour les modifications des prêts
#Il est utiliser dans loans et dans check up
import pandas as pd
from datetime import date, timedelta
# ============================================================================
# CONSTANTES ET SEUILS
# ============================================================================
SEUILS = {
"taux_endettement_mensuel": {
"excellent": 25, "bon": 33, "acceptable": 40, "critique": 50
},
"taux_effort_epargne": {
"excellent": 20, "bon": 30, "acceptable": 40, "critique": 50
},
"reste_a_vivre_min": {
"excellent": 50000, "bon": 30000, "acceptable": 20000, "critique": 15000
},
"liquidite_min_ratio": 1.2,
"duree_courte_semaines": 6
}
# ============================================================================
# FONCTIONS DE NETTOYAGE
# ============================================================================
def clean_currency_value(val):
"""Convertit une valeur monétaire (string ou float) en float propre"""
try:
if isinstance(val, (int, float)):
return float(val)
cleaned = str(val).upper().replace("XOF", "").replace("FCFA", "").replace(" ", "").replace(",", "")
return float(cleaned) if cleaned else 0.0
except (ValueError, AttributeError):
return 0.0
def clean_taux_value(val):
"""
Convertit un taux D'INTÉRÊT correctement.
Gère : "5,3%" → 5.3 | "5.3%" → 5.3 | "5.3" → 5.3 | 53 → 5.3
⚠️ ATTENTION : Cette fonction divise par 10 si > 20%, donc à utiliser UNIQUEMENT pour les taux d'intérêt !
Pour les taux d'endettement, utiliser clean_percentage_value()
"""
try:
if isinstance(val, str):
val = val.replace('%', '').replace(' ', '').replace(',', '.')
taux = float(val)
# Division par 10 si > 20% (logique spécifique aux taux d'intérêt)
if taux > 20:
taux = taux / 10
return round(taux, 2)
except (ValueError, AttributeError, TypeError):
return 0.0
def clean_percentage_value(val):
"""
Convertit un POURCENTAGE (taux d'endettement, etc.) SANS division par 10.
Gère : "54,94%" → 54.94 | "54.94" → 54.94 | "54,94" → 54.94 | 0.5494 → 54.94 | 5494 → 54.94
"""
import numpy as np # ✅ Import numpy pour gérer les types numpy
try:
# ✅ GESTION DES TYPES NUMPY (int64, float64, etc.)
if isinstance(val, (int, float, np.integer, np.floating)):
taux = float(val)
# ✅ Si > 100, c'est probablement stocké comme 5494 au lieu de 54.94
# On divise par 100
if taux > 100:
taux = taux / 100
# Si entre 0 et 1, c'est une fraction (0.5494 → 54.94%)
elif 0 < taux < 1:
taux = taux * 100
return round(taux, 2)
# ✅ GESTION DES STRINGS
elif isinstance(val, str):
# Retirer les symboles et normaliser les séparateurs décimaux
val = val.replace('%', '').replace(' ', '').replace(',', '.')
taux = float(val)
# Même logique
if taux > 100:
taux = taux / 100
elif 0 < taux < 1:
taux = taux * 100
return round(taux, 2)
else:
return 0.0
except (ValueError, AttributeError, TypeError):
return 0.0
# ============================================================================
# ✅ NOUVEAU — IPF : INDICE DE PRESSION FAMILIALE
# ============================================================================
def calculer_ipf(revenus_mensuels, pers_charge):
"""
Calcule l'IPF (Indice de Pression Familiale) = Revenus / (1 + Pers_Charge).
L'IPF reflète le revenu réellement disponible par unité de ménage.
Activé uniquement si le client a au moins 1 personne à charge
(colonne Pers_Charge du CSV KYC Vortex-Flux).
Args:
revenus_mensuels (float | str) : Revenus mensuels bruts du client.
pers_charge (int) : Nombre de personnes à charge (0 = pas d'ajustement).
Returns:
float : IPF arrondi à 2 décimales. Identique aux revenus si pers_charge == 0.
"""
revenus_mensuels = clean_currency_value(revenus_mensuels)
try:
pers_charge = int(pers_charge)
except (ValueError, TypeError):
pers_charge = 0
if pers_charge <= 0:
return round(revenus_mensuels, 2)
return round(revenus_mensuels / (1 + pers_charge), 2)
def get_revenus_ajustes(revenus_mensuels, pers_charge):
"""
Retourne la base de revenus à utiliser pour le calcul du taux d'endettement.
- pers_charge >= 1 → IPF = Revenus / (1 + Pers_Charge)
- pers_charge == 0 → revenus bruts (aucun ajustement)
Args:
revenus_mensuels (float | str) : Revenus mensuels du client.
pers_charge (int) : Nombre de personnes à charge (colonne Pers_Charge du KYC).
Returns:
tuple(float, bool) : (revenus_base, ipf_applique)
- revenus_base : montant à utiliser comme dénominateur du taux d'endettement
- ipf_applique : True si l'IPF a été appliqué (pour affichage dans l'UI)
"""
try:
pers_charge = int(pers_charge)
except (ValueError, TypeError):
pers_charge = 0
ipf = calculer_ipf(revenus_mensuels, pers_charge)
ipf_applique = pers_charge >= 1
return ipf, ipf_applique
# ============================================================================
# FONCTIONS DE CALCUL DU TAUX D'ENDETTEMENT
# ============================================================================
def calculer_taux_endettement(type_code, montant_versement, nb_versements, duree_semaines, revenus_mensuels, pers_charge=0):
"""
Calcule le taux d'endettement mensuel selon le type de prêt.
Retourne un float arrondi à 2 décimales.
✅ IPF : Si pers_charge >= 1 (colonne Pers_Charge du KYC), le calcul utilise
l'IPF = Revenus / (1 + Pers_Charge) comme base au lieu des revenus bruts.
"""
# ✅ Base de revenus ajustée par l'IPF si nécessaire
revenus_base, _ = get_revenus_ajustes(revenus_mensuels, pers_charge)
if revenus_base == 0:
return 0.0
# Calcul de la charge mensuelle selon le type
if type_code == "IN_FINE":
duree_mois = duree_semaines / 4.33
if duree_mois < 2:
charge_mensuelle = montant_versement / duree_mois
else:
charge_mensuelle = 0
elif type_code == "MENSUEL_INTERETS":
charge_mensuelle = montant_versement
elif type_code == "MENSUEL_CONSTANT":
charge_mensuelle = montant_versement
elif type_code == "HEBDOMADAIRE":
charge_mensuelle = montant_versement * 4.33
elif type_code == "PERSONNALISE":
charge_mensuelle = montant_versement * (nb_versements / (duree_semaines / 4.33)) if duree_semaines > 0 else montant_versement
else:
charge_mensuelle = 0
taux = (charge_mensuelle / revenus_base) * 100
return round(taux, 2)
# ============================================================================
# LOGIQUE D'AMORTISSEMENT
# ============================================================================
def generer_tableau_amortissement(type_code, montant, taux_hebdo, duree_semaines, montant_versement, nb_versements, date_debut, dates_versements=None):
tableau = []
if type_code == "IN_FINE":
date_fin = date_debut + timedelta(weeks=duree_semaines)
montant_total = montant * (1 + (taux_hebdo / 100) * duree_semaines)
interets = montant_total - montant
tableau.append({
"Periode": 1, "Date": date_fin.strftime("%d/%m/%Y"),
"Capital": round(montant), "Interets": round(interets),
"Versement": round(montant_total), "Solde_Restant": 0
})
elif type_code == "MENSUEL_INTERETS":
duree_mois = nb_versements
taux_mensuel = (taux_hebdo / 100) * 4.33
interet_mensuel = montant * taux_mensuel
solde = montant
for mois in range(1, duree_mois + 1):
date_echeance = date_debut + timedelta(days=30 * mois)
if mois == duree_mois:
capital_paye = montant
versement = montant + interet_mensuel
solde = 0
else:
capital_paye = 0
versement = interet_mensuel
tableau.append({
"Periode": mois, "Date": date_echeance.strftime("%d/%m/%Y"),
"Capital": round(capital_paye), "Interets": round(interet_mensuel),
"Versement": round(versement), "Solde_Restant": round(solde)
})
elif type_code == "MENSUEL_CONSTANT":
duree_mois = nb_versements
taux_mensuel = (taux_hebdo / 100) * 4.33
solde = montant
for mois in range(1, duree_mois + 1):
date_echeance = date_debut + timedelta(days=30 * mois)
interets = solde * taux_mensuel
capital_paye = montant_versement - interets
solde -= capital_paye
tableau.append({
"Periode": mois, "Date": date_echeance.strftime("%d/%m/%Y"),
"Capital": round(capital_paye), "Interets": round(interets),
"Versement": round(montant_versement), "Solde_Restant": max(0, round(solde))
})
elif type_code == "HEBDOMADAIRE":
taux_hebdo_decimal = taux_hebdo / 100
solde = montant
for semaine in range(1, int(duree_semaines) + 1):
date_echeance = date_debut + timedelta(weeks=semaine)
interets = solde * taux_hebdo_decimal
capital_paye = montant_versement - interets
solde -= capital_paye
tableau.append({
"Periode": semaine, "Date": date_echeance.strftime("%d/%m/%Y"),
"Capital": round(capital_paye), "Interets": round(interets),
"Versement": round(montant_versement), "Solde_Restant": max(0, round(solde))
})
elif type_code == "PERSONNALISE" and dates_versements:
# ✅ CORRECTION : Pour type PERSONNALISÉ, le solde doit partir du MONTANT TOTAL
# Car les intérêts sont répartis sur toute la durée
montant_total_pret = montant * (1 + (taux_hebdo / 100) * duree_semaines)
solde = montant_total_pret # ✅ On part du montant TOTAL (capital + intérêts)
for idx, date_v in enumerate(dates_versements):
solde_apres = max(0, solde - montant_versement)
# Répartition capital/intérêts proportionnelle
ratio_interets = (taux_hebdo / 100) * duree_semaines
interets_part = (montant_versement * ratio_interets) / (1 + ratio_interets)
capital_part = montant_versement - interets_part
tableau.append({
"Periode": idx + 1,
"Date": date_v.strftime("%d/%m/%Y"),
"Capital": round(capital_part),
"Interets": round(interets_part),
"Versement": round(montant_versement),
"Solde_Restant": round(solde_apres)
})
solde = solde_apres
return pd.DataFrame(tableau)
# ============================================================================
# LOGIQUE D'ANALYSE (CORRIGÉE - TOUS LES TYPES)
# ============================================================================
def analyser_capacite(type_code, montant, taux_hebdo, duree_semaines, montant_versement, nb_versements, revenus_mensuels, charges_mensuelles, montant_total=0, cout_credit=0, pers_charge=0):
"""
Analyse la capacité de remboursement du client.
✅ Nouveau paramètre : pers_charge (int) — colonne Pers_Charge du KYC CSV.
Si >= 1, tous les calculs de taux d'endettement et reste à vivre utilisent
l'IPF = Revenus / (1 + Pers_Charge) au lieu des revenus bruts, afin de
refléter la pression financière réelle du foyer.
"""
revenus_mensuels = clean_currency_value(revenus_mensuels)
charges_mensuelles = clean_currency_value(charges_mensuelles)
# ✅ Calcul de la base de revenus ajustée par l'IPF
revenus_base, ipf_applique = get_revenus_ajustes(revenus_mensuels, pers_charge)
ipf_note = (
f"\n📊 IPF appliqué ({int(pers_charge)} pers. à charge) : "
f"base revenus = {int(revenus_base):,} XOF "
f"(au lieu de {int(revenus_mensuels):,} XOF bruts)"
) if ipf_applique else ""
# --- LOGIQUE IN FINE ---
if type_code == "IN_FINE":
if duree_semaines < SEUILS["duree_courte_semaines"]:
duree_mois = duree_semaines / 4.33
revenus_cumules = revenus_base * duree_mois # ✅ IPF
charges_cumules = charges_mensuelles * duree_mois
disponible_echeance = revenus_cumules - charges_cumules
marge_securite = disponible_echeance - montant_total
ratio_couverture = disponible_echeance / montant_total if montant_total > 0 else 0
if ratio_couverture >= 1.5: statut, couleur, msg = "EXCELLENT", "green", "Le client dispose de liquidités très confortables."
elif ratio_couverture >= SEUILS["liquidite_min_ratio"]: statut, couleur, msg = "BON", "blue", "Liquidités suffisantes."
elif ratio_couverture >= 1.0: statut, couleur, msg = "ACCEPTABLE", "orange", "⚠️ Liquidités justes sans marge d'erreur."
else: statut, couleur, msg = "INSUFFISANT", "red", "❌ ATTENTION : Liquidités insuffisantes."
details = f"**Analyse Liquidité Directe**\nRatio couverture: {ratio_couverture:.2f}x\nDisponible: {int(disponible_echeance):,} XOF{ipf_note}"
return {"statut": statut, "couleur": couleur, "message": msg, "details": details, "metriques": {"ratio": ratio_couverture, "ipf_applique": ipf_applique, "revenus_base": revenus_base}}
else: # Épargne progressive
epargne_hebdo = montant_total / duree_semaines
revenus_hebdo = revenus_base / 4.33 # ✅ IPF
taux_effort = (epargne_hebdo / revenus_hebdo * 100) if revenus_hebdo > 0 else 0
if taux_effort <= SEUILS["taux_effort_epargne"]["excellent"]: statut, couleur, msg = "EXCELLENT", "green", "Capacité d'épargne très confortable."
elif taux_effort <= SEUILS["taux_effort_epargne"]["acceptable"]: statut, couleur, msg = "ACCEPTABLE", "orange", "⚠️ Capacité d'épargne limitée."
else: statut, couleur, msg = "INSUFFISANT", "red", "❌ Capacité insuffisante."
details = f"**Analyse Épargne**\nTaux effort: {taux_effort:.1f}%\nEpargne req/sem: {int(epargne_hebdo):,} XOF{ipf_note}"
return {"statut": statut, "couleur": couleur, "message": msg, "details": details, "metriques": {"taux_effort": taux_effort, "ipf_applique": ipf_applique, "revenus_base": revenus_base}}
# --- LOGIQUE MENSUEL - INTÉRÊTS ---
elif type_code == "MENSUEL_INTERETS":
duree_mois = nb_versements
taux_mensuel = (taux_hebdo / 100) * 4.33
interet_mensuel = montant * taux_mensuel
# ✅ IPF
taux_endettement = (interet_mensuel / revenus_base * 100) if revenus_base > 0 else 0
reste_vivre = revenus_base - charges_mensuelles - interet_mensuel
epargne_necessaire = montant / duree_mois
ratio_epargne = (epargne_necessaire / reste_vivre * 100) if reste_vivre > 0 else 0
if taux_endettement <= SEUILS["taux_endettement_mensuel"]["excellent"] and reste_vivre >= SEUILS["reste_a_vivre_min"]["bon"]:
statut, couleur, msg = "EXCELLENT", "green", "Capacité très confortable pour les intérêts mensuels."
elif taux_endettement <= SEUILS["taux_endettement_mensuel"]["bon"] and reste_vivre >= SEUILS["reste_a_vivre_min"]["acceptable"]:
statut, couleur, msg = "BON", "blue", "Bonne capacité de paiement des intérêts."
elif taux_endettement <= SEUILS["taux_endettement_mensuel"]["acceptable"]:
statut, couleur, msg = "ACCEPTABLE", "orange", "⚠️ Charge acceptable mais attention au capital final."
else:
statut, couleur, msg = "INSUFFISANT", "red", "🚨 Risque élevé : intérêts mensuels trop lourds."
if ratio_epargne > 50:
msg += f"\n⚠️ ATTENTION : Le capital final ({int(montant):,} XOF) nécessiterait une épargne de {int(epargne_necessaire):,} XOF/mois."
details = f"""**Analyse Mensuel - Intérêts**
Taux endettement (intérêts seuls): {taux_endettement:.1f}%
Intérêts mensuels: {int(interet_mensuel):,} XOF
Reste à vivre: {int(reste_vivre):,} XOF
Capital final à prévoir: {int(montant):,} XOF au mois {duree_mois}{ipf_note}"""
return {"statut": statut, "couleur": couleur, "message": msg, "details": details,
"metriques": {"taux_endettement": taux_endettement, "reste_vivre": reste_vivre, "ipf_applique": ipf_applique, "revenus_base": revenus_base}}
# --- LOGIQUE MENSUEL CONSTANT ---
elif type_code == "MENSUEL_CONSTANT":
# ✅ IPF
taux_endettement = (montant_versement / revenus_base * 100) if revenus_base > 0 else 0
reste_vivre = revenus_base - charges_mensuelles - montant_versement
if taux_endettement <= SEUILS["taux_endettement_mensuel"]["excellent"] and reste_vivre >= SEUILS["reste_a_vivre_min"]["excellent"]:
statut, couleur, msg = "EXCELLENT", "green", "Capacité très confortable."
elif taux_endettement <= SEUILS["taux_endettement_mensuel"]["acceptable"]:
statut, couleur, msg = "ACCEPTABLE", "orange", "⚠️ Marge limitée."
else:
statut, couleur, msg = "INSUFFISANT", "red", "🚨 Risque élevé."
details = f"**Analyse Endettement**\nTaux: {taux_endettement:.1f}%\nReste à vivre: {int(reste_vivre):,} XOF{ipf_note}"
return {"statut": statut, "couleur": couleur, "message": msg, "details": details, "metriques": {"taux_endettement": taux_endettement, "ipf_applique": ipf_applique, "revenus_base": revenus_base}}
# --- LOGIQUE HEBDOMADAIRE ---
elif type_code == "HEBDOMADAIRE":
charge_mensuelle = montant_versement * 4.33
# ✅ IPF
taux_endettement = (charge_mensuelle / revenus_base * 100) if revenus_base > 0 else 0
if taux_endettement <= SEUILS["taux_endettement_mensuel"]["bon"]: statut, couleur, msg = "BON", "blue", "Bonne capacité hebdo."
elif taux_endettement <= SEUILS["taux_endettement_mensuel"]["critique"]: statut, couleur, msg = "LIMITE", "red", "🚨 Charge hebdo élevée."
else: statut, couleur, msg = "INSUFFISANT", "darkred", "❌ Trop lourd."
details = f"**Analyse Rythme Hebdo**\nCharge mensuelle équivalente: {int(charge_mensuelle):,} XOF\nTaux: {taux_endettement:.1f}%{ipf_note}"
return {"statut": statut, "couleur": couleur, "message": msg, "details": details, "metriques": {"taux_endettement": taux_endettement, "ipf_applique": ipf_applique, "revenus_base": revenus_base}}
# --- LOGIQUE PERSONNALISÉ ---
elif type_code == "PERSONNALISE":
charge_mensuelle = montant_versement * (nb_versements / (duree_semaines / 4.33)) if duree_semaines > 0 else montant_versement
# ✅ IPF
taux_endettement = (charge_mensuelle / revenus_base * 100) if revenus_base > 0 else 0
if taux_endettement <= SEUILS["taux_endettement_mensuel"]["bon"]:
statut, couleur, msg = "BON", "blue", "Rythme personnalisé acceptable."
elif taux_endettement <= SEUILS["taux_endettement_mensuel"]["acceptable"]:
statut, couleur, msg = "ACCEPTABLE", "orange", "⚠️ Rythme à surveiller."
else:
statut, couleur, msg = "INSUFFISANT", "red", "🚨 Rythme trop exigeant."
details = f"**Analyse Rythme Personnalisé**\nCharge mensuelle moyenne: {int(charge_mensuelle):,} XOF\nTaux: {taux_endettement:.1f}%{ipf_note}"
return {"statut": statut, "couleur": couleur, "message": msg, "details": details, "metriques": {"taux_endettement": taux_endettement, "ipf_applique": ipf_applique, "revenus_base": revenus_base}}
# --- PAR DEFAUT (Ne devrait plus arriver) ---
return {"statut": "INCONNU", "couleur": "grey", "message": "Type non analysé", "details": "", "metriques": {}} |