34f202af40
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>
62 lines
2.4 KiB
Python
62 lines
2.4 KiB
Python
"""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)
|