File size: 16,302 Bytes
4f24ce4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
397b23d
4f24ce4
 
 
 
 
397b23d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c922ea2
 
 
3b3da67
c922ea2
4d00d77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c922ea2
4d00d77
 
 
 
 
 
c922ea2
c20487a
 
 
 
 
 
 
 
 
 
96cc64a
 
 
 
 
397b23d
4f24ce4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
631cd58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4f24ce4
631cd58
 
 
 
4f24ce4
 
631cd58
 
 
 
 
 
397b23d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
PARAMETRES VORTEX - ACCESSEUR PARTAGE
======================================
Source de verite unique pour les parametres transverses stockes dans
l'onglet Google Sheets `Parametres_Vortex` (objectif annuel, horodatages
de synchronisation, etc.).

Le schema existant (`Timestamp, Variable, Marge_Globale, Raison`) est
utilise par jasmine_optimizer.py (ecriture de la marge) et lu par
client_simulator.py (filtre sur Variable == 'Marge_Globale_Optimizer').
Ce module ajoute une 5e colonne `Valeur`, de maniere additive uniquement :
aucune ligne existante n'est modifiee, aucune migration n'est necessaire.
"""

import pandas as pd
import json
from datetime import datetime

HEADER = ["Timestamp", "Variable", "Marge_Globale", "Raison", "Valeur"]
OBJECTIF_ANNUEL_VARIABLE = "Objectif_Annuel_Profit"
DERNIER_SYNC_FORECASTING_VARIABLE = "Dernier_Sync_Forecasting"
MARGE_OPTIMIZER_VARIABLE = "Marge_Globale_Optimizer"

# ==============================================================================
# RÈGLES MÉTIER DU SIMULATEUR — pilotables depuis le module "Règles Métier"
# (modules/regles_metier.py) sans redéploiement. Chaque écriture via
# set_regle_metier() ajoute une nouvelle ligne horodatée dans Parametres_Vortex
# (jamais de modification en place) : l'historique complet des valeurs
# appliquées dans le temps est donc toujours disponible pour audit/comparatif.
# ==============================================================================
REGLES_METIER = {
    "Regle_Whitelist_Decouvert_Privilege": {
        "label": "Clients éligibles — Offre Découvert Privilège",
        "type": "liste",
        "default": ["CLI-2026-0001", "CLI-2026-0002", "CLI-2026-0005", "CLI-2026-0014", "CLI-2026-0016"],
        "description": "ID_Client autorisés à souscrire l'Offre Découvert Privilège dans le simulateur public.",
    },
    "Regle_Seuil_Petit_Montant": {
        "label": "Seuil petit montant (taux plancher forcé)",
        "type": "float", "default": 60000.0, "unite": "XOF",
        "description": "En dessous de ce capital demandé (hors Découvert Privilège), le taux hebdo est forcé au plancher dérogatoire.",
    },
    "Regle_Seuil_Gros_Montant_Risque": {
        "label": "Seuil gros montant à risque",
        "type": "float", "default": 80000.0, "unite": "XOF",
        "description": "Au-dessus de ce capital demandé, si le taux brut IA dépasse le seuil de risque, la marge stratégique est annulée.",
    },
    "Regle_Taux_Brut_Seuil_Annulation_Marge": {
        "label": "Seuil taux brut IA (annulation marge)",
        "type": "float", "default": 3.0, "unite": "%/semaine",
        "description": "Au-delà de ce taux de risque brut (modèle), combiné au seuil de gros montant, la marge stratégique est annulée.",
    },
    "Regle_Taux_Plafond_Decouvert_Court": {
        "label": "Plafond taux — Découvert Privilège (< 6 mois)",
        "type": "float", "default": 5.0, "unite": "%/semaine",
        "description": "Plafond du taux hebdomadaire pour l'Offre Découvert Privilège sur une durée inférieure à 6 mois.",
    },
    "Regle_Taux_Plafond_Decouvert_Long": {
        "label": "Plafond taux — Découvert Privilège (≥ 6 mois)",
        "type": "float", "default": 3.0, "unite": "%/semaine",
        "description": "Taux préférentiel garanti (plafond) pour l'Offre Découvert Privilège sur une durée de 6 mois ou plus.",
    },
    "Regle_Taux_Force_Petit_Montant": {
        "label": "Taux plancher forcé — petit montant",
        "type": "float", "default": 5.0, "unite": "%/semaine",
        "description": "Taux hebdomadaire plancher forcé pour les demandes sous le seuil de petit montant (hors Découvert Privilège).",
    },
    "Regle_Taux_Plancher_Classique": {
        "label": "Taux plancher — offres classiques",
        "type": "float", "default": 2.0, "unite": "%/semaine",
        "description": "Borne basse du taux hebdomadaire pour les offres autres que Découvert Privilège, au-dessus du seuil petit montant.",
    },
    "Regle_Taux_Plafond_Classique": {
        "label": "Taux plafond — offres classiques",
        "type": "float", "default": 7.0, "unite": "%/semaine",
        "description": "Borne haute du taux hebdomadaire pour les offres autres que Découvert Privilège, au-dessus du seuil petit montant.",
    },
    "Regle_Seuil_Jours_Retard_Eligible": {
        "label": "Seuil de retard éligible à la majoration",
        "type": "float", "default": 3.0, "unite": "jours",
        "description": "Nombre de jours de retard à partir duquel (inclus) un remboursement pardonné (pénalité à 0) compte comme une occurrence facturable dans une future simulation.",
    },
    "Regle_Penalite_Retard_Seuil_Moyen": {
        "label": "Majoration retard — seuil « montant moyen »",
        "type": "float", "default": 50000.0, "unite": "XOF",
        "description": "Montant demandé à partir duquel (inclus) s'applique le taux « montant moyen » de la majoration pour retard pardonné, plutôt que le taux « petit montant ».",
    },
    "Regle_Penalite_Retard_Seuil_Grand": {
        "label": "Majoration retard — seuil « montant élevé »",
        "type": "float", "default": 150000.0, "unite": "XOF",
        "description": "Montant demandé strictement au-dessus duquel s'applique le taux « montant élevé » de la majoration pour retard pardonné, plutôt que le taux « montant moyen ».",
    },
    "Regle_Penalite_Retard_Pct_Petit": {
        "label": "Majoration retard — taux petit montant",
        "type": "float", "default": 5.0, "unite": "% (forfait)",
        "description": "Majoration forfaitaire appliquée quand le montant demandé est inférieur au seuil « montant moyen ».",
    },
    "Regle_Penalite_Retard_Pct_Moyen": {
        "label": "Majoration retard — taux montant moyen",
        "type": "float", "default": 2.5, "unite": "% (forfait)",
        "description": "Majoration forfaitaire appliquée quand le montant demandé est entre le seuil « montant moyen » et le seuil « montant élevé » (bornes incluses).",
    },
    "Regle_Penalite_Retard_Pct_Grand": {
        "label": "Majoration retard — taux montant élevé",
        "type": "float", "default": 2.0, "unite": "% (forfait)",
        "description": "Majoration forfaitaire appliquée quand le montant demandé dépasse le seuil « montant élevé ».",
    },
    "Regle_Duree_Minimale_Semaines": {
        "label": "Durée minimale d'engagement",
        "type": "float", "default": 2.0, "unite": "semaines",
        "description": "En dessous de cette durée réelle, le taux hebdomadaire normal est remplacé par un forfait fixe (voir Forfait — durée minimale non respectée), quel que soit le type de remboursement.",
    },
    "Regle_Forfait_Duree_Minimale_Pct": {
        "label": "Forfait — durée minimale non respectée",
        "type": "float", "default": 10.0, "unite": "% du capital (forfait)",
        "description": "Forfait fixe (% du capital financé) facturé quand la durée réelle est inférieure à la durée minimale d'engagement, à la place du taux hebdomadaire calculé.",
    },
    "Regle_Plafond_Marge_Optimizer": {
        "label": "Plafond de marge stratégique — Optimizer",
        "type": "float", "default": 1.0, "unite": "%/semaine",
        "description": "Marge maximale recommandée par les scénarios de l'Optimizer; la marge brute requise reste affichée séparément.",
    },
}


def _get_worksheet(client, sheet_name):
    sh = client.open(sheet_name)
    try:
        return sh.worksheet("Parametres_Vortex")
    except Exception:
        ws = sh.add_worksheet("Parametres_Vortex", 100, len(HEADER))
        ws.append_row(HEADER)
        return ws


def _ensure_valeur_column(ws):
    """Ajoute l'en-tete 'Valeur' en 5e colonne si elle n'existe pas encore (idempotent)."""
    try:
        row1 = ws.row_values(1)
    except Exception:
        return
    if len(row1) < 5 or row1[4] != "Valeur":
        ws.update_cell(1, 5, "Valeur")


def get_parametre(client, sheet_name, variable, default=None):
    """Renvoie la derniere valeur enregistree pour `variable` (colonne Valeur), ou `default`."""
    try:
        ws = _get_worksheet(client, sheet_name)
        df = pd.DataFrame(ws.get_all_records())
        if df.empty or "Variable" not in df.columns or "Valeur" not in df.columns:
            return default
        rows = df[df["Variable"] == variable]
        if rows.empty:
            return default
        valeur = rows["Valeur"].iloc[-1]
        if valeur is None or str(valeur).strip() == "":
            return default
        return valeur
    except Exception:
        return default


def set_parametre(client, sheet_name, variable, valeur, raison=""):
    """Ajoute une nouvelle ligne journalisant `variable` = `valeur` dans Parametres_Vortex."""
    try:
        ws = _get_worksheet(client, sheet_name)
        _ensure_valeur_column(ws)
        date_now = datetime.now().strftime("%d/%m/%Y %H:%M:%S")
        ws.append_row([date_now, variable, "", raison, str(valeur)])
        return True
    except Exception:
        return False


# Objectifs de benefice par exercice tant qu'aucune valeur n'est enregistree
# dans Parametres_Vortex pour l'annee (fixes par le gerant).
OBJECTIFS_PAR_DEFAUT = {2026: 2_200_000.0, 2027: 3_500_000.0}


def variable_objectif(annee: int) -> str:
    """Nom de la variable Parametres_Vortex de l'objectif d'un exercice."""
    return f"{OBJECTIF_ANNUEL_VARIABLE}_{int(annee)}"


def objectif_depuis_df(df: pd.DataFrame, annee: int, default=None) -> float:
    """Objectif de benefice de l'exercice `annee`, par ordre de priorite :
    1. derniere valeur de la variable Objectif_Annuel_Profit_<annee> ;
    2. ancienne variable sans annee (Objectif_Annuel_Profit), rattachee a
       l'annee de son Timestamp (valeurs saisies avant la separation par annee) ;
    3. OBJECTIFS_PAR_DEFAUT[annee] ;
    4. objectif connu de l'exercice precedent le plus proche (annee future) ;
    5. `default`, sinon 0."""
    annee = int(annee)

    def _dernier(lignes):
        if lignes.empty or "Valeur" not in lignes.columns:
            return None
        for v in reversed(lignes["Valeur"].tolist()):
            try:
                if str(v).strip() != "":
                    return float(str(v).replace(",", ".").replace(" ", ""))
            except (TypeError, ValueError):
                continue
        return None

    if isinstance(df, pd.DataFrame) and not df.empty and "Variable" in df.columns:
        valeur = _dernier(df[df["Variable"] == variable_objectif(annee)])
        if valeur is not None:
            return valeur
        anciens = df[df["Variable"] == OBJECTIF_ANNUEL_VARIABLE]
        if not anciens.empty and "Timestamp" in anciens.columns:
            from core.exercice import annee_de
            valeur = _dernier(anciens[annee_de(anciens["Timestamp"]).eq(annee).fillna(False).to_numpy()])
            if valeur is not None:
                return valeur
    if annee in OBJECTIFS_PAR_DEFAUT:
        return OBJECTIFS_PAR_DEFAUT[annee]
    if annee > min(OBJECTIFS_PAR_DEFAUT):
        return objectif_depuis_df(df, annee - 1, default)
    return float(default) if default is not None else 0.0


def get_objectif_annuel(client, sheet_name, default=None, annee=None):
    """Objectif de benefice de l'exercice `annee` (exercice courant par defaut)."""
    from core.exercice import exercice_courant
    annee = annee or exercice_courant()
    try:
        df = pd.DataFrame(_get_worksheet(client, sheet_name).get_all_records())
    except Exception:
        df = pd.DataFrame()
    return objectif_depuis_df(df, annee, default)


