#!/usr/bin/env python3 """Generateur de Workspaces ERPNext v15 · 5 portails role (Sprint 4 · Frontend Console). Livrable Frontend Console de la roadmap Sprint 4 (l.49 « 5 portails role : Ventes/Construction/Achat/Compta/Direction »). Contrainte #1 « ERPNext natif = priorite absolue » : un portail role, dans ERPNext v15, EST un `Workspace` (page de landing du desk, visible selon les roles). On produit donc des fixtures `Workspace` importables — aucun framework de dashboard externe. Transforme (rbac_50_roles.json + portails_spec.json) en : - workspace.json -> 1 fixture `Workspace` par portail metier (shortcuts + links + roles) - MANIFEST.json -> tracabilite (comptes, roles couverts, DocTypes custom, marque) Sous-commandes : build [-o OUT] -> ecrit workspace.json / MANIFEST.json (refuse si invariant KO) validate [-o OUT] -> (re)genere en memoire, valide vs workspace.schema.json + invariants Sortie deterministe (tri stable, aucun horodatage) -> diffable + re-generable. Anti-invention (#6) : chaque lien / raccourci vise un DocType present dans les permissions_cibles du portail (droit prouve) ; couverture exhaustive ; flag custom issu du contrat ; roles issus du contrat ; tokens de marque repris de CLAUDE.md. Ce worker n'ecrit JAMAIS sur le VPS (#8) : hand-off a l'agent ERPNext Backend. """ from __future__ import annotations import argparse import json import os import sys _HERE = os.path.dirname(os.path.abspath(__file__)) _FRONTEND = os.path.normpath(os.path.join(_HERE, "..")) # frontend/ _DELIVERABLES = os.path.normpath(os.path.join(_FRONTEND, "..")) # 05_deliverables_mvp/ _RBAC_DIR = os.path.join(_DELIVERABLES, "rbac") sys.path.insert(0, _HERE) # Reutilisation (workflow #5 : zero duplication) du validateur maison Publiciste. sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste")) from wslib import builder, frappe # noqa: E402 from lib import validator as maison # type: ignore # noqa: E402 _CONTRACT_PATH = os.path.join(_RBAC_DIR, "rbac_50_roles.json") _SPEC_PATH = os.path.join(_HERE, "portails_spec.json") _SCHEMA_PATH = os.path.join(_HERE, "workspace.schema.json") _DEFAULT_OUT = os.path.join(_HERE, "out") def _eprint(*args) -> None: print(*args, file=sys.stderr) def _load(path: str) -> dict: with open(path, encoding="utf-8") as fh: return json.load(fh) def _write_json(path: str, data) -> None: with open(path, "w", encoding="utf-8") as fh: json.dump(data, fh, ensure_ascii=False, indent=2) fh.write("\n") def _build_bundle() -> dict: return builder.build_bundle(_load(_CONTRACT_PATH), _load(_SPEC_PATH)) def _validate_bundle(bundle: dict) -> list[str]: """Valide le bundle contre workspace.schema.json + invariants de cross-coherence.""" schema = _load(_SCHEMA_PATH) errors = list(maison.validate(bundle, schema)) contract = _load(_CONTRACT_PATH) spec = _load(_SPEC_PATH) workspaces = bundle["workspace"] manifest = bundle["manifest"] roles_map = builder.roles_by_portail(contract) dt_map = builder.doctypes_by_portail(contract) flags = builder.custom_flags(contract) portails_metier = set(contract.get("portails_business", [])) spec_keys = [p["key"] for p in spec["portails"]] # Invariant 1 · exactement les 5 portails METIER du contrat (roadmap « 5 portails »), # la console technique `plateforme` etant explicitement exclue. if set(spec_keys) != portails_metier: errors.append( f"portails spec {sorted(set(spec_keys))} != portails_business contrat {sorted(portails_metier)}." ) if len(workspaces) != len(portails_metier): errors.append(f"{len(workspaces)} Workspaces != {len(portails_metier)} portails metier.") spec_by_key = {p["key"]: p for p in spec["portails"]} for ws, key in zip(workspaces, spec_keys): p = spec_by_key[key] authorized = dt_map.get(key, set()) carte_dts = [dt for carte in p["cartes"] for dt in carte["doctypes"]] # Invariant 2 · ANTI-INVENTION : tout DocType lie/raccourci est autorise # (present dans les permissions_cibles du portail). for dt in carte_dts: if dt not in authorized: errors.append(f"[{key}] carte vise DocType non autorise: {dt!r}.") for sc in p["raccourcis"]: if sc not in authorized: errors.append(f"[{key}] raccourci vise DocType non autorise: {sc!r}.") # Invariant 3 · COUVERTURE EXHAUSTIVE + sans doublon : chaque DocType # autorise apparait dans exactement une carte (aucun oubli silencieux). if len(carte_dts) != len(set(carte_dts)): errors.append(f"[{key}] un DocType apparait dans plusieurs cartes.") if set(carte_dts) != authorized: manquants = sorted(authorized - set(carte_dts)) en_trop = sorted(set(carte_dts) - authorized) errors.append(f"[{key}] couverture != autorises (manquants={manquants}, en_trop={en_trop}).") # Invariant 4 · restriction de visibilite = TOUS les roles du portail, tries. ws_roles = [row["role"] for row in ws["roles"]] if ws_roles != sorted(ws_roles): errors.append(f"[{key}] Has Role non tries (determinisme).") if set(ws_roles) != set(roles_map.get(key, [])): errors.append(f"[{key}] roles du Workspace != roles du portail (contrat).") # Invariant 5 · table Links coherente : chaque Card Break annonce le bon # nombre de Link qui la suivent, et le total de Link == DocTypes couverts. nb_link_rows = 0 i = 0 rows = ws["links"] while i < len(rows): row = rows[i] if row["type"] != "Card Break": errors.append(f"[{key}] table links : attendu Card Break en position {i}.") break declared = row["link_count"] j = i + 1 seen = 0 while j < len(rows) and rows[j]["type"] == "Link": seen += 1 nb_link_rows += 1 j += 1 if seen != declared: errors.append(f"[{key}] Card Break {row['label']!r}: link_count={declared} != {seen} liens reels.") i = j if nb_link_rows != len(authorized): errors.append(f"[{key}] {nb_link_rows} liens != {len(authorized)} DocTypes autorises.") # Invariant 6 · un raccourci par shortcut, couleur = accent du portail. sc_targets = [s["link_to"] for s in ws["shortcuts"]] if sc_targets != p["raccourcis"]: errors.append(f"[{key}] shortcuts != raccourcis spec (ordre/contenu).") for s in ws["shortcuts"]: if s["color"] != p["accent_desk"]: errors.append(f"[{key}] raccourci {s['link_to']!r}: couleur != accent portail.") # Invariant 7 · module owner laisse a None (a_confirmer, hand-off VPS #8). if ws["module"] is not None: errors.append(f"[{key}] Workspace.module doit rester None (a_confirmer).") # Invariant 8 · le champ `content` est un JSON de blocs coherent avec les # raccourcis + cartes (aucun bloc oriente vers un element inexistant). blocks = json.loads(ws["content"]) headers = [b for b in blocks if b["type"] == "header"] sc_blocks = [b["data"]["shortcut_name"] for b in blocks if b["type"] == "shortcut"] card_blocks = [b["data"]["card_name"] for b in blocks if b["type"] == "card"] if len(headers) != 1 or headers[0]["data"]["text"] != p["label"]: errors.append(f"[{key}] content: header manquant/incoherent.") if sc_blocks != p["raccourcis"]: errors.append(f"[{key}] content: blocs shortcut != raccourcis.") if card_blocks != [c["titre"] for c in p["cartes"]]: errors.append(f"[{key}] content: blocs card != titres de cartes.") # Invariant 9 · flag custom du manifeste = flag du CONTRAT (source unique). for m in manifest["workspaces"]: expected_custom = sorted( dt for carte in spec_by_key[m["portail"]]["cartes"] for dt in carte["doctypes"] if flags.get(dt, False) ) # dedoublonne en conservant l'ordre trie expected_custom = sorted(set(expected_custom)) if m["doctypes_custom"] != expected_custom: errors.append(f"[{m['portail']}] doctypes_custom manifeste != contrat.") # Invariant 10 · comptes agreges du manifeste coherents. c = manifest["counts"] if c["workspaces"] != len(workspaces): errors.append("counts.workspaces incoherent.") if c["roles_couverts"] != sum(len(w["roles"]) for w in workspaces): errors.append("counts.roles_couverts incoherent.") # Invariant 11 · les tokens de marque proviennent de la spec (CLAUDE.md #4), # jamais fabriques : fond + accent presents et sources. brand = manifest["brand"] if brand.get("accent", {}).get("valeur") != "#f0b429": errors.append("brand.accent != #f0b429 (CLAUDE.md #4).") if brand.get("fond", {}).get("valeur") != "#0a0a12": errors.append("brand.fond != #0a0a12 (CLAUDE.md #4).") # Invariant 12 · determinisme : deux builds successifs identiques. again = _build_bundle() if json.dumps(again, sort_keys=True, ensure_ascii=False) != json.dumps(bundle, sort_keys=True, ensure_ascii=False): errors.append("build non deterministe (deux passes divergent).") return errors def cmd_build(args: argparse.Namespace) -> int: bundle = _build_bundle() errors = _validate_bundle(bundle) if errors: _eprint("❌ Bundle invalide — generation refusee (anti-regression) :") for e in errors: _eprint(f" - {e}") return 1 out = os.path.abspath(args.out) os.makedirs(out, exist_ok=True) _write_json(os.path.join(out, "workspace.json"), bundle["workspace"]) _write_json(os.path.join(out, "MANIFEST.json"), bundle["manifest"]) c = bundle["manifest"]["counts"] print(f"✅ Workspaces generes dans {out}") print( f" workspace.json : {c['workspaces']} portails · {c['roles_couverts']} roles " f"restreints · {c['liens_total']} liens · {c['raccourcis_total']} raccourcis" ) for m in bundle["manifest"]["workspaces"]: cu = f" · {len(m['doctypes_custom'])} custom" if m["doctypes_custom"] else "" print( f" - {m['workspace']} : {m['nb_cartes']} cartes / {m['nb_liens']} liens / " f"{m['nb_roles']} roles{cu}" ) if bundle["manifest"]["doctypes_custom_a_creer"]: print( " DocTypes custom a creer cote VPS (hand-off) : " + ", ".join(bundle["manifest"]["doctypes_custom_a_creer"]) ) return 0 def cmd_validate(args: argparse.Namespace) -> int: bundle = _build_bundle() errors = _validate_bundle(bundle) if errors: _eprint("❌ Validation KO :") for e in errors: _eprint(f" - {e}") return 1 c = bundle["manifest"]["counts"] print( f"✅ Validation OK — {c['workspaces']} Workspaces, {c['roles_couverts']} roles " f"restreints, schema + 12 invariants verts." ) return 0 def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser( description="Generateur de Workspaces ERPNext (5 portails role) depuis RBAC + spec." ) sub = p.add_subparsers(dest="cmd", required=True) pb = sub.add_parser("build", help="genere workspace.json / MANIFEST.json") pb.add_argument("-o", "--out", default=_DEFAULT_OUT, help="dossier de sortie (defaut: ./out)") pb.set_defaults(func=cmd_build) pv = sub.add_parser("validate", help="valide le bundle (schema + invariants) sans ecrire") pv.add_argument("-o", "--out", default=_DEFAULT_OUT, help="ignore (compat)") pv.set_defaults(func=cmd_validate) args = p.parse_args(argv) return args.func(args) if __name__ == "__main__": raise SystemExit(main())