diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 0f17081..6bc3b22 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -218,10 +218,29 @@ jobs: - name: Tests générateur DocType Dossier Vente run: python3 -m unittest discover -s tests -v + # -------------------------------------------------------------------------- + # Barème commissions vendeurs (Sprint 4 · ERPNext Backend « commissions + # vendeurs auto »). Cross-cohérence barème ↔ workflow ↔ DocType ↔ RBAC : chaque + # évènement paie sur un état SOUMIS, sur un champ Currency réel du Dossier + # Vente, pour un rôle ventes résolu. Anti-invention (#6) : aucun taux fixé + # (taux_pct null tant qu'à confirmer) ; calcul commission = base × taux + # traçable. Valide le schéma de sortie + 10 invariants. stdlib pur, zéro pip. + # -------------------------------------------------------------------------- + crm-commissions-tests: + name: CRM · barème commissions vendeurs + runs-on: ubuntu-latest + defaults: + run: + working-directory: 05_deliverables_mvp/crm/commissions + steps: + - uses: actions/checkout@v4 + - name: Tests générateur barème commissions + run: python3 -m unittest discover -s tests -v + gate: name: Gate qualité (agrégat) runs-on: ubuntu-latest - needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests, rbac-userperm-tests, rbac-roleprofile-tests, rbac-applyplan-tests, bancable-tests, crm-workflow-vente-tests, crm-dossier-vente-tests] + needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests, rbac-userperm-tests, rbac-roleprofile-tests, rbac-applyplan-tests, bancable-tests, crm-workflow-vente-tests, crm-dossier-vente-tests, crm-commissions-tests] steps: - name: Résultat run: echo "✅ Gate qualité 4Big franchi — tous les checks verts." diff --git a/05_activity_log/2026-07-30.md b/05_activity_log/2026-07-30.md index 0cba9a8..b85f559 100644 --- a/05_activity_log/2026-07-30.md +++ b/05_activity_log/2026-07-30.md @@ -1,5 +1,44 @@ # Activity Log · 2026-07-30 · Claude Code DTP +## Session `20260730_062706` (session 13) + +**Tâche** : Sprint 4 · ERPNext Backend — Générateur du **barème de commissions +vendeurs** (roadmap ligne 51 « commissions vendeurs auto »). + +**Fichiers créés** — `05_deliverables_mvp/crm/commissions/` : +- `bareme_spec.json` (5 évènements · tous `taux_pct: null` · anti-invention #6) +- `commlib/{__init__,deps,finance,builder}.py` (réutilise `is_filled`/`CANONICAL`/ + `validate` + `RoleResolver` du module `workflow_vente` · calcul traçable + `commission = base × taux` façon `banclib/finance.py`) +- `commissions_gen.py` (CLI `build`/`validate` · 10 invariants de cross-cohérence) +- `bareme.schema.json` (contrat de sortie draft-07) +- `fixtures/dossier_exemple.json` (test only · chiffres fictifs sourcés) +- `out/{commission_plan,MANIFEST}.json` (hand-off) · `tests/test_commissions.py` + (25 tests) · `README.md` · `.gitignore` + +**Fichiers modifiés** : +- `.gitea/workflows/ci.yml` : job `crm-commissions-tests` + ajout au `gate`. + +**Anti-invention (cœur · #6)** : aucun taux de commission n'est documenté dans +CLAUDE.md → le barème livré porte `taux_pct: null` partout ; un invariant refuse +tout taux fourni **sans `source`**. Le calcul reste `None` tant qu'un opérande +manque (jamais 0-inventé · formule toujours affichée). + +**Cross-cohérence** : chaque évènement paie sur un état **soumis** du workflow +(pas de brouillon), sur un champ **Currency** réel du Dossier Vente, pour un rôle +**ventes** résolu depuis `rbac_50_roles.json`. + +**Vérifs** : 25/25 tests ; gate CI local vert (guard + JSON + docs + YAML) ; +régression **202 tests verts** au total. + +**Hors périmètre worker (VPS · #8)** : confirmation des taux réels par la +Direction + câblage du calcul sur les transitions Workflow → agent ERPNext Backend. + +**Détail complet** : voir +[`05_deliverables_mvp/daily_reports/2026-07-30-session13.md`](../05_deliverables_mvp/daily_reports/2026-07-30-session13.md). + +**Auto-score 4Big** : 96/100. + ## Session `20260730_055704` (session 12) **Tâche** : Sprint 4 · CRM — Générateur du **DocType porteur `OTO Dossier Vente`** diff --git a/05_deliverables_mvp/crm/commissions/.gitignore b/05_deliverables_mvp/crm/commissions/.gitignore new file mode 100644 index 0000000..cece918 --- /dev/null +++ b/05_deliverables_mvp/crm/commissions/.gitignore @@ -0,0 +1,4 @@ +# Caches Python +__pycache__/ +*.pyc +# Le dossier out/ EST commité (hand-off ERPNext direct) — voir README. diff --git a/05_deliverables_mvp/crm/commissions/README.md b/05_deliverables_mvp/crm/commissions/README.md new file mode 100644 index 0000000..123f027 --- /dev/null +++ b/05_deliverables_mvp/crm/commissions/README.md @@ -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). diff --git a/05_deliverables_mvp/crm/commissions/bareme.schema.json b/05_deliverables_mvp/crm/commissions/bareme.schema.json new file mode 100644 index 0000000..728cf51 --- /dev/null +++ b/05_deliverables_mvp/crm/commissions/bareme.schema.json @@ -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" } + } + } + } + } + } + } +} diff --git a/05_deliverables_mvp/crm/commissions/bareme_spec.json b/05_deliverables_mvp/crm/commissions/bareme_spec.json new file mode 100644 index 0000000..ac468ef --- /dev/null +++ b/05_deliverables_mvp/crm/commissions/bareme_spec.json @@ -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 + } + ] +} diff --git a/05_deliverables_mvp/crm/commissions/commissions_gen.py b/05_deliverables_mvp/crm/commissions/commissions_gen.py new file mode 100644 index 0000000..53efd04 --- /dev/null +++ b/05_deliverables_mvp/crm/commissions/commissions_gen.py @@ -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()) diff --git a/05_deliverables_mvp/crm/commissions/commlib/__init__.py b/05_deliverables_mvp/crm/commissions/commlib/__init__.py new file mode 100644 index 0000000..396103c --- /dev/null +++ b/05_deliverables_mvp/crm/commissions/commlib/__init__.py @@ -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. +""" diff --git a/05_deliverables_mvp/crm/commissions/commlib/builder.py b/05_deliverables_mvp/crm/commissions/commlib/builder.py new file mode 100644 index 0000000..507cc2d --- /dev/null +++ b/05_deliverables_mvp/crm/commissions/commlib/builder.py @@ -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} diff --git a/05_deliverables_mvp/crm/commissions/commlib/deps.py b/05_deliverables_mvp/crm/commissions/commlib/deps.py new file mode 100644 index 0000000..d84b315 --- /dev/null +++ b/05_deliverables_mvp/crm/commissions/commlib/deps.py @@ -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"] diff --git a/05_deliverables_mvp/crm/commissions/commlib/finance.py b/05_deliverables_mvp/crm/commissions/commlib/finance.py new file mode 100644 index 0000000..1525378 --- /dev/null +++ b/05_deliverables_mvp/crm/commissions/commlib/finance.py @@ -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"]] diff --git a/05_deliverables_mvp/crm/commissions/fixtures/dossier_exemple.json b/05_deliverables_mvp/crm/commissions/fixtures/dossier_exemple.json new file mode 100644 index 0000000..08b05f2 --- /dev/null +++ b/05_deliverables_mvp/crm/commissions/fixtures/dossier_exemple.json @@ -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)" + } +} diff --git a/05_deliverables_mvp/crm/commissions/out/MANIFEST.json b/05_deliverables_mvp/crm/commissions/out/MANIFEST.json new file mode 100644 index 0000000..18b5c97 --- /dev/null +++ b/05_deliverables_mvp/crm/commissions/out/MANIFEST.json @@ -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)." +} diff --git a/05_deliverables_mvp/crm/commissions/out/commission_plan.json b/05_deliverables_mvp/crm/commissions/out/commission_plan.json new file mode 100644 index 0000000..d733d06 --- /dev/null +++ b/05_deliverables_mvp/crm/commissions/out/commission_plan.json @@ -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 + } + ] +} diff --git a/05_deliverables_mvp/crm/commissions/tests/test_commissions.py b/05_deliverables_mvp/crm/commissions/tests/test_commissions.py new file mode 100644 index 0000000..bc123bc --- /dev/null +++ b/05_deliverables_mvp/crm/commissions/tests/test_commissions.py @@ -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) diff --git a/05_deliverables_mvp/daily_reports/2026-07-30-session13.md b/05_deliverables_mvp/daily_reports/2026-07-30-session13.md new file mode 100644 index 0000000..371c984 --- /dev/null +++ b/05_deliverables_mvp/daily_reports/2026-07-30-session13.md @@ -0,0 +1,78 @@ +# Daily Report · 2026-07-30 · Claude Code DTP Worker (session 13) + +**Session** : `20260730_062706` + +## Tâche exécutée +**Sprint 4 · ERPNext Backend — Générateur du barème de commissions vendeurs** +(roadmap ligne 51 : _« commissions vendeurs auto »_). C'est la « prochaine tâche +suggérée » de la session 12. Le module produit un **plan de commissions** +cross-cohérent avec les trois contrats CRM déjà livrés (pipeline vente + DocType +porteur + RBAC) et un **calculateur traçable** `commission = base × taux`. + +## Contexte / analyse +- Relu `CLAUDE.md`, `ROADMAP_8_WEEKS_OR_LESS.md`, les daily reports sessions 11-12 + et l'idiome des générateurs (`wflib`/`dvlib`/`banclib` : `deps.py` réutilisé + + `builder.py` + schéma draft-07 + CLI `build|validate` + tests + job CI + gate). +- **Constat anti-invention (#6)** : aucun taux de commission n'est documenté + (`CANONICAL` = 3 % / 8.5 % / 52 %, aucune commission). Fixer un taux serait une + invention → le barème livré porte `taux_pct: null` partout, avec un invariant + qui **refuse** tout taux fourni sans `source`. + +## Réalisé — module `05_deliverables_mvp/crm/commissions/` +- `bareme_spec.json` — contrat **structurel** : 5 évènements (réservation, + contrat ×3 rôles, CONFOTUR approuvé) référençant chacun un `update_value` du + workflow, un `base_field` Currency du Dossier Vente, un `role_id` RBAC ventes. + Tous les `taux_pct` à `null` (`a_confirmer: true`). +- `commlib/deps.py` — réutilise (`#5`) `is_filled` / `CANONICAL` / `validate` + (import `sys.path`, zéro duplication, zéro pip). +- `commlib/finance.py` — calcul **traçable** `commission = base × taux` façon + `banclib/finance.py` : formule publiée, `None` si opérande manque (jamais + 0-inventé), parseur de taux (`2.5` ou `« 2.5 % »`). +- `commlib/builder.py` — assemblage **déterministe** ; réutilise le `RoleResolver` + du module `workflow_vente` (résolution `role_id → erpnext_role_name`). +- `bareme.schema.json` — contrat de sortie draft-07. +- `commissions_gen.py` — CLI `build`/`validate`. **Refuse d'écrire** si un des + **10 invariants** de cross-cohérence casse. +- `fixtures/dossier_exemple.json` — fixture **de test uniquement** (chiffres + fictifs + `source` explicite « non contractuel ») pour exercer le calcul. +- `out/` (commité, hand-off direct) : `commission_plan.json`, `MANIFEST.json`. +- `tests/test_commissions.py` — **25 tests `unittest` (stdlib pur)**. +- **CI** : job `crm-commissions-tests` ajouté au **gate** (`.gitea/workflows/ci.yml`). + +## Cross-cohérence barème ↔ workflow ↔ DocType ↔ RBAC (le cœur du livrable) +Les 10 invariants dérivent le barème des contrats voisins (anti-dérive) : +- `update_value` ∈ workflow **et** état **soumis** (`doc_status = 1`) : jamais de + commission sur un brouillon (lead/visite/devis/abandonné). +- `base_field` = champ **Currency réel** du DocType Dossier Vente. +- `role_id` résolu depuis `rbac_50_roles.json` **et** portail `ventes`. +- `devise_field` = champ `devise` (Select USD/DOP · #10). +- **Anti-invention (#6)** : aucun `taux_pct` sans `source` (le barème livré étant + tout `null`, `taux_a_confirmer = 5/5`). + +## Vérifications effectuées (en-repo, sans toucher au VPS) +- **25/25 tests verts** (validateur maison + oracle `jsonschema`) ; génération : + 5 évènements · 4 rôles · 5 taux à confirmer ; `out/` commité == régénération + bit-à-bit. +- **Gate CI local vert (exit 0)** : `guard_constraints.sh`, `validate_json.sh`, + `check_docs.sh` (score présent, 0 lien cassé), YAML `ci.yml` valide. +- **Régression** : 23 Publiciste + 10 RBAC + 16 Faisabilité + 11 fixtures + 12 + userperm + 11 roleprofile + 16 apply_plan + 22 bancable + 25 workflow + 31 + dossier + 25 commissions = **202 tests verts** au total. + +## Non fait (hors périmètre worker · VPS · #8) +- Confirmation des `taux_pct` réels + `source` par la Direction. +- Câblage du calcul de commission sur les transitions du Workflow (champ/table + enfant ou DocType commission dédié) → agent ERPNext Backend. + +## Prochaine tâche suggérée +- Sprint 4 ERPNext Backend : **plan e-CF DGII (Compupar)** — structure de la + facturation électronique fiscale (hand-off, zéro chiffre). +- Sprint 4 Frontend Console : squelette des **5 portails rôle** + (Ventes/Construction/Achat/Compta/Direction) façon clone `/waf-home`. + +--- + +**Auto-score 4Big du barème commissions : 96/100.** Réserve −4 : confirmation des +taux réels + câblage du calcul en production = côté VPS (agent ERPNext Backend · +#8). Validé statiquement en-repo (25 tests verts + schéma conforme + 10 +invariants de cross-cohérence + gate CI · 202 tests de régression au total).