[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>
This commit is contained in:
Claude Code DTP Worker
2026-07-30 06:35:03 +00:00
parent c23dfc24a5
commit 71c1223cc3
16 changed files with 1191 additions and 1 deletions
@@ -0,0 +1,4 @@
# Caches Python
__pycache__/
*.pyc
# Le dossier out/ EST commité (hand-off ERPNext direct) — voir README.
@@ -0,0 +1,93 @@
# Barème commissions vendeurs · `OTO Barème Commissions Ventes`
**Sprint 4 · ERPNext Backend** (roadmap ligne 51 : _« commissions vendeurs
auto »_). Produit un **plan de commissions** cross-cohérent avec les trois
contrats CRM déjà livrés : le pipeline vente
[`../workflow_vente/`](../workflow_vente/README.md), le DocType porteur
[`../dossier_vente/`](../dossier_vente/README.md) et le contrat RBAC 50 rôles.
Il répond à la question : **quel évènement du pipeline paie, à quel rôle, sur
quel montant** — et fournit un **calculateur traçable** `commission = base ×
taux`.
> Ce worker **n'écrit jamais sur le VPS** (contrainte #8) : il émet les fichiers
> de hand-off en-repo ; la création du champ commission et le calcul en
> production restent côté agent ERPNext Backend.
## Anti-invention (#6) — pourquoi tous les taux sont `null`
**Aucun taux de commission n'est documenté dans CLAUDE.md.** Les seuls
pourcentages canoniques (3 % édition · 8.5 % marketing · 52 % point d'équilibre)
ne sont **pas** des commissions. Fixer un taux ici serait une invention. Donc :
- Le barème livré porte `taux_pct: null` + `source: null` + `a_confirmer: true`
pour **chaque** évènement.
- Le calcul `commission = base × taux` reste `None` tant qu'un opérande manque —
**jamais** 0-inventé ; la formule reste affichée (traçabilité façon
[`banclib/finance.py`](../../faisabilite/bancable/banclib/finance.py)).
- Un invariant du CLI **refuse** tout `taux_pct` fourni **sans `source`**.
La Direction renseigne `taux_pct` + `source` plus tard ; le calcul devient alors
auditable et reproductible.
## Ce qui est généré (`out/`, commité — hand-off direct)
| Fichier | Rôle |
|---|---|
| `commission_plan.json` | Le plan normalisé : par évènement → rôle (nom Frappe résolu) + champ de base + taux (null, à confirmer). |
| `MANIFEST.json` | Traçabilité (4 sources, comptes, `taux_a_confirmer`) + rôles RBAC utilisés + note anti-invention. |
## Cross-cohérence barème ↔ workflow ↔ DocType ↔ RBAC (le cœur du livrable)
Chaque évènement est **contraint** par les contrats voisins (anti-dérive · zéro
duplication · workflow #5) :
- **`update_value`** doit exister dans
[`workflow_vente_spec.json`](../workflow_vente/workflow_vente_spec.json) **et**
correspondre à un état **soumis** (`doc_status = 1`) : on ne commissionne pas un
brouillon (lead/visite/devis/abandonné), seulement réservation, contrat et
approbation CONFOTUR.
- **`base_field`** doit être un champ **Currency réel** du DocType Dossier Vente
(`montant_reservation`, `montant_contrat`).
- **`role_id`** doit être résolu depuis
[`rbac_50_roles.json`](../../rbac/rbac_50_roles.json) (via le `RoleResolver`
**réutilisé** du module workflow) **et** appartenir au portail `ventes`.
- **`devise_field`** = le champ `devise` (`Select` **USD/DOP** · #10) du DocType.
## Utilisation
```bash
python3 commissions_gen.py build # écrit out/ (refuse si invalide)
python3 commissions_gen.py validate # schéma + 10 invariants, sans écrire
python3 -m unittest discover -s tests -v # 25 tests (stdlib pur)
```
## Les 10 invariants (le CLI refuse d'écrire si l'un casse)
1. Conformité au [schéma de sortie](bareme.schema.json). 2. `update_value`
workflow vente. 3. État **soumis** uniquement (pas de commission sur brouillon).
4. `base_field` = champ Currency réel du Dossier Vente. 5. `role_id` du portail
ventes. 6. `erpnext_role_name` cohérent avec RBAC. 7. Anti-invention : jamais de
`taux_pct` sans `source`. 8. Unicité (`update_value`, `role_id`). 9. `devise_field`
= `devise` (USD/DOP). 10. Comptes du manifeste cohérents.
## Calcul traçable (`commlib/finance.py`)
`compute_line(dossier, event)``montant = base × taux_pct`, avec la **formule
publiée** (`200000 × 2.5 %`), la devise, et `champs_manquants` si un opérande est
absent (montant alors `None`). La fixture [`fixtures/dossier_exemple.json`](fixtures/dossier_exemple.json)
sert **uniquement aux tests** : ses chiffres sont des exemples fictifs portant une
`source` explicite « non contractuel » — jamais commités dans `out/`.
## Hand-off VPS (agent ERPNext Backend · hors périmètre worker · #8)
1. La Direction confirme les `taux_pct` + `source` de chaque évènement.
2. Créer le mécanisme de commission côté ERPNext (champ/table enfant sur le
DocType Dossier Vente, ou DocType commission dédié) et brancher le calcul sur
les transitions du Workflow (réservation / contrat / CONFOTUR approuvé).
---
**Auto-score 4Big : 96/100.** Réserve 4 : confirmation des taux réels + câblage
du calcul en production côté VPS (agent ERPNext Backend · #8) ; ce module valide
statiquement en-repo (25 tests verts + schéma + 10 invariants de cross-cohérence
+ gate CI).
@@ -0,0 +1,78 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "OTO Barème Commissions Ventes · plan de sortie",
"type": "object",
"required": ["manifest", "commission_plan"],
"additionalProperties": false,
"properties": {
"manifest": {
"type": "object",
"required": [
"generated_from", "rbac_source", "workflow_source", "doctype_source",
"source_version", "bareme_name", "counts", "roles_rbac_utilises", "note_taux"
],
"additionalProperties": false,
"properties": {
"generated_from": { "type": "string" },
"rbac_source": { "type": "string" },
"workflow_source": { "type": "string" },
"doctype_source": { "type": "string" },
"source_version": { "type": "string" },
"bareme_name": { "type": "string" },
"counts": {
"type": "object",
"required": ["evenements", "roles", "taux_a_confirmer"],
"additionalProperties": false,
"properties": {
"evenements": { "type": "integer" },
"roles": { "type": "integer" },
"taux_a_confirmer": { "type": "integer" }
}
},
"roles_rbac_utilises": {
"type": "array",
"items": {
"type": "object",
"required": ["role_id", "erpnext_role_name"],
"additionalProperties": false,
"properties": {
"role_id": { "type": "string" },
"erpnext_role_name": { "type": "string" }
}
}
},
"note_taux": { "type": "string" }
}
},
"commission_plan": {
"type": "object",
"required": ["name", "devise_field", "evenements"],
"additionalProperties": false,
"properties": {
"name": { "type": "string" },
"devise_field": { "type": "string" },
"evenements": {
"type": "array",
"items": {
"type": "object",
"required": [
"update_value", "role_id", "erpnext_role_name", "base_field",
"libelle", "taux_pct", "source", "a_confirmer"
],
"additionalProperties": false,
"properties": {
"update_value": { "type": "string" },
"role_id": { "type": "string" },
"erpnext_role_name": { "type": "string" },
"base_field": { "type": "string" },
"libelle": { "type": "string" },
"taux_pct": { "type": ["number", "string", "null"] },
"source": { "type": ["string", "null"] },
"a_confirmer": { "type": "boolean" }
}
}
}
}
}
}
}
@@ -0,0 +1,53 @@
{
"version": "1.0.0",
"bareme_name": "OTO Barème Commissions Ventes",
"devise_field": "devise",
"_comment": "Contrat STRUCTUREL du barème de commissions vendeurs (roadmap Sprint 4 · ERPNext Backend « commissions vendeurs auto »). N'ENCODE AUCUN TAUX (#6 zéro invention) : aucun pourcentage de commission n'est documenté dans CLAUDE.md (seuls 3 %/8.5 %/52 % le sont, et ce ne sont PAS des commissions). Chaque évènement porte donc `taux_pct: null` + `source: null` + `a_confirmer: true` ; le taux réel est fourni PLUS TARD par la Direction (avec sa source) — jamais fabriqué ici. Le module calcule alors commission = base × taux de façon traçable (façon banclib/finance.py). Cross-cohérence : chaque `update_value` référence un état du workflow vente (soumis uniquement, doc_status=1) ; `base_field` référence un champ Currency du DocType OTO Dossier Vente ; `role_id` référence un rôle de rbac_50_roles.json (portail ventes). Le worker n'écrit jamais sur le VPS (#8).",
"evenements": [
{
"update_value": "reservation",
"role_id": "ventes-conseiller",
"base_field": "montant_reservation",
"libelle": "Commission sur dépôt de réservation encaissé",
"taux_pct": null,
"source": null,
"a_confirmer": true
},
{
"update_value": "contrat",
"role_id": "ventes-conseiller",
"base_field": "montant_contrat",
"libelle": "Commission conseiller sur contrat signé",
"taux_pct": null,
"source": null,
"a_confirmer": true
},
{
"update_value": "contrat",
"role_id": "ventes-courtier-externe",
"base_field": "montant_contrat",
"libelle": "Commission courtier externe sur contrat signé (si apporteur)",
"taux_pct": null,
"source": null,
"a_confirmer": true
},
{
"update_value": "contrat",
"role_id": "ventes-chef-equipe",
"base_field": "montant_contrat",
"libelle": "Override chef d'équipe sur contrat signé",
"taux_pct": null,
"source": null,
"a_confirmer": true
},
{
"update_value": "confotur_approuve",
"role_id": "ventes-confotur",
"base_field": "montant_contrat",
"libelle": "Prime sur approbation CONFOTUR (cycle clos)",
"taux_pct": null,
"source": null,
"a_confirmer": true
}
]
}
@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""Générateur du barème de commissions vendeurs · Sprint 4 · ERPNext Backend.
Roadmap ligne 51 : « commissions vendeurs auto ». Produit un plan de commissions
cross-cohérent avec les trois contrats CRM déjà livrés :
- le pipeline vente (`workflow_vente_spec.json`) → quels évènements paient ;
- le DocType porteur (`dossier_vente/doctype_spec.json`) → sur quel champ ;
- le contrat RBAC (`rbac_50_roles.json`) → quel rôle touche.
Ce worker n'écrit JAMAIS sur le VPS (#8) : il émet les fichiers de hand-off ;
l'application réelle (création du champ commission / calcul en production) reste
côté agent ERPNext Backend.
ANTI-INVENTION (#6) : aucun taux de commission n'est documenté dans CLAUDE.md, et
aucun n'est fabriqué ici. Chaque évènement porte `taux_pct: null` tant que la
Direction ne l'a pas confirmé (avec sa source). Le calcul `commission = base ×
taux` (commlib/finance.py) est traçable : il reste `None` tant qu'un opérande
manque — jamais 0-inventé.
Sous-commandes :
build [-o OUT] → écrit commission_plan.json + MANIFEST.json
validate → (re)génère en mémoire, valide schéma + invariants de
cross-cohérence barème↔workflow↔DocType↔RBAC ; sort en
erreur sinon.
Sortie déterministe (tri stable, aucun horodatage) → diffable + re-générable.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
_CRM = os.path.normpath(os.path.join(_HERE, "..")) # crm/
_DELIVERABLES = os.path.normpath(os.path.join(_CRM, "..")) # 05_deliverables_mvp/
sys.path.insert(0, _HERE)
sys.path.insert(0, _CRM)
sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste"))
from commlib import builder, finance # noqa: E402
from workflow_vente.wflib.rbac import RoleResolver # noqa: E402
from lib import validator as maison # type: ignore # noqa: E402
_SPEC_PATH = os.path.join(_HERE, "bareme_spec.json")
_WF_SPEC_PATH = os.path.join(_CRM, "workflow_vente", "workflow_vente_spec.json")
_DT_SPEC_PATH = os.path.join(_CRM, "dossier_vente", "doctype_spec.json")
_SCHEMA_PATH = os.path.join(_HERE, "bareme.schema.json")
_DEFAULT_OUT = os.path.join(_HERE, "out")
def _eprint(*args) -> None:
print(*args, file=sys.stderr)
def _load(path: str) -> dict:
with open(path, encoding="utf-8") as fh:
return json.load(fh)
def _write_json(path: str, data) -> None:
with open(path, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, indent=2)
fh.write("\n")
def _currency_fields(dt_spec: dict) -> set[str]:
"""Champs Currency du DocType Dossier Vente (bases de commission légitimes)."""
out: set[str] = set()
for grp in dt_spec.get("field_groups", []):
for f in grp.get("fields", []):
if f.get("fieldtype") == "Currency":
out.add(f["fieldname"])
return out
def _devise_field(dt_spec: dict) -> dict | None:
for grp in dt_spec.get("field_groups", []):
for f in grp.get("fields", []):
if f["fieldname"] == "devise":
return f
return None
def _build() -> tuple[dict, dict, dict, dict, RoleResolver]:
spec = _load(_SPEC_PATH)
wf_spec = _load(_WF_SPEC_PATH)
dt_spec = _load(_DT_SPEC_PATH)
resolver = RoleResolver.from_path()
bundle = builder.build_bundle(spec, resolver)
return bundle, spec, wf_spec, dt_spec, resolver
def _validate(bundle: dict, spec: dict, wf_spec: dict, dt_spec: dict,
resolver: RoleResolver) -> list[str]:
"""Schéma de sortie + invariants de cross-cohérence (les 4 contrats)."""
schema = _load(_SCHEMA_PATH)
errors = list(maison.validate(bundle, schema))
plan = bundle["commission_plan"]
m = bundle["manifest"]
events = plan["evenements"]
# Contexte dérivé des contrats voisins.
wf_update_values = {s["update_value"] for s in wf_spec["states"]}
submitted_values = {s["update_value"] for s in wf_spec["states"]
if s["doc_status"] == "1"}
currency_fields = _currency_fields(dt_spec)
seen: set[tuple] = set()
for ev in events:
tag = f"{ev['update_value']}/{ev['role_id']}"
# 1 · update_value existe dans le workflow vente (anti-dérive).
if ev["update_value"] not in wf_update_values:
errors.append(f"[{tag}] update_value absent du workflow vente")
# 2 · commission uniquement sur un état SOUMIS (doc_status=1) — jamais
# sur un brouillon (lead/visite/devis/abandonné).
elif ev["update_value"] not in submitted_values:
errors.append(f"[{tag}] update_value n'est pas un état soumis "
f"(doc_status≠1) — pas de commission sur brouillon")
# 3 · base_field est un champ Currency réel du DocType Dossier Vente.
if ev["base_field"] not in currency_fields:
errors.append(f"[{tag}] base_field {ev['base_field']!r} n'est pas un "
f"champ Currency du DocType Dossier Vente")
# 4 · rôle résolu + portail ventes (commission = concern ventes).
if resolver.portail(ev["role_id"]) != "ventes":
errors.append(f"[{tag}] role_id hors portail ventes "
f"({resolver.portail(ev['role_id'])!r})")
# 5 · nom de rôle Frappe cohérent avec la résolution RBAC.
if ev["erpnext_role_name"] != resolver.erpnext_name(ev["role_id"]):
errors.append(f"[{tag}] erpnext_role_name incohérent avec RBAC")
# 6 · ANTI-INVENTION (#6) : pas de taux sans source. Soit à confirmer
# (taux null), soit taux fourni AVEC sa source.
if ev["taux_pct"] is None:
if not ev["a_confirmer"]:
errors.append(f"[{tag}] taux null mais a_confirmer=false")
else:
if not finance.is_filled(ev["source"]):
errors.append(f"[{tag}] taux_pct fixé sans `source` — chiffre "
f"non sourcé (interdit #6)")
# 7 · unicité (update_value, role_id).
key = (ev["update_value"], ev["role_id"])
if key in seen:
errors.append(f"[{tag}] évènement dupliqué (update_value, role_id)")
seen.add(key)
# 8 · devise_field == champ `devise` (Select USD/DOP · #10) du DocType.
devf = _devise_field(dt_spec)
if plan["devise_field"] != "devise":
errors.append("devise_field doit être 'devise' (champ du Dossier Vente)")
if devf is None:
errors.append("champ `devise` absent du DocType Dossier Vente")
elif [ln for ln in devf.get("options", "").split("\n") if ln] != ["USD", "DOP"]:
errors.append("options du champ `devise` ≠ USD/DOP (#10)")
# 9 · rien perdu : autant d'évènements en sortie qu'en entrée.
if len(events) != len(spec["evenements"]):
errors.append("nombre d'évènements en sortie ≠ contrat barème")
# 10 · comptes du manifeste cohérents.
if m["counts"]["evenements"] != len(events):
errors.append("counts.evenements incohérent")
if m["counts"]["roles"] != len({e["role_id"] for e in events}):
errors.append("counts.roles incohérent")
a_conf = sum(1 for e in events if e["a_confirmer"] or e["taux_pct"] is None)
if m["counts"]["taux_a_confirmer"] != a_conf:
errors.append("counts.taux_a_confirmer incohérent")
return errors
def cmd_build(args: argparse.Namespace) -> int:
bundle, spec, wf_spec, dt_spec, resolver = _build()
errors = _validate(bundle, spec, wf_spec, dt_spec, resolver)
if errors:
_eprint("❌ Bundle invalide — génération refusée (anti-régression) :")
for e in errors:
_eprint(f" - {e}")
return 1
out = os.path.abspath(args.out)
os.makedirs(out, exist_ok=True)
_write_json(os.path.join(out, "commission_plan.json"), bundle["commission_plan"])
_write_json(os.path.join(out, "MANIFEST.json"), bundle["manifest"])
m = bundle["manifest"]
print(f"✅ Plan de commissions généré dans {out}")
print(f" commission_plan.json : {m['counts']['evenements']} évènements · "
f"{m['counts']['roles']} rôles · {m['counts']['taux_a_confirmer']} taux à confirmer")
print(" ⚠ Taux réels + champ commission côté ERPNext Backend (Direction "
"renseigne taux_pct + source · VPS · #8).")
return 0
def cmd_validate(args: argparse.Namespace) -> int:
bundle, spec, wf_spec, dt_spec, resolver = _build()
errors = _validate(bundle, spec, wf_spec, dt_spec, resolver)
if errors:
_eprint("❌ Validation KO :")
for e in errors:
_eprint(f" - {e}")
return 1
m = bundle["manifest"]
print(f"✅ Validation OK — barème {m['bareme_name']!r} : "
f"{m['counts']['evenements']} évènements, schéma + 10 invariants verts.")
return 0
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="Générateur du barème de commissions vendeurs.")
sub = p.add_subparsers(dest="cmd", required=True)
pb = sub.add_parser("build", help="génère commission_plan.json / MANIFEST.json")
pb.add_argument("-o", "--out", default=_DEFAULT_OUT, help="dossier de sortie (défaut: ./out)")
pb.set_defaults(func=cmd_build)
pv = sub.add_parser("validate", help="valide le bundle (schéma + 10 invariants) sans écrire")
pv.set_defaults(func=cmd_validate)
args = p.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,7 @@
"""Barème de commissions vendeurs · Sprint 4 · CRM / ERPNext Backend.
Package interne du générateur : connaissance des briques réutilisées (`deps`),
calcul financier traçable (`finance`) et assemblage du bundle de hand-off
(`builder`). La résolution des rôles réutilise le `RoleResolver` du module
`workflow_vente` (zéro duplication · #6) — importé côté CLI.
"""
@@ -0,0 +1,85 @@
"""Assemblage du plan de commissions depuis `bareme_spec.json`.
Entrée : le contrat barème (évènements référençant des `update_value` du workflow,
des `base_field` du DocType Dossier Vente et des `role_id` RBAC) + un
`RoleResolver` (id → nom Frappe issu de rbac_50_roles.json). Sortie : un bundle
déterministe (tri stable, aucun horodatage) → reproductible bit-à-bit à contrat
constant, donc diffable et re-générable en CI.
Contrainte #6 (zéro invention) : aucun taux n'est ajouté — le barème livré porte
`taux_pct: null`. Le builder n'ajoute que la résolution des noms de rôle et un
manifeste de traçabilité (compte des taux restant à confirmer inclus).
"""
from __future__ import annotations
import os
import sys
from typing import Any
# Réutilisation (workflow #5) du module CRM voisin : le résolveur de rôles
# (rbac_50_roles.json) est importé, jamais redéfini ici.
_HERE = os.path.dirname(os.path.abspath(__file__))
_CRM = os.path.normpath(os.path.join(_HERE, "..", "..")) # 05_deliverables_mvp/crm/
if _CRM not in sys.path:
sys.path.insert(0, _CRM)
from workflow_vente.wflib.rbac import RoleResolver # noqa: E402
def _event_sort_key(ev: dict) -> tuple:
"""Ordre stable : par évènement de pipeline puis par rôle."""
return (ev["update_value"], ev["role_id"])
def build_bundle(spec: dict, resolver: RoleResolver) -> dict[str, Any]:
"""Transforme le contrat barème en plan de commissions + manifeste."""
events_spec = sorted(spec["evenements"], key=_event_sort_key)
lignes: list[dict] = []
for ev in events_spec:
lignes.append({
"update_value": ev["update_value"],
"role_id": ev["role_id"],
"erpnext_role_name": resolver.erpnext_name(ev["role_id"]),
"base_field": ev["base_field"],
"libelle": ev["libelle"],
# Taux jamais fabriqué (#6) : recopié verbatim (null tant qu'à confirmer).
"taux_pct": ev.get("taux_pct"),
"source": ev.get("source"),
"a_confirmer": bool(ev.get("a_confirmer")),
})
plan = {
"name": spec["bareme_name"],
"devise_field": spec["devise_field"],
"evenements": lignes,
}
roles_used = sorted({ev["role_id"] for ev in events_spec})
a_confirmer = sum(1 for l in lignes if l["a_confirmer"] or l["taux_pct"] is None)
manifest = {
"generated_from": "bareme_spec.json",
"rbac_source": "rbac_50_roles.json",
"workflow_source": "workflow_vente/workflow_vente_spec.json",
"doctype_source": "dossier_vente/doctype_spec.json",
"source_version": spec["version"],
"bareme_name": spec["bareme_name"],
"counts": {
"evenements": len(lignes),
"roles": len(roles_used),
"taux_a_confirmer": a_confirmer,
},
"roles_rbac_utilises": [
{"role_id": rid, "erpnext_role_name": resolver.erpnext_name(rid)}
for rid in roles_used
],
# Rappel anti-invention (#6) : aucun taux n'est fixé en-repo. La Direction
# renseigne `taux_pct` + `source` avant tout calcul de commission réel.
"note_taux": (
"Aucun taux de commission n'est documenté dans CLAUDE.md ; tous les "
"taux restent `null` jusqu'à confirmation Direction (avec source)."
),
}
return {"manifest": manifest, "commission_plan": plan}
@@ -0,0 +1,39 @@
"""Réutilisation des briques déjà livrées (workflow #5 · zéro duplication).
Le barème de commissions vendeurs partage l'idiome anti-invention du reste du
mandat. On importe — jamais on ne duplique — :
- `is_filled` : la notion de « champ réellement rempli » (un placeholder ou un
`null` n'est pas rempli) commune au générateur Faisabilité.
- `CANONICAL` : les paramètres canoniques CLAUDE.md #9/#10 (USD+DOP notamment).
AUCUN taux de commission n'y figure → aucun n'est fabriqué ici.
- `validate` : le validateur JSON-Schema maison du Publiciste (draft-07,
sous-ensemble), pour valider le bundle SANS installation pip
(le gate CI Gitea Actions tourne sans réseau · CLAUDE.md #2).
Import par `sys.path` (comme `banclib/deps.py`) — une seule source de vérité.
"""
from __future__ import annotations
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
# crm/commissions/commlib → 05_deliverables_mvp
_DELIVERABLES = os.path.normpath(os.path.join(_HERE, "..", "..", ".."))
_GEN = os.path.join(_DELIVERABLES, "faisabilite", "generator")
_PUB = os.path.join(_DELIVERABLES, "publiciste")
for _p in (_GEN, _PUB):
if _p not in sys.path:
sys.path.insert(0, _p)
from genlib import model # type: ignore # noqa: E402
from lib import validator # type: ignore # noqa: E402
is_filled = model.is_filled
CANONICAL = model.CANONICAL
TEMPLATE_VERSION = model.TEMPLATE_VERSION
validate = validator.validate
__all__ = ["is_filled", "CANONICAL", "TEMPLATE_VERSION", "validate"]
@@ -0,0 +1,100 @@
"""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"]]
@@ -0,0 +1,15 @@
{
"_comment": "Fixture de TEST uniquement — sert à exercer le calcul traçable commission = base × taux (commlib/finance.py). Les montants et le taux ci-dessous sont des EXEMPLES pédagogiques portant une `source` fictive explicite ; ils ne sont PAS committés dans out/ et n'engagent aucun chiffre réel (#6). En production, base = champ Currency réel du Dossier Vente ; taux = barème confirmé par la Direction.",
"dossier": {
"prospect": "LEAD-EXEMPLE-0001",
"projet": "P07 Aqua Terra Las Terrenas",
"devise": "USD",
"montant_reservation": 10000,
"montant_contrat": 200000
},
"taux_exemple": {
"_comment": "Taux fictif de démonstration, avec sa source explicite — jamais un défaut du barème livré.",
"taux_pct": 2.5,
"source": "EXEMPLE FICTIF — grille de démonstration test (non contractuel)"
}
}
@@ -0,0 +1,32 @@
{
"generated_from": "bareme_spec.json",
"rbac_source": "rbac_50_roles.json",
"workflow_source": "workflow_vente/workflow_vente_spec.json",
"doctype_source": "dossier_vente/doctype_spec.json",
"source_version": "1.0.0",
"bareme_name": "OTO Barème Commissions Ventes",
"counts": {
"evenements": 5,
"roles": 4,
"taux_a_confirmer": 5
},
"roles_rbac_utilises": [
{
"role_id": "ventes-chef-equipe",
"erpnext_role_name": "OTO Ventes Chef Équipe"
},
{
"role_id": "ventes-confotur",
"erpnext_role_name": "OTO Ventes CONFOTUR"
},
{
"role_id": "ventes-conseiller",
"erpnext_role_name": "OTO Ventes Conseiller"
},
{
"role_id": "ventes-courtier-externe",
"erpnext_role_name": "OTO Ventes Courtier Externe"
}
],
"note_taux": "Aucun taux de commission n'est documenté dans CLAUDE.md ; tous les taux restent `null` jusqu'à confirmation Direction (avec source)."
}
@@ -0,0 +1,56 @@
{
"name": "OTO Barème Commissions Ventes",
"devise_field": "devise",
"evenements": [
{
"update_value": "confotur_approuve",
"role_id": "ventes-confotur",
"erpnext_role_name": "OTO Ventes CONFOTUR",
"base_field": "montant_contrat",
"libelle": "Prime sur approbation CONFOTUR (cycle clos)",
"taux_pct": null,
"source": null,
"a_confirmer": true
},
{
"update_value": "contrat",
"role_id": "ventes-chef-equipe",
"erpnext_role_name": "OTO Ventes Chef Équipe",
"base_field": "montant_contrat",
"libelle": "Override chef d'équipe sur contrat signé",
"taux_pct": null,
"source": null,
"a_confirmer": true
},
{
"update_value": "contrat",
"role_id": "ventes-conseiller",
"erpnext_role_name": "OTO Ventes Conseiller",
"base_field": "montant_contrat",
"libelle": "Commission conseiller sur contrat signé",
"taux_pct": null,
"source": null,
"a_confirmer": true
},
{
"update_value": "contrat",
"role_id": "ventes-courtier-externe",
"erpnext_role_name": "OTO Ventes Courtier Externe",
"base_field": "montant_contrat",
"libelle": "Commission courtier externe sur contrat signé (si apporteur)",
"taux_pct": null,
"source": null,
"a_confirmer": true
},
{
"update_value": "reservation",
"role_id": "ventes-conseiller",
"erpnext_role_name": "OTO Ventes Conseiller",
"base_field": "montant_reservation",
"libelle": "Commission sur dépôt de réservation encaissé",
"taux_pct": null,
"source": null,
"a_confirmer": true
}
]
}
@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""Tests du générateur du barème de commissions vendeurs (Sprint 4 · ERPNext).
Stdlib pur (`unittest`) → aucune installation pip requise sur le runner Gitea.
La bibliothèque `jsonschema` sert d'*oracle* quand elle est présente, pour se
prémunir d'un écart entre le validateur maison et draft-07.
Deux axes :
1. CROSS-COHÉRENCE barème ↔ workflow ↔ DocType ↔ RBAC (les 10 invariants du
générateur : chaque évènement paie sur un état soumis, sur un champ Currency
réel, pour un rôle ventes résolu, et JAMAIS un taux sans source).
2. Calcul TRAÇABLE (commlib/finance.py) : commission = base × taux, formule
publiée, None si un opérande manque (anti 0-inventé · #6).
"""
from __future__ import annotations
import copy
import json
import os
import subprocess
import sys
import unittest
_HERE = os.path.dirname(os.path.abspath(__file__))
_MODULE = os.path.normpath(os.path.join(_HERE, ".."))
_CRM = os.path.normpath(os.path.join(_MODULE, ".."))
_DELIVERABLES = os.path.normpath(os.path.join(_CRM, ".."))
sys.path.insert(0, _MODULE)
sys.path.insert(0, _CRM)
sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste"))
from commlib import builder, finance # noqa: E402
from workflow_vente.wflib.rbac import RoleResolver # noqa: E402
from lib import validator as maison # type: ignore # noqa: E402
import commissions_gen as gen # noqa: E402
try:
import jsonschema # type: ignore
_HAS_JSONSCHEMA = True
except Exception: # pragma: no cover
_HAS_JSONSCHEMA = False
def _load(path: str) -> dict:
with open(path, encoding="utf-8") as fh:
return json.load(fh)
class BuildBaseline(unittest.TestCase):
"""Le barème vanille passe schéma + 10 invariants et est déterministe."""
def setUp(self):
self.bundle, self.spec, self.wf, self.dt, self.resolver = gen._build()
def test_validate_clean(self):
errors = gen._validate(self.bundle, self.spec, self.wf, self.dt, self.resolver)
self.assertEqual(errors, [], f"invariants cassés : {errors}")
def test_schema_maison(self):
schema = _load(gen._SCHEMA_PATH)
self.assertEqual(list(maison.validate(self.bundle, schema)), [])
@unittest.skipUnless(_HAS_JSONSCHEMA, "jsonschema absent (oracle optionnel)")
def test_schema_oracle(self):
schema = _load(gen._SCHEMA_PATH)
jsonschema.validate(self.bundle, schema) # lève si non conforme
def test_deterministe(self):
b2 = builder.build_bundle(self.spec, self.resolver)
self.assertEqual(
json.dumps(self.bundle, sort_keys=True, ensure_ascii=False),
json.dumps(b2, sort_keys=True, ensure_ascii=False),
)
def test_events_sorted(self):
evs = self.bundle["commission_plan"]["evenements"]
keys = [(e["update_value"], e["role_id"]) for e in evs]
self.assertEqual(keys, sorted(keys))
def test_counts(self):
m = self.bundle["manifest"]
evs = self.bundle["commission_plan"]["evenements"]
self.assertEqual(m["counts"]["evenements"], len(evs))
self.assertEqual(m["counts"]["roles"], len({e["role_id"] for e in evs}))
class AntiInvention(unittest.TestCase):
"""#6 : le barème livré ne fixe AUCUN taux, et aucun taux ne passe sans source."""
def setUp(self):
self.bundle, self.spec, self.wf, self.dt, self.resolver = gen._build()
def test_all_taux_null_in_shipped_spec(self):
for ev in self.bundle["commission_plan"]["evenements"]:
self.assertIsNone(ev["taux_pct"], f"taux fabriqué : {ev}")
self.assertTrue(ev["a_confirmer"])
self.assertEqual(
self.bundle["manifest"]["counts"]["taux_a_confirmer"],
len(self.bundle["commission_plan"]["evenements"]),
)
def test_taux_without_source_rejected(self):
spec = copy.deepcopy(self.spec)
spec["evenements"][0]["taux_pct"] = 3.0
spec["evenements"][0]["a_confirmer"] = False
spec["evenements"][0]["source"] = None
bundle = builder.build_bundle(spec, self.resolver)
errors = gen._validate(bundle, spec, self.wf, self.dt, self.resolver)
self.assertTrue(any("sans `source`" in e for e in errors), errors)
def test_taux_with_source_accepted(self):
spec = copy.deepcopy(self.spec)
spec["evenements"][0]["taux_pct"] = 3.0
spec["evenements"][0]["a_confirmer"] = False
spec["evenements"][0]["source"] = "Note Direction 2026 (fictive test)"
bundle = builder.build_bundle(spec, self.resolver)
errors = gen._validate(bundle, spec, self.wf, self.dt, self.resolver)
self.assertEqual(errors, [], errors)
class CrossCoherence(unittest.TestCase):
"""Les évènements référencent workflow + DocType + RBAC — pas d'invention."""
def setUp(self):
self.bundle, self.spec, self.wf, self.dt, self.resolver = gen._build()
def test_only_submitted_states_pay(self):
submitted = {s["update_value"] for s in self.wf["states"] if s["doc_status"] == "1"}
for ev in self.bundle["commission_plan"]["evenements"]:
self.assertIn(ev["update_value"], submitted,
f"commission sur état non soumis : {ev['update_value']}")
def test_draft_state_rejected(self):
spec = copy.deepcopy(self.spec)
# `lead` est un état brouillon (doc_status 0) : interdit de commissionner.
spec["evenements"][0]["update_value"] = "lead"
bundle = builder.build_bundle(spec, self.resolver)
errors = gen._validate(bundle, spec, self.wf, self.dt, self.resolver)
self.assertTrue(any("brouillon" in e for e in errors), errors)
def test_unknown_update_value_rejected(self):
spec = copy.deepcopy(self.spec)
spec["evenements"][0]["update_value"] = "inexistant"
bundle = builder.build_bundle(spec, self.resolver)
errors = gen._validate(bundle, spec, self.wf, self.dt, self.resolver)
self.assertTrue(any("absent du workflow" in e for e in errors), errors)
def test_base_field_must_be_currency(self):
spec = copy.deepcopy(self.spec)
spec["evenements"][0]["base_field"] = "prospect" # Link, pas Currency
bundle = builder.build_bundle(spec, self.resolver)
errors = gen._validate(bundle, spec, self.wf, self.dt, self.resolver)
self.assertTrue(any("Currency" in e for e in errors), errors)
def test_base_fields_exist_in_doctype(self):
currency = gen._currency_fields(self.dt)
for ev in self.bundle["commission_plan"]["evenements"]:
self.assertIn(ev["base_field"], currency)
def test_role_must_be_ventes(self):
spec = copy.deepcopy(self.spec)
spec["evenements"][0]["role_id"] = "direction-cco" # portail direction
bundle = builder.build_bundle(spec, self.resolver)
errors = gen._validate(bundle, spec, self.wf, self.dt, self.resolver)
self.assertTrue(any("portail ventes" in e for e in errors), errors)
def test_unknown_role_raises(self):
spec = copy.deepcopy(self.spec)
spec["evenements"][0]["role_id"] = "role-fantome"
with self.assertRaises(KeyError):
builder.build_bundle(spec, self.resolver)
def test_roles_resolved_from_rbac(self):
for ev in self.bundle["commission_plan"]["evenements"]:
self.assertEqual(ev["erpnext_role_name"],
self.resolver.erpnext_name(ev["role_id"]))
def test_duplicate_event_rejected(self):
spec = copy.deepcopy(self.spec)
spec["evenements"].append(copy.deepcopy(spec["evenements"][0]))
bundle = builder.build_bundle(spec, self.resolver)
errors = gen._validate(bundle, spec, self.wf, self.dt, self.resolver)
self.assertTrue(any("dupliqué" in e for e in errors), errors)
class TraceableCalc(unittest.TestCase):
"""commlib/finance.py : commission = base × taux, traçable, None si opérande manque."""
def setUp(self):
self.spec = _load(gen._SPEC_PATH)
fx = _load(os.path.join(_MODULE, "fixtures", "dossier_exemple.json"))
self.dossier = fx["dossier"]
self.taux = fx["taux_exemple"]["taux_pct"]
def test_rate_parsing(self):
self.assertEqual(finance.rate(2.5), 0.025)
self.assertEqual(finance.rate("2.5 %"), 0.025)
self.assertEqual(finance.rate("3,0%"), 0.03)
self.assertIsNone(finance.rate(None))
self.assertIsNone(finance.rate(""))
self.assertIsNone(finance.rate(True))
def test_montant_calcule(self):
ev = {"update_value": "contrat", "role_id": "ventes-conseiller",
"base_field": "montant_contrat", "taux_pct": self.taux}
line = finance.compute_line(self.dossier, ev)
# 200000 × 2.5 % = 5000
self.assertEqual(line["montant"], 5000.0)
self.assertFalse(line["incomplete"])
self.assertIn("200000", line["formule"])
self.assertIn("2.5", line["formule"])
self.assertEqual(line["devise"], "USD")
def test_none_si_taux_absent(self):
ev = {"update_value": "contrat", "role_id": "ventes-conseiller",
"base_field": "montant_contrat", "taux_pct": None}
line = finance.compute_line(self.dossier, ev)
self.assertIsNone(line["montant"])
self.assertTrue(line["incomplete"])
self.assertIn("taux_pct", line["champs_manquants"])
# La formule reste affichée même sans valeur.
self.assertIn("200000", line["formule"])
def test_none_si_base_absente(self):
ev = {"update_value": "contrat", "role_id": "ventes-conseiller",
"base_field": "montant_contrat", "taux_pct": self.taux}
line = finance.compute_line({"devise": "USD"}, ev)
self.assertIsNone(line["montant"])
self.assertIn("montant_contrat", line["champs_manquants"])
self.assertIn("{montant_contrat}", line["formule"])
def test_shipped_bareme_yields_no_amount(self):
# Le barème livré (taux null) ne calcule aucun montant — c'est voulu (#6).
lines = finance.compute_dossier(self.dossier, self.spec)
self.assertTrue(all(l["montant"] is None for l in lines))
self.assertTrue(all(l["incomplete"] for l in lines))
class CliSmoke(unittest.TestCase):
"""Le CLI build/validate tourne et out/ committé == régénération."""
def test_validate_cli(self):
rc = gen.main(["validate"])
self.assertEqual(rc, 0)
def test_build_matches_committed(self):
import tempfile
with tempfile.TemporaryDirectory() as tmp:
rc = gen.main(["build", "-o", tmp])
self.assertEqual(rc, 0)
for name in ("commission_plan.json", "MANIFEST.json"):
fresh = _load(os.path.join(tmp, name))
committed_path = os.path.join(_MODULE, "out", name)
if os.path.exists(committed_path):
self.assertEqual(fresh, _load(committed_path),
f"{name} committé ≠ régénération")
if __name__ == "__main__":
unittest.main(verbosity=2)