"""Construction du plan `User Permission` depuis `rbac_50_roles.json`. Entrée : le contrat RBAC 50 rôles (déjà validé par `rbac.schema.json` dans le job CI `rbac-tests`). Sortie : un « plan » déterministe qui, pour CHAQUE rôle, indique le mécanisme Frappe d'enforcement row-level de son `scope_donnees` et, le cas échéant, un template `User Permission` (allow=Company) à matérialiser par utilisateur côté VPS. Déterminisme : tri stable des entrées par nom de rôle Frappe → sortie reproductible bit-à-bit à contrat constant (diffable, re-générable en CI). Anti-invention (#6) : 100 % des données proviennent du contrat (nom du rôle, scope, entité). Aucun utilisateur n'est inventé (sentinelle `USER_PLACEHOLDER`). Les rôles `equipe` (pas de dimension row-level native) ne reçoivent AUCUN template et sont signalés « à confirmer VPS » plutôt que mappés arbitrairement. """ from __future__ import annotations from typing import Any from . import frappe def _sorted_roles(contract: dict) -> list[dict]: return sorted(contract["roles"], key=lambda r: r["erpnext_role_name"]) def build_plan(contract: dict) -> dict[str, Any]: """Transforme le contrat RBAC en plan `User Permission` déterministe.""" roles = _sorted_roles(contract) plan: list[dict] = [] companies: set[str] = set() # for_value distincts (portée entite) equipe_roles: list[str] = [] # rôles equipe → à confirmer VPS by_mechanism: dict[str, int] = {m: 0 for m in frappe.SCOPE_MECHANISM.values()} for r in roles: role_name = r["erpnext_role_name"] scope = r["scope_donnees"] entite = r["entite_principale"] mechanism = frappe.mechanism_for(scope) by_mechanism[mechanism] += 1 template = None if mechanism == frappe.MECH_WITH_TEMPLATE: # Lève ValueError si le contrat couple `entite` à un périmètre # consolidé (« Groupe ») — garde-fou anti-contrat corrompu. template = frappe.user_permission_template(entite) companies.add(entite) elif scope == "equipe": equipe_roles.append(role_name) plan.append( { "erpnext_role_name": role_name, "scope_donnees": scope, "entite_principale": entite, "mechanism": mechanism, "user_permission_template": template, } ) manifest = { "generated_from": "rbac_50_roles.json", "source_version": contract["version"], "cible_rbac_roles": contract["cible_rbac_roles"], "counts": { "plan_entries": len(plan), "user_permission_templates": by_mechanism[frappe.MECH_WITH_TEMPLATE], "by_mechanism": by_mechanism, }, # Companies ERPNext à CRÉER/confirmer sur le VPS avant d'appliquer les # User Permission (les noms viennent de entite_principale du contrat). "companies_a_confirmer": sorted(companies), # Rôles `equipe` : aucune dimension row-level native → l'agent ERPNext # doit décider du mécanisme (champ custom + User Permission, ou # hiérarchie Employee.reports_to). Jamais mappé en douce ici (#6). "roles_scope_equipe_a_confirmer": sorted(equipe_roles), } return {"manifest": manifest, "plan": plan}