[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>
This commit is contained in:
Claude Code DTP Worker
2026-07-30 07:38:44 +00:00
parent 7117394651
commit c0d2e21ef3
14 changed files with 2891 additions and 1 deletions
@@ -0,0 +1,169 @@
"""Modele Frappe/ERPNext v15 : DocType `Workspace` (portail role natif).
Pourquoi un Workspace (et pas une page HTML externe)
----------------------------------------------------
La roadmap Sprint 4 demande « 5 portails role ». Contrainte #1 « ERPNext natif =
priorite absolue avant tout outil externe » : le portail de landing par role,
dans ERPNext v15, EST le DocType `Workspace` (le desk affiche a l'utilisateur les
Workspaces dont il possede au moins un role autorise). On ne fabrique donc aucun
framework de dashboard externe — on produit des fixtures `Workspace` importables.
Reference Frappe (v15, `frappe/desk/doctype/workspace`) :
- `Workspace` : `title`/`label` (libelle), `public` (1 = visible au desk),
`module` (Link Module Def — l'app proprietaire), `icon` (jeu d'icones desk),
`sequence_id` (ordre), `content` (Text = JSON de blocs de mise en page),
tables enfant `shortcuts` (Workspace Shortcut), `links` (Workspace Link),
`roles` (Has Role — RESTREINT la visibilite aux roles listes).
- `content` : liste de blocs `{"id","type","data"}` ; types utilises ici :
`header` (titre), `shortcut` (renvoie vers un raccourci), `card` (renvoie vers
une carte de liens). Les blocs pointent les enfants par leur libelle.
- `Workspace Shortcut` : `type` (DocType/Report/Page/URL), `link_to`, `label`,
`color`, `doc_view`. Un raccourci type DocType affiche le compteur LIVE calcule
par le desk — aucun chiffre n'est donc stocke (anti-invention #6).
- `Workspace Link` : deux natures dans la meme table — `Card Break` (ouvre une
carte : `label`, `link_count`) puis des `Link` (`link_type`=DocType, `link_to`,
`label`). L'ordre de la table = l'ordre d'affichage.
- `Has Role` : un `role` (Link Role) par ligne ; `parent`/`idx`/`name` poses par
Frappe a l'import (on ne les invente pas).
Anti-invention (#6) : ce module ne connait AUCUN DocType metier en dur ni aucun
chiffre. Tout (DocTypes lies, roles) est injecte par le builder depuis le contrat
RBAC. Le champ `module` reste None (a_confirmer) : l'agent ERPNext le fixe au
module de l'app OTO a l'import — le worker ne fabrique pas de nom d'app.
Ce module ne touche JAMAIS le VPS : il retourne des dicts serialisables.
"""
from __future__ import annotations
import json
from typing import Any
WORKSPACE_DOCTYPE = "Workspace"
SHORTCUT_DOCTYPE = "Workspace Shortcut"
LINK_DOCTYPE = "Workspace Link"
HAS_ROLE_DOCTYPE = "Has Role"
# Le module proprietaire (Link -> Module Def) est volontairement laisse a None :
# le nommer serait inventer l'app OTO cible. L'agent ERPNext Backend le fixe a
# l'import (hand-off documente dans le README / MANIFEST).
OWNER_MODULE = None
def has_role_row(erpnext_role_name: str) -> dict[str, Any]:
"""Ligne enfant `Has Role` restreignant la visibilite du Workspace."""
return {
"doctype": HAS_ROLE_DOCTYPE,
"parentfield": "roles",
"parenttype": WORKSPACE_DOCTYPE,
"role": erpnext_role_name,
}
def shortcut_row(doctype: str, color: str) -> dict[str, Any]:
"""Raccourci vers un DocType (compteur calcule LIVE par le desk)."""
return {
"doctype": SHORTCUT_DOCTYPE,
"parentfield": "shortcuts",
"parenttype": WORKSPACE_DOCTYPE,
"type": "DocType",
"link_to": doctype,
"label": doctype,
"color": color,
"doc_view": "List",
}
def card_break_row(titre: str, link_count: int) -> dict[str, Any]:
"""Ligne `Card Break` : ouvre une carte de `link_count` liens."""
return {
"doctype": LINK_DOCTYPE,
"parentfield": "links",
"parenttype": WORKSPACE_DOCTYPE,
"type": "Card Break",
"label": titre,
"link_count": link_count,
"onboard": 0,
"hidden": 0,
}
def link_row(doctype: str) -> dict[str, Any]:
"""Ligne `Link` vers un DocType (dans la carte ouverte precedemment)."""
return {
"doctype": LINK_DOCTYPE,
"parentfield": "links",
"parenttype": WORKSPACE_DOCTYPE,
"type": "Link",
"label": doctype,
"link_type": "DocType",
"link_to": doctype,
"onboard": 0,
"hidden": 0,
"is_query_report": 0,
"dependencies": "",
}
def _block(block_id: str, block_type: str, data: dict[str, Any]) -> dict[str, Any]:
return {"id": block_id, "type": block_type, "data": data}
def build_content(key: str, label: str, raccourcis: list[str], cartes: list[dict]) -> str:
"""Serialise le champ `content` (JSON de blocs) de facon DETERMINISTE.
Ordre : header, puis un bloc `shortcut` par raccourci (dans l'ordre spec),
puis un bloc `card` par carte (dans l'ordre spec). Les identifiants de bloc
sont derives de la cle du portail (aucun aleatoire -> diffable, re-generable).
"""
blocks: list[dict[str, Any]] = [
_block(f"hdr_{key}", "header", {"text": label, "col": 12}),
]
for sc in raccourcis:
blocks.append(
_block(f"sc_{key}_{sc}", "shortcut", {"shortcut_name": sc, "col": 3})
)
for carte in cartes:
blocks.append(
_block(
f"card_{key}_{carte['titre']}",
"card",
{"card_name": carte["titre"], "col": 4},
)
)
# Serialisation stable : cles triees, pas d'espaces volatils.
return json.dumps(blocks, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def workspace_fixture(
portail: dict,
roles: list[str],
links: list[dict[str, Any]],
) -> dict[str, Any]:
"""Assemble une fixture `Workspace` complete pour un portail.
`roles` doit etre deja trie (determinisme gere par le builder). `links` est la
table `Workspace Link` deja construite (Card Break + Link, dans l'ordre).
"""
label = portail["label"]
content = build_content(
portail["key"], label, portail["raccourcis"], portail["cartes"]
)
return {
"doctype": WORKSPACE_DOCTYPE,
"name": label,
"title": label,
"label": label,
"public": 1,
"is_hidden": 0,
"module": OWNER_MODULE,
"icon": portail["icon"],
"indicator_color": portail["accent_desk"],
"sequence_id": float(portail["sequence_id"]),
"parent_page": "",
"for_user": "",
"content": content,
"shortcuts": [shortcut_row(sc, portail["accent_desk"]) for sc in portail["raccourcis"]],
"links": links,
"roles": [has_role_row(r) for r in roles],
}