[DTP-Worker] Sprint 4 · Générateur workflow vente ERPNext (lead → CONFOTUR)

Contrat pipeline commercial CRM natif (lead → visite → devis → réservation →
contrat → CONFOTUR) → fixtures Frappe/ERPNext v15 : Workflow (9 états / 11
transitions) + Workflow State + Workflow Action Master + MANIFEST.

Rôles gardant états/transitions résolus depuis rbac_50_roles.json (réutilisation,
zéro duplication · #6) : le contrat ne cite qu'un id de rôle, jamais un nom
Frappe en dur. CLI build/validate refuse d'écrire si l'un des 9 invariants de
graphe casse (monotonie doc_status, atteignabilité, séparation des pouvoirs sur
les étapes engageant de l'argent / clôturant).

25 tests (stdlib pur + oracle jsonschema) · job CI crm-workflow-vente-tests ajouté
au gate · 146 tests de régression verts au total. Application VPS (DocType porteur
OTO Dossier Vente + bench migrate) = agent ERPNext Backend, hors périmètre worker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Code DTP Worker
2026-07-30 05:36:27 +00:00
parent 915fc5194a
commit 34f202af40
17 changed files with 1797 additions and 1 deletions
+20 -1
View File
@@ -180,10 +180,29 @@ jobs:
- name: Tests générateur bancable trilingue - name: Tests générateur bancable trilingue
run: python3 -m unittest discover -s tests -v run: python3 -m unittest discover -s tests -v
# --------------------------------------------------------------------------
# Générateur de workflow vente ERPNext (Sprint 4 · CRM natif) : le contrat
# pipeline workflow_vente_spec.json (lead → visite → devis → réservation →
# contrat → CONFOTUR) → fixtures Frappe Workflow + Workflow State + Workflow
# Action Master. Rôles résolus depuis rbac_50_roles.json (zéro duplication).
# Valide le schéma de sortie + 9 invariants de graphe (monotonie doc_status,
# atteignabilité, séparation des pouvoirs). stdlib pur, zéro pip.
# --------------------------------------------------------------------------
crm-workflow-vente-tests:
name: CRM · workflow vente ERPNext (lead → CONFOTUR)
runs-on: ubuntu-latest
defaults:
run:
working-directory: 05_deliverables_mvp/crm/workflow_vente
steps:
- uses: actions/checkout@v4
- name: Tests générateur workflow vente
run: python3 -m unittest discover -s tests -v
gate: gate:
name: Gate qualité (agrégat) name: Gate qualité (agrégat)
runs-on: ubuntu-latest 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] 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]
steps: steps:
- name: Résultat - name: Résultat
run: echo "✅ Gate qualité 4Big franchi — tous les checks verts." run: echo "✅ Gate qualité 4Big franchi — tous les checks verts."
+33
View File
@@ -0,0 +1,33 @@
# Activity Log · 2026-07-30 · Claude Code DTP
## Session `20260730_052701` (session 11)
**Tâche** : Sprint 4 · CRM — Générateur de workflow vente ERPNext
(`lead → visite → devis → réservation → contrat → CONFOTUR`).
**Fichiers créés**`05_deliverables_mvp/crm/workflow_vente/` :
- `workflow_vente_spec.json` (contrat pipeline · 9 états / 11 transitions)
- `wflib/{__init__,rbac,erpnext,builder}.py` (résolution RBAC + connaissance
Frappe + assemblage déterministe)
- `workflow_vente_gen.py` (CLI `build`/`validate` · 9 invariants de graphe)
- `workflow.schema.json` (contrat de sortie draft-07)
- `out/{workflow,workflow_state,workflow_action_master,MANIFEST}.json` (hand-off)
- `tests/test_workflow_vente.py` (25 tests) · `README.md` · `.gitignore`
**Fichiers modifiés** :
- `.gitea/workflows/ci.yml` : job `crm-workflow-vente-tests` + ajout au `gate`.
**Réutilisation (zéro duplication · #6)** : rôles résolus depuis
`rbac/rbac_50_roles.json` (jamais de nom Frappe en dur) + validateur maison
Publiciste.
**Vérifs** : 25/25 tests ; gate CI local vert (guard + JSON + docs + YAML) ;
régression **146 tests verts** au total.
**Hors périmètre worker (VPS)** : création DocType `OTO Dossier Vente` + import
fixtures (`bench migrate`) → agent ERPNext Backend (#8).
**Détail complet** : voir
[`05_deliverables_mvp/daily_reports/2026-07-30-session11.md`](../05_deliverables_mvp/daily_reports/2026-07-30-session11.md).
**Auto-score 4Big** : 96/100.
@@ -0,0 +1,4 @@
# Caches Python
__pycache__/
*.pyc
# Le dossier out/ EST commité (hand-off ERPNext direct) — voir README.
@@ -0,0 +1,125 @@
# Générateur de workflow vente ERPNext · lead → CONFOTUR
**Sprint 4 · CRM natif ERPNext.** Transforme le contrat pipeline
[`workflow_vente_spec.json`](workflow_vente_spec.json) en **fixtures
Frappe/ERPNext v15 natives** du moteur *Workflow*, prêtes à appliquer sur le VPS
par `bench`. Réalise le deliverable roadmap Sprint 4 : « workflow complet **lead
→ visite → devis → réservation → contrat → CONFOTUR** » (CRM = ERPNext natif ·
contrainte #3, JAMAIS d'outil externe).
> Ce worker **n'écrit jamais sur le VPS** (contrainte #8). Il produit les
> fichiers en-repo ; l'application réelle (`bench migrate`) reste côté serveur
> (agent ERPNext Backend).
## Ce qui est généré (`out/`, commité — hand-off direct)
| Fichier | DocType Frappe | Rôle |
|---|---|---|
| `workflow.json` | `Workflow` | Le graphe : `document_type` + table `states[]` + table `transitions[]`. |
| `workflow_state.json` | `Workflow State` | Maîtres d'états (nom + `style` de badge desk). |
| `workflow_action_master.json` | `Workflow Action Master` | Maîtres d'actions (noms de boutons de transition). |
| `MANIFEST.json` | — | Traçabilité (comptes, version) + **DocType porteur `custom` à confirmer VPS** + rôles RBAC utilisés. |
## Le pipeline (9 états · 11 transitions)
```
Lead ──Planifier visite──▶ Visite planifiée ──Émettre devis──▶ Devis émis
│ │ │
└──Abandonner──┐ └──Abandonner──┐ ┌──Abandonner───┘
▼ ▼ ▼
Abandonné (0, terminal) Devis émis ──Confirmer réservation──▶ Réservation confirmée (1)
│ │
Signer contrat ◀─────────────────────────┘ └──Annuler──▶ Perdu (2, terminal)
Contrat signé (1) ──Déposer CONFOTUR──▶ CONFOTUR déposé (1)
│ │
└──Résilier──▶ Perdu (2) Approuver CONFOTUR
CONFOTUR approuvé (1, terminal succès)
```
`doc_status` natif Frappe : **0** = Brouillon · **1** = Soumis · **2** = Annulé.
Le long d'une transition, `doc_status` est **monotone** (0→0, 0→1, 1→1, 1→2) —
jamais de saut 0→2 ni de retour arrière (invariant vérifié par le CLI).
## Rôles = contrat RBAC (réutilisation · zéro duplication · #6)
Le pipeline **ne cite jamais un nom de rôle Frappe en dur** : chaque état/transition
référence l'`id` stable d'un rôle de
[`../../rbac/rbac_50_roles.json`](../../rbac/rbac_50_roles.json), résolu par
`wflib/rbac.py` en `erpnext_role_name`. Un `id` absent du contrat RBAC lève une
erreur (aucun rôle inventé) ; renommer un rôle côté RBAC se propage
automatiquement.
| Étape | Rôle qui garde la transition |
|---|---|
| Planifier visite · Émettre devis | OTO Ventes Conseiller |
| Abandonner (après devis) | OTO Ventes Chef Équipe |
| **Confirmer réservation** (soumission · argent) | OTO Ventes Réservations |
| **Signer contrat** | OTO Ventes Contrats |
| Déposer / **Approuver CONFOTUR** | OTO Ventes CONFOTUR |
| Annuler / Résilier (perdu) | OTO Ventes Directeur · OTO Direction Commerciale |
## Séparation des pouvoirs (défense en profondeur · #6)
Les transitions qui **engagent de l'argent ou clôturent** — confirmer
réservation, signer contrat, approuver CONFOTUR, annuler/résilier — sont marquées
`separation_of_duties` dans le contrat et **interdisent l'auto-approbation**
(`allow_self_approval = 0` : quatre-yeux obligatoire). Le CLI `validate` échoue
si l'une d'elles autorise l'auto-approbation.
## Utilisation
```bash
# Génère les 4 fichiers dans out/
python3 workflow_vente_gen.py build # [-o DOSSIER]
# Valide (schéma + 9 invariants métier) sans rien écrire
python3 workflow_vente_gen.py validate
# Tests (stdlib pur, zéro pip)
python3 -m unittest discover -s tests -v
```
## Invariants vérifiés par le CLI (refus d'écrire si l'un casse)
1. Conformité au schéma de sortie (`workflow.schema.json`).
2. Unicité des noms d'état.
3. Toute transition référence des états déclarés.
4. Monotonie `doc_status` (pas de saut 0→2 ni décroissant).
5. Unicité du couple (état, action) — action déterministe (exigence Frappe).
6. Atteignabilité de tous les états depuis `Lead`.
7. Au moins un état terminal de succès (soumis, sans sortie) ; les terminaux
déclarés n'ont pas de transition sortante.
8. Séparation des pouvoirs (pas d'auto-approbation sur les étapes sensibles).
9. Maîtres `Workflow State`/`Workflow Action Master` = exactement les états/actions ;
comptes du manifeste cohérents.
## Application sur VPS (agent ERPNext Backend · hors périmètre worker)
1. Créer d'abord le DocType porteur `custom` listé dans
`MANIFEST.custom_doctypes_a_confirmer` (`OTO Dossier Vente`) **après
confirmation d'existence** — champ `workflow_state` (Select) + `statut_pipeline`.
2. Déposer `workflow.json` + `workflow_state.json` + `workflow_action_master.json`
dans `fixtures/` de l'app OTO, référencés dans `hooks.py`.
3. `bench --site frontend migrate` (ou `bench import-fixtures`).
4. Les rôles cibles doivent exister au préalable → fixtures
[`../../rbac/fixtures_gen`](../../rbac/fixtures_gen/README.md).
5. Vérification HTTP post-déploiement (workflow #3) + audit QA 4Big.
## Vérification en-repo
- `python3 -m unittest discover -s tests -v`**25/25 verts** (résolution RBAC,
structure Frappe, 9 invariants de graphe, schéma maison + oracle `jsonschema`,
déterminisme, CLI, `out/` == régénération).
- Job CI dédié `crm-workflow-vente-tests` ajouté au **gate**
(`.gitea/workflows/ci.yml`, Gitea Actions uniquement · #2).
## Auto-score 4Big du livrable : **96/100**
_Réserve 4_ : la création du DocType porteur `OTO Dossier Vente` + l'import des
fixtures (`bench migrate`) restent côté VPS (agent ERPNext Backend · #8) ; les
conditions de transition (`condition`) sont laissées à `null` (le contrat métier
ne documente pas de seuil chiffré → anti-invention #6). Validé statiquement
en-repo (25 tests verts + schéma conforme + 9 invariants de graphe + gate CI).
@@ -0,0 +1,48 @@
{
"generated_from": "workflow_vente_spec.json",
"rbac_source": "rbac_50_roles.json",
"source_version": "1.0.0",
"workflow_name": "OTO Vente Pipeline",
"document_type": "OTO Dossier Vente",
"document_type_custom": true,
"counts": {
"states": 9,
"transitions": 11,
"workflow_state_masters": 9,
"workflow_action_masters": 9,
"terminal_states": 3
},
"custom_doctypes_a_confirmer": [
"OTO Dossier Vente"
],
"roles_rbac_utilises": [
{
"role_id": "direction-cco",
"erpnext_role_name": "OTO Direction Commerciale"
},
{
"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-contrats",
"erpnext_role_name": "OTO Ventes Contrats"
},
{
"role_id": "ventes-directeur",
"erpnext_role_name": "OTO Ventes Directeur"
},
{
"role_id": "ventes-reservations",
"erpnext_role_name": "OTO Ventes Réservations"
}
]
}
@@ -0,0 +1,185 @@
[
{
"doctype": "Workflow",
"name": "OTO Vente Pipeline",
"workflow_name": "OTO Vente Pipeline",
"document_type": "OTO Dossier Vente",
"workflow_state_field": "workflow_state",
"is_active": 1,
"send_email_alert": 0,
"override_status": 0,
"states": [
{
"state": "Lead",
"doc_status": "0",
"allow_edit": "OTO Ventes Conseiller",
"update_field": "statut_pipeline",
"update_value": "lead",
"is_optional_state": 0,
"message": "Nouveau prospect qualifie a contacter."
},
{
"state": "Visite planifiée",
"doc_status": "0",
"allow_edit": "OTO Ventes Conseiller",
"update_field": "statut_pipeline",
"update_value": "visite",
"is_optional_state": 0,
"message": "Visite du site ou de l'unite temoin programmee."
},
{
"state": "Devis émis",
"doc_status": "0",
"allow_edit": "OTO Ventes Conseiller",
"update_field": "statut_pipeline",
"update_value": "devis",
"is_optional_state": 0,
"message": "Devis chiffre remis au prospect (attente decision)."
},
{
"state": "Réservation confirmée",
"doc_status": "1",
"allow_edit": "OTO Ventes Réservations",
"update_field": "statut_pipeline",
"update_value": "reservation",
"is_optional_state": 0,
"message": "Depot de reservation encaisse ; dossier verrouille (soumis)."
},
{
"state": "Contrat signé",
"doc_status": "1",
"allow_edit": "OTO Ventes Contrats",
"update_field": "statut_pipeline",
"update_value": "contrat",
"is_optional_state": 0,
"message": "Contrat de vente signe par les deux parties."
},
{
"state": "CONFOTUR déposé",
"doc_status": "1",
"allow_edit": "OTO Ventes CONFOTUR",
"update_field": "statut_pipeline",
"update_value": "confotur_depose",
"is_optional_state": 0,
"message": "Dossier d'incitation CONFOTUR depose aupres de l'autorite."
},
{
"state": "CONFOTUR approuvé",
"doc_status": "1",
"allow_edit": "OTO Ventes CONFOTUR",
"update_field": "statut_pipeline",
"update_value": "confotur_approuve",
"is_optional_state": 0,
"message": "Incitation CONFOTUR approuvee ; cycle de vente clos avec succes."
},
{
"state": "Abandonné",
"doc_status": "0",
"allow_edit": "OTO Ventes Chef Équipe",
"update_field": "statut_pipeline",
"update_value": "abandonne",
"is_optional_state": 0,
"message": "Piste abandonnee avant reservation (brouillon)."
},
{
"state": "Perdu",
"doc_status": "2",
"allow_edit": "OTO Ventes Directeur",
"update_field": "statut_pipeline",
"update_value": "perdu",
"is_optional_state": 0,
"message": "Affaire soumise resiliee/annulee (dossier annule)."
}
],
"transitions": [
{
"state": "CONFOTUR déposé",
"action": "Approuver CONFOTUR",
"next_state": "CONFOTUR approuvé",
"allowed": "OTO Ventes CONFOTUR",
"allow_self_approval": 0,
"condition": null
},
{
"state": "Contrat signé",
"action": "Déposer CONFOTUR",
"next_state": "CONFOTUR déposé",
"allowed": "OTO Ventes CONFOTUR",
"allow_self_approval": 1,
"condition": null
},
{
"state": "Contrat signé",
"action": "Résilier (perdu)",
"next_state": "Perdu",
"allowed": "OTO Direction Commerciale",
"allow_self_approval": 0,
"condition": null
},
{
"state": "Devis émis",
"action": "Abandonner",
"next_state": "Abandonné",
"allowed": "OTO Ventes Chef Équipe",
"allow_self_approval": 1,
"condition": null
},
{
"state": "Devis émis",
"action": "Confirmer réservation",
"next_state": "Réservation confirmée",
"allowed": "OTO Ventes Réservations",
"allow_self_approval": 0,
"condition": null
},
{
"state": "Lead",
"action": "Abandonner",
"next_state": "Abandonné",
"allowed": "OTO Ventes Conseiller",
"allow_self_approval": 1,
"condition": null
},
{
"state": "Lead",
"action": "Planifier visite",
"next_state": "Visite planifiée",
"allowed": "OTO Ventes Conseiller",
"allow_self_approval": 1,
"condition": null
},
{
"state": "Réservation confirmée",
"action": "Annuler (perdu)",
"next_state": "Perdu",
"allowed": "OTO Ventes Directeur",
"allow_self_approval": 0,
"condition": null
},
{
"state": "Réservation confirmée",
"action": "Signer contrat",
"next_state": "Contrat signé",
"allowed": "OTO Ventes Contrats",
"allow_self_approval": 0,
"condition": null
},
{
"state": "Visite planifiée",
"action": "Abandonner",
"next_state": "Abandonné",
"allowed": "OTO Ventes Conseiller",
"allow_self_approval": 1,
"condition": null
},
{
"state": "Visite planifiée",
"action": "Émettre devis",
"next_state": "Devis émis",
"allowed": "OTO Ventes Conseiller",
"allow_self_approval": 1,
"condition": null
}
]
}
]
@@ -0,0 +1,47 @@
[
{
"doctype": "Workflow Action Master",
"name": "Abandonner",
"workflow_action_name": "Abandonner"
},
{
"doctype": "Workflow Action Master",
"name": "Annuler (perdu)",
"workflow_action_name": "Annuler (perdu)"
},
{
"doctype": "Workflow Action Master",
"name": "Approuver CONFOTUR",
"workflow_action_name": "Approuver CONFOTUR"
},
{
"doctype": "Workflow Action Master",
"name": "Confirmer réservation",
"workflow_action_name": "Confirmer réservation"
},
{
"doctype": "Workflow Action Master",
"name": "Déposer CONFOTUR",
"workflow_action_name": "Déposer CONFOTUR"
},
{
"doctype": "Workflow Action Master",
"name": "Planifier visite",
"workflow_action_name": "Planifier visite"
},
{
"doctype": "Workflow Action Master",
"name": "Résilier (perdu)",
"workflow_action_name": "Résilier (perdu)"
},
{
"doctype": "Workflow Action Master",
"name": "Signer contrat",
"workflow_action_name": "Signer contrat"
},
{
"doctype": "Workflow Action Master",
"name": "Émettre devis",
"workflow_action_name": "Émettre devis"
}
]
@@ -0,0 +1,56 @@
[
{
"doctype": "Workflow State",
"name": "Abandonné",
"workflow_state_name": "Abandonné",
"style": "Danger"
},
{
"doctype": "Workflow State",
"name": "CONFOTUR approuvé",
"workflow_state_name": "CONFOTUR approuvé",
"style": "Success"
},
{
"doctype": "Workflow State",
"name": "CONFOTUR déposé",
"workflow_state_name": "CONFOTUR déposé",
"style": "Info"
},
{
"doctype": "Workflow State",
"name": "Contrat signé",
"workflow_state_name": "Contrat signé",
"style": "Success"
},
{
"doctype": "Workflow State",
"name": "Devis émis",
"workflow_state_name": "Devis émis",
"style": "Warning"
},
{
"doctype": "Workflow State",
"name": "Lead",
"workflow_state_name": "Lead",
"style": "Primary"
},
{
"doctype": "Workflow State",
"name": "Perdu",
"workflow_state_name": "Perdu",
"style": "Danger"
},
{
"doctype": "Workflow State",
"name": "Réservation confirmée",
"workflow_state_name": "Réservation confirmée",
"style": "Primary"
},
{
"doctype": "Workflow State",
"name": "Visite planifiée",
"workflow_state_name": "Visite planifiée",
"style": "Info"
}
]
@@ -0,0 +1,272 @@
#!/usr/bin/env python3
"""Tests du générateur de fixtures ERPNext Workflow (pipeline vente · Sprint 4).
Stdlib pur (`unittest`) → aucune installation pip requise sur le runner Gitea.
La bibliothèque `jsonschema` est utilisée comme *oracle* quand elle est présente,
pour se prémunir d'un écart entre le validateur maison et draft-07.
"""
from __future__ import annotations
import copy
import json
import os
import subprocess
import sys
import tempfile
import unittest
_HERE = os.path.dirname(os.path.abspath(__file__))
_MODULE = os.path.normpath(os.path.join(_HERE, ".."))
_DELIVERABLES = os.path.normpath(os.path.join(_MODULE, "..", ".."))
sys.path.insert(0, _MODULE)
sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste"))
from wflib import builder, erpnext # noqa: E402
from wflib.rbac import RoleResolver # noqa: E402
from lib import validator as maison # type: ignore # noqa: E402
import workflow_vente_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 BaseFixture(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.spec = _load(gen._SPEC_PATH)
cls.schema = _load(gen._SCHEMA_PATH)
cls.resolver = RoleResolver.from_path()
cls.bundle = builder.build_bundle(cls.spec, cls.resolver)
# --------------------------------------------------------------------------- #
# Résolution des rôles RBAC (réutilisation · zéro invention · #6)
# --------------------------------------------------------------------------- #
class TestRoleResolver(BaseFixture):
def test_resolves_known_id(self) -> None:
self.assertEqual(
self.resolver.erpnext_name("ventes-conseiller"), "OTO Ventes Conseiller"
)
def test_unknown_id_raises(self) -> None:
with self.assertRaises(KeyError):
self.resolver.erpnext_name("role-inexistant")
def test_every_spec_role_id_exists_in_rbac(self) -> None:
ids = {s["role_id"] for s in self.spec["states"]}
ids |= {t["role_id"] for t in self.spec["transitions"]}
for rid in ids:
self.assertIn(rid, self.resolver.known_ids(), rid)
def test_all_resolved_roles_are_oto_prefixed(self) -> None:
for entry in self.bundle["manifest"]["roles_rbac_utilises"]:
self.assertTrue(entry["erpnext_role_name"].startswith("OTO "))
# --------------------------------------------------------------------------- #
# Structure du bundle / conformité Frappe
# --------------------------------------------------------------------------- #
class TestBundleStructure(BaseFixture):
def test_workflow_targets_pipeline_doctype(self) -> None:
wf = self.bundle["workflow"]
self.assertEqual(wf["doctype"], "Workflow")
self.assertEqual(wf["document_type"], "OTO Dossier Vente")
self.assertEqual(wf["workflow_state_field"], "workflow_state")
def test_pipeline_covers_the_six_roadmap_stages(self) -> None:
# roadmap Sprint 4 : lead -> visite -> devis -> reservation -> contrat -> CONFOTUR
states = {s["state"] for s in self.bundle["workflow"]["states"]}
for expected in [
"Lead",
"Visite planifiée",
"Devis émis",
"Réservation confirmée",
"Contrat signé",
"CONFOTUR déposé",
]:
self.assertIn(expected, states, expected)
def test_masters_cover_states_and_actions_exactly(self) -> None:
states = {s["state"] for s in self.bundle["workflow"]["states"]}
masters = {m["name"] for m in self.bundle["workflow_state"]}
self.assertEqual(states, masters)
actions = {t["action"] for t in self.bundle["workflow"]["transitions"]}
act_masters = {m["name"] for m in self.bundle["workflow_action_master"]}
self.assertEqual(actions, act_masters)
def test_styles_are_native_frappe(self) -> None:
for m in self.bundle["workflow_state"]:
self.assertIn(m["style"], erpnext.VALID_STYLES)
def test_manifest_flags_custom_carrier_doctype(self) -> None:
self.assertEqual(
self.bundle["manifest"]["custom_doctypes_a_confirmer"], ["OTO Dossier Vente"]
)
# --------------------------------------------------------------------------- #
# Invariants métier du graphe (le CLI refuse d'écrire s'ils cassent)
# --------------------------------------------------------------------------- #
class TestGraphInvariants(BaseFixture):
def test_clean_bundle_has_no_errors(self) -> None:
self.assertEqual(gen._validate_bundle(self.spec, self.bundle), [])
def test_docstatus_monotonic_no_illegal_jump(self) -> None:
docstatus = {s["state"]: s["doc_status"] for s in self.bundle["workflow"]["states"]}
for t in self.bundle["workflow"]["transitions"]:
pair = (docstatus[t["state"]], docstatus[t["next_state"]])
self.assertIn(pair, gen._ALLOWED_DOCSTATUS_STEPS, f"{t['state']}{t['next_state']}")
def test_detects_unreachable_state(self) -> None:
spec = copy.deepcopy(self.spec)
spec["states"].append(
{
"state": "Orphelin",
"doc_status": "0",
"role_id": "ventes-conseiller",
"style": "Info",
"update_value": "orphelin",
"is_terminal": True,
"message": "État jamais atteint.",
}
)
bundle = builder.build_bundle(spec, self.resolver)
errors = gen._validate_bundle(spec, bundle)
self.assertTrue(any("inatteignable" in e for e in errors), errors)
def test_detects_illegal_docstatus_jump(self) -> None:
# Lead(0) -> Perdu(2) : saut 0→2 interdit.
spec = copy.deepcopy(self.spec)
spec["transitions"].append(
{
"state": "Lead",
"action": "Sauter à perdu",
"next_state": "Perdu",
"role_id": "ventes-directeur",
"allow_self_approval": 1,
}
)
bundle = builder.build_bundle(spec, self.resolver)
errors = gen._validate_bundle(spec, bundle)
self.assertTrue(any("doc_status illégal" in e for e in errors), errors)
def test_detects_duplicate_action_from_same_state(self) -> None:
spec = copy.deepcopy(self.spec)
spec["transitions"].append(
{
"state": "Lead",
"action": "Planifier visite", # doublon (état, action)
"next_state": "Abandonné",
"role_id": "ventes-conseiller",
"allow_self_approval": 1,
}
)
bundle = builder.build_bundle(spec, self.resolver)
errors = gen._validate_bundle(spec, bundle)
self.assertTrue(any("dupliqué" in e for e in errors), errors)
def test_detects_separation_of_duties_violation(self) -> None:
spec = copy.deepcopy(self.spec)
for t in spec["transitions"]:
if t.get("separation_of_duties"):
t["allow_self_approval"] = 1 # viole la règle
break
bundle = builder.build_bundle(spec, self.resolver)
errors = gen._validate_bundle(spec, bundle)
self.assertTrue(any("Séparation des pouvoirs" in e for e in errors), errors)
def test_reservation_confirm_forbids_self_approval(self) -> None:
# Étape argent (devis → réservation) : quatre-yeux obligatoire.
tr = next(
t for t in self.bundle["workflow"]["transitions"]
if t["action"] == "Confirmer réservation"
)
self.assertEqual(tr["allow_self_approval"], 0)
def test_confotur_approval_forbids_self_approval(self) -> None:
tr = next(
t for t in self.bundle["workflow"]["transitions"]
if t["action"] == "Approuver CONFOTUR"
)
self.assertEqual(tr["allow_self_approval"], 0)
def test_has_submitted_terminal_success_state(self) -> None:
outgoing = {t["state"] for t in self.bundle["workflow"]["transitions"]}
terminal_success = [
s["state"] for s in self.bundle["workflow"]["states"]
if s["doc_status"] == "1" and s["state"] not in outgoing
]
self.assertIn("CONFOTUR approuvé", terminal_success)
# --------------------------------------------------------------------------- #
# Schéma de sortie (validateur maison + oracle jsonschema)
# --------------------------------------------------------------------------- #
class TestSchema(BaseFixture):
def test_bundle_matches_schema_maison(self) -> None:
self.assertEqual(list(maison.validate(self.bundle, self.schema)), [])
@unittest.skipUnless(_HAS_JSONSCHEMA, "jsonschema non installé (oracle facultatif)")
def test_bundle_matches_schema_oracle(self) -> None:
jsonschema.validate(self.bundle, self.schema) # type: ignore
# --------------------------------------------------------------------------- #
# Déterminisme + CLI (build/validate)
# --------------------------------------------------------------------------- #
class TestDeterminismAndCLI(BaseFixture):
def test_build_is_deterministic(self) -> None:
a = builder.build_bundle(self.spec, self.resolver)
b = builder.build_bundle(self.spec, self.resolver)
self.assertEqual(
json.dumps(a, ensure_ascii=False, sort_keys=True),
json.dumps(b, ensure_ascii=False, sort_keys=True),
)
def test_transitions_sorted_stable(self) -> None:
trans = self.bundle["workflow"]["transitions"]
keys = [(t["state"], t["action"]) for t in trans]
self.assertEqual(keys, sorted(keys))
def test_cli_validate_exit_zero(self) -> None:
rc = gen.main(["validate"])
self.assertEqual(rc, 0)
def test_cli_build_writes_four_files(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
rc = gen.main(["build", "-o", tmp])
self.assertEqual(rc, 0)
for f in [
"workflow.json",
"workflow_state.json",
"workflow_action_master.json",
"MANIFEST.json",
]:
self.assertTrue(os.path.exists(os.path.join(tmp, f)), f)
wf = _load(os.path.join(tmp, "workflow.json"))
self.assertEqual(wf[0]["doctype"], "Workflow")
def test_committed_out_matches_regeneration(self) -> None:
# La sortie commitée dans out/ doit correspondre bit-à-bit à une
# régénération (garantie « diffable + re-générable » · anti-dérive).
with tempfile.TemporaryDirectory() as tmp:
gen.main(["build", "-o", tmp])
for f in ["workflow.json", "workflow_state.json", "workflow_action_master.json", "MANIFEST.json"]:
committed = os.path.join(_MODULE, "out", f)
if os.path.exists(committed):
self.assertEqual(_load(committed), _load(os.path.join(tmp, f)), f)
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -0,0 +1,6 @@
"""wflib · construction des fixtures ERPNext Workflow depuis le contrat pipeline.
Sépare la connaissance Frappe (`erpnext.py`), la résolution des rôles RBAC
(`rbac.py`) et l'assemblage déterministe du bundle (`builder.py`). Aucun accès
VPS : le module produit uniquement des dicts sérialisables.
"""
@@ -0,0 +1,116 @@
"""Assemblage du bundle de fixtures Workflow depuis `workflow_vente_spec.json`.
Entrée : le contrat pipeline (états + transitions référençant des `role_id`
RBAC) + un `RoleResolver` (table id → nom Frappe issue 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) : tous les noms de rôle proviennent du contrat
RBAC ; toutes les valeurs d'état/transition proviennent du contrat pipeline.
Le builder n'ajoute que la structure Frappe native (flags par défaut) + un
manifeste de traçabilité.
"""
from __future__ import annotations
from typing import Any
from . import erpnext
from .rbac import RoleResolver
_UPDATE_FIELD = "statut_pipeline"
def build_bundle(spec: dict, resolver: RoleResolver) -> dict[str, Any]:
"""Transforme le contrat pipeline en bundle de fixtures Workflow Frappe."""
states_spec = spec["states"]
transitions_spec = spec["transitions"]
# --- 1. Table enfant `states` (ordre = ordre du contrat = ordre pipeline) --
state_rows: list[dict] = []
state_styles: dict[str, str] = {}
for st in states_spec:
allow_edit = resolver.erpnext_name(st["role_id"])
state_rows.append(
erpnext.document_state_row(
st["state"],
st["doc_status"],
allow_edit,
update_field=_UPDATE_FIELD,
update_value=st["update_value"],
message=st["message"],
)
)
state_styles[st["state"]] = st["style"]
# --- 2. Table enfant `transitions` (tri stable état→action) ---------------
trans_rows: list[dict] = []
for tr in transitions_spec:
allowed = resolver.erpnext_name(tr["role_id"])
trans_rows.append(
erpnext.transition_row(
tr["state"],
tr["action"],
tr["next_state"],
allowed,
allow_self_approval=tr.get("allow_self_approval", 0),
)
)
trans_rows.sort(key=lambda t: (t["state"], t["action"]))
# --- 3. Document Workflow -------------------------------------------------
workflow = erpnext.workflow_doc(
name=spec["workflow_name"],
document_type=spec["document_type"],
workflow_state_field=spec["workflow_state_field"],
is_active=spec.get("is_active", 1),
send_email_alert=spec.get("send_email_alert", 0),
override_status=spec.get("override_status", 0),
states=state_rows,
transitions=trans_rows,
)
# --- 4. Maîtres `Workflow State` + `Workflow Action Master` ---------------
# Uniques + triés → import idempotent, diff stable.
unique_states = sorted({s["state"] for s in state_rows})
workflow_state_masters = [
erpnext.workflow_state_master(s, state_styles[s]) for s in unique_states
]
unique_actions = sorted({t["action"] for t in trans_rows})
workflow_action_masters = [
erpnext.workflow_action_master(a) for a in unique_actions
]
# --- 5. Manifeste de traçabilité ------------------------------------------
roles_used = sorted({st["role_id"] for st in states_spec} | {tr["role_id"] for tr in transitions_spec})
manifest = {
"generated_from": "workflow_vente_spec.json",
"rbac_source": "rbac_50_roles.json",
"source_version": spec["version"],
"workflow_name": spec["workflow_name"],
"document_type": spec["document_type"],
"document_type_custom": bool(spec.get("document_type_custom")),
"counts": {
"states": len(state_rows),
"transitions": len(trans_rows),
"workflow_state_masters": len(workflow_state_masters),
"workflow_action_masters": len(workflow_action_masters),
"terminal_states": sum(1 for st in states_spec if st.get("is_terminal")),
},
# DocType porteur non natif : à CRÉER + confirmer sur le VPS (SPEC §7)
# avant d'importer le Workflow qui le cible. Jamais supposé existant.
"custom_doctypes_a_confirmer": (
[spec["document_type"]] if spec.get("document_type_custom") else []
),
"roles_rbac_utilises": [
{"role_id": rid, "erpnext_role_name": resolver.erpnext_name(rid)}
for rid in roles_used
],
}
return {
"manifest": manifest,
"workflow": workflow,
"workflow_state": workflow_state_masters,
"workflow_action_master": workflow_action_masters,
}
@@ -0,0 +1,118 @@
"""Modèle Frappe/ERPNext v15 : structure native des fixtures Workflow.
Sépare la CONNAISSANCE FRAPPE (noms de DocTypes, champs de table enfant, valeurs
autorisées) de l'assemblage métier (`builder.py`). Contrainte #1 « ERPNext natif
= priorité absolue » : on n'invente aucun moteur de workflow externe — on
produit les DocTypes standard du moteur Workflow de Frappe.
DocTypes produits (references Frappe) :
- `Workflow` → le graphe (document_type + states[] + transitions[]).
- `Workflow Document State`→ table enfant `states` : un état + son doc_status +
le rôle qui peut éditer (`allow_edit`).
- `Workflow Transition` → table enfant `transitions` : arête état→action→état,
gardée par un rôle (`allowed`) + `allow_self_approval`.
- `Workflow State` → DocType maître (nom d'état + `style` d'affichage).
- `Workflow Action Master` → DocType maître (nom d'action).
Aucun accès VPS : chaque fonction renvoie un dict sérialisable.
"""
from __future__ import annotations
from typing import Any
# doc_status Frappe natifs. Le long d'une transition, doc_status est MONOTONE
# (0→0 mise à jour, 0→1 soumission, 1→1 mise à jour, 1→2 annulation) — jamais
# 0→2 ni décroissant. Le CLI `validate` fait respecter cet invariant.
DOCSTATUS_DRAFT = "0"
DOCSTATUS_SUBMITTED = "1"
DOCSTATUS_CANCELLED = "2"
VALID_DOCSTATUS: frozenset[str] = frozenset(
{DOCSTATUS_DRAFT, DOCSTATUS_SUBMITTED, DOCSTATUS_CANCELLED}
)
# Styles d'affichage natifs du DocType `Workflow State` (badge de couleur desk).
# Une valeur hors de cet ensemble = invention → refusée.
VALID_STYLES: frozenset[str] = frozenset(
{"", "Primary", "Info", "Success", "Warning", "Danger", "Inverse"}
)
def workflow_state_master(state: str, style: str) -> dict[str, Any]:
"""DocType maître `Workflow State` (nom + style de badge)."""
if style not in VALID_STYLES:
raise ValueError(f"Style non natif Frappe pour {state!r} : {style!r}")
return {"doctype": "Workflow State", "name": state, "workflow_state_name": state, "style": style}
def workflow_action_master(action: str) -> dict[str, Any]:
"""DocType maître `Workflow Action Master` (nom d'action)."""
return {"doctype": "Workflow Action Master", "name": action, "workflow_action_name": action}
def document_state_row(
state: str,
doc_status: str,
allow_edit: str,
*,
update_field: str,
update_value: str,
message: str,
) -> dict[str, Any]:
"""Ligne de la table enfant `states` d'un Workflow."""
if doc_status not in VALID_DOCSTATUS:
raise ValueError(f"doc_status non natif pour {state!r} : {doc_status!r}")
return {
"state": state,
"doc_status": doc_status,
"allow_edit": allow_edit,
"update_field": update_field,
"update_value": update_value,
"is_optional_state": 0,
"message": message,
}
def transition_row(
state: str,
action: str,
next_state: str,
allowed: str,
*,
allow_self_approval: int,
) -> dict[str, Any]:
"""Ligne de la table enfant `transitions` d'un Workflow."""
return {
"state": state,
"action": action,
"next_state": next_state,
"allowed": allowed,
"allow_self_approval": 1 if allow_self_approval else 0,
"condition": None,
}
def workflow_doc(
*,
name: str,
document_type: str,
workflow_state_field: str,
is_active: int,
send_email_alert: int,
override_status: int,
states: list[dict],
transitions: list[dict],
) -> dict[str, Any]:
"""Le document `Workflow` complet (states + transitions en tables enfant)."""
return {
"doctype": "Workflow",
"name": name,
"workflow_name": name,
"document_type": document_type,
"workflow_state_field": workflow_state_field,
"is_active": 1 if is_active else 0,
"send_email_alert": 1 if send_email_alert else 0,
"override_status": 1 if override_status else 0,
"states": states,
"transitions": transitions,
}
@@ -0,0 +1,61 @@
"""Résolution des rôles du pipeline vente depuis le contrat RBAC 50 rôles.
Réutilisation (workflow #5 : zéro duplication) de l'unique source de vérité des
rôles ERPNext : `05_deliverables_mvp/rbac/rbac_50_roles.json` (validé par
`rbac.schema.json` dans le job CI `rbac-tests`). Le contrat pipeline
(`workflow_vente_spec.json`) ne cite JAMAIS un nom de rôle Frappe en dur : il
référence l'`id` stable d'un rôle RBAC → ici on résout `id → erpnext_role_name`.
Conséquence anti-invention (#6) : un `role_id` absent du contrat RBAC lève une
erreur (aucun rôle fabriqué), et renommer un rôle côté RBAC se propage
automatiquement au workflow sans édition manuelle.
"""
from __future__ import annotations
import json
import os
_HERE = os.path.dirname(os.path.abspath(__file__))
_DELIVERABLES = os.path.normpath(os.path.join(_HERE, "..", "..", ".."))
RBAC_CONTRACT_PATH = os.path.join(_DELIVERABLES, "rbac", "rbac_50_roles.json")
class RoleResolver:
"""Table `role_id → erpnext_role_name` construite depuis le contrat RBAC."""
def __init__(self, contract: dict) -> None:
self._by_id: dict[str, dict] = {}
for role in contract.get("roles", []):
rid = role.get("id")
if rid is None:
raise ValueError("Rôle RBAC sans `id` — contrat corrompu.")
if rid in self._by_id:
raise ValueError(f"`id` de rôle RBAC dupliqué : {rid!r}")
self._by_id[rid] = role
self._portails = set(contract.get("portails_business", []))
@classmethod
def from_path(cls, path: str = RBAC_CONTRACT_PATH) -> "RoleResolver":
with open(path, encoding="utf-8") as fh:
return cls(json.load(fh))
def erpnext_name(self, role_id: str) -> str:
"""`id` RBAC → nom de rôle Frappe. Lève si l'`id` n'existe pas."""
role = self._by_id.get(role_id)
if role is None:
raise KeyError(
f"role_id {role_id!r} introuvable dans rbac_50_roles.json "
f"(aucun rôle inventé · #6)."
)
name = role.get("erpnext_role_name")
if not name:
raise ValueError(f"Rôle {role_id!r} sans `erpnext_role_name`.")
return name
def portail(self, role_id: str) -> str | None:
role = self._by_id.get(role_id)
return role.get("portail") if role else None
def known_ids(self) -> frozenset[str]:
return frozenset(self._by_id)
@@ -0,0 +1,187 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://oto-enterprise-os-dtp/crm/workflow.schema.json",
"title": "Bundle de fixtures ERPNext Workflow généré depuis workflow_vente_spec.json",
"description": "Contrat de sortie du générateur workflow_vente_gen.py. Validé par le validateur maison Publiciste (zéro pip) dans le job CI crm-workflow-vente-tests.",
"type": "object",
"additionalProperties": false,
"required": ["manifest", "workflow", "workflow_state", "workflow_action_master"],
"properties": {
"manifest": {
"type": "object",
"additionalProperties": false,
"required": [
"generated_from",
"rbac_source",
"source_version",
"workflow_name",
"document_type",
"document_type_custom",
"counts",
"custom_doctypes_a_confirmer",
"roles_rbac_utilises"
],
"properties": {
"generated_from": { "type": "string", "const": "workflow_vente_spec.json" },
"rbac_source": { "type": "string", "const": "rbac_50_roles.json" },
"source_version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" },
"workflow_name": { "type": "string", "minLength": 1 },
"document_type": { "type": "string", "minLength": 1 },
"document_type_custom": { "type": "boolean" },
"counts": {
"type": "object",
"additionalProperties": false,
"required": [
"states",
"transitions",
"workflow_state_masters",
"workflow_action_masters",
"terminal_states"
],
"properties": {
"states": { "type": "integer", "minimum": 2 },
"transitions": { "type": "integer", "minimum": 1 },
"workflow_state_masters": { "type": "integer", "minimum": 2 },
"workflow_action_masters": { "type": "integer", "minimum": 1 },
"terminal_states": { "type": "integer", "minimum": 1 }
}
},
"custom_doctypes_a_confirmer": {
"type": "array",
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 }
},
"roles_rbac_utilises": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["role_id", "erpnext_role_name"],
"properties": {
"role_id": { "type": "string", "minLength": 1 },
"erpnext_role_name": { "type": "string", "pattern": "^OTO " }
}
}
}
}
},
"workflow": {
"type": "object",
"additionalProperties": false,
"required": [
"doctype",
"name",
"workflow_name",
"document_type",
"workflow_state_field",
"is_active",
"send_email_alert",
"override_status",
"states",
"transitions"
],
"properties": {
"doctype": { "type": "string", "const": "Workflow" },
"name": { "type": "string", "minLength": 1 },
"workflow_name": { "type": "string", "minLength": 1 },
"document_type": { "type": "string", "minLength": 1 },
"workflow_state_field": { "type": "string", "minLength": 1 },
"is_active": { "$ref": "#/definitions/flag" },
"send_email_alert": { "$ref": "#/definitions/flag" },
"override_status": { "$ref": "#/definitions/flag" },
"states": {
"type": "array",
"minItems": 2,
"items": { "$ref": "#/definitions/document_state" }
},
"transitions": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/transition" }
}
}
},
"workflow_state": {
"type": "array",
"minItems": 2,
"items": { "$ref": "#/definitions/state_master" }
},
"workflow_action_master": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/action_master" }
}
},
"definitions": {
"flag": { "type": "integer", "enum": [0, 1] },
"docstatus": { "type": "string", "enum": ["0", "1", "2"] },
"style": {
"type": "string",
"enum": ["", "Primary", "Info", "Success", "Warning", "Danger", "Inverse"]
},
"document_state": {
"type": "object",
"additionalProperties": false,
"required": [
"state",
"doc_status",
"allow_edit",
"update_field",
"update_value",
"is_optional_state",
"message"
],
"properties": {
"state": { "type": "string", "minLength": 1 },
"doc_status": { "$ref": "#/definitions/docstatus" },
"allow_edit": { "type": "string", "pattern": "^OTO " },
"update_field": { "type": "string", "minLength": 1 },
"update_value": { "type": "string", "minLength": 1 },
"is_optional_state": { "$ref": "#/definitions/flag" },
"message": { "type": "string", "minLength": 1 }
}
},
"transition": {
"type": "object",
"additionalProperties": false,
"required": [
"state",
"action",
"next_state",
"allowed",
"allow_self_approval",
"condition"
],
"properties": {
"state": { "type": "string", "minLength": 1 },
"action": { "type": "string", "minLength": 1 },
"next_state": { "type": "string", "minLength": 1 },
"allowed": { "type": "string", "pattern": "^OTO " },
"allow_self_approval": { "$ref": "#/definitions/flag" },
"condition": { "type": ["string", "null"] }
}
},
"state_master": {
"type": "object",
"additionalProperties": false,
"required": ["doctype", "name", "workflow_state_name", "style"],
"properties": {
"doctype": { "type": "string", "const": "Workflow State" },
"name": { "type": "string", "minLength": 1 },
"workflow_state_name": { "type": "string", "minLength": 1 },
"style": { "$ref": "#/definitions/style" }
}
},
"action_master": {
"type": "object",
"additionalProperties": false,
"required": ["doctype", "name", "workflow_action_name"],
"properties": {
"doctype": { "type": "string", "const": "Workflow Action Master" },
"name": { "type": "string", "minLength": 1 },
"workflow_action_name": { "type": "string", "minLength": 1 }
}
}
}
}
@@ -0,0 +1,246 @@
#!/usr/bin/env python3
"""Générateur de fixtures ERPNext Workflow · pipeline vente (Sprint 4 · CRM natif).
Transforme le contrat pipeline `workflow_vente_spec.json` (lead → visite → devis
→ réservation → contrat → CONFOTUR) en **fixtures Frappe/ERPNext v15 natives** :
- workflow.json → DocType `Workflow` (states[] + transitions[])
- workflow_state.json → DocType maître `Workflow State`
- workflow_action_master.json → DocType maître `Workflow Action Master`
- MANIFEST.json → traçabilité + DocType porteur `custom` à confirmer VPS
Les rôles qui gardent les états/transitions sont **résolus depuis le contrat
RBAC** `rbac_50_roles.json` (réutilisation · zéro duplication · #6) : le pipeline
ne cite qu'un `id` de rôle, jamais un nom Frappe en dur.
Ce worker n'écrit JAMAIS sur le VPS (contrainte #8) : l'application réelle
(`bench --site frontend migrate` / `import-fixtures`) reste côté serveur, faite
par l'agent ERPNext Backend.
Sous-commandes :
build [-o OUT] → écrit les 4 fichiers de fixtures
validate → (re)génère en mémoire, valide schéma + invariants métier
(graphe, monotonie doc_status, séparation des pouvoirs) ;
sort en erreur si un invariant casse (anti-régression).
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__))
_DELIVERABLES = os.path.normpath(os.path.join(_HERE, "..", ".."))
sys.path.insert(0, _HERE)
# Réutilisation (workflow #5 : zéro duplication) du validateur maison Publiciste.
sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste"))
from wflib import builder, erpnext # noqa: E402
from wflib.rbac import RoleResolver # noqa: E402
from lib import validator as maison # type: ignore # noqa: E402
_SPEC_PATH = os.path.join(_HERE, "workflow_vente_spec.json")
_SCHEMA_PATH = os.path.join(_HERE, "workflow.schema.json")
_DEFAULT_OUT = os.path.join(_HERE, "out")
# doc_status atteignable en une transition (monotonie native Frappe) :
# 0→0 (mise à jour), 0→1 (soumission), 1→1 (mise à jour), 1→2 (annulation).
_ALLOWED_DOCSTATUS_STEPS = {("0", "0"), ("0", "1"), ("1", "1"), ("1", "2")}
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 _build_bundle() -> tuple[dict, dict]:
spec = _load(_SPEC_PATH)
resolver = RoleResolver.from_path()
return spec, builder.build_bundle(spec, resolver)
def _validate_bundle(spec: dict, bundle: dict) -> list[str]:
"""Schéma de sortie + invariants métier du graphe de workflow."""
schema = _load(_SCHEMA_PATH)
errors = list(maison.validate(bundle, schema))
wf = bundle["workflow"]
states = wf["states"]
transitions = wf["transitions"]
manifest = bundle["manifest"]
state_names = [s["state"] for s in states]
state_set = set(state_names)
docstatus_of = {s["state"]: s["doc_status"] for s in states}
# Invariant 1 · unicité des noms d'état.
if len(state_names) != len(state_set):
errors.append("Nom d'état dupliqué dans la table `states`.")
# Invariant 2 · toute transition référence des états déclarés.
for t in transitions:
if t["state"] not in state_set:
errors.append(f"Transition depuis un état inconnu : {t['state']!r}")
if t["next_state"] not in state_set:
errors.append(f"Transition vers un état inconnu : {t['next_state']!r}")
# Invariant 3 · monotonie doc_status (natif Frappe, pas de 0→2 ni décroissant).
for t in transitions:
pair = (docstatus_of.get(t["state"]), docstatus_of.get(t["next_state"]))
if None in pair:
continue # déjà signalé par l'invariant 2
if pair not in _ALLOWED_DOCSTATUS_STEPS:
errors.append(
f"Saut doc_status illégal {pair[0]}{pair[1]} "
f"({t['state']} --{t['action']}--> {t['next_state']})."
)
# Invariant 4 · unicité (état, action) — Frappe exige une action déterministe.
keys = [(t["state"], t["action"]) for t in transitions]
if len(keys) != len(set(keys)):
errors.append("Couple (état, action) dupliqué dans les transitions.")
# Invariant 5 · atteignabilité depuis l'état initial (première ligne = Lead).
initial = state_names[0]
adj: dict[str, list[str]] = {}
for t in transitions:
adj.setdefault(t["state"], []).append(t["next_state"])
seen = {initial}
stack = [initial]
while stack:
cur = stack.pop()
for nxt in adj.get(cur, []):
if nxt not in seen:
seen.add(nxt)
stack.append(nxt)
unreachable = state_set - seen
if unreachable:
errors.append(
f"État(s) inatteignable(s) depuis {initial!r} : {sorted(unreachable)}"
)
# Invariant 6 · au moins un état terminal de succès (soumis, sans sortie) et
# les états terminaux annoncés au contrat n'ont effectivement pas de sortie.
outgoing = {t["state"] for t in transitions}
terminal_success = [
s["state"] for s in states
if s["doc_status"] == erpnext.DOCSTATUS_SUBMITTED and s["state"] not in outgoing
]
if not terminal_success:
errors.append("Aucun état terminal de succès (soumis + sans transition sortante).")
for st in spec["states"]:
if st.get("is_terminal") and st["state"] in outgoing:
errors.append(
f"État {st['state']!r} déclaré terminal mais porte une transition sortante."
)
# Invariant 7 · séparation des pouvoirs (#6) : toute transition marquée
# `separation_of_duties` DOIT interdire l'auto-approbation.
sep_actions = {
(t["state"], t["action"])
for t in spec["transitions"]
if t.get("separation_of_duties")
}
for t in transitions:
if (t["state"], t["action"]) in sep_actions and t["allow_self_approval"] != 0:
errors.append(
f"Séparation des pouvoirs violée : {t['state']} --{t['action']}--> "
f"{t['next_state']} autorise l'auto-approbation."
)
# Invariant 8 · les maîtres couvrent exactement les états/actions du graphe.
master_states = {m["name"] for m in bundle["workflow_state"]}
if master_states != state_set:
errors.append("Les maîtres `Workflow State` ne couvrent pas exactement les états.")
master_actions = {m["name"] for m in bundle["workflow_action_master"]}
if master_actions != {t["action"] for t in transitions}:
errors.append("Les maîtres `Workflow Action Master` ne couvrent pas exactement les actions.")
# Invariant 9 · cohérence des comptes du manifeste.
c = manifest["counts"]
if c["states"] != len(states) or c["transitions"] != len(transitions):
errors.append("counts (états/transitions) incohérents avec le workflow.")
return errors
def cmd_build(args: argparse.Namespace) -> int:
spec, bundle = _build_bundle()
errors = _validate_bundle(spec, bundle)
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, "workflow.json"), [bundle["workflow"]])
_write_json(os.path.join(out, "workflow_state.json"), bundle["workflow_state"])
_write_json(
os.path.join(out, "workflow_action_master.json"), bundle["workflow_action_master"]
)
_write_json(os.path.join(out, "MANIFEST.json"), bundle["manifest"])
m = bundle["manifest"]
print(f"✅ Fixtures Workflow générées dans {out}")
print(f" workflow.json : {m['workflow_name']} · {m['counts']['states']} états / {m['counts']['transitions']} transitions")
print(f" workflow_state.json : {m['counts']['workflow_state_masters']} maîtres")
print(f" workflow_action_master.json : {m['counts']['workflow_action_masters']} maîtres")
if m["custom_doctypes_a_confirmer"]:
print(
" ⚠ DocType porteur `custom` à créer + confirmer VPS avant import : "
+ ", ".join(m["custom_doctypes_a_confirmer"])
)
return 0
def cmd_validate(args: argparse.Namespace) -> int:
spec, bundle = _build_bundle()
errors = _validate_bundle(spec, bundle)
if errors:
_eprint("❌ Validation KO :")
for e in errors:
_eprint(f" - {e}")
return 1
m = bundle["manifest"]
print(
f"✅ Validation OK — {m['counts']['states']} états, "
f"{m['counts']['transitions']} transitions, schéma + 9 invariants verts."
)
return 0
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(
description="Générateur de fixtures ERPNext Workflow depuis le contrat pipeline vente."
)
sub = p.add_subparsers(dest="cmd", required=True)
pb = sub.add_parser("build", help="génère workflow.json / workflow_state.json / workflow_action_master.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 + 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,178 @@
{
"version": "1.0.0",
"workflow_name": "OTO Vente Pipeline",
"document_type": "OTO Dossier Vente",
"document_type_custom": true,
"workflow_state_field": "workflow_state",
"is_active": 1,
"send_email_alert": 0,
"override_status": 0,
"_comment": "Pipeline commercial OTO (roadmap Sprint 4 · CRM natif ERPNext) : lead -> visite -> devis -> reservation -> contrat -> CONFOTUR. Les roles (`allow_edit`/`allowed`) referencent l'`id` d'un role du contrat rbac_50_roles.json (reutilisation, zero duplication · #6) ; le builder resout id -> erpnext_role_name. doc_status : 0=Brouillon, 1=Soumis, 2=Annule (monotone le long des transitions, natif Frappe).",
"states": [
{
"state": "Lead",
"doc_status": "0",
"role_id": "ventes-conseiller",
"style": "Primary",
"update_value": "lead",
"is_terminal": false,
"message": "Nouveau prospect qualifie a contacter."
},
{
"state": "Visite planifiée",
"doc_status": "0",
"role_id": "ventes-conseiller",
"style": "Info",
"update_value": "visite",
"is_terminal": false,
"message": "Visite du site ou de l'unite temoin programmee."
},
{
"state": "Devis émis",
"doc_status": "0",
"role_id": "ventes-conseiller",
"style": "Warning",
"update_value": "devis",
"is_terminal": false,
"message": "Devis chiffre remis au prospect (attente decision)."
},
{
"state": "Réservation confirmée",
"doc_status": "1",
"role_id": "ventes-reservations",
"style": "Primary",
"update_value": "reservation",
"is_terminal": false,
"message": "Depot de reservation encaisse ; dossier verrouille (soumis)."
},
{
"state": "Contrat signé",
"doc_status": "1",
"role_id": "ventes-contrats",
"style": "Success",
"update_value": "contrat",
"is_terminal": false,
"message": "Contrat de vente signe par les deux parties."
},
{
"state": "CONFOTUR déposé",
"doc_status": "1",
"role_id": "ventes-confotur",
"style": "Info",
"update_value": "confotur_depose",
"is_terminal": false,
"message": "Dossier d'incitation CONFOTUR depose aupres de l'autorite."
},
{
"state": "CONFOTUR approuvé",
"doc_status": "1",
"role_id": "ventes-confotur",
"style": "Success",
"update_value": "confotur_approuve",
"is_terminal": true,
"message": "Incitation CONFOTUR approuvee ; cycle de vente clos avec succes."
},
{
"state": "Abandonné",
"doc_status": "0",
"role_id": "ventes-chef-equipe",
"style": "Danger",
"update_value": "abandonne",
"is_terminal": true,
"message": "Piste abandonnee avant reservation (brouillon)."
},
{
"state": "Perdu",
"doc_status": "2",
"role_id": "ventes-directeur",
"style": "Danger",
"update_value": "perdu",
"is_terminal": true,
"message": "Affaire soumise resiliee/annulee (dossier annule)."
}
],
"transitions": [
{
"state": "Lead",
"action": "Planifier visite",
"next_state": "Visite planifiée",
"role_id": "ventes-conseiller",
"allow_self_approval": 1
},
{
"state": "Lead",
"action": "Abandonner",
"next_state": "Abandonné",
"role_id": "ventes-conseiller",
"allow_self_approval": 1
},
{
"state": "Visite planifiée",
"action": "Émettre devis",
"next_state": "Devis émis",
"role_id": "ventes-conseiller",
"allow_self_approval": 1
},
{
"state": "Visite planifiée",
"action": "Abandonner",
"next_state": "Abandonné",
"role_id": "ventes-conseiller",
"allow_self_approval": 1
},
{
"state": "Devis émis",
"action": "Abandonner",
"next_state": "Abandonné",
"role_id": "ventes-chef-equipe",
"allow_self_approval": 1
},
{
"state": "Devis émis",
"action": "Confirmer réservation",
"next_state": "Réservation confirmée",
"role_id": "ventes-reservations",
"allow_self_approval": 0,
"separation_of_duties": true
},
{
"state": "Réservation confirmée",
"action": "Signer contrat",
"next_state": "Contrat signé",
"role_id": "ventes-contrats",
"allow_self_approval": 0,
"separation_of_duties": true
},
{
"state": "Réservation confirmée",
"action": "Annuler (perdu)",
"next_state": "Perdu",
"role_id": "ventes-directeur",
"allow_self_approval": 0,
"separation_of_duties": true
},
{
"state": "Contrat signé",
"action": "Déposer CONFOTUR",
"next_state": "CONFOTUR déposé",
"role_id": "ventes-confotur",
"allow_self_approval": 1
},
{
"state": "Contrat signé",
"action": "Résilier (perdu)",
"next_state": "Perdu",
"role_id": "direction-cco",
"allow_self_approval": 0,
"separation_of_duties": true
},
{
"state": "CONFOTUR déposé",
"action": "Approuver CONFOTUR",
"next_state": "CONFOTUR approuvé",
"role_id": "ventes-confotur",
"allow_self_approval": 0,
"separation_of_duties": true
}
]
}
@@ -0,0 +1,95 @@
# Daily Report · 2026-07-30 · Claude Code DTP Worker (session 11)
**Session** : `20260730_052701`
## Tâche exécutée
**Sprint 4 · CRM — Générateur de workflow vente ERPNext** (roadmap
`04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md` §Sprint 4 : « workflow complet **lead →
visite → devis → réservation → contrat → CONFOTUR** »). Prochaine tâche
prioritaire **in-scope worker** après la clôture de la Faisabilité S3 (bancable,
session 10) : premier livrable Sprint 4 100 % autorable sans VPS.
## Contexte / analyse
- Relu `CLAUDE.md`, `ROADMAP_8_WEEKS_OR_LESS.md`, daily report session 10,
l'AGENT.md CRM, le contrat `rbac_50_roles.json` (rôles portails
ventes/direction/compta) et l'idiome des générateurs RBAC (`fixtures_gen` :
`frappe.py`/`builder.py`/`schema`/CLI `build|validate`/tests + job CI + gate).
- État : RBAC **clos** (4 générateurs + run-book), Faisabilité **close**
(4 volets + bancable trilingue). Sprint 4 non entamé en-repo. Le deliverable
CRM « workflow lead→CONFOTUR » est **structurel** (états + rôles), donc sans
aucun chiffre à inventer (#6) et sans dépendance VPS → cible idéale.
- Contrainte #3 (CRM = ERPNext natif) : on produit les DocTypes **natifs du
moteur Workflow de Frappe**, jamais un moteur externe.
## Réalisé — module `05_deliverables_mvp/crm/workflow_vente/`
- `workflow_vente_spec.json` — contrat pipeline canonique : **9 états**
(Lead → Visite planifiée → Devis émis → Réservation confirmée → Contrat signé →
CONFOTUR déposé → CONFOTUR approuvé + Abandonné/Perdu terminaux) · **11
transitions**. Chaque état/transition référence un **`id` de rôle RBAC**
(jamais un nom Frappe en dur).
- `wflib/rbac.py`**réutilisation (workflow #5, zéro duplication)** de
`rbac_50_roles.json` : résout `role_id → erpnext_role_name`. `id` absent ⇒
erreur (aucun rôle inventé · #6) ; renommage RBAC propagé automatiquement.
- `wflib/erpnext.py` — connaissance Frappe v15 native : DocTypes `Workflow`,
`Workflow Document State`, `Workflow Transition`, `Workflow State`,
`Workflow Action Master` ; `doc_status` (0/1/2) + styles de badge natifs.
- `wflib/builder.py` — assemblage **déterministe** (tri stable, aucun
horodatage) : table `states[]`, table `transitions[]` (triées état→action),
maîtres États/Actions uniques, manifeste de traçabilité (rôles RBAC utilisés +
DocType porteur `custom` à confirmer VPS).
- `workflow.schema.json` — contrat de sortie draft-07 (sous-ensemble validateur
maison Publiciste).
- `workflow_vente_gen.py` — CLI `build` / `validate`. **Refuse d'écrire** si un
des **9 invariants de graphe** casse.
- `out/` (commité, hand-off direct) : `workflow.json`, `workflow_state.json`,
`workflow_action_master.json`, `MANIFEST.json`.
- `tests/test_workflow_vente.py`**25 tests `unittest` (stdlib pur)**.
- **CI** : job `crm-workflow-vente-tests` ajouté au **gate**
(`.gitea/workflows/ci.yml`, Gitea Actions uniquement · #2).
## 9 invariants de graphe (le CLI refuse d'écrire si l'un casse)
1. Conformité au schéma de sortie. 2. Unicité des noms d'état. 3. Transitions
vers/depuis états déclarés. 4. **Monotonie `doc_status`** (pas de saut 0→2 ni
retour arrière — natif Frappe). 5. Unicité (état, action). 6. **Atteignabilité**
de tous les états depuis `Lead`. 7. ≥1 état terminal de succès (soumis, sans
sortie) + terminaux sans transition sortante. 8. **Séparation des pouvoirs** :
confirmer réservation / signer contrat / approuver CONFOTUR / annuler = pas
d'auto-approbation. 9. Maîtres États/Actions == graphe + comptes manifeste
cohérents.
## Anti-invention (#6) + séparation des pouvoirs appliqués
- Aucun nom de rôle en dur : tous résolus depuis le contrat RBAC (source unique).
- Aucun chiffre inventé (workflow purement structurel) ; `condition` de
transition laissée `null` (le métier ne documente pas de seuil chiffré).
- Étapes engageant de l'argent / clôturant ⇒ `allow_self_approval = 0`
(quatre-yeux), vérifié par test + CLI.
## Vérifications effectuées (en-repo, sans toucher au VPS)
- **25/25 tests verts** (schéma maison + oracle `jsonschema`) ; génération réelle :
9 états / 11 transitions / 9 maîtres États / 9 maîtres Actions ; `out/` commité
== régénération bit-à-bit.
- **Gate CI local vert (exit 0)** : `guard_constraints.sh`, `validate_json.sh`
(nouveaux JSON valides), `check_docs.sh` (README 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 vente =
**146 tests verts** au total.
## Non fait (hors périmètre worker · VPS)
- Création du DocType porteur `OTO Dossier Vente` + import des fixtures
(`bench migrate`) → agent ERPNext Backend (contrainte #8).
## Prochaine tâche suggérée
- Sprint 4 CRM : générateur des **Notification/Email Alert** par transition, ou
du **DocType porteur `OTO Dossier Vente`** (fixture DocType) pour compléter le
hand-off workflow.
- Sprint 4 ERPNext Backend : plan e-CF DGII (Compupar) + barème commissions
vendeurs (calcul traçable façon module `finance.py` du bancable · #6).
---
**Auto-score 4Big du livrable Générateur workflow vente : 96/100.**
Réserve 4 : création du DocType porteur + import des fixtures = côté VPS (agent
ERPNext Backend, hors périmètre worker · #8) ; `condition` de transition non
peuplée (anti-invention #6). Validé statiquement en-repo (25 tests verts + schéma
conforme + 9 invariants de graphe + gate CI · 146 tests de régression au total).