[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,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)