Spaces:
Running
Running
| #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": {}} |