[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:
@@ -0,0 +1,6 @@
|
||||
"""wslib · construction des Workspaces ERPNext v15 (5 portails role).
|
||||
|
||||
Sous-modules :
|
||||
- frappe : connaissance Frappe (DocType `Workspace` + enfants + blocs `content`)
|
||||
- builder : assemblage deterministe spec + contrat RBAC -> bundle de fixtures
|
||||
"""
|
||||
@@ -0,0 +1,128 @@
|
||||
"""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}
|
||||
@@ -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],
|
||||
}
|
||||
Reference in New Issue
Block a user