From 34f202af4047a399c058555cdb1e82414cae4167 Mon Sep 17 00:00:00 2001 From: Claude Code DTP Worker Date: Thu, 30 Jul 2026 05:36:27 +0000 Subject: [PATCH] =?UTF-8?q?[DTP-Worker]=20Sprint=204=20=C2=B7=20G=C3=A9n?= =?UTF-8?q?=C3=A9rateur=20workflow=20vente=20ERPNext=20(lead=20=E2=86=92?= =?UTF-8?q?=20CONFOTUR)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitea/workflows/ci.yml | 21 +- 05_activity_log/2026-07-30.md | 33 +++ .../crm/workflow_vente/.gitignore | 4 + .../crm/workflow_vente/README.md | 125 ++++++++ .../crm/workflow_vente/out/MANIFEST.json | 48 ++++ .../crm/workflow_vente/out/workflow.json | 185 ++++++++++++ .../out/workflow_action_master.json | 47 +++ .../workflow_vente/out/workflow_state.json | 56 ++++ .../tests/test_workflow_vente.py | 272 ++++++++++++++++++ .../crm/workflow_vente/wflib/__init__.py | 6 + .../crm/workflow_vente/wflib/builder.py | 116 ++++++++ .../crm/workflow_vente/wflib/erpnext.py | 118 ++++++++ .../crm/workflow_vente/wflib/rbac.py | 61 ++++ .../crm/workflow_vente/workflow.schema.json | 187 ++++++++++++ .../crm/workflow_vente/workflow_vente_gen.py | 246 ++++++++++++++++ .../workflow_vente/workflow_vente_spec.json | 178 ++++++++++++ .../daily_reports/2026-07-30-session11.md | 95 ++++++ 17 files changed, 1797 insertions(+), 1 deletion(-) create mode 100644 05_activity_log/2026-07-30.md create mode 100644 05_deliverables_mvp/crm/workflow_vente/.gitignore create mode 100644 05_deliverables_mvp/crm/workflow_vente/README.md create mode 100644 05_deliverables_mvp/crm/workflow_vente/out/MANIFEST.json create mode 100644 05_deliverables_mvp/crm/workflow_vente/out/workflow.json create mode 100644 05_deliverables_mvp/crm/workflow_vente/out/workflow_action_master.json create mode 100644 05_deliverables_mvp/crm/workflow_vente/out/workflow_state.json create mode 100644 05_deliverables_mvp/crm/workflow_vente/tests/test_workflow_vente.py create mode 100644 05_deliverables_mvp/crm/workflow_vente/wflib/__init__.py create mode 100644 05_deliverables_mvp/crm/workflow_vente/wflib/builder.py create mode 100644 05_deliverables_mvp/crm/workflow_vente/wflib/erpnext.py create mode 100644 05_deliverables_mvp/crm/workflow_vente/wflib/rbac.py create mode 100644 05_deliverables_mvp/crm/workflow_vente/workflow.schema.json create mode 100644 05_deliverables_mvp/crm/workflow_vente/workflow_vente_gen.py create mode 100644 05_deliverables_mvp/crm/workflow_vente/workflow_vente_spec.json create mode 100644 05_deliverables_mvp/daily_reports/2026-07-30-session11.md diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 7edf963..a0a3106 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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." diff --git a/05_activity_log/2026-07-30.md b/05_activity_log/2026-07-30.md new file mode 100644 index 0000000..dca2e13 --- /dev/null +++ b/05_activity_log/2026-07-30.md @@ -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. diff --git a/05_deliverables_mvp/crm/workflow_vente/.gitignore b/05_deliverables_mvp/crm/workflow_vente/.gitignore new file mode 100644 index 0000000..cece918 --- /dev/null +++ b/05_deliverables_mvp/crm/workflow_vente/.gitignore @@ -0,0 +1,4 @@ +# Caches Python +__pycache__/ +*.pyc +# Le dossier out/ EST commité (hand-off ERPNext direct) — voir README. diff --git a/05_deliverables_mvp/crm/workflow_vente/README.md b/05_deliverables_mvp/crm/workflow_vente/README.md new file mode 100644 index 0000000..a34a78b --- /dev/null +++ b/05_deliverables_mvp/crm/workflow_vente/README.md @@ -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). diff --git a/05_deliverables_mvp/crm/workflow_vente/out/MANIFEST.json b/05_deliverables_mvp/crm/workflow_vente/out/MANIFEST.json new file mode 100644 index 0000000..1ecbdf6 --- /dev/null +++ b/05_deliverables_mvp/crm/workflow_vente/out/MANIFEST.json @@ -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" + } + ] +} diff --git a/05_deliverables_mvp/crm/workflow_vente/out/workflow.json b/05_deliverables_mvp/crm/workflow_vente/out/workflow.json new file mode 100644 index 0000000..ac36408 --- /dev/null +++ b/05_deliverables_mvp/crm/workflow_vente/out/workflow.json @@ -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 + } + ] + } +] diff --git a/05_deliverables_mvp/crm/workflow_vente/out/workflow_action_master.json b/05_deliverables_mvp/crm/workflow_vente/out/workflow_action_master.json new file mode 100644 index 0000000..1b8d570 --- /dev/null +++ b/05_deliverables_mvp/crm/workflow_vente/out/workflow_action_master.json @@ -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" + } +] diff --git a/05_deliverables_mvp/crm/workflow_vente/out/workflow_state.json b/05_deliverables_mvp/crm/workflow_vente/out/workflow_state.json new file mode 100644 index 0000000..9503d7d --- /dev/null +++ b/05_deliverables_mvp/crm/workflow_vente/out/workflow_state.json @@ -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" + } +] diff --git a/05_deliverables_mvp/crm/workflow_vente/tests/test_workflow_vente.py b/05_deliverables_mvp/crm/workflow_vente/tests/test_workflow_vente.py new file mode 100644 index 0000000..d8f8d6b --- /dev/null +++ b/05_deliverables_mvp/crm/workflow_vente/tests/test_workflow_vente.py @@ -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) diff --git a/05_deliverables_mvp/crm/workflow_vente/wflib/__init__.py b/05_deliverables_mvp/crm/workflow_vente/wflib/__init__.py new file mode 100644 index 0000000..6cf627c --- /dev/null +++ b/05_deliverables_mvp/crm/workflow_vente/wflib/__init__.py @@ -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. +""" diff --git a/05_deliverables_mvp/crm/workflow_vente/wflib/builder.py b/05_deliverables_mvp/crm/workflow_vente/wflib/builder.py new file mode 100644 index 0000000..de6ba7b --- /dev/null +++ b/05_deliverables_mvp/crm/workflow_vente/wflib/builder.py @@ -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, + } diff --git a/05_deliverables_mvp/crm/workflow_vente/wflib/erpnext.py b/05_deliverables_mvp/crm/workflow_vente/wflib/erpnext.py new file mode 100644 index 0000000..2488e14 --- /dev/null +++ b/05_deliverables_mvp/crm/workflow_vente/wflib/erpnext.py @@ -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, + } diff --git a/05_deliverables_mvp/crm/workflow_vente/wflib/rbac.py b/05_deliverables_mvp/crm/workflow_vente/wflib/rbac.py new file mode 100644 index 0000000..7ebfc59 --- /dev/null +++ b/05_deliverables_mvp/crm/workflow_vente/wflib/rbac.py @@ -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) diff --git a/05_deliverables_mvp/crm/workflow_vente/workflow.schema.json b/05_deliverables_mvp/crm/workflow_vente/workflow.schema.json new file mode 100644 index 0000000..4f4ad24 --- /dev/null +++ b/05_deliverables_mvp/crm/workflow_vente/workflow.schema.json @@ -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 } + } + } + } +} diff --git a/05_deliverables_mvp/crm/workflow_vente/workflow_vente_gen.py b/05_deliverables_mvp/crm/workflow_vente/workflow_vente_gen.py new file mode 100644 index 0000000..68b6348 --- /dev/null +++ b/05_deliverables_mvp/crm/workflow_vente/workflow_vente_gen.py @@ -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()) diff --git a/05_deliverables_mvp/crm/workflow_vente/workflow_vente_spec.json b/05_deliverables_mvp/crm/workflow_vente/workflow_vente_spec.json new file mode 100644 index 0000000..f95a3a6 --- /dev/null +++ b/05_deliverables_mvp/crm/workflow_vente/workflow_vente_spec.json @@ -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 + } + ] +} diff --git a/05_deliverables_mvp/daily_reports/2026-07-30-session11.md b/05_deliverables_mvp/daily_reports/2026-07-30-session11.md new file mode 100644 index 0000000..ca4f4e2 --- /dev/null +++ b/05_deliverables_mvp/daily_reports/2026-07-30-session11.md @@ -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).