[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:
+20
-1
@@ -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."
|
||||
|
||||
@@ -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`**
|
||||
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
Reference in New Issue
Block a user