Files
oto-enterprise-os-dtp/05_deliverables_mvp/frontend/portails/workspaces_gen.py
T
Claude Code DTP Worker c0d2e21ef3 [DTP-Worker] Sprint 4 · Générateur Workspaces ERPNext 5 portails rôle (Frontend Console · roadmap L49)
Livre le dernier volet ouvert de Sprint 4 : Frontend Console « 5 portails
(Ventes/Construction/Achat/Compta/Direction) ». Contrainte #1 (ERPNext natif) :
le portail de landing par rôle EST le DocType Workspace v15 → 5 Workspaces natifs.

Anti-invention (#6) : rôles et DocTypes dérivés du contrat rbac_50_roles.json ;
chaque lien/raccourci vise un DocType présent dans les permissions_cibles du
portail (droit prouvé) ; couverture exhaustive ; flag custom issu du contrat ;
tokens de marque repris verbatim de CLAUDE.md #4. CLI + 12 invariants + 19 tests
(4 négatifs). Régression 260 tests verts. Hand-off VPS #8 documenté.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 07:38:44 +00:00

277 lines
12 KiB
Python

#!/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())