Files
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

230 lines
9.5 KiB
Python
Raw Permalink 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.
#!/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())