[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:
@@ -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)
|
||||
Reference in New Issue
Block a user