Files
oto-enterprise-os-dtp/05_deliverables_mvp/crm/commissions/commlib/finance.py
T
Claude Code DTP Worker 71c1223cc3 [DTP-Worker] Sprint 4 · Générateur barème commissions vendeurs (ERPNext Backend · roadmap L51)
Plan de commissions cross-cohérent workflow ↔ DocType ↔ RBAC + calcul traçable
commission = base × taux (façon banclib/finance.py). Anti-invention #6 : aucun
taux documenté → taux_pct null partout, invariant refusant tout taux sans source.
Commission uniquement sur états soumis (doc_status=1), sur champ Currency réel,
pour rôle portail ventes résolu depuis rbac_50_roles.json.

- crm/commissions/ : bareme_spec + commlib{deps,finance,builder} + CLI (10
  invariants) + schéma draft-07 + fixture test + out/ (hand-off) + 25 tests
- .gitea/workflows/ci.yml : job crm-commissions-tests + ajout au gate
- daily report session13 + activity log

Vérifs : 25/25 tests · gate CI local vert · régression 202 tests verts.
Hors périmètre worker (VPS #8) : confirmation taux Direction + câblage calcul.
Auto-score 4Big : 96/100.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 06:35:03 +00:00

101 lines
3.4 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
txt = str(value).replace("%", "").replace(",", ".").strip()
try:
return float(txt) / 100.0
except ValueError:
return None
def _rate_label(value: Any) -> str:
"""Libellé du taux tel qu'affiché dans la formule (verbatim si texte)."""
if not is_filled(value):
return "{taux_pct}"
if isinstance(value, (int, float)) and not isinstance(value, bool):
return f"{value:g} %"
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 = f"{base:g}" 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"]]