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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Code DTP Worker
2026-07-30 05:36:27 +00:00
parent 915fc5194a
commit 34f202af40
17 changed files with 1797 additions and 1 deletions
@@ -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)