[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:
+20
-1
@@ -180,10 +180,29 @@ jobs:
|
||||
- name: Tests générateur bancable trilingue
|
||||
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:
|
||||
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]
|
||||
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:
|
||||
- name: Résultat
|
||||
run: echo "✅ Gate qualité 4Big franchi — tous les checks verts."
|
||||
|
||||
@@ -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).
|
||||
Reference in New Issue
Block a user