def set_objectif_annuel(client, sheet_name, nouvelle_valeur, raison="Ajustement manuel", annee=None):
    """Enregistre l'objectif de l'exercice `annee` (exercice courant par defaut)."""
    from core.exercice import exercice_courant
    annee = annee or exercice_courant()
    return set_parametre(client, sheet_name, variable_objectif(annee), nouvelle_valeur,
                         f"{raison} (exercice {annee})")


# ==============================================================================
# ACCÈS AUX RÈGLES MÉTIER (lecture groupée + écriture historisée)
# ==============================================================================

def charger_df_parametres(client, sheet_name) -> pd.DataFrame:
    """Lit Parametres_Vortex EN UNE SEULE FOIS. A reutiliser pour en extraire
    plusieurs valeurs (regles metier, marge strategique...) sans multiplier les
    appels Google Sheets."""
    try:
        ws = _get_worksheet(client, sheet_name)
        return pd.DataFrame(ws.get_all_records())
    except Exception:
        return pd.DataFrame()


def _parse_valeur_regle(brut, type_):
    if brut is None:
        return None
    if type_ == "liste":
        try:
            valeur = json.loads(brut) if isinstance(brut, str) else brut
        except (TypeError, ValueError):
            return None
        return valeur if isinstance(valeur, list) else None
    try:
        return float(brut)
    except (TypeError, ValueError):
        return None


