From c0d2e21ef37d615806c15b62df3b1db0c3cc7880 Mon Sep 17 00:00:00 2001 From: Claude Code DTP Worker Date: Thu, 30 Jul 2026 07:38:44 +0000 Subject: [PATCH] =?UTF-8?q?[DTP-Worker]=20Sprint=204=20=C2=B7=20G=C3=A9n?= =?UTF-8?q?=C3=A9rateur=20Workspaces=20ERPNext=205=20portails=20r=C3=B4le?= =?UTF-8?q?=20(Frontend=20Console=20=C2=B7=20roadmap=20L49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitea/workflows/ci.yml | 22 +- 05_activity_log/2026-07-30.md | 45 + .../daily_reports/2026-07-30-session15.md | 82 + .../frontend/portails/.gitignore | 4 + .../frontend/portails/README.md | 100 ++ .../frontend/portails/out/MANIFEST.json | 94 ++ .../frontend/portails/out/workspace.json | 1450 +++++++++++++++++ .../frontend/portails/portails_spec.json | 87 + .../portails/tests/test_workspaces.py | 248 +++ .../frontend/portails/workspace.schema.json | 181 ++ .../frontend/portails/workspaces_gen.py | 276 ++++ .../frontend/portails/wslib/__init__.py | 6 + .../frontend/portails/wslib/builder.py | 128 ++ .../frontend/portails/wslib/frappe.py | 169 ++ 14 files changed, 2891 insertions(+), 1 deletion(-) create mode 100644 05_deliverables_mvp/daily_reports/2026-07-30-session15.md create mode 100644 05_deliverables_mvp/frontend/portails/.gitignore create mode 100644 05_deliverables_mvp/frontend/portails/README.md create mode 100644 05_deliverables_mvp/frontend/portails/out/MANIFEST.json create mode 100644 05_deliverables_mvp/frontend/portails/out/workspace.json create mode 100644 05_deliverables_mvp/frontend/portails/portails_spec.json create mode 100644 05_deliverables_mvp/frontend/portails/tests/test_workspaces.py create mode 100644 05_deliverables_mvp/frontend/portails/workspace.schema.json create mode 100644 05_deliverables_mvp/frontend/portails/workspaces_gen.py create mode 100644 05_deliverables_mvp/frontend/portails/wslib/__init__.py create mode 100644 05_deliverables_mvp/frontend/portails/wslib/builder.py create mode 100644 05_deliverables_mvp/frontend/portails/wslib/frappe.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index c55573d..b39f1cc 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -258,10 +258,30 @@ jobs: - name: Tests générateur e-CF DGII run: python3 -m unittest discover -s tests -v + # -------------------------------------------------------------------------- + # Générateur de Workspaces ERPNext (Sprint 4 · Frontend Console « 5 portails + # rôle »). Un `Workspace` natif v15 par portail métier (Ventes/Construction/ + # Achat/Compta/Direction) — le portail de landing par rôle du desk (#1 ERPNext + # natif). Cross-cohérence Workspace ↔ RBAC : chaque lien/raccourci vise un + # DocType présent dans les permissions_cibles du portail (anti-invention #6), + # couverture exhaustive, rôles restreints dérivés du contrat, tokens de marque + # repris de CLAUDE.md #4. Schéma de sortie + 12 invariants. stdlib pur, zéro pip. + # -------------------------------------------------------------------------- + frontend-portails-tests: + name: Frontend · Workspaces 5 portails rôle + runs-on: ubuntu-latest + defaults: + run: + working-directory: 05_deliverables_mvp/frontend/portails + steps: + - uses: actions/checkout@v4 + - name: Tests générateur de Workspaces + run: python3 -m unittest discover -s tests -v + gate: name: Gate qualité (agrégat) runs-on: ubuntu-latest - needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests, rbac-userperm-tests, rbac-roleprofile-tests, rbac-applyplan-tests, bancable-tests, crm-workflow-vente-tests, crm-dossier-vente-tests, crm-commissions-tests, fiscal-ecf-tests] + needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests, rbac-userperm-tests, rbac-roleprofile-tests, rbac-applyplan-tests, bancable-tests, crm-workflow-vente-tests, crm-dossier-vente-tests, crm-commissions-tests, fiscal-ecf-tests, frontend-portails-tests] steps: - name: Résultat run: echo "✅ Gate qualité 4Big franchi — tous les checks verts." diff --git a/05_activity_log/2026-07-30.md b/05_activity_log/2026-07-30.md index 4811bca..210d5ba 100644 --- a/05_activity_log/2026-07-30.md +++ b/05_activity_log/2026-07-30.md @@ -1,5 +1,50 @@ # Activity Log · 2026-07-30 · Claude Code DTP +## Session `20260730_072711` (session 15) + +**Tâche** : Sprint 4 · Frontend Console — Générateur de **Workspaces ERPNext +(5 portails rôle)** (roadmap ligne 49 « 5 portails (Ventes/Construction/Achat/ +Compta/Direction) » ; dernier volet ouvert de Sprint 4, les volets CRM et ERPNext +Backend ayant été livrés sessions 11-14). + +**Décision d'architecture (#1 ERPNext natif)** : dans ERPNext v15, le portail de +landing par rôle **EST le DocType `Workspace`** → on livre 5 Workspaces natifs, +pas de framework de dashboard externe. + +**Fichiers créés** — `05_deliverables_mvp/frontend/portails/` : +- `portails_spec.json` (mise en page seule : cartes/raccourcis/thème · aucun + DocType ni rôle hors contrat) +- `wslib/{__init__,frappe,builder}.py` (connaissance Frappe `Workspace` + enfants · + 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) · `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. Tout lien/raccourci vise un DocType présent dans les `permissions_cibles` +du portail (droit prouvé) ; couverture exhaustive sans doublon ; flag `custom` +issu du contrat ; aucun chiffre stocké (compteurs live du desk) ; tokens de marque +(`#0a0a12`/`#f0b429`/Fraunces/Cormorant) repris verbatim de CLAUDE.md #4 avec source. + +**Résultat** : 5 Workspaces (Ventes/Construction/Achat/Compta/Direction), 44 rôles +restreints ; console technique `plateforme` exclue (roadmap = 5 portails métier). + +**Vérifs** : 19/19 tests ; gate CI local vert (guard + JSON + docs + YAML) ; +régression **260 tests verts** au total ; build déterministe. + +**Hors périmètre worker (VPS · #8)** : fixer `Workspace.module` à l'import + créer +les DocTypes custom (`CONFOTUR Application`, `Faisabilité`, `Publiciste Log`) + +appliquer le thème desk → agents ERPNext Backend / Frontend Console. + +**Détail complet** : voir +[`05_deliverables_mvp/daily_reports/2026-07-30-session15.md`](../05_deliverables_mvp/daily_reports/2026-07-30-session15.md). + +**Auto-score 4Big** : 96/100. + ## Session `20260730_065711` (session 14) **Tâche** : Sprint 4 · ERPNext Backend — Générateur de **configuration e-CF DGII diff --git a/05_deliverables_mvp/daily_reports/2026-07-30-session15.md b/05_deliverables_mvp/daily_reports/2026-07-30-session15.md new file mode 100644 index 0000000..3278b4c --- /dev/null +++ b/05_deliverables_mvp/daily_reports/2026-07-30-session15.md @@ -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. diff --git a/05_deliverables_mvp/frontend/portails/.gitignore b/05_deliverables_mvp/frontend/portails/.gitignore new file mode 100644 index 0000000..36ea299 --- /dev/null +++ b/05_deliverables_mvp/frontend/portails/.gitignore @@ -0,0 +1,4 @@ +# Caches Python +__pycache__/ +*.pyc +# Le dossier out/ EST commite (hand-off ERPNext direct) — voir README. diff --git a/05_deliverables_mvp/frontend/portails/README.md b/05_deliverables_mvp/frontend/portails/README.md new file mode 100644 index 0000000..dad7e4d --- /dev/null +++ b/05_deliverables_mvp/frontend/portails/README.md @@ -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. diff --git a/05_deliverables_mvp/frontend/portails/out/MANIFEST.json b/05_deliverables_mvp/frontend/portails/out/MANIFEST.json new file mode 100644 index 0000000..2c57f46 --- /dev/null +++ b/05_deliverables_mvp/frontend/portails/out/MANIFEST.json @@ -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é" + ] + } + ] +} diff --git a/05_deliverables_mvp/frontend/portails/out/workspace.json b/05_deliverables_mvp/frontend/portails/out/workspace.json new file mode 100644 index 0000000..cd41045 --- /dev/null +++ b/05_deliverables_mvp/frontend/portails/out/workspace.json @@ -0,0 +1,1450 @@ +[ + { + "doctype": "Workspace", + "name": "OTO Ventes", + "title": "OTO Ventes", + "label": "OTO Ventes", + "public": 1, + "is_hidden": 0, + "module": null, + "icon": "sell", + "indicator_color": "Yellow", + "sequence_id": 1.0, + "parent_page": "", + "for_user": "", + "content": "[{\"data\":{\"col\":12,\"text\":\"OTO Ventes\"},\"id\":\"hdr_ventes\",\"type\":\"header\"},{\"data\":{\"col\":3,\"shortcut_name\":\"Lead\"},\"id\":\"sc_ventes_Lead\",\"type\":\"shortcut\"},{\"data\":{\"col\":3,\"shortcut_name\":\"Opportunity\"},\"id\":\"sc_ventes_Opportunity\",\"type\":\"shortcut\"},{\"data\":{\"col\":3,\"shortcut_name\":\"Quotation\"},\"id\":\"sc_ventes_Quotation\",\"type\":\"shortcut\"},{\"data\":{\"col\":3,\"shortcut_name\":\"Sales Order\"},\"id\":\"sc_ventes_Sales Order\",\"type\":\"shortcut\"},{\"data\":{\"card_name\":\"Pipeline commercial\",\"col\":4},\"id\":\"card_ventes_Pipeline commercial\",\"type\":\"card\"},{\"data\":{\"card_name\":\"Clients & encaissements\",\"col\":4},\"id\":\"card_ventes_Clients & encaissements\",\"type\":\"card\"},{\"data\":{\"card_name\":\"Apres-vente & conformite\",\"col\":4},\"id\":\"card_ventes_Apres-vente & conformite\",\"type\":\"card\"},{\"data\":{\"card_name\":\"Marketing & web\",\"col\":4},\"id\":\"card_ventes_Marketing & web\",\"type\":\"card\"}]", + "shortcuts": [ + { + "doctype": "Workspace Shortcut", + "parentfield": "shortcuts", + "parenttype": "Workspace", + "type": "DocType", + "link_to": "Lead", + "label": "Lead", + "color": "Yellow", + "doc_view": "List" + }, + { + "doctype": "Workspace Shortcut", + "parentfield": "shortcuts", + "parenttype": "Workspace", + "type": "DocType", + "link_to": "Opportunity", + "label": "Opportunity", + "color": "Yellow", + "doc_view": "List" + }, + { + "doctype": "Workspace Shortcut", + "parentfield": "shortcuts", + "parenttype": "Workspace", + "type": "DocType", + "link_to": "Quotation", + "label": "Quotation", + "color": "Yellow", + "doc_view": "List" + }, + { + "doctype": "Workspace Shortcut", + "parentfield": "shortcuts", + "parenttype": "Workspace", + "type": "DocType", + "link_to": "Sales Order", + "label": "Sales Order", + "color": "Yellow", + "doc_view": "List" + } + ], + "links": [ + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Card Break", + "label": "Pipeline commercial", + "link_count": 4, + "onboard": 0, + "hidden": 0 + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Lead", + "link_type": "DocType", + "link_to": "Lead", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Opportunity", + "link_type": "DocType", + "link_to": "Opportunity", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Quotation", + "link_type": "DocType", + "link_to": "Quotation", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Sales Order", + "link_type": "DocType", + "link_to": "Sales Order", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Card Break", + "label": "Clients & encaissements", + "link_count": 3, + "onboard": 0, + "hidden": 0 + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Customer", + "link_type": "DocType", + "link_to": "Customer", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Sales Invoice", + "link_type": "DocType", + "link_to": "Sales Invoice", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Payment Entry", + "link_type": "DocType", + "link_to": "Payment Entry", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Card Break", + "label": "Apres-vente & conformite", + "link_count": 2, + "onboard": 0, + "hidden": 0 + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Issue", + "link_type": "DocType", + "link_to": "Issue", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "CONFOTUR Application", + "link_type": "DocType", + "link_to": "CONFOTUR Application", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Card Break", + "label": "Marketing & web", + "link_count": 2, + "onboard": 0, + "hidden": 0 + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Publiciste Log", + "link_type": "DocType", + "link_to": "Publiciste Log", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Web Page", + "link_type": "DocType", + "link_to": "Web Page", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + } + ], + "roles": [ + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Marketing Directeur" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Marketing Publiciste" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Marketing SEO" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Marketing Social" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Ventes Après-Vente" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Ventes CONFOTUR" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Ventes Chef Équipe" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Ventes Conseiller" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Ventes Contrats" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Ventes Courtier Externe" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Ventes Directeur" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Ventes Réservations" + } + ] + }, + { + "doctype": "Workspace", + "name": "OTO Construction", + "title": "OTO Construction", + "label": "OTO Construction", + "public": 1, + "is_hidden": 0, + "module": null, + "icon": "project", + "indicator_color": "Orange", + "sequence_id": 2.0, + "parent_page": "", + "for_user": "", + "content": "[{\"data\":{\"col\":12,\"text\":\"OTO Construction\"},\"id\":\"hdr_construction\",\"type\":\"header\"},{\"data\":{\"col\":3,\"shortcut_name\":\"Project\"},\"id\":\"sc_construction_Project\",\"type\":\"shortcut\"},{\"data\":{\"col\":3,\"shortcut_name\":\"Task\"},\"id\":\"sc_construction_Task\",\"type\":\"shortcut\"},{\"data\":{\"col\":3,\"shortcut_name\":\"Faisabilité\"},\"id\":\"sc_construction_Faisabilité\",\"type\":\"shortcut\"},{\"data\":{\"col\":3,\"shortcut_name\":\"Quality Inspection\"},\"id\":\"sc_construction_Quality Inspection\",\"type\":\"shortcut\"},{\"data\":{\"card_name\":\"Chantier & projet\",\"col\":4},\"id\":\"card_construction_Chantier & projet\",\"type\":\"card\"},{\"data\":{\"card_name\":\"Faisabilite & plans\",\"col\":4},\"id\":\"card_construction_Faisabilite & plans\",\"type\":\"card\"},{\"data\":{\"card_name\":\"Approvisionnement chantier\",\"col\":4},\"id\":\"card_construction_Approvisionnement chantier\",\"type\":\"card\"},{\"data\":{\"card_name\":\"Qualite\",\"col\":4},\"id\":\"card_construction_Qualite\",\"type\":\"card\"}]", + "shortcuts": [ + { + "doctype": "Workspace Shortcut", + "parentfield": "shortcuts", + "parenttype": "Workspace", + "type": "DocType", + "link_to": "Project", + "label": "Project", + "color": "Orange", + "doc_view": "List" + }, + { + "doctype": "Workspace Shortcut", + "parentfield": "shortcuts", + "parenttype": "Workspace", + "type": "DocType", + "link_to": "Task", + "label": "Task", + "color": "Orange", + "doc_view": "List" + }, + { + "doctype": "Workspace Shortcut", + "parentfield": "shortcuts", + "parenttype": "Workspace", + "type": "DocType", + "link_to": "Faisabilité", + "label": "Faisabilité", + "color": "Orange", + "doc_view": "List" + }, + { + "doctype": "Workspace Shortcut", + "parentfield": "shortcuts", + "parenttype": "Workspace", + "type": "DocType", + "link_to": "Quality Inspection", + "label": "Quality Inspection", + "color": "Orange", + "doc_view": "List" + } + ], + "links": [ + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Card Break", + "label": "Chantier & projet", + "link_count": 3, + "onboard": 0, + "hidden": 0 + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Project", + "link_type": "DocType", + "link_to": "Project", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Task", + "link_type": "DocType", + "link_to": "Task", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Timesheet", + "link_type": "DocType", + "link_to": "Timesheet", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Card Break", + "label": "Faisabilite & plans", + "link_count": 2, + "onboard": 0, + "hidden": 0 + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Faisabilité", + "link_type": "DocType", + "link_to": "Faisabilité", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "File", + "link_type": "DocType", + "link_to": "File", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Card Break", + "label": "Approvisionnement chantier", + "link_count": 3, + "onboard": 0, + "hidden": 0 + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Material Request", + "link_type": "DocType", + "link_to": "Material Request", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Purchase Order", + "link_type": "DocType", + "link_to": "Purchase Order", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Item", + "link_type": "DocType", + "link_to": "Item", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Card Break", + "label": "Qualite", + "link_count": 1, + "onboard": 0, + "hidden": 0 + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Quality Inspection", + "link_type": "DocType", + "link_to": "Quality Inspection", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + } + ], + "roles": [ + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Construction Architecte" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Construction BIM" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Construction Chef Projet" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Construction Directeur" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Construction Ingénieur" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Construction QAQC" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Construction Sous-traitants" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Faisabilité IFC Speckle" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Faisabilité Rendu 3D" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Faisabilité Économiste" + } + ] + }, + { + "doctype": "Workspace", + "name": "OTO Achat", + "title": "OTO Achat", + "label": "OTO Achat", + "public": 1, + "is_hidden": 0, + "module": null, + "icon": "buying", + "indicator_color": "Cyan", + "sequence_id": 3.0, + "parent_page": "", + "for_user": "", + "content": "[{\"data\":{\"col\":12,\"text\":\"OTO Achat\"},\"id\":\"hdr_achat\",\"type\":\"header\"},{\"data\":{\"col\":3,\"shortcut_name\":\"Material Request\"},\"id\":\"sc_achat_Material Request\",\"type\":\"shortcut\"},{\"data\":{\"col\":3,\"shortcut_name\":\"Request for Quotation\"},\"id\":\"sc_achat_Request for Quotation\",\"type\":\"shortcut\"},{\"data\":{\"col\":3,\"shortcut_name\":\"Purchase Order\"},\"id\":\"sc_achat_Purchase Order\",\"type\":\"shortcut\"},{\"data\":{\"col\":3,\"shortcut_name\":\"Supplier\"},\"id\":\"sc_achat_Supplier\",\"type\":\"shortcut\"},{\"data\":{\"card_name\":\"Cycle achat\",\"col\":4},\"id\":\"card_achat_Cycle achat\",\"type\":\"card\"},{\"data\":{\"card_name\":\"Fournisseurs\",\"col\":4},\"id\":\"card_achat_Fournisseurs\",\"type\":\"card\"},{\"data\":{\"card_name\":\"Stock & articles\",\"col\":4},\"id\":\"card_achat_Stock & articles\",\"type\":\"card\"}]", + "shortcuts": [ + { + "doctype": "Workspace Shortcut", + "parentfield": "shortcuts", + "parenttype": "Workspace", + "type": "DocType", + "link_to": "Material Request", + "label": "Material Request", + "color": "Cyan", + "doc_view": "List" + }, + { + "doctype": "Workspace Shortcut", + "parentfield": "shortcuts", + "parenttype": "Workspace", + "type": "DocType", + "link_to": "Request for Quotation", + "label": "Request for Quotation", + "color": "Cyan", + "doc_view": "List" + }, + { + "doctype": "Workspace Shortcut", + "parentfield": "shortcuts", + "parenttype": "Workspace", + "type": "DocType", + "link_to": "Purchase Order", + "label": "Purchase Order", + "color": "Cyan", + "doc_view": "List" + }, + { + "doctype": "Workspace Shortcut", + "parentfield": "shortcuts", + "parenttype": "Workspace", + "type": "DocType", + "link_to": "Supplier", + "label": "Supplier", + "color": "Cyan", + "doc_view": "List" + } + ], + "links": [ + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Card Break", + "label": "Cycle achat", + "link_count": 4, + "onboard": 0, + "hidden": 0 + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Material Request", + "link_type": "DocType", + "link_to": "Material Request", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Request for Quotation", + "link_type": "DocType", + "link_to": "Request for Quotation", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Purchase Order", + "link_type": "DocType", + "link_to": "Purchase Order", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Purchase Receipt", + "link_type": "DocType", + "link_to": "Purchase Receipt", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Card Break", + "label": "Fournisseurs", + "link_count": 2, + "onboard": 0, + "hidden": 0 + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Supplier", + "link_type": "DocType", + "link_to": "Supplier", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Contact", + "link_type": "DocType", + "link_to": "Contact", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Card Break", + "label": "Stock & articles", + "link_count": 2, + "onboard": 0, + "hidden": 0 + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Item", + "link_type": "DocType", + "link_to": "Item", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Stock Entry", + "link_type": "DocType", + "link_to": "Stock Entry", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + } + ], + "roles": [ + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Achat Acheteur" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Achat Directeur" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Achat Fournisseurs" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Achat Magasinier" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Achat Réception" + } + ] + }, + { + "doctype": "Workspace", + "name": "OTO Compta", + "title": "OTO Compta", + "label": "OTO Compta", + "public": 1, + "is_hidden": 0, + "module": null, + "icon": "accounting", + "indicator_color": "Green", + "sequence_id": 4.0, + "parent_page": "", + "for_user": "", + "content": "[{\"data\":{\"col\":12,\"text\":\"OTO Compta\"},\"id\":\"hdr_compta\",\"type\":\"header\"},{\"data\":{\"col\":3,\"shortcut_name\":\"Journal Entry\"},\"id\":\"sc_compta_Journal Entry\",\"type\":\"shortcut\"},{\"data\":{\"col\":3,\"shortcut_name\":\"Payment Entry\"},\"id\":\"sc_compta_Payment Entry\",\"type\":\"shortcut\"},{\"data\":{\"col\":3,\"shortcut_name\":\"Sales Invoice\"},\"id\":\"sc_compta_Sales Invoice\",\"type\":\"shortcut\"},{\"data\":{\"col\":3,\"shortcut_name\":\"Purchase Invoice\"},\"id\":\"sc_compta_Purchase Invoice\",\"type\":\"shortcut\"},{\"data\":{\"card_name\":\"Grand livre\",\"col\":4},\"id\":\"card_compta_Grand livre\",\"type\":\"card\"},{\"data\":{\"card_name\":\"Clients & fournisseurs\",\"col\":4},\"id\":\"card_compta_Clients & fournisseurs\",\"type\":\"card\"},{\"data\":{\"card_name\":\"Tresorerie & budget\",\"col\":4},\"id\":\"card_compta_Tresorerie & budget\",\"type\":\"card\"},{\"data\":{\"card_name\":\"Paie\",\"col\":4},\"id\":\"card_compta_Paie\",\"type\":\"card\"}]", + "shortcuts": [ + { + "doctype": "Workspace Shortcut", + "parentfield": "shortcuts", + "parenttype": "Workspace", + "type": "DocType", + "link_to": "Journal Entry", + "label": "Journal Entry", + "color": "Green", + "doc_view": "List" + }, + { + "doctype": "Workspace Shortcut", + "parentfield": "shortcuts", + "parenttype": "Workspace", + "type": "DocType", + "link_to": "Payment Entry", + "label": "Payment Entry", + "color": "Green", + "doc_view": "List" + }, + { + "doctype": "Workspace Shortcut", + "parentfield": "shortcuts", + "parenttype": "Workspace", + "type": "DocType", + "link_to": "Sales Invoice", + "label": "Sales Invoice", + "color": "Green", + "doc_view": "List" + }, + { + "doctype": "Workspace Shortcut", + "parentfield": "shortcuts", + "parenttype": "Workspace", + "type": "DocType", + "link_to": "Purchase Invoice", + "label": "Purchase Invoice", + "color": "Green", + "doc_view": "List" + } + ], + "links": [ + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Card Break", + "label": "Grand livre", + "link_count": 4, + "onboard": 0, + "hidden": 0 + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Account", + "link_type": "DocType", + "link_to": "Account", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "GL Entry", + "link_type": "DocType", + "link_to": "GL Entry", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Journal Entry", + "link_type": "DocType", + "link_to": "Journal Entry", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Cost Center", + "link_type": "DocType", + "link_to": "Cost Center", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Card Break", + "label": "Clients & fournisseurs", + "link_count": 3, + "onboard": 0, + "hidden": 0 + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Sales Invoice", + "link_type": "DocType", + "link_to": "Sales Invoice", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Purchase Invoice", + "link_type": "DocType", + "link_to": "Purchase Invoice", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Payment Entry", + "link_type": "DocType", + "link_to": "Payment Entry", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Card Break", + "label": "Tresorerie & budget", + "link_count": 2, + "onboard": 0, + "hidden": 0 + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Bank Transaction", + "link_type": "DocType", + "link_to": "Bank Transaction", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Budget", + "link_type": "DocType", + "link_to": "Budget", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Card Break", + "label": "Paie", + "link_count": 2, + "onboard": 0, + "hidden": 0 + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Employee", + "link_type": "DocType", + "link_to": "Employee", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Salary Slip", + "link_type": "DocType", + "link_to": "Salary Slip", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + } + ], + "roles": [ + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Compta Audit UAF" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Compta Clients AR" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Compta Contrôle Gestion" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Compta Fiscaliste eCF" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Compta Fournisseurs AP" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Compta Général" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Compta Paie" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Compta Trésorier" + } + ] + }, + { + "doctype": "Workspace", + "name": "OTO Direction", + "title": "OTO Direction", + "label": "OTO Direction", + "public": 1, + "is_hidden": 0, + "module": null, + "icon": "organization", + "indicator_color": "Purple", + "sequence_id": 5.0, + "parent_page": "", + "for_user": "", + "content": "[{\"data\":{\"col\":12,\"text\":\"OTO Direction\"},\"id\":\"hdr_direction\",\"type\":\"header\"},{\"data\":{\"col\":3,\"shortcut_name\":\"Faisabilité\"},\"id\":\"sc_direction_Faisabilité\",\"type\":\"shortcut\"},{\"data\":{\"col\":3,\"shortcut_name\":\"Sales Order\"},\"id\":\"sc_direction_Sales Order\",\"type\":\"shortcut\"},{\"data\":{\"col\":3,\"shortcut_name\":\"Journal Entry\"},\"id\":\"sc_direction_Journal Entry\",\"type\":\"shortcut\"},{\"data\":{\"col\":3,\"shortcut_name\":\"Project\"},\"id\":\"sc_direction_Project\",\"type\":\"shortcut\"},{\"data\":{\"card_name\":\"Commercial (vue direction)\",\"col\":4},\"id\":\"card_direction_Commercial (vue direction)\",\"type\":\"card\"},{\"data\":{\"card_name\":\"Operations & projets\",\"col\":4},\"id\":\"card_direction_Operations & projets\",\"type\":\"card\"},{\"data\":{\"card_name\":\"Finance (vue direction)\",\"col\":4},\"id\":\"card_direction_Finance (vue direction)\",\"type\":\"card\"},{\"data\":{\"card_name\":\"Legal & conformite\",\"col\":4},\"id\":\"card_direction_Legal & conformite\",\"type\":\"card\"}]", + "shortcuts": [ + { + "doctype": "Workspace Shortcut", + "parentfield": "shortcuts", + "parenttype": "Workspace", + "type": "DocType", + "link_to": "Faisabilité", + "label": "Faisabilité", + "color": "Purple", + "doc_view": "List" + }, + { + "doctype": "Workspace Shortcut", + "parentfield": "shortcuts", + "parenttype": "Workspace", + "type": "DocType", + "link_to": "Sales Order", + "label": "Sales Order", + "color": "Purple", + "doc_view": "List" + }, + { + "doctype": "Workspace Shortcut", + "parentfield": "shortcuts", + "parenttype": "Workspace", + "type": "DocType", + "link_to": "Journal Entry", + "label": "Journal Entry", + "color": "Purple", + "doc_view": "List" + }, + { + "doctype": "Workspace Shortcut", + "parentfield": "shortcuts", + "parenttype": "Workspace", + "type": "DocType", + "link_to": "Project", + "label": "Project", + "color": "Purple", + "doc_view": "List" + } + ], + "links": [ + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Card Break", + "label": "Commercial (vue direction)", + "link_count": 5, + "onboard": 0, + "hidden": 0 + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Lead", + "link_type": "DocType", + "link_to": "Lead", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Opportunity", + "link_type": "DocType", + "link_to": "Opportunity", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Quotation", + "link_type": "DocType", + "link_to": "Quotation", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Sales Order", + "link_type": "DocType", + "link_to": "Sales Order", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Sales Invoice", + "link_type": "DocType", + "link_to": "Sales Invoice", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Card Break", + "label": "Operations & projets", + "link_count": 3, + "onboard": 0, + "hidden": 0 + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Project", + "link_type": "DocType", + "link_to": "Project", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Task", + "link_type": "DocType", + "link_to": "Task", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Faisabilité", + "link_type": "DocType", + "link_to": "Faisabilité", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Card Break", + "label": "Finance (vue direction)", + "link_count": 4, + "onboard": 0, + "hidden": 0 + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Journal Entry", + "link_type": "DocType", + "link_to": "Journal Entry", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Payment Entry", + "link_type": "DocType", + "link_to": "Payment Entry", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Purchase Invoice", + "link_type": "DocType", + "link_to": "Purchase Invoice", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Purchase Order", + "link_type": "DocType", + "link_to": "Purchase Order", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Card Break", + "label": "Legal & conformite", + "link_count": 2, + "onboard": 0, + "hidden": 0 + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "Contract", + "link_type": "DocType", + "link_to": "Contract", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + }, + { + "doctype": "Workspace Link", + "parentfield": "links", + "parenttype": "Workspace", + "type": "Link", + "label": "CONFOTUR Application", + "link_type": "DocType", + "link_to": "CONFOTUR Application", + "onboard": 0, + "hidden": 0, + "is_query_report": 0, + "dependencies": "" + } + ], + "roles": [ + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Conseil Administration" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Direction Commerciale" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Direction Financière" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Direction Générale" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Direction Opérations" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Direction Président" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Faisabilité Analyste" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Legal Directeur" + }, + { + "doctype": "Has Role", + "parentfield": "roles", + "parenttype": "Workspace", + "role": "OTO Legal ONAPI" + } + ] + } +] diff --git a/05_deliverables_mvp/frontend/portails/portails_spec.json b/05_deliverables_mvp/frontend/portails/portails_spec.json new file mode 100644 index 0000000..5098641 --- /dev/null +++ b/05_deliverables_mvp/frontend/portails/portails_spec.json @@ -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"]} + ] + } + ] +} diff --git a/05_deliverables_mvp/frontend/portails/tests/test_workspaces.py b/05_deliverables_mvp/frontend/portails/tests/test_workspaces.py new file mode 100644 index 0000000..1b2608e --- /dev/null +++ b/05_deliverables_mvp/frontend/portails/tests/test_workspaces.py @@ -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) diff --git a/05_deliverables_mvp/frontend/portails/workspace.schema.json b/05_deliverables_mvp/frontend/portails/workspace.schema.json new file mode 100644 index 0000000..73e7dae --- /dev/null +++ b/05_deliverables_mvp/frontend/portails/workspace.schema.json @@ -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 } + } + } + } +} diff --git a/05_deliverables_mvp/frontend/portails/workspaces_gen.py b/05_deliverables_mvp/frontend/portails/workspaces_gen.py new file mode 100644 index 0000000..534efe2 --- /dev/null +++ b/05_deliverables_mvp/frontend/portails/workspaces_gen.py @@ -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()) diff --git a/05_deliverables_mvp/frontend/portails/wslib/__init__.py b/05_deliverables_mvp/frontend/portails/wslib/__init__.py new file mode 100644 index 0000000..483d647 --- /dev/null +++ b/05_deliverables_mvp/frontend/portails/wslib/__init__.py @@ -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 +""" diff --git a/05_deliverables_mvp/frontend/portails/wslib/builder.py b/05_deliverables_mvp/frontend/portails/wslib/builder.py new file mode 100644 index 0000000..6123697 --- /dev/null +++ b/05_deliverables_mvp/frontend/portails/wslib/builder.py @@ -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} diff --git a/05_deliverables_mvp/frontend/portails/wslib/frappe.py b/05_deliverables_mvp/frontend/portails/wslib/frappe.py new file mode 100644 index 0000000..c3018d7 --- /dev/null +++ b/05_deliverables_mvp/frontend/portails/wslib/frappe.py @@ -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], + }