[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,82 @@
# Daily Report · 2026-07-30 · Claude Code DTP Worker (session 15)
**Session** : `20260730_072711`
## Tâche exécutée
**Sprint 4 · Frontend Console — Générateur de Workspaces ERPNext (5 portails
rôle)** (roadmap `04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md` §Sprint 4 l.49 :
_« Frontend Console : 5 portails (Ventes/Construction/Achat/Compta/Direction) »_).
## Contexte / analyse
- Relu `CLAUDE.md`, `ROADMAP_8_WEEKS_OR_LESS.md`, l'`05_activity_log/2026-07-30.md`
et les rapports sessions 11-14.
- **Constat** : les deux autres volets de Sprint 4 sont livrés — CRM (workflow
vente s11, DocType porteur s12) et ERPNext Backend (commissions s13, e-CF DGII
s14). Le volet **Frontend Console « 5 portails rôle »** restait ouvert (aucun
artefact `portail`/`workspace` dans le repo).
- **Décision d'architecture** (contrainte #1 « ERPNext natif = priorité absolue
avant tout outil externe ») : dans ERPNext v15, le portail de landing par rôle
**EST le DocType `Workspace`** (le desk affiche à l'utilisateur les Workspaces
dont il détient un rôle autorisé). On livre donc 5 Workspaces natifs plutôt
qu'un framework de dashboard externe.
## Fichiers créés — `05_deliverables_mvp/frontend/portails/`
- `portails_spec.json` — mise en page des 5 portails (cartes, raccourcis, thème) ;
ne déclare **aucun** DocType ni rôle hors du contrat RBAC.
- `wslib/{__init__,frappe,builder}.py` — connaissance Frappe (DocType `Workspace`
+ enfants `shortcuts`/`links`/`roles` + blocs `content`) + assemblage
déterministe (dérive rôles et DocTypes du contrat `rbac_50_roles.json`).
- `workspaces_gen.py` — CLI `build`/`validate` · **12 invariants** de
cross-cohérence.
- `workspace.schema.json` — contrat de sortie draft-07.
- `out/{workspace,MANIFEST}.json` — hand-off (commité).
- `tests/test_workspaces.py`**19 tests** (dont 4 négatifs). `README.md` · `.gitignore`.
## Fichiers modifiés
- `.gitea/workflows/ci.yml` : job `frontend-portails-tests` + ajout au `gate`.
## Anti-invention (cœur · #6)
La **source de vérité** est le contrat RBAC, jamais la spec :
- **Aucun lien / raccourci inventé** : tout DocType visé (carte ou raccourci) doit
figurer dans les `permissions_cibles` du portail → le portail a **prouvablement**
le droit dessus (invariants 2-3, testés en négatif).
- **Couverture exhaustive et sans doublon** des DocTypes autorisés par les cartes
(aucun oubli silencieux — invariant 3).
- **Rôles** dérivés du contrat (mêmes rôles que le `Role Profile` du portail),
triés ; 44 rôles restreints au total.
- **Flag `custom`** (DocType OTO à créer vs natif v15) lu dans le **contrat** (source
unique — invariant 9).
- **Aucun chiffre stocké** : raccourcis type DocType = compteur **live** du desk.
- **Tokens de marque** (`#0a0a12`, `#f0b429`, Fraunces, Cormorant Garamond) repris
**verbatim** de CLAUDE.md #4, chacun avec `source` (invariant 11).
## Résultat
5 Workspaces : `OTO Ventes` (4 cartes/11 liens/12 rôles), `OTO Construction`
(4/9/10), `OTO Achat` (3/8/5), `OTO Compta` (4/11/8), `OTO Direction` (4/14/9).
Console technique `plateforme` **exclue** (roadmap = 5 portails métier ; invariant
d'égalité stricte avec `portails_business`).
## Vérifs
- 19/19 tests ; gate CI local vert (guard + JSON + docs + YAML `ci.yml` OK).
- **Régression : 260 tests verts** au total (241 + 19).
- Build déterministe (invariant 12 : deux passes identiques).
## Hors périmètre worker (VPS · #8)
- Fixer `Workspace.module` au module de l'app OTO à l'import (laissé `null`).
- Créer les DocTypes custom avant import : `CONFOTUR Application`, `Faisabilité`,
`Publiciste Log` (issus des modules OTOV7 du contrat).
- Déposer `workspace.json` dans `fixtures/` puis `bench migrate`.
- Appliquer le thème luxury dark+doré (couche thème desk / Website Theme).
→ agent **ERPNext Backend** + **Frontend Console** (déploiement thème).
**Auto-score 4Big** : 96/100.
@@ -0,0 +1,4 @@
# Caches Python
__pycache__/
*.pyc
# Le dossier out/ EST commite (hand-off ERPNext direct) — voir README.
@@ -0,0 +1,100 @@
# Générateur de Workspaces ERPNext · 5 portails rôle
**Sprint 4 · agent Frontend Console.** Livre le volet _« Frontend Console : 5
portails (Ventes/Construction/Achat/Compta/Direction) »_ de la roadmap
([`../../../04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md`](../../../04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md)
§Sprint 4 l.49).
Transforme le contrat RBAC [`../../rbac/rbac_50_roles.json`](../../rbac/rbac_50_roles.json)
+ la mise en page [`portails_spec.json`](portails_spec.json) en **fixtures Frappe
`Workspace`** prêtes à importer : un portail rôle par entrée.
> Ce worker **n'écrit jamais sur le VPS** (contrainte #8). Il produit un bundle de
> fixtures en-repo ; l'application réelle (`bench migrate`) reste côté serveur.
## Pourquoi un `Workspace` (et pas une page HTML externe)
Contrainte #1 « **ERPNext natif = priorité absolue avant tout outil externe** ».
Dans ERPNext v15, le **portail de landing par rôle EST le DocType `Workspace`** :
le desk affiche à chaque utilisateur les Workspaces dont il détient au moins un
rôle autorisé (table enfant `roles`). On ne fabrique donc aucun framework de
dashboard externe — on livre 5 Workspaces natifs, un par portail métier. Le thème
**luxury dark+doré** (#4) s'applique par-dessus via la couche thème desk (voir
_Hand-off_).
## Les 5 portails (métier)
| Portail | Workspace | Cartes | Liens | Rôles restreints |
|---|---|---|---|---|
| ventes | `OTO Ventes` | 4 | 11 | 12 |
| construction | `OTO Construction` | 4 | 9 | 10 |
| achat | `OTO Achat` | 3 | 8 | 5 |
| compta | `OTO Compta` | 4 | 11 | 8 |
| direction | `OTO Direction` | 4 | 14 | 9 |
> La console technique `plateforme` (6ᵉ portail RBAC) est **exclue** : la roadmap
> demande 5 portails métier. Un invariant vérifie l'égalité stricte avec
> `portails_business` du contrat.
## Anti-invention (#6) — comment c'est garanti
La **source de vérité** est le contrat RBAC, jamais la spec :
- **Aucun lien / raccourci inventé** : tout DocType visé par une carte ou un
raccourci doit figurer dans les `permissions_cibles` du portail (le portail a
donc **prouvablement** le droit dessus). Invariant #2/#3.
- **Couverture exhaustive** : chaque DocType autorisé apparaît dans **exactement
une** carte — aucun oubli silencieux, aucun doublon. Invariant #3.
- **Rôles** dérivés du contrat (mêmes rôles que le [`Role Profile`](../../rbac/roleprofile_gen/README.md)
du portail), triés. Invariant #4.
- **Flag `custom`** (DocType OTO à créer vs natif v15) lu dans le **contrat**, pas
dans la spec. Invariant #9.
- **Aucun chiffre stocké** : les raccourcis type DocType affichent le **compteur
live** calculé par le desk — rien n'est figé.
- **Tokens de marque** (`#0a0a12`, `#f0b429`, Fraunces, Cormorant Garamond) repris
**verbatim** de CLAUDE.md #4, chacun avec sa `source`. Invariant #11.
## Ce qui est généré (`out/`, commité comme hand-off)
| Fichier | Rôle |
|---|---|
| `workspace.json` | **1 fixture `Workspace` par portail** (tables `shortcuts` / `links` / `roles`). |
| `MANIFEST.json` | Traçabilité : comptes, rôles couverts, DocTypes custom à créer, tokens de marque, note `module`. |
## Utilisation
```bash
python3 workspaces_gen.py build # écrit out/workspace.json + out/MANIFEST.json
python3 workspaces_gen.py validate # schéma + 12 invariants, sans écrire
python3 -m unittest discover -s tests # 19 tests (stdlib pur, zéro pip)
```
La génération est **refusée** si un invariant casse (anti-régression). Sortie
**déterministe** (tri stable, aucun horodatage) → diffable, re-générable en CI.
## Hand-off → agent ERPNext Backend (VPS · #8)
1. **`Workspace.module`** est laissé à `null` (`a_confirmer`) : le fixer au module
de l'app OTO au moment de l'import (le worker ne fabrique pas de nom d'app).
2. **DocTypes custom à créer** avant import (sinon les liens pointent dans le
vide) : `CONFOTUR Application`, `Faisabilité`, `Publiciste Log` (issus des
modules OTOV7 du contrat). Les autres DocTypes sont **natifs v15**.
3. Déposer `workspace.json` dans les `fixtures/` de l'app OTO puis `bench migrate`.
4. **Thème luxury** : appliquer les tokens du `MANIFEST.brand` via la couche thème
desk (Website Theme / CSS custom) — l'`indicator_color` / la couleur des
raccourcis utilisent la palette native Frappe (mapping cosmétique documenté).
## Réutilisation (zéro duplication · #5)
- Validateur JSON-Schema **maison Publiciste** (`../../publiciste/lib/validator.py`)
— pas de dépendance pip sur le runner Gitea.
- Rôles et DocTypes **dérivés du contrat RBAC** — même source que les générateurs
RBAC (`fixtures_gen`, `userperm_gen`, `roleprofile_gen`).
## Auto-score 4Big
**96/100.** ERPNext natif (#1), anti-invention traçable au contrat (#6), 5
portails métier exacts, 19 tests (dont 4 négatifs prouvant que les invariants
mordent), sortie déterministe, hand-off VPS explicite. Points non tenus par ce
worker (hors périmètre #8) : création des DocTypes custom + application du thème
desk + fixation du `module` à l'import.
@@ -0,0 +1,94 @@
{
"generated_from": "rbac_50_roles.json (permissions_cibles + portail par rôle)",
"spec_version": "1.0.0",
"source_version": "1.0.0",
"roadmap_ref": "04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md · Sprint 4 l.49 « Frontend Console : 5 portails (Ventes/Construction/Achat/Compta/Direction) »",
"counts": {
"workspaces": 5,
"roles_couverts": 44,
"liens_total": 53,
"raccourcis_total": 20
},
"brand": {
"note": "Tokens de marque luxury dark+doré (CLAUDE.md #4). Appliqués par la couche thème desk (Website Theme / CSS custom) déposée par l'agent Frontend sur le VPS — HORS de ce worker (#8). Repris ici pour le hand-off, jamais fabriqués.",
"fond": {
"valeur": "#0a0a12",
"source": "CLAUDE.md #4"
},
"accent": {
"valeur": "#f0b429",
"source": "CLAUDE.md #4"
},
"polices": [
{
"valeur": "Fraunces",
"source": "CLAUDE.md #4"
},
{
"valeur": "Cormorant Garamond",
"source": "CLAUDE.md #4"
}
]
},
"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": [
"CONFOTUR Application",
"Faisabilité",
"Publiciste Log"
],
"workspaces": [
{
"portail": "ventes",
"workspace": "OTO Ventes",
"nb_cartes": 4,
"nb_raccourcis": 4,
"nb_liens": 11,
"nb_roles": 12,
"doctypes_custom": [
"CONFOTUR Application",
"Publiciste Log"
]
},
{
"portail": "construction",
"workspace": "OTO Construction",
"nb_cartes": 4,
"nb_raccourcis": 4,
"nb_liens": 9,
"nb_roles": 10,
"doctypes_custom": [
"Faisabilité"
]
},
{
"portail": "achat",
"workspace": "OTO Achat",
"nb_cartes": 3,
"nb_raccourcis": 4,
"nb_liens": 8,
"nb_roles": 5,
"doctypes_custom": []
},
{
"portail": "compta",
"workspace": "OTO Compta",
"nb_cartes": 4,
"nb_raccourcis": 4,
"nb_liens": 11,
"nb_roles": 8,
"doctypes_custom": []
},
{
"portail": "direction",
"workspace": "OTO Direction",
"nb_cartes": 4,
"nb_raccourcis": 4,
"nb_liens": 14,
"nb_roles": 9,
"doctypes_custom": [
"CONFOTUR Application",
"Faisabilité"
]
}
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
{
"version": "1.0.0",
"roadmap_ref": "04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md · Sprint 4 l.49 « Frontend Console : 5 portails (Ventes/Construction/Achat/Compta/Direction) »",
"description": "Spec machine-lisible des 5 portails rôle rendus en Workspaces ERPNext v15 natifs. La source de vérité des DocTypes et des rôles reste le contrat RBAC (rbac_50_roles.json) : ce fichier ne fait QUE la mise en page (cartes, raccourcis, thème). Un invariant refuse tout DocType absent des permissions_cibles du portail (anti-invention #6) et impose la couverture exhaustive.",
"generated_from": "rbac_50_roles.json (permissions_cibles + portail par rôle)",
"brand": {
"note": "Tokens de marque luxury dark+doré (CLAUDE.md #4). Appliqués par la couche thème desk (Website Theme / CSS custom) déposée par l'agent Frontend sur le VPS — HORS de ce worker (#8). Repris ici pour le hand-off, jamais fabriqués.",
"fond": {"valeur": "#0a0a12", "source": "CLAUDE.md #4"},
"accent": {"valeur": "#f0b429", "source": "CLAUDE.md #4"},
"polices": [
{"valeur": "Fraunces", "source": "CLAUDE.md #4"},
{"valeur": "Cormorant Garamond", "source": "CLAUDE.md #4"}
]
},
"palette_note": "Chaque portail porte un `accent_desk` = nom de couleur de la palette native Frappe (Workspace Shortcut.color / Workspace.indicator_color). C'est un libellé COSMÉTIQUE reproductible (le desk n'accepte pas un hex arbitraire par raccourci) ; le doré réel de la marque vit dans `brand.accent` et s'applique par le thème. Idem pour `icon` (jeu d'icônes desk Frappe v15).",
"portails": [
{
"key": "ventes",
"label": "OTO Ventes",
"icon": "sell",
"accent_desk": "Yellow",
"sequence_id": 1,
"raccourcis": ["Lead", "Opportunity", "Quotation", "Sales Order"],
"cartes": [
{"titre": "Pipeline commercial", "doctypes": ["Lead", "Opportunity", "Quotation", "Sales Order"]},
{"titre": "Clients & encaissements", "doctypes": ["Customer", "Sales Invoice", "Payment Entry"]},
{"titre": "Apres-vente & conformite", "doctypes": ["Issue", "CONFOTUR Application"]},
{"titre": "Marketing & web", "doctypes": ["Publiciste Log", "Web Page"]}
]
},
{
"key": "construction",
"label": "OTO Construction",
"icon": "project",
"accent_desk": "Orange",
"sequence_id": 2,
"raccourcis": ["Project", "Task", "Faisabilité", "Quality Inspection"],
"cartes": [
{"titre": "Chantier & projet", "doctypes": ["Project", "Task", "Timesheet"]},
{"titre": "Faisabilite & plans", "doctypes": ["Faisabilité", "File"]},
{"titre": "Approvisionnement chantier", "doctypes": ["Material Request", "Purchase Order", "Item"]},
{"titre": "Qualite", "doctypes": ["Quality Inspection"]}
]
},
{
"key": "achat",
"label": "OTO Achat",
"icon": "buying",
"accent_desk": "Cyan",
"sequence_id": 3,
"raccourcis": ["Material Request", "Request for Quotation", "Purchase Order", "Supplier"],
"cartes": [
{"titre": "Cycle achat", "doctypes": ["Material Request", "Request for Quotation", "Purchase Order", "Purchase Receipt"]},
{"titre": "Fournisseurs", "doctypes": ["Supplier", "Contact"]},
{"titre": "Stock & articles", "doctypes": ["Item", "Stock Entry"]}
]
},
{
"key": "compta",
"label": "OTO Compta",
"icon": "accounting",
"accent_desk": "Green",
"sequence_id": 4,
"raccourcis": ["Journal Entry", "Payment Entry", "Sales Invoice", "Purchase Invoice"],
"cartes": [
{"titre": "Grand livre", "doctypes": ["Account", "GL Entry", "Journal Entry", "Cost Center"]},
{"titre": "Clients & fournisseurs", "doctypes": ["Sales Invoice", "Purchase Invoice", "Payment Entry"]},
{"titre": "Tresorerie & budget", "doctypes": ["Bank Transaction", "Budget"]},
{"titre": "Paie", "doctypes": ["Employee", "Salary Slip"]}
]
},
{
"key": "direction",
"label": "OTO Direction",
"icon": "organization",
"accent_desk": "Purple",
"sequence_id": 5,
"raccourcis": ["Faisabilité", "Sales Order", "Journal Entry", "Project"],
"cartes": [
{"titre": "Commercial (vue direction)", "doctypes": ["Lead", "Opportunity", "Quotation", "Sales Order", "Sales Invoice"]},
{"titre": "Operations & projets", "doctypes": ["Project", "Task", "Faisabilité"]},
{"titre": "Finance (vue direction)", "doctypes": ["Journal Entry", "Payment Entry", "Purchase Invoice", "Purchase Order"]},
{"titre": "Legal & conformite", "doctypes": ["Contract", "CONFOTUR Application"]}
]
}
]
}
@@ -0,0 +1,248 @@
"""Tests du generateur de Workspaces (stdlib `unittest`, zero pip).
Reutilise le validateur maison Publiciste (workflow #5 · pas de duplication) et,
s'il est installe, l'oracle `jsonschema` en plus. Invariants couverts :
1. Le bundle valide contre workspace.schema.json (maison + oracle).
2. Exactement les 5 portails METIER du contrat (plateforme exclue), ordre spec.
3. ANTI-INVENTION (#6) : tout lien/raccourci vise un DocType autorise (permissions_cibles).
4. Couverture EXHAUSTIVE + sans doublon des DocTypes autorises par les cartes.
5. Restriction de visibilite = tous les roles du portail (contrat), tries.
6. Table Links coherente (Card Break -> link_count == liens reels).
7. Raccourcis : couleur = accent, ordre = spec ; compteurs desk (aucun chiffre stocke).
8. Workspace.module reste None (a_confirmer, hand-off VPS #8).
9. `content` (JSON de blocs) coherent avec raccourcis + cartes.
10. Flag `custom` issu du CONTRAT (source unique).
11. Tokens de marque = CLAUDE.md #4 (jamais fabriques).
12. Determinisme : deux builds bit-a-bit identiques.
13. NEGATIFS : les invariants mordent (invention, oubli de couverture, mauvais portail).
"""
import copy
import importlib.util
import json
import os
import sys
import tempfile
import unittest
_HERE = os.path.dirname(os.path.abspath(__file__))
_GEN = os.path.normpath(os.path.join(_HERE, "..")) # portails/
_FRONTEND = os.path.normpath(os.path.join(_GEN, "..")) # frontend/
_DELIVERABLES = os.path.normpath(os.path.join(_FRONTEND, "..")) # 05_deliverables_mvp/
_RBAC = os.path.join(_DELIVERABLES, "rbac")
sys.path.insert(0, _GEN)
sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste"))
from wslib import builder, frappe # noqa: E402
from lib import validator as maison # noqa: E402
_CONTRACT = os.path.join(_RBAC, "rbac_50_roles.json")
_SPEC = os.path.join(_GEN, "portails_spec.json")
_SCHEMA = os.path.join(_GEN, "workspace.schema.json")
def _load(path):
with open(path, encoding="utf-8") as fh:
return json.load(fh)
def _load_cli():
spec = importlib.util.spec_from_file_location(
"workspaces_gen", os.path.join(_GEN, "workspaces_gen.py")
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
class WorkspacesGenTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.contract = _load(_CONTRACT)
cls.spec = _load(_SPEC)
cls.schema = _load(_SCHEMA)
cls.bundle = builder.build_bundle(cls.contract, cls.spec)
cls.workspaces = cls.bundle["workspace"]
cls.dt_map = builder.doctypes_by_portail(cls.contract)
cls.roles_map = builder.roles_by_portail(cls.contract)
cls.flags = builder.custom_flags(cls.contract)
cls.cli = _load_cli()
# 1 --------------------------------------------------------------------
def test_valide_schema_maison(self):
self.assertEqual(maison.validate(self.bundle, self.schema), [])
def test_valide_schema_oracle(self):
try:
import jsonschema
except ImportError:
self.skipTest("jsonschema absent (oracle facultatif)")
jsonschema.validate(self.bundle, self.schema)
def test_validate_bundle_reel_ok(self):
# schema + 12 invariants du CLI, sur le bundle reel (disque).
self.assertEqual(self.cli._validate_bundle(self.bundle), [])
# 2 --------------------------------------------------------------------
def test_cinq_portails_metier_ordonnes(self):
keys = [w["label"] for w in self.workspaces]
self.assertEqual(len(keys), 5)
spec_keys = [p["key"] for p in self.spec["portails"]]
self.assertEqual(set(spec_keys), set(self.contract["portails_business"]))
self.assertNotIn("plateforme", spec_keys) # console technique exclue
# 3 --------------------------------------------------------------------
def test_anti_invention_liens_autorises(self):
for p, w in zip(self.spec["portails"], self.workspaces):
authorized = self.dt_map[p["key"]]
for row in w["links"]:
if row["type"] == "Link":
self.assertIn(row["link_to"], authorized)
for s in w["shortcuts"]:
self.assertIn(s["link_to"], authorized)
# 4 --------------------------------------------------------------------
def test_couverture_exhaustive(self):
for p in self.spec["portails"]:
dts = [dt for c in p["cartes"] for dt in c["doctypes"]]
self.assertEqual(len(dts), len(set(dts)), f"{p['key']}: doublon")
self.assertEqual(set(dts), self.dt_map[p["key"]], f"{p['key']}: couverture")
# 5 --------------------------------------------------------------------
def test_roles_restriction(self):
total = 0
for p, w in zip(self.spec["portails"], self.workspaces):
roles = [r["role"] for r in w["roles"]]
self.assertEqual(roles, sorted(roles))
self.assertEqual(set(roles), set(self.roles_map[p["key"]]))
self.assertTrue(all(r["parenttype"] == "Workspace" for r in w["roles"]))
total += len(roles)
self.assertEqual(total, self.bundle["manifest"]["counts"]["roles_couverts"])
# 6 --------------------------------------------------------------------
def test_links_card_break_coherent(self):
for w in self.workspaces:
rows = w["links"]
self.assertEqual(rows[0]["type"], "Card Break")
i = 0
while i < len(rows):
self.assertEqual(rows[i]["type"], "Card Break")
declared = rows[i]["link_count"]
j = i + 1
seen = 0
while j < len(rows) and rows[j]["type"] == "Link":
seen += 1
j += 1
self.assertEqual(seen, declared)
i = j
# 7 --------------------------------------------------------------------
def test_shortcuts_accent_et_ordre(self):
for p, w in zip(self.spec["portails"], self.workspaces):
self.assertEqual([s["link_to"] for s in w["shortcuts"]], p["raccourcis"])
for s in w["shortcuts"]:
self.assertEqual(s["color"], p["accent_desk"])
self.assertEqual(s["type"], "DocType")
self.assertEqual(s["doc_view"], "List")
# Aucun chiffre stocke (compteur calcule LIVE par le desk).
self.assertNotIn("stats_filter", s)
# 8 --------------------------------------------------------------------
def test_module_none(self):
for w in self.workspaces:
self.assertIsNone(w["module"])
self.assertEqual(w["public"], 1)
# 9 --------------------------------------------------------------------
def test_content_coherent(self):
for p, w in zip(self.spec["portails"], self.workspaces):
blocks = json.loads(w["content"])
headers = [b for b in blocks if b["type"] == "header"]
self.assertEqual(len(headers), 1)
self.assertEqual(headers[0]["data"]["text"], p["label"])
self.assertEqual(
[b["data"]["shortcut_name"] for b in blocks if b["type"] == "shortcut"],
p["raccourcis"],
)
self.assertEqual(
[b["data"]["card_name"] for b in blocks if b["type"] == "card"],
[c["titre"] for c in p["cartes"]],
)
# 10 -------------------------------------------------------------------
def test_flag_custom_du_contrat(self):
for m in self.bundle["manifest"]["workspaces"]:
p = next(x for x in self.spec["portails"] if x["key"] == m["portail"])
expected = sorted({
dt for c in p["cartes"] for dt in c["doctypes"] if self.flags.get(dt)
})
self.assertEqual(m["doctypes_custom"], expected)
self.assertEqual(
self.bundle["manifest"]["doctypes_custom_a_creer"],
sorted({"CONFOTUR Application", "Faisabilité", "Publiciste Log"}),
)
# 11 -------------------------------------------------------------------
def test_marque_claude_md(self):
brand = self.bundle["manifest"]["brand"]
self.assertEqual(brand["accent"]["valeur"], "#f0b429")
self.assertEqual(brand["fond"]["valeur"], "#0a0a12")
self.assertTrue(brand["accent"]["source"])
# 12 -------------------------------------------------------------------
def test_determinisme(self):
b1 = builder.build_bundle(_load(_CONTRACT), _load(_SPEC))
b2 = builder.build_bundle(_load(_CONTRACT), _load(_SPEC))
self.assertEqual(json.dumps(b1, sort_keys=True), json.dumps(b2, sort_keys=True))
# 13 · NEGATIFS ---------------------------------------------------------
def _validate_with_spec(self, mutated_spec):
"""Ecrit une spec mutee dans un tmp, y pointe le CLI, revalide."""
with tempfile.TemporaryDirectory() as td:
path = os.path.join(td, "spec.json")
with open(path, "w", encoding="utf-8") as fh:
json.dump(mutated_spec, fh, ensure_ascii=False)
orig = self.cli._SPEC_PATH
self.cli._SPEC_PATH = path
try:
bundle = self.cli._build_bundle()
return self.cli._validate_bundle(bundle)
finally:
self.cli._SPEC_PATH = orig
def test_negatif_doctype_invente(self):
bad = copy.deepcopy(self.spec)
bad["portails"][0]["cartes"][0]["doctypes"].append("DocType Bidon Invente")
errs = self._validate_with_spec(bad)
self.assertTrue(any("non autorise" in e for e in errs), errs)
def test_negatif_couverture_incomplete(self):
bad = copy.deepcopy(self.spec)
# retire un DocType d'une carte -> couverture non exhaustive.
bad["portails"][0]["cartes"][0]["doctypes"].pop()
errs = self._validate_with_spec(bad)
self.assertTrue(any("couverture" in e for e in errs), errs)
def test_negatif_mauvais_ensemble_portails(self):
bad = copy.deepcopy(self.spec)
bad["portails"] = bad["portails"][:4] # 4 portails != 5 metier
errs = self._validate_with_spec(bad)
self.assertTrue(any("portails_business" in e or "portails metier" in e for e in errs), errs)
def test_negatif_raccourci_hors_permissions(self):
bad = copy.deepcopy(self.spec)
bad["portails"][1]["raccourcis"].append("Sales Invoice") # non autorise en construction
errs = self._validate_with_spec(bad)
self.assertTrue(any("raccourci vise DocType non autorise" in e for e in errs), errs)
# Garde-fous frappe.py --------------------------------------------------
def test_content_deterministe_frappe(self):
c1 = frappe.build_content("x", "X", ["A"], [{"titre": "T", "doctypes": ["A"]}])
c2 = frappe.build_content("x", "X", ["A"], [{"titre": "T", "doctypes": ["A"]}])
self.assertEqual(c1, c2)
self.assertTrue(json.loads(c1)) # JSON valide
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -0,0 +1,181 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Bundle Workspaces ERPNext · 5 portails role (OTO DTP Sprint 4)",
"type": "object",
"additionalProperties": false,
"required": ["manifest", "workspace"],
"properties": {
"manifest": { "$ref": "#/definitions/manifest" },
"workspace": {
"type": "array",
"minItems": 5,
"maxItems": 5,
"items": { "$ref": "#/definitions/workspace" }
}
},
"definitions": {
"manifest": {
"type": "object",
"additionalProperties": false,
"required": [
"generated_from", "spec_version", "source_version", "roadmap_ref",
"counts", "brand", "module_note", "doctypes_custom_a_creer", "workspaces"
],
"properties": {
"generated_from": { "type": "string", "minLength": 1 },
"spec_version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" },
"source_version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" },
"roadmap_ref": { "type": "string", "minLength": 1 },
"counts": {
"type": "object",
"additionalProperties": false,
"required": ["workspaces", "roles_couverts", "liens_total", "raccourcis_total"],
"properties": {
"workspaces": { "type": "integer", "minimum": 5, "maximum": 5 },
"roles_couverts": { "type": "integer", "minimum": 1 },
"liens_total": { "type": "integer", "minimum": 1 },
"raccourcis_total": { "type": "integer", "minimum": 1 }
}
},
"brand": { "$ref": "#/definitions/brand" },
"module_note": { "type": "string", "minLength": 1 },
"doctypes_custom_a_creer": {
"type": "array",
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 }
},
"workspaces": {
"type": "array",
"minItems": 5,
"maxItems": 5,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["portail", "workspace", "nb_cartes", "nb_raccourcis", "nb_liens", "nb_roles", "doctypes_custom"],
"properties": {
"portail": { "type": "string", "enum": ["ventes", "construction", "achat", "compta", "direction"] },
"workspace": { "type": "string", "minLength": 1 },
"nb_cartes": { "type": "integer", "minimum": 1 },
"nb_raccourcis": { "type": "integer", "minimum": 1 },
"nb_liens": { "type": "integer", "minimum": 1 },
"nb_roles": { "type": "integer", "minimum": 1 },
"doctypes_custom": {
"type": "array",
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 }
}
}
}
}
}
},
"brand": {
"type": "object",
"additionalProperties": false,
"required": ["note", "fond", "accent", "polices"],
"properties": {
"note": { "type": "string", "minLength": 1 },
"fond": { "$ref": "#/definitions/token" },
"accent": { "$ref": "#/definitions/token" },
"polices": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/token" }
}
}
},
"token": {
"type": "object",
"additionalProperties": false,
"required": ["valeur", "source"],
"properties": {
"valeur": { "type": "string", "minLength": 1 },
"source": { "type": "string", "minLength": 1 }
}
},
"workspace": {
"type": "object",
"additionalProperties": false,
"required": [
"doctype", "name", "title", "label", "public", "is_hidden", "module",
"icon", "indicator_color", "sequence_id", "parent_page", "for_user",
"content", "shortcuts", "links", "roles"
],
"properties": {
"doctype": { "type": "string", "const": "Workspace" },
"name": { "type": "string", "minLength": 1 },
"title": { "type": "string", "minLength": 1 },
"label": { "type": "string", "minLength": 1 },
"public": { "type": "integer", "minimum": 1, "maximum": 1 },
"is_hidden": { "type": "integer", "minimum": 0, "maximum": 0 },
"module": { "type": "null" },
"icon": { "type": "string", "minLength": 1 },
"indicator_color": { "type": "string", "minLength": 1 },
"sequence_id": { "type": "number", "minimum": 1 },
"parent_page": { "type": "string" },
"for_user": { "type": "string" },
"content": { "type": "string", "minLength": 2 },
"shortcuts": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/shortcut" }
},
"links": {
"type": "array",
"minItems": 2,
"items": { "$ref": "#/definitions/link" }
},
"roles": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/has_role" }
}
}
},
"shortcut": {
"type": "object",
"additionalProperties": false,
"required": ["doctype", "parentfield", "parenttype", "type", "link_to", "label", "color", "doc_view"],
"properties": {
"doctype": { "type": "string", "const": "Workspace Shortcut" },
"parentfield": { "type": "string", "const": "shortcuts" },
"parenttype": { "type": "string", "const": "Workspace" },
"type": { "type": "string", "const": "DocType" },
"link_to": { "type": "string", "minLength": 1 },
"label": { "type": "string", "minLength": 1 },
"color": { "type": "string", "minLength": 1 },
"doc_view": { "type": "string", "const": "List" }
}
},
"link": {
"type": "object",
"additionalProperties": false,
"required": ["doctype", "parentfield", "parenttype", "type", "label", "onboard", "hidden"],
"properties": {
"doctype": { "type": "string", "const": "Workspace Link" },
"parentfield": { "type": "string", "const": "links" },
"parenttype": { "type": "string", "const": "Workspace" },
"type": { "type": "string", "enum": ["Card Break", "Link"] },
"label": { "type": "string", "minLength": 1 },
"link_count": { "type": "integer", "minimum": 0 },
"link_type": { "type": "string", "const": "DocType" },
"link_to": { "type": "string", "minLength": 1 },
"is_query_report": { "type": "integer", "minimum": 0, "maximum": 0 },
"dependencies": { "type": "string" },
"onboard": { "type": "integer", "minimum": 0, "maximum": 0 },
"hidden": { "type": "integer", "minimum": 0, "maximum": 0 }
}
},
"has_role": {
"type": "object",
"additionalProperties": false,
"required": ["doctype", "parentfield", "parenttype", "role"],
"properties": {
"doctype": { "type": "string", "const": "Has Role" },
"parentfield": { "type": "string", "const": "roles" },
"parenttype": { "type": "string", "const": "Workspace" },
"role": { "type": "string", "minLength": 1 }
}
}
}
}
@@ -0,0 +1,276 @@
#!/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())
@@ -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],
}