def _serialiser_valeur_regle(valeur, type_):
    if type_ == "liste":
        return json.dumps(list(valeur))
    return str(float(valeur))


def get_regles_actuelles_depuis_df(df_param: pd.DataFrame) -> dict:
    """Valeur courante (derniere ligne) de chaque regle metier definie dans
    REGLES_METIER, a partir d'un DataFrame Parametres_Vortex deja charge.
    Renvoie la valeur par defaut codee en dur pour toute regle jamais modifiee."""
    resultat = {}
    for variable, meta in REGLES_METIER.items():
        brut = None
        if not df_param.empty and "Variable" in df_param.columns and "Valeur" in df_param.columns:
            rows = df_param[df_param["Variable"] == variable]
            if not rows.empty:
                v = rows["Valeur"].iloc[-1]
                if v is not None and str(v).strip() != "":
                    brut = v
        valeur = _parse_valeur_regle(brut, meta["type"]) if brut is not None else None
        resultat[variable] = valeur if valeur not in (None, []) else meta["default"]
    return resultat


def get_regles_actuelles(client, sheet_name) -> dict:
    """Comme get_regles_actuelles_depuis_df, mais lit Parametres_Vortex elle-meme
    (a utiliser quand on n'a pas deja un df_param sous la main)."""
    return get_regles_actuelles_depuis_df(charger_df_parametres(client, sheet_name))


