c0d2e21ef3
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>
277 lines
12 KiB
Python
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())
|