Files
oto-enterprise-os-dtp/05_deliverables_mvp/crm/commissions/commlib/finance.py
T

128 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Calcul TRAÇABLE des commissions vendeurs (anti-invention · CLAUDE.md #6).
Même doctrine que `banclib/finance.py` : un calcul transparent, reproductible et
entièrement sourcé n'est PAS une invention — c'est de la modélisation auditable.
Chaque ligne de commission publie sa FORMULE avec sa valeur :
montant = base × taux_pct
où `base` provient du Dossier Vente (un champ Currency réel, ex. montant_contrat)
et `taux_pct` provient du barème (fourni par la Direction AVEC sa source). Si l'un
des deux opérandes manque (`null` / placeholder), la valeur reste `None`
(placeholder, jamais 0-inventé) et la ligne est marquée `incomplete` — la formule
reste affichée. Aucun taux n'est jamais fabriqué : le barème livré porte
`taux_pct: null` tant que la Direction ne l'a pas confirmé.
"""
from __future__ import annotations
from typing import Any, Optional
from . import deps
is_filled = deps.is_filled
def _num(value: Any) -> Optional[float]:
"""Valeur numérique réelle, ou None si absente/placeholder/non numérique."""
if not is_filled(value):
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def rate(value: Any) -> Optional[float]:
"""Taux de commission → fraction. Accepte 3.5 (nombre) ou « 3.5 % » (texte).
Retourne None si absent/placeholder — jamais un taux par défaut fabriqué.
"""
if not is_filled(value):
return None
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value) / 100.0
# `replace(",", ".")` accepte la virgule décimale française (« 3,5 % » → 3.5).
# Un séparateur de milliers (« 1.234,56 ») casserait ce parse — mais il est
# INATTEIGNABLE : un taux de commission est borné 0-100 (cf. doctrine de
# `_rate_label`), donc jamais ≥ 1000 → aucun séparateur de milliers. La virgule
# est donc toujours décimale ici. Ne pas « corriger » vers locale.atof (#5/#6).
txt = str(value).replace("%", "").replace(",", ".").strip()
try:
return float(txt) / 100.0
except ValueError:
return None
def _amount_label(x: float) -> str:
"""Montant affiché dans la formule : décimal FIDÈLE.
`:g` (l'ancien encodeur) cassait deux fois sur des montants réels : (1) il bascule
en notation exponentielle dès 1e6 (« 18000000 » → « 1.8e+07 » — illisible dans une
formule censée être auditable) et (2) il arrondit à 6 chiffres significatifs
(« 123456.78 » → « 123457 ») ce qui FABRIQUE une base différente de la réelle —
exactement l'invention interdite par CLAUDE.md #6. `:f` (jamais exponentiel) puis
strip des zéros/point superflus donne un décimal fidèle et lisible, identique à
l'ancien pour les montants simples (« 200000 » reste « 200000 »)."""
s = f"{x:f}" # décimal complet, jamais de notation exponentielle
if "." in s:
s = s.rstrip("0").rstrip(".")
return s
def _rate_label(value: Any) -> str:
"""Libellé du taux tel qu'affiché dans la formule (verbatim si texte).
NB : `:g` est VOLONTAIRE et sûr ici (≠ montants → `_amount_label`). Un taux de
commission est borné (0-100), donc jamais en notation exponentielle (< 1e6) et
jamais au-delà de 6 chiffres significatifs : les deux pièges de `:g` documentés
sur `_amount_label` sont inatteignables sur ce domaine, et `:g` nettoie en prime
le bruit flottant (« 3.0 » → « 3 »). Ne pas « corriger » en `:f` (audit récurrent)."""
if not is_filled(value):
return "{taux_pct}"
if isinstance(value, (int, float)) and not isinstance(value, bool):
return f"{value:g} %" # borné 0-100 : sûr, cf. docstring
return str(value).strip()
def compute_line(dossier: dict, event: dict) -> dict:
"""Une ligne de commission traçable pour un évènement du barème.
`dossier` : instance (partielle) d'un OTO Dossier Vente (base + devise).
`event` : un évènement du barème (update_value, role_id, base_field, taux).
"""
base_field = event["base_field"]
base = _num(dossier.get(base_field))
taux = rate(event.get("taux_pct"))
devise = dossier.get("devise")
montant = base * taux if (base is not None and taux is not None) else None
base_lbl = _amount_label(base) if base is not None else f"{{{base_field}}}"
formule = f"{base_lbl} × {_rate_label(event.get('taux_pct'))}"
manquants: list[str] = []
if base is None:
manquants.append(base_field)
if taux is None:
manquants.append("taux_pct")
return {
"update_value": event["update_value"],
"role_id": event["role_id"],
"base_field": base_field,
"base": base,
"taux_pct": taux,
"devise": devise if is_filled(devise) else None,
"montant": montant,
"formule": formule,
"incomplete": bool(manquants),
"champs_manquants": manquants,
}
def compute_dossier(dossier: dict, bareme: dict) -> list[dict]:
"""Toutes les lignes de commission d'un dossier (ordre = ordre du barème)."""
return [compute_line(dossier, ev) for ev in bareme["evenements"]]