Files
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

129 lines
5.2 KiB
Python

"""Construction du bundle de Workspaces depuis le contrat RBAC + la spec portails.
Entrees :
- contract : rbac_50_roles.json (deja valide par rbac.schema.json en CI). Source
de verite des ROLES par portail et des DOCTYPES autorises (permissions_cibles).
- spec : portails_spec.json. Ne fait QUE la mise en page (cartes, raccourcis,
theme). Ne declare aucun DocType ni role hors du contrat.
Sortie : bundle deterministe :
- workspace -> 1 fixture `Workspace` par portail metier (5), avec ses tables
enfant `shortcuts` / `links` / `roles`.
- manifest -> tracabilite (comptes, roles couverts, DocTypes custom a creer
cote VPS, tokens de marque, note module a_confirmer).
Anti-invention (#6), garanti ici + par les invariants du CLI :
- un lien de carte / un raccourci ne peut viser qu'un DocType present dans les
`permissions_cibles` du portail (le portail a prouvablement le droit dessus) ;
- la couverture est exhaustive (chaque DocType autorise apparait dans EXACTEMENT
une carte) : aucun DocType silencieusement oublie, aucun invente ;
- le flag `custom` (natif v15 vs DocType OTO a creer) vient du CONTRAT, jamais
de la spec (source unique) ;
- les roles proviennent du contrat (meme logique que le generateur Role Profile).
Determinisme : portails dans l'ordre de la spec ; roles tries par nom ; liens dans
l'ordre spec des cartes -> sortie reproductible bit-a-bit (diffable, re-generable
sans horodatage).
"""
from __future__ import annotations
from typing import Any
from . import frappe
def roles_by_portail(contract: dict) -> dict[str, list[str]]:
"""`erpnext_role_name` tries par portail (source de verite des roles)."""
grouped: dict[str, list[str]] = {}
for r in contract["roles"]:
grouped.setdefault(r["portail"], []).append(r["erpnext_role_name"])
for portail in grouped:
grouped[portail] = sorted(set(grouped[portail]))
return grouped
def doctypes_by_portail(contract: dict) -> dict[str, set[str]]:
"""Ensemble des DocTypes cites dans les permissions_cibles, par portail."""
grouped: dict[str, set[str]] = {}
for r in contract["roles"]:
bucket = grouped.setdefault(r["portail"], set())
for perm in r.get("permissions_cibles", []):
if isinstance(perm, dict) and perm.get("doctype"):
bucket.add(perm["doctype"])
return grouped
def custom_flags(contract: dict) -> dict[str, bool]:
"""Flag `custom` par DocType (natif v15 = False), depuis le contrat (source unique)."""
flags: dict[str, bool] = {}
for r in contract["roles"]:
for perm in r.get("permissions_cibles", []):
if isinstance(perm, dict) and perm.get("doctype"):
flags[perm["doctype"]] = bool(perm.get("custom", False))
return flags
def _links_for_portail(portail: dict) -> list[dict[str, Any]]:
"""Table `Workspace Link` : Card Break + Link, dans l'ordre de la spec."""
links: list[dict[str, Any]] = []
for carte in portail["cartes"]:
doctypes = carte["doctypes"]
links.append(frappe.card_break_row(carte["titre"], len(doctypes)))
for dt in doctypes:
links.append(frappe.link_row(dt))
return links
def build_bundle(contract: dict, spec: dict) -> dict[str, Any]:
"""Transforme (contrat RBAC + spec portails) en bundle de Workspaces."""
roles_map = roles_by_portail(contract)
dt_map = doctypes_by_portail(contract)
flags = custom_flags(contract)
workspaces: list[dict] = []
ws_meta: list[dict] = []
for portail in spec["portails"]:
key = portail["key"]
roles = roles_map.get(key, [])
links = _links_for_portail(portail)
workspaces.append(frappe.workspace_fixture(portail, roles, links))
couverts = sorted({dt for carte in portail["cartes"] for dt in carte["doctypes"]})
customs = sorted(dt for dt in couverts if flags.get(dt, False))
ws_meta.append(
{
"portail": key,
"workspace": portail["label"],
"nb_cartes": len(portail["cartes"]),
"nb_raccourcis": len(portail["raccourcis"]),
"nb_liens": len(couverts),
"nb_roles": len(roles),
"doctypes_custom": customs,
}
)
total_customs = sorted({dt for m in ws_meta for dt in m["doctypes_custom"]})
manifest = {
"generated_from": spec["generated_from"],
"spec_version": spec["version"],
"source_version": contract["version"],
"roadmap_ref": spec["roadmap_ref"],
"counts": {
"workspaces": len(workspaces),
"roles_couverts": sum(m["nb_roles"] for m in ws_meta),
"liens_total": sum(m["nb_liens"] for m in ws_meta),
"raccourcis_total": sum(m["nb_raccourcis"] for m in ws_meta),
},
"brand": spec["brand"],
"module_note": (
"Workspace.module laisse a None (a_confirmer) : l'agent ERPNext Backend "
"le fixe au module de l'app OTO a l'import (#8). Le worker ne fabrique "
"pas de nom d'app."
),
"doctypes_custom_a_creer": total_customs,
"workspaces": ws_meta,
}
return {"manifest": manifest, "workspace": workspaces}