def set_regle_metier(client, sheet_name, variable, valeur, raison=""):
    """Historise une nouvelle valeur pour une regle metier : ajoute une ligne,
    ne modifie jamais les lignes precedentes (historique + comparatif intacts)."""
    meta = REGLES_METIER[variable]
    return set_parametre(client, sheet_name, variable, _serialiser_valeur_regle(valeur, meta["type"]), raison=raison)


def get_historique_regle(client, sheet_name, variable) -> pd.DataFrame:
    """Historique complet (une ligne par modification, ordre chronologique) d'une
    regle metier. Pour les regles numeriques, ajoute Valeur_Num et Diff_Pct
    (variation en % par rapport a la valeur precedente)."""
    meta = REGLES_METIER[variable]
    df = charger_df_parametres(client, sheet_name)
    if df.empty or "Variable" not in df.columns or "Valeur" not in df.columns:
        return pd.DataFrame(columns=["Timestamp", "Valeur", "Raison"])

    hist = df[df["Variable"] == variable][["Timestamp", "Valeur", "Raison"]].reset_index(drop=True)
    if meta["type"] == "float":
        hist["Valeur_Num"] = hist["Valeur"].apply(lambda v: _parse_valeur_regle(v, "float"))
        hist["Diff_Pct"] = hist["Valeur_Num"].pct_change() * 100
    return hist


def get_marge_strategique_depuis_df(df_param: pd.DataFrame) -> float:
    """Derniere marge strategique ecrite par jasmine_optimizer.py (colonne
    Marge_Globale, schema historique — distinct du systeme Valeur/regles)."""
    if df_param.empty or "Marge_Globale" not in df_param.columns or "Variable" not in df_param.columns:
        return 0.0
    rows = df_param[df_param["Variable"] == MARGE_OPTIMIZER_VARIABLE]
    if rows.empty:
        return 0.0
    try:
        return float(rows["Marge_Globale"].iloc[-1])
    except (TypeError, ValueError):
        return 0.0