"""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, }