From 35a20247f8a25990d87d3ef05ad0d9e789f39fd2 Mon Sep 17 00:00:00 2001 From: Claude Code DTP Worker Date: Thu, 30 Jul 2026 08:09:41 +0000 Subject: [PATCH] =?UTF-8?q?[DTP-Worker]=20Sprint=205=20=C2=B7=20G=C3=A9n?= =?UTF-8?q?=C3=A9rateur=20DocType=20CONFOTUR=20Application=20(ONAPI/Legal?= =?UTF-8?q?=20=C2=B7=20roadmap=20L55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DocType custom porteur des dossiers d'incitation touristique CONFOTUR (Ley 158-01, RD), référencé par RBAC (3 rôles) et le workflow vente mais jamais généré. Permissions = permissions_cibles RBAC mot pour mot ; is_submittable déduit de l'action submit ; estado/dossier_vente dérivés du workflow ; aucun taux/loi/montant fabriqué (#6). 14 invariants, 44 tests, hand-off dans out/. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/ci.yml | 23 +- 05_activity_log/2026-07-30.md | 53 +++ .../daily_reports/2026-07-30-session16.md | 76 ++++ 05_deliverables_mvp/legal/confotur/.gitignore | 4 + 05_deliverables_mvp/legal/confotur/README.md | 86 +++++ .../legal/confotur/cflib/__init__.py | 14 + .../legal/confotur/cflib/builder.py | 193 ++++++++++ .../legal/confotur/cflib/frappe.py | 151 ++++++++ .../legal/confotur/cflib/rbac_scan.py | 57 +++ .../legal/confotur/confotur.schema.json | 202 +++++++++++ .../confotur/confotur_application_gen.py | 303 ++++++++++++++++ .../legal/confotur/confotur_spec.json | 59 +++ .../legal/confotur/out/MANIFEST.json | 82 +++++ .../out/doctype_confotur_application.json | 172 +++++++++ .../legal/confotur/tests/test_confotur.py | 336 ++++++++++++++++++ 15 files changed, 1810 insertions(+), 1 deletion(-) create mode 100644 05_deliverables_mvp/daily_reports/2026-07-30-session16.md create mode 100644 05_deliverables_mvp/legal/confotur/.gitignore create mode 100644 05_deliverables_mvp/legal/confotur/README.md create mode 100644 05_deliverables_mvp/legal/confotur/cflib/__init__.py create mode 100644 05_deliverables_mvp/legal/confotur/cflib/builder.py create mode 100644 05_deliverables_mvp/legal/confotur/cflib/frappe.py create mode 100644 05_deliverables_mvp/legal/confotur/cflib/rbac_scan.py create mode 100644 05_deliverables_mvp/legal/confotur/confotur.schema.json create mode 100644 05_deliverables_mvp/legal/confotur/confotur_application_gen.py create mode 100644 05_deliverables_mvp/legal/confotur/confotur_spec.json create mode 100644 05_deliverables_mvp/legal/confotur/out/MANIFEST.json create mode 100644 05_deliverables_mvp/legal/confotur/out/doctype_confotur_application.json create mode 100644 05_deliverables_mvp/legal/confotur/tests/test_confotur.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b39f1cc..15a99da 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -278,10 +278,31 @@ jobs: - name: Tests générateur de Workspaces run: python3 -m unittest discover -s tests -v + # -------------------------------------------------------------------------- + # Générateur du DocType `CONFOTUR Application` (Sprint 5 · ONAPI/Legal · roadmap + # L55 « Refactor oto_module_confotur_application.py → dépôts automatiques »). + # DocType custom porteur des dossiers d'incitation touristique CONFOTUR + # (Ley 158-01, RD). Cross-cohérence DocType ↔ RBAC ↔ workflow : les permissions + # SONT, mot pour mot, les permissions_cibles RBAC (rôles ventes-confotur / + # legal-onapi / legal-directeur) ; le caractère soumissible en découle ; + # `estado`/`dossier_vente` dérivent du workflow_vente ; aucun taux/loi/montant + # CONFOTUR n'est fabriqué (#6). Schéma de sortie + 14 invariants. stdlib pur. + # -------------------------------------------------------------------------- + legal-confotur-tests: + name: Legal · DocType CONFOTUR Application + runs-on: ubuntu-latest + defaults: + run: + working-directory: 05_deliverables_mvp/legal/confotur + steps: + - uses: actions/checkout@v4 + - name: Tests générateur CONFOTUR Application + 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, frontend-portails-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, legal-confotur-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 210d5ba..ad2a0ba 100644 --- a/05_activity_log/2026-07-30.md +++ b/05_activity_log/2026-07-30.md @@ -1,5 +1,58 @@ # Activity Log · 2026-07-30 · Claude Code DTP +## Session `20260730_075712` (session 16) + +**Tâche** : **Sprint 5 · ONAPI/Legal** — Générateur du **DocType porteur +`CONFOTUR Application`** (roadmap **L55** « Refactor `oto_module_confotur_application.py` +→ dépôts automatiques »). Sprint 4 étant clos (sessions 11-15), c'est le premier +volet Sprint 5 réalisable en repo — Mobile (builds/submit stores) et déploiement +dépendent d'API externes / VPS (hors périmètre worker · #8). + +**Gap comblé** : le DocType custom `CONFOTUR Application` est référencé par le +contrat RBAC (3 rôles) et par les états terminaux du workflow vente +(`confotur_depose`/`confotur_approuve`), mais **aucun générateur ne le produisait** +(la session 15 le listait comme DocType custom « à créer » côté VPS). + +**Décision d'architecture (#1 ERPNext natif)** : le porteur d'un dossier CONFOTUR +**EST un DocType Frappe custom soumissible** → on livre le fixture natif, pas de +module externe. + +**Fichiers créés** — `05_deliverables_mvp/legal/confotur/` : +- `confotur_spec.json` (structure métier seule · zéro chiffre) +- `cflib/{__init__,frappe,rbac_scan,builder}.py` (`frappe` VALID_PERMS incluant + `report` · `rbac_scan` lit les rôles RBAC visant le DocType · réutilise le + `RoleResolver` de `workflow_vente`) +- `confotur_application_gen.py` (CLI `build`/`validate` · **14 invariants**) +- `confotur.schema.json` (contrat de sortie draft-07) +- `out/{doctype_confotur_application,MANIFEST}.json` (hand-off) · + `tests/test_confotur.py` (**44 tests** dont 8 négatifs) · `README.md` · `.gitignore` + +**Fichiers modifiés** : +- `.gitea/workflows/ci.yml` : job `legal-confotur-tests` + ajout au `gate`. + +**Anti-invention (cœur · #6)** : les **permissions du DocType SONT, mot pour mot, +les `permissions_cibles` RBAC** (ventes-confotur/legal-onapi/legal-directeur) — ni +ajout ni retrait ; `is_submittable` déduit de l'action `submit` RBAC ; +`estado`/`dossier_vente` dérivés du workflow ; **aucun** taux/loi/montant/référence +d'autorité (deux invariants refusent tout champ de type montant et tout `default`) ; +paramètres légaux réels → `data_room P05/P07` côté VPS. Les `depot_events` (« dépôts +automatiques ») dérivent des transitions confotur du workflow. + +**Résultat** : DocType `CONFOTUR Application` — 18 champs (14 de donnée), 4 sections, +3 rôles, soumissible, 2 évènements de dépôt. + +**Vérifs** : 44/44 tests ; gate CI local vert (guard + JSON + docs + YAML) ; +régression **304 tests verts** au total ; build déterministe. + +**Hors périmètre worker (VPS · #8)** : créer le module `OTOV7 CONFOTUR`, importer +le DocType, câbler les `depot_events` sur le Workflow, renseigner les paramètres +légaux/fiscaux depuis `data_room P05/P07` → agent ONAPI/Legal / ERPNext Backend. + +**Détail complet** : voir +[`05_deliverables_mvp/daily_reports/2026-07-30-session16.md`](../05_deliverables_mvp/daily_reports/2026-07-30-session16.md). + +**Auto-score 4Big** : 96/100. + ## Session `20260730_072711` (session 15) **Tâche** : Sprint 4 · Frontend Console — Générateur de **Workspaces ERPNext diff --git a/05_deliverables_mvp/daily_reports/2026-07-30-session16.md b/05_deliverables_mvp/daily_reports/2026-07-30-session16.md new file mode 100644 index 0000000..b7bd482 --- /dev/null +++ b/05_deliverables_mvp/daily_reports/2026-07-30-session16.md @@ -0,0 +1,76 @@ +# Rapport de session · 2026-07-30 · session 16 + +## Tâche +**Sprint 5 · ONAPI/Legal** — Générateur du **DocType porteur `CONFOTUR Application`** +(roadmap **L55** « Refactor `oto_module_confotur_application.py` → dépôts +automatiques »). + +## Contexte / choix +Sprint 4 clos (sessions 11-15 : workflow vente, Dossier Vente, commissions, e-CF, +5 Workspaces). Prochain jalon = **Sprint 5**. Des trois volets Sprint 5, Mobile +(builds/submit stores) et le déploiement dépendent d'API externes / du VPS → +**hors périmètre worker** (#8). Le volet **ONAPI/Legal** est le seul entièrement +réalisable en repo, et il comble un **gap réel** : le DocType custom +`CONFOTUR Application` est référencé par le contrat RBAC (3 rôles) et par les états +terminaux du workflow vente (`confotur_depose`/`confotur_approuve`), **mais aucun +générateur ne le produisait**. La session 15 le listait justement comme DocType +custom « à créer » côté VPS. + +## Décision d'architecture (#1 ERPNext natif) +Le porteur d'un dossier CONFOTUR **EST un DocType Frappe custom** ; on livre donc +le fixture DocType natif (soumissible), pas un module externe. On refactorise +l'intention de `oto_module_confotur_application.py` en un **générateur déterministe** +aligné sur le pattern des livrables voisins (`crm/dossier_vente`, `fiscal/ecf_dgii`). + +## Fichiers créés — `05_deliverables_mvp/legal/confotur/` +- `confotur_spec.json` — structure métier seule (libellés + types + drapeaux · + **zéro chiffre**) ; nom/permissions/soumissibilité **non** portés par le spec. +- `cflib/frappe.py` — connaissance Frappe DocType (VALID_PERMS incluant `report`, + porté par `legal-directeur`). +- `cflib/rbac_scan.py` — **lecture** (jamais réécriture) des rôles RBAC visant le + DocType `CONFOTUR Application` + leurs actions ; réutilise `RoleResolver` pour + `id → nom Frappe` / `portail`. +- `cflib/builder.py` — assemblage déterministe `{manifest, doctype}`. +- `confotur_application_gen.py` — CLI `build`/`validate` · **14 invariants** de + cross-cohérence DocType↔RBAC↔workflow. +- `confotur.schema.json` — contrat de sortie draft-07. +- `out/{doctype_confotur_application,MANIFEST}.json` — hand-off (commité). +- `tests/test_confotur.py` — **44 tests** (dont 8 négatifs + 3 gardes builder). +- `README.md` · `.gitignore`. + +## Fichiers modifiés +- `.gitea/workflows/ci.yml` : job `legal-confotur-tests` + ajout au `gate`. + +## Anti-invention / anti-dérive (cœur · #6) +- **Nom** du DocType = identité prouvée présente dans RBAC (invariant : ≥1 rôle le + vise + tous `custom`). +- **Permissions** = **mot pour mot** les `permissions_cibles` RBAC des 3 rôles + (`ventes-confotur` read/write/create/print · `legal-onapi` read/write/create · + `legal-directeur` read/write/**submit**/report) — ni ajout ni retrait. +- **`is_submittable`** déduit de l'action `submit` RBAC, recoupé avec le workflow + (états CONFOTUR = `doc_status 1`). +- **`estado`** / **`dossier_vente`** dérivés du `workflow_vente` (jamais en dur) ; + **`entite_porteuse`** = entités CLAUDE.md. +- **Aucun** taux d'incitation / article de loi / montant / référence d'autorité : + deux invariants refusent tout champ de type montant et tout `default` (hors série + de nommage). Paramètres légaux réels → `data_room P05/P07` côté VPS. +- **`depot_events`** (les « dépôts automatiques ») dérivés des transitions confotur + du workflow ; invariant : leur rôle a bien accès au DocType (RBAC). + +## Résultat +DocType `CONFOTUR Application` : 18 champs (14 de donnée) · 4 sections · 3 rôles · +soumissible · 2 évènements de dépôt (Déposer / Approuver). + +## Vérifs +- 44/44 tests ; `validate` (schéma + 14 invariants) vert ; build déterministe. +- Gate CI local vert (guard constraints · JSON · docs · YAML `ci.yml`). +- **Régression : 304 tests verts au total** (279 modules antérieurs + 25 + workflow_vente + les 44 nouveaux se recoupant dans le discover). + +## Hors périmètre worker (VPS · #8) +Créer le module Frappe `OTOV7 CONFOTUR`, importer le DocType (`bench +import-fixtures`), câbler les `depot_events` sur le Workflow `OTO Vente Pipeline`, +et renseigner les paramètres légaux/fiscaux depuis `data_room P05/P07` (avec source) +→ agent ONAPI/Legal / ERPNext Backend. + +## Auto-score 4Big : 96/100 diff --git a/05_deliverables_mvp/legal/confotur/.gitignore b/05_deliverables_mvp/legal/confotur/.gitignore new file mode 100644 index 0000000..36ea299 --- /dev/null +++ b/05_deliverables_mvp/legal/confotur/.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/legal/confotur/README.md b/05_deliverables_mvp/legal/confotur/README.md new file mode 100644 index 0000000..5667fad --- /dev/null +++ b/05_deliverables_mvp/legal/confotur/README.md @@ -0,0 +1,86 @@ +# Générateur du DocType `CONFOTUR Application` · Sprint 5 · ONAPI/Legal + +> Roadmap **L55** — « Refactor `oto_module_confotur_application.py` → dépôts +> automatiques ». Produit le **fixture DocType custom** qui porte les dossiers +> d'incitation touristique **CONFOTUR** (Ley 158-01, République Dominicaine), +> jusqu'ici référencé partout (RBAC, workflow vente) mais jamais généré. + +## Ce que ça produit + +`build` écrit deux fichiers de hand-off dans `out/` : + +| Fichier | Contenu | +|---|---| +| `doctype_confotur_application.json` | Le `DocType` Frappe v15 complet (custom, soumissible), prêt pour `bench import-fixtures`. | +| `MANIFEST.json` | Traçabilité : sources, comptes, rôles RBAC utilisés, **évènements de dépôt** dérivés du workflow, note anti-invention. | + +18 champs (14 de donnée) · 4 sections · 3 rôles · soumissible · 2 évènements de dépôt. + +## Cœur du livrable : cross-cohérence (zéro invention · #6) + +Le DocType n'invente **rien** — chaque facette est **dérivée** d'un contrat déjà livré : + +- **Nom** (`CONFOTUR Application`) = l'identité que le contrat **RBAC** référence + (`rbac_50_roles.json`, `permissions_cibles[].doctype`). Un invariant prouve + qu'au moins un rôle le vise et que tous le marquent `custom`. +- **Permissions** = **mot pour mot** les `permissions_cibles` RBAC des 3 rôles qui + y ont accès — ni ajout ni retrait : + - `ventes-confotur` (portail Ventes) → read/write/create/print + - `legal-onapi` (portail Direction) → read/write/create + - `legal-directeur` (portail Direction) → read/write/**submit**/report +- **`is_submittable`** = **déduit** de la présence de l'action `submit` côté RBAC + (donc 1) ; recoupé avec le workflow (les états CONFOTUR sont `doc_status = 1`). +- **`estado`** (Select) : options = les états `confotur_*` du **workflow vente** + (`crm/workflow_vente/`), dérivées — jamais réécrites en dur. +- **`dossier_vente`** (Link) : cible = `workflow.document_type` (= `OTO Dossier + Vente`), dérivée du workflow. +- **`entite_porteuse`** (Select) : entités CLAUDE.md (WAF · WA SRL · AC · Consortium + ECR DR · Helios RD · Ploutos · 9060 QC). + +Renommer un rôle, retirer une action ou renommer un état côté contrat se propage +ici **sans édition manuelle** ; l'invariant casse sinon. + +## Anti-invention CONFOTUR (#6) + +**Aucun** taux d'incitation, article de loi, montant ou référence d'autorité n'est +encodé. Le DocType est une **structure** ; ses champs substantiels +(`referencia_autoridad`, dates, `estado`) restent **vides**, renseignés côté VPS +par l'agent ONAPI/Legal depuis `data_room/P05` et `data_room/P07`. Deux invariants +refusent tout champ de type montant (`Currency/Float/Int/Percent`) et tout +`default` (hors série de nommage). Les cases `piece_*` sont un **suivi interne non +normatif** — la liste légale des pièces est confirmée hors-repo. + +## Utilisation + +```bash +python3 confotur_application_gen.py validate # schéma + 14 invariants, sans écrire +python3 confotur_application_gen.py build # écrit out/*.json +python3 -m unittest discover -s tests -v # 44 tests (dont 8 négatifs) +``` + +Sortie **déterministe** (tri stable, aucun horodatage) → diffable et re-générable +bit-à-bit à contrat constant. + +## Hand-off VPS (hors périmètre worker · #8) + +1. Créer le module Frappe **`OTOV7 CONFOTUR`** (cité par les 3 rôles RBAC). +2. Importer `doctype_confotur_application.json` (`bench import-fixtures`) **avant** + de câbler les `depot_events` sur le Workflow `OTO Vente Pipeline` + (`crm/workflow_vente/out/`). +3. Renseigner, depuis `data_room P05/P07`, les paramètres légaux/fiscaux réels + (avec source · #6). + +## Arborescence + +``` +legal/confotur/ +├── confotur_spec.json # structure métier (libellés + types · zéro chiffre) +├── cflib/ +│ ├── frappe.py # connaissance Frappe DocType (VALID_PERMS incl. report) +│ ├── rbac_scan.py # lecture des rôles RBAC visant le DocType +│ └── builder.py # assemblage déterministe {manifest, doctype} +├── confotur_application_gen.py # CLI build/validate · 14 invariants +├── confotur.schema.json # contrat de sortie draft-07 +├── out/ # hand-off (commité) +└── tests/test_confotur.py # 44 tests +``` diff --git a/05_deliverables_mvp/legal/confotur/cflib/__init__.py b/05_deliverables_mvp/legal/confotur/cflib/__init__.py new file mode 100644 index 0000000..3860822 --- /dev/null +++ b/05_deliverables_mvp/legal/confotur/cflib/__init__.py @@ -0,0 +1,14 @@ +"""Librairie du generateur DocType `CONFOTUR Application` (Sprint 5 · ONAPI/Legal). + +Trois briques : + - `frappe` : connaissance Frappe/ERPNext v15 (types de champ, DocPerm, + enveloppe DocType) — separee de l'assemblage metier. + - `rbac_scan` : lecture (jamais reecriture) des roles du contrat RBAC qui + referencent le DocType `CONFOTUR Application` -> source unique + du nom, des permissions et du caractere soumissible. + - `builder` : assemblage deterministe du bundle {manifest, doctype}. +""" + +from __future__ import annotations + +__all__ = ["frappe", "rbac_scan", "builder"] diff --git a/05_deliverables_mvp/legal/confotur/cflib/builder.py b/05_deliverables_mvp/legal/confotur/cflib/builder.py new file mode 100644 index 0000000..7a606bb --- /dev/null +++ b/05_deliverables_mvp/legal/confotur/cflib/builder.py @@ -0,0 +1,193 @@ +"""Assemblage du fixture DocType `CONFOTUR Application` — cross-coherent RBAC. + +Entrees : + - `confotur_spec.json` : structure metier (libelles + types de champ). + - `rbac_50_roles.json` : source UNIQUE du nom du DocType, des permissions + et du caractere soumissible (roles referencant `CONFOTUR Application`). + - `workflow_vente_spec.json` : le pipeline vente (source des options `estado`, + du DocType lie `dossier_vente`, et des evenements de depot CONFOTUR). + - `RoleResolver` (rbac) : `role_id -> erpnext_role_name` + `portail`. + +Sortie : un bundle deterministe `{manifest, doctype}` reproductible bit-a-bit. + +Anti-invention / anti-derive (#6, workflow #5) : + - Le NOM du DocType est l'identite de ce generateur ; un invariant PROUVE qu'au + moins un role RBAC le reference et que tous le marquent `custom` (=> custom=1). + - Les PERMISSIONS sont, mot pour mot, les `permissions_cibles` du contrat RBAC — + ni ajout ni retrait. Renommer/retirer un role cote RBAC se propage ici. + - `is_submittable` est DEDUIT de la presence d'une action `submit` cote RBAC. + - Les options de `dossier_vente`/`estado` sont DERIVEES du workflow (jamais en + dur) ; celles de `entite_porteuse` viennent des entites CLAUDE.md du spec. + - AUCUN chiffre d'incitation, article de loi, montant ou reference d'autorite : + ces champs restent vides, renseignes cote VPS par l'agent ONAPI. +""" + +from __future__ import annotations + +from typing import Any + +from . import frappe, rbac_scan + +# Identite du generateur : le DocType custom qu'il produit. Un invariant verifie +# que le contrat RBAC le reference reellement (sinon le livrable n'a pas lieu). +TARGET_DOCTYPE = "CONFOTUR Application" + +# Drapeaux de docfield que le spec metier peut porter (le reste = mise en page). +_SPEC_FLAGS = ("reqd", "read_only", "in_list_view", "in_standard_filter", "hidden", "bold") + + +def confotur_state_values(wf_spec: dict) -> list[str]: + """`update_value` des etats CONFOTUR du workflow, dans l'ordre du pipeline.""" + return [s["update_value"] for s in wf_spec["states"] + if s["update_value"].startswith("confotur")] + + +def _resolve_options(f: dict, spec: dict, wf_spec: dict) -> str | None: + """Resout `options` litterales OU derivees (`options_source`).""" + src = f.get("options_source") + if src is None: + return f.get("options") + if src == "workflow_document_type": + return wf_spec["document_type"] + if src == "entites": + return "\n".join(spec["entites"]) + if src == "workflow_confotur_states": + # Select facultatif -> ligne vide en tete (valeur non imposee a la creation). + return "\n" + "\n".join(confotur_state_values(wf_spec)) + raise ValueError(f"options_source inconnu pour {f['fieldname']!r} : {src!r}") + + +def _spec_docfield(f: dict, spec: dict, wf_spec: dict) -> dict[str, Any]: + flags = {k: f[k] for k in _SPEC_FLAGS if k in f} + return frappe.docfield( + f["fieldname"], + f["fieldtype"], + label=f.get("label"), + options=_resolve_options(f, spec, wf_spec), + default=f.get("default"), + flags=flags, + ) + + +def _depot_events(wf_spec: dict, resolver) -> list[dict[str, Any]]: + """Evenements de depot CONFOTUR derives des transitions du workflow vente. + + Ce sont les points de cablage « depots automatiques » (roadmap L55) : quelle + transition depose / approuve, par quel role. Aucun evenement fabrique — ils + sortent tous du contrat pipeline. + """ + val_by_state = {s["state"]: s["update_value"] for s in wf_spec["states"]} + events: list[dict[str, Any]] = [] + for t in wf_spec["transitions"]: + uv = val_by_state.get(t["next_state"], "") + if not uv.startswith("confotur"): + continue + events.append({ + "action": t["action"], + "next_state": t["next_state"], + "update_value": uv, + "role_id": t["role_id"], + "erpnext_role_name": resolver.erpnext_name(t["role_id"]), + "portail": resolver.portail(t["role_id"]), + }) + events.sort(key=lambda e: (e["update_value"], e["role_id"])) + return events + + +def build_bundle(spec: dict, wf_spec: dict, contract: dict, resolver) -> dict[str, Any]: + """Transforme les contrats en bundle `{manifest, doctype}` deterministe.""" + dt_cfg = spec["doctype"] + + # --- 1. Roles RBAC visant le DocType (source du nom / permissions) --------- + targeting = rbac_scan.roles_targeting(TARGET_DOCTYPE, contract) + if not targeting: + raise ValueError( + f"Aucun role RBAC ne reference {TARGET_DOCTYPE!r} — livrable sans objet." + ) + is_submittable = 1 if any("submit" in r["actions"] for r in targeting) else 0 + + # --- 2. Champs de pilotage (naming series injectee, non re-saisie) --------- + fields: list[dict] = [frappe.naming_series_field(dt_cfg["naming_series"])] + + # --- 3. Champs metier (spec) : une Section Break par groupe ---------------- + linked_doctypes: set[str] = set() + for gi, group in enumerate(spec["field_groups"]): + slug = "sb_" + "".join( + c if (c.isascii() and c.isalnum()) else "_" for c in group["section"].lower() + ).strip("_") + fields.append(frappe.section_break(f"{slug}_{gi}", group["section"])) + for f in group["fields"]: + row = _spec_docfield(f, spec, wf_spec) + fields.append(row) + if row["fieldtype"] == "Link" and row.get("options"): + linked_doctypes.add(row["options"]) + + # --- 4. Permissions = permissions_cibles RBAC (mot pour mot) --------------- + permissions = [ + frappe.permission_row(resolver.erpnext_name(r["role_id"]), set(r["actions"])) + for r in sorted(targeting, key=lambda r: resolver.erpnext_name(r["role_id"])) + ] + + # --- 5. Document DocType --------------------------------------------------- + doctype = frappe.doctype_doc( + name=TARGET_DOCTYPE, + module=dt_cfg["module"], + is_submittable=is_submittable, + title_field=dt_cfg["title_field"], + search_fields=dt_cfg["search_fields"], + track_changes=dt_cfg.get("track_changes", 1), + track_seen=dt_cfg.get("track_seen", 0), + fields=fields, + permissions=permissions, + ) + + # --- 6. Evenements de depot derives du workflow ---------------------------- + depot_events = _depot_events(wf_spec, resolver) + + # --- 7. Manifeste de tracabilite ------------------------------------------- + data_fields = [f for f in fields if f["fieldtype"] not in frappe.LAYOUT_FIELDTYPES] + sections = [f for f in fields if f["fieldtype"] == "Section Break"] + manifest = { + "generated_from": "confotur_spec.json", + "rbac_source": "rbac_50_roles.json", + "workflow_source": "crm/workflow_vente/workflow_vente_spec.json", + "source_version": spec["version"], + "rbac_version": contract["version"], + "workflow_version": wf_spec["version"], + "doctype_name": TARGET_DOCTYPE, + "custom": bool(doctype["custom"]), + "is_submittable": bool(is_submittable), + "counts": { + "fields": len(fields), + "data_fields": len(data_fields), + "sections": len(sections), + "permissions": len(permissions), + "roles": len(targeting), + "depot_events": len(depot_events), + }, + "module_a_confirmer": dt_cfg["module"], + "doctypes_lies_a_confirmer": sorted(linked_doctypes), + "roles_rbac_utilises": [ + { + "role_id": r["role_id"], + "erpnext_role_name": resolver.erpnext_name(r["role_id"]), + "portail": resolver.portail(r["role_id"]), + "permissions": sorted(r["actions"]), + "custom": r["custom"], + } + for r in targeting + ], + "depot_events": depot_events, + "note_anti_invention": ( + "Aucun taux d'incitation, article de loi, montant ni reference " + "d'autorite CONFOTUR n'est encode (#6). Ces valeurs restent vides " + "jusqu'a confirmation de l'agent ONAPI/Legal depuis data_room P05/P07." + ), + "hand_off_vps": ( + "Creer le module Frappe 'OTOV7 CONFOTUR' puis importer ce DocType " + "(bench migrate / import-fixtures) AVANT de cabler les evenements de " + "depot du Workflow 'OTO Vente Pipeline' (crm/workflow_vente/out/)." + ), + } + + return {"manifest": manifest, "doctype": doctype} diff --git a/05_deliverables_mvp/legal/confotur/cflib/frappe.py b/05_deliverables_mvp/legal/confotur/cflib/frappe.py new file mode 100644 index 0000000..a1443a5 --- /dev/null +++ b/05_deliverables_mvp/legal/confotur/cflib/frappe.py @@ -0,0 +1,151 @@ +"""Modele Frappe/ERPNext v15 : structure native du fixture `DocType`. + +Separe la CONNAISSANCE FRAPPE (types de champ legitimes, drapeaux de docfield, +modele DocPerm, enveloppe du document DocType) de l'assemblage metier +(`builder.py`). Contrainte #1 « ERPNext natif = priorite absolue » : on produit +le DocType standard du moteur Frappe, porteur des dossiers d'incitation CONFOTUR. + +Aucun acces VPS : chaque fonction renvoie un dict serialisable que l'agent +ONAPI/Legal importera via `bench` (hand-off · #8). + +Nota vs `crm/dossier_vente/dvlib/frappe.py` : ce module ajoute la permission +native `report` (le role `legal-directeur` la porte cote RBAC). Les deux modules +restent volontairement independants — chaque livrable possede sa connaissance +Frappe, on n'ouvre que ce que le contrat emploie reellement. +""" + +from __future__ import annotations + +from typing import Any + +# Types de champ Frappe employes par ce DocType. Hors de cet ensemble = invention +# -> refusee par `docfield()`. +VALID_FIELDTYPES: frozenset[str] = frozenset( + { + "Section Break", + "Column Break", + "Data", + "Select", + "Link", + "Date", + "Datetime", + "Small Text", + "Text", + "Check", + } +) + +# Types « de mise en page » : ni requis, ni porteurs de donnee. +LAYOUT_FIELDTYPES: frozenset[str] = frozenset({"Section Break", "Column Break"}) + +# Permissions natives du child table `DocPerm` d'un DocType Frappe (v15). +# `report` est inclus (porte par le role legal-directeur cote RBAC). +VALID_PERMS: frozenset[str] = frozenset( + {"read", "write", "create", "submit", "cancel", "amend", "delete", + "print", "email", "report", "import", "export", "share"} +) + +# Ordre de serialisation stable des drapeaux DocPerm (diff propre). +_PERM_ORDER = ("read", "write", "create", "submit", "cancel", "amend", "delete", + "print", "email", "report", "import", "export", "share") + +# Drapeaux de docfield connus (defaut 0) — on ne serialise que ceux a 1. +_DOCFIELD_FLAGS = ("reqd", "read_only", "in_list_view", "in_standard_filter", "hidden", "bold") + + +def docfield( + fieldname: str, + fieldtype: str, + *, + label: str | None = None, + options: str | None = None, + default: str | None = None, + flags: dict[str, int] | None = None, +) -> dict[str, Any]: + """Une ligne de la table enfant `fields` d'un DocType.""" + if fieldtype not in VALID_FIELDTYPES: + raise ValueError(f"Fieldtype non natif Frappe pour {fieldname!r} : {fieldtype!r}") + row: dict[str, Any] = {"fieldname": fieldname, "fieldtype": fieldtype} + if label is not None: + row["label"] = label + if options is not None: + row["options"] = options + if default is not None: + row["default"] = default + flags = flags or {} + for f in _DOCFIELD_FLAGS: + v = int(flags.get(f, 0)) + if v not in (0, 1): + raise ValueError(f"Drapeau {f} de {fieldname!r} non booleen : {v!r}") + if v: + row[f] = 1 + return row + + +def section_break(fieldname: str, label: str) -> dict[str, Any]: + return {"fieldname": fieldname, "fieldtype": "Section Break", "label": label} + + +def permission_row(role: str, perms: set[str], *, permlevel: int = 0) -> dict[str, Any]: + """Ligne `permissions` (DocPerm integre) d'un DocType. + + Emet uniquement les drapeaux a 1 (defaut sur = refus), dans l'ordre stable + `_PERM_ORDER` — diff reproductible. + """ + unknown = perms - VALID_PERMS + if unknown: + raise ValueError(f"Permission(s) non native(s) pour {role!r} : {sorted(unknown)}") + row: dict[str, Any] = {"role": role, "permlevel": permlevel} + for p in _PERM_ORDER: + if p in perms: + row[p] = 1 + return row + + +def naming_series_field(series: str) -> dict[str, Any]: + """Le champ Select `naming_series` requis quand `autoname = "naming_series:"`. + + Frappe nomme le document depuis ce champ ; ses `options` portent le motif de + serie. Rendu `read_only` (serie imposee, pas de saisie libre). + """ + return docfield( + "naming_series", + "Select", + label="Serie de nommage", + options=series, + default=series, + flags={"read_only": 1}, + ) + + +def doctype_doc( + *, + name: str, + module: str, + is_submittable: int, + title_field: str, + search_fields: str, + track_changes: int, + track_seen: int, + fields: list[dict], + permissions: list[dict], +) -> dict[str, Any]: + """Le document `DocType` complet (fixture custom pret pour `bench`).""" + return { + "doctype": "DocType", + "name": name, + "module": module, + "custom": 1, + "is_submittable": 1 if is_submittable else 0, + "naming_rule": "By \"Naming Series\" field", + "autoname": "naming_series:", + "title_field": title_field, + "search_fields": search_fields, + "track_changes": 1 if track_changes else 0, + "track_seen": 1 if track_seen else 0, + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [f["fieldname"] for f in fields], + "fields": fields, + "permissions": permissions, + } diff --git a/05_deliverables_mvp/legal/confotur/cflib/rbac_scan.py b/05_deliverables_mvp/legal/confotur/cflib/rbac_scan.py new file mode 100644 index 0000000..e2f74c7 --- /dev/null +++ b/05_deliverables_mvp/legal/confotur/cflib/rbac_scan.py @@ -0,0 +1,57 @@ +"""Lecture des roles RBAC qui referencent un DocType donne (source unique · #6). + +Le contrat `rbac_50_roles.json` est l'UNIQUE source de verite des roles ERPNext +(valide par `rbac.schema.json` dans le job CI `rbac-tests`). Ici on ne fait que +le LIRE : pour le DocType `CONFOTUR Application`, quels roles y ont acces et avec +quelles actions. Ces `permissions_cibles` deviennent, mot pour mot, les DocPerm +du DocType genere -> les permissions du fixture SONT le contrat RBAC, pas une +reinvention. + +Reutilisation (workflow #5, zero duplication) : la resolution `id -> nom Frappe` +et le `portail` restent delegues au `RoleResolver` du module CRM voisin — on ne +redefinit ni le format des roles ni leur nommage. +""" + +from __future__ import annotations + +import json +import os +from typing import Any + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_DELIVERABLES = os.path.normpath(os.path.join(_HERE, "..", "..", "..")) +RBAC_CONTRACT_PATH = os.path.join(_DELIVERABLES, "rbac", "rbac_50_roles.json") + + +def load_contract(path: str = RBAC_CONTRACT_PATH) -> dict[str, Any]: + with open(path, encoding="utf-8") as fh: + return json.load(fh) + + +def roles_targeting(doctype: str, contract: dict[str, Any]) -> list[dict[str, Any]]: + """Roles dont un `permissions_cibles` vise `doctype`. + + Retour trie par `id` de role (determinisme). Chaque element : + {role_id, actions: [..], custom: bool} + `actions` reprend, sans ajout ni retrait, la liste declaree dans le contrat. + Leve si un meme role declare deux fois le meme doctype (contrat ambigu). + """ + out: list[dict[str, Any]] = [] + for role in contract.get("roles", []): + matches = [pc for pc in role.get("permissions_cibles", []) + if pc.get("doctype") == doctype] + if not matches: + continue + if len(matches) > 1: + raise ValueError( + f"Role {role.get('id')!r} declare {doctype!r} plusieurs fois " + f"dans permissions_cibles (contrat ambigu)." + ) + pc = matches[0] + out.append({ + "role_id": role["id"], + "actions": list(pc.get("actions", [])), + "custom": bool(pc.get("custom", False)), + }) + out.sort(key=lambda r: r["role_id"]) + return out diff --git a/05_deliverables_mvp/legal/confotur/confotur.schema.json b/05_deliverables_mvp/legal/confotur/confotur.schema.json new file mode 100644 index 0000000..6698029 --- /dev/null +++ b/05_deliverables_mvp/legal/confotur/confotur.schema.json @@ -0,0 +1,202 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://oto-enterprise-os-dtp/legal/confotur_application.schema.json", + "title": "Bundle fixture DocType 'CONFOTUR Application' genere depuis confotur_spec.json + rbac_50_roles.json + workflow_vente_spec.json", + "description": "Contrat de sortie du generateur confotur_application_gen.py. Valide par le validateur maison Publiciste (zero pip) dans le job CI legal-confotur-tests.", + "type": "object", + "additionalProperties": false, + "required": ["manifest", "doctype"], + "properties": { + "manifest": { + "type": "object", + "additionalProperties": false, + "required": [ + "generated_from", + "rbac_source", + "workflow_source", + "source_version", + "rbac_version", + "workflow_version", + "doctype_name", + "custom", + "is_submittable", + "counts", + "module_a_confirmer", + "doctypes_lies_a_confirmer", + "roles_rbac_utilises", + "depot_events", + "note_anti_invention", + "hand_off_vps" + ], + "properties": { + "generated_from": { "type": "string", "const": "confotur_spec.json" }, + "rbac_source": { "type": "string", "const": "rbac_50_roles.json" }, + "workflow_source": { "type": "string", "const": "crm/workflow_vente/workflow_vente_spec.json" }, + "source_version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "rbac_version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "workflow_version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "doctype_name": { "type": "string", "const": "CONFOTUR Application" }, + "custom": { "type": "boolean" }, + "is_submittable": { "type": "boolean" }, + "counts": { + "type": "object", + "additionalProperties": false, + "required": ["fields", "data_fields", "sections", "permissions", "roles", "depot_events"], + "properties": { + "fields": { "type": "integer", "minimum": 3 }, + "data_fields": { "type": "integer", "minimum": 2 }, + "sections": { "type": "integer", "minimum": 1 }, + "permissions": { "type": "integer", "minimum": 1 }, + "roles": { "type": "integer", "minimum": 1 }, + "depot_events": { "type": "integer", "minimum": 1 } + } + }, + "module_a_confirmer": { "type": "string", "minLength": 1 }, + "doctypes_lies_a_confirmer": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "roles_rbac_utilises": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["role_id", "erpnext_role_name", "portail", "permissions", "custom"], + "properties": { + "role_id": { "type": "string", "minLength": 1 }, + "erpnext_role_name": { "type": "string", "pattern": "^OTO " }, + "portail": { "type": "string", "minLength": 1 }, + "permissions": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "enum": ["read", "write", "create", "submit", "cancel", "amend", "delete", "print", "email", "report", "import", "export", "share"] } + }, + "custom": { "type": "boolean" } + } + } + }, + "depot_events": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["action", "next_state", "update_value", "role_id", "erpnext_role_name", "portail"], + "properties": { + "action": { "type": "string", "minLength": 1 }, + "next_state": { "type": "string", "minLength": 1 }, + "update_value": { "type": "string", "pattern": "^confotur" }, + "role_id": { "type": "string", "minLength": 1 }, + "erpnext_role_name": { "type": "string", "pattern": "^OTO " }, + "portail": { "type": "string", "minLength": 1 } + } + } + }, + "note_anti_invention": { "type": "string", "minLength": 1 }, + "hand_off_vps": { "type": "string", "minLength": 1 } + } + }, + "doctype": { + "type": "object", + "additionalProperties": false, + "required": [ + "doctype", + "name", + "module", + "custom", + "is_submittable", + "naming_rule", + "autoname", + "title_field", + "search_fields", + "track_changes", + "track_seen", + "editable_grid", + "engine", + "field_order", + "fields", + "permissions" + ], + "properties": { + "doctype": { "type": "string", "const": "DocType" }, + "name": { "type": "string", "const": "CONFOTUR Application" }, + "module": { "type": "string", "minLength": 1 }, + "custom": { "$ref": "#/definitions/flag" }, + "is_submittable": { "$ref": "#/definitions/flag" }, + "naming_rule": { "type": "string", "minLength": 1 }, + "autoname": { "type": "string", "minLength": 1 }, + "title_field": { "type": "string", "minLength": 1 }, + "search_fields": { "type": "string", "minLength": 1 }, + "track_changes": { "$ref": "#/definitions/flag" }, + "track_seen": { "$ref": "#/definitions/flag" }, + "editable_grid": { "$ref": "#/definitions/flag" }, + "engine": { "type": "string", "const": "InnoDB" }, + "field_order": { + "type": "array", + "minItems": 3, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "fields": { + "type": "array", + "minItems": 3, + "items": { "$ref": "#/definitions/docfield" } + }, + "permissions": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/definitions/docperm" } + } + } + } + }, + "definitions": { + "flag": { "type": "integer", "enum": [0, 1] }, + "docfield": { + "type": "object", + "additionalProperties": false, + "required": ["fieldname", "fieldtype"], + "properties": { + "fieldname": { "type": "string", "pattern": "^[a-z0-9_]+$" }, + "fieldtype": { + "type": "string", + "enum": ["Section Break", "Column Break", "Data", "Select", "Link", "Date", "Datetime", "Small Text", "Text", "Check"] + }, + "label": { "type": "string", "minLength": 1 }, + "options": { "type": "string" }, + "default": { "type": "string" }, + "reqd": { "$ref": "#/definitions/flag" }, + "read_only": { "$ref": "#/definitions/flag" }, + "in_list_view": { "$ref": "#/definitions/flag" }, + "in_standard_filter": { "$ref": "#/definitions/flag" }, + "hidden": { "$ref": "#/definitions/flag" }, + "bold": { "$ref": "#/definitions/flag" } + } + }, + "docperm": { + "type": "object", + "additionalProperties": false, + "required": ["role", "permlevel"], + "properties": { + "role": { "type": "string", "pattern": "^OTO " }, + "permlevel": { "type": "integer", "minimum": 0 }, + "read": { "$ref": "#/definitions/flag" }, + "write": { "$ref": "#/definitions/flag" }, + "create": { "$ref": "#/definitions/flag" }, + "submit": { "$ref": "#/definitions/flag" }, + "cancel": { "$ref": "#/definitions/flag" }, + "amend": { "$ref": "#/definitions/flag" }, + "delete": { "$ref": "#/definitions/flag" }, + "print": { "$ref": "#/definitions/flag" }, + "email": { "$ref": "#/definitions/flag" }, + "report": { "$ref": "#/definitions/flag" }, + "import": { "$ref": "#/definitions/flag" }, + "export": { "$ref": "#/definitions/flag" }, + "share": { "$ref": "#/definitions/flag" } + } + } + } +} diff --git a/05_deliverables_mvp/legal/confotur/confotur_application_gen.py b/05_deliverables_mvp/legal/confotur/confotur_application_gen.py new file mode 100644 index 0000000..33e8bd5 --- /dev/null +++ b/05_deliverables_mvp/legal/confotur/confotur_application_gen.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +"""Generateur du DocType porteur `CONFOTUR Application` · Sprint 5 · ONAPI/Legal. + +Roadmap ligne 55 : « Refactor oto_module_confotur_application.py -> depots +automatiques ». Produit le fixture DocType custom qui porte les dossiers +d'incitation touristique CONFOTUR (Ley 158-01, RD), cross-coherent avec les +contrats deja livres : + - le contrat RBAC (`rbac_50_roles.json`) -> nom, permissions, submit ; + - le pipeline vente (`workflow_vente_spec.json`) -> etats `estado`, DocType + lie `dossier_vente`, et evenements de depot (confotur_depose/_approuve). + +Ce worker n'ecrit JAMAIS sur le VPS (#8) : il emet les fichiers de hand-off ; +la creation du module Frappe `OTOV7 CONFOTUR`, l'import du DocType et le cablage +des evenements restent cote agent ONAPI/Legal / ERPNext Backend. + +ANTI-INVENTION (#6) : aucun taux d'incitation, article de loi, montant ni +reference d'autorite n'est fabrique. Le DocType est une STRUCTURE ; ses champs +substantiels restent vides, renseignes cote VPS depuis data_room P05/P07. Un +invariant refuse tout `default` de champ (hors serie de nommage) et tout type de +champ porteur de montant. + +Sous-commandes : + build [-o OUT] -> ecrit doctype_confotur_application.json + MANIFEST.json + validate -> (re)genere en memoire, valide schema + 14 invariants de + cross-coherence DocType<->RBAC<->workflow ; sort en erreur + sinon. + +Sortie deterministe (tri stable, aucun horodatage) -> diffable + re-generable. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_DELIVERABLES = os.path.normpath(os.path.join(_HERE, "..", "..")) # 05_deliverables_mvp/ +_CRM = os.path.join(_DELIVERABLES, "crm") + +sys.path.insert(0, _HERE) +sys.path.insert(0, _CRM) +sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste")) + +from cflib import builder, frappe, rbac_scan # noqa: E402 +from workflow_vente.wflib.rbac import RoleResolver # noqa: E402 +from lib import validator as maison # type: ignore # noqa: E402 + +_SPEC_PATH = os.path.join(_HERE, "confotur_spec.json") +_WF_SPEC_PATH = os.path.join(_CRM, "workflow_vente", "workflow_vente_spec.json") +_SCHEMA_PATH = os.path.join(_HERE, "confotur.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() -> tuple[dict, dict, dict, dict, RoleResolver]: + spec = _load(_SPEC_PATH) + wf_spec = _load(_WF_SPEC_PATH) + contract = rbac_scan.load_contract() + resolver = RoleResolver.from_path() + bundle = builder.build_bundle(spec, wf_spec, contract, resolver) + return bundle, spec, wf_spec, contract, resolver + + +def _granted_perms(perm_row: dict) -> set[str]: + """Reconstruit l'ensemble d'actions accordees depuis une ligne DocPerm.""" + return {p for p in frappe.VALID_PERMS if perm_row.get(p) == 1} + + +def _validate(bundle: dict, spec: dict, wf_spec: dict, contract: dict, + resolver: RoleResolver) -> list[str]: + """Schema de sortie + 14 invariants de cross-coherence (3 contrats).""" + schema = _load(_SCHEMA_PATH) + errors = list(maison.validate(bundle, schema)) + + dt = bundle["doctype"] + m = bundle["manifest"] + fields = dt["fields"] + by_name = {f["fieldname"]: f for f in fields} + + targeting = rbac_scan.roles_targeting(builder.TARGET_DOCTYPE, contract) + tgt_by_name = {resolver.erpnext_name(r["role_id"]): r for r in targeting} + confotur_vals = builder.confotur_state_values(wf_spec) + docstatus_by_val = {s["update_value"]: s["doc_status"] for s in wf_spec["states"]} + + # 1 · nom du DocType coherent partout (identite du generateur). + if dt["name"] != builder.TARGET_DOCTYPE or m["doctype_name"] != builder.TARGET_DOCTYPE: + errors.append(f"nom du DocType != {builder.TARGET_DOCTYPE!r}") + + # 2 · permissions == permissions_cibles RBAC, mot pour mot + exhaustif. + perm_roles = [p["role"] for p in dt["permissions"]] + if len(perm_roles) != len(set(perm_roles)): + errors.append("role duplique dans doctype.permissions") + if set(perm_roles) != set(tgt_by_name): + errors.append("ensemble des roles du DocType != roles RBAC visant le DocType") + for row in dt["permissions"]: + r = tgt_by_name.get(row["role"]) + if r is None: + errors.append(f"permission pour role {row['role']!r} absent du contrat RBAC") + continue + granted = _granted_perms(row) + if granted != set(r["actions"]): + errors.append( + f"permissions de {row['role']!r} = {sorted(granted)} != RBAC " + f"{sorted(set(r['actions']))}" + ) + if row["permlevel"] != 0: + errors.append(f"permlevel != 0 pour {row['role']!r}") + + # 3 · custom : DocType custom ET tous les roles le declarent custom (#1). + if dt["custom"] != 1 or m["custom"] is not True: + errors.append("doctype.custom doit valoir 1") + if not all(r["custom"] for r in targeting): + errors.append("un role RBAC visant le DocType ne le marque pas `custom`") + + # 4 · is_submittable deduit de l'action `submit` cote RBAC. + expect_sub = 1 if any("submit" in r["actions"] for r in targeting) else 0 + if dt["is_submittable"] != expect_sub or m["is_submittable"] is not bool(expect_sub): + errors.append(f"is_submittable={dt['is_submittable']} != attendu {expect_sub}") + + # 5 · coherence avec le workflow : les etats CONFOTUR sont soumis (doc_status 1) + # -> un DocType soumissible porte un pipeline soumissible. + if not confotur_vals: + errors.append("aucun etat `confotur*` dans le workflow vente") + for uv in confotur_vals: + if docstatus_by_val.get(uv) != "1": + errors.append(f"etat confotur {uv!r} n'est pas soumis (doc_status != 1)") + + # 6 · liens : options des champs Link = DocTypes attendus (dossier_vente derive + # du workflow, jamais en dur) + manifeste `doctypes_lies_a_confirmer` aligne. + if by_name.get("projet", {}).get("options") != "Project": + errors.append("champ `projet` : options != 'Project'") + if by_name.get("cliente", {}).get("options") != "Customer": + errors.append("champ `cliente` : options != 'Customer'") + if by_name.get("dossier_vente", {}).get("options") != wf_spec["document_type"]: + errors.append("champ `dossier_vente` : options != workflow document_type") + linked = sorted({f.get("options") for f in fields + if f["fieldtype"] == "Link" and f.get("options")}) + if m["doctypes_lies_a_confirmer"] != linked: + errors.append("manifest.doctypes_lies_a_confirmer incoherent") + + # 7 · `estado` : options Select derivees des etats CONFOTUR (exhaustif, sans + # extra) avec ligne vide en tete (facultatif a la creation). + est = by_name.get("estado", {}) + est_opts = est.get("options", "").split("\n") + if est_opts[:1] != [""]: + errors.append("`estado` : ligne vide en tete attendue (Select facultatif)") + if [o for o in est_opts if o] != confotur_vals: + errors.append("`estado` : options != etats confotur du workflow") + + # 8 · `entite_porteuse` : options = entites CLAUDE.md du spec (Select requis). + ent = by_name.get("entite_porteuse", {}) + if ent.get("options", "").split("\n") != spec["entites"]: + errors.append("`entite_porteuse` : options != entites du spec") + if ent.get("reqd") != 1: + errors.append("`entite_porteuse` doit etre reqd") + + # 9 · depot_events derives des transitions confotur du workflow. + de = m["depot_events"] + wf_confotur_tr = [t for t in wf_spec["transitions"] + if {s["state"]: s["update_value"] for s in wf_spec["states"]} + .get(t["next_state"], "").startswith("confotur")] + if len(de) != len(wf_confotur_tr): + errors.append("depot_events : compte != transitions confotur du workflow") + for ev in de: + if not ev["update_value"].startswith("confotur"): + errors.append(f"depot_event {ev['action']!r} : update_value non confotur") + if ev["update_value"] not in confotur_vals: + errors.append(f"depot_event {ev['action']!r} : etat absent du workflow") + if ev["role_id"] not in {r["role_id"] for r in targeting}: + errors.append( + f"depot_event {ev['action']!r} : role {ev['role_id']!r} sans acces " + f"au DocType CONFOTUR Application (RBAC)" + ) + if ev["erpnext_role_name"] != resolver.erpnext_name(ev["role_id"]): + errors.append(f"depot_event {ev['action']!r} : erpnext_role_name incoherent") + if ev["portail"] != resolver.portail(ev["role_id"]): + errors.append(f"depot_event {ev['action']!r} : portail incoherent") + + # 10 · ANTI-INVENTION (#6) : aucun champ ne porte de montant ni de `default` + # (hors serie de nommage) -> aucun chiffre OTO smuggle dans la structure. + money_types = {"Currency", "Float", "Int", "Percent"} + for f in fields: + if f["fieldtype"] in money_types: + errors.append(f"champ {f['fieldname']!r} de type montant interdit (#6)") + if "default" in f and f["fieldname"] != "naming_series": + errors.append(f"champ {f['fieldname']!r} porte un `default` (interdit #6)") + + # 11 · serie de nommage native (autoname naming_series + champ read_only). + ns = by_name.get("naming_series", {}) + if dt["autoname"] != "naming_series:": + errors.append("autoname != 'naming_series:'") + if ns.get("options") != spec["doctype"]["naming_series"] or ns.get("read_only") != 1: + errors.append("champ naming_series absent / non read_only / options != serie") + + # 12 · field_order == fieldnames, unicite, title/search fields reels. + names = [f["fieldname"] for f in fields] + if dt["field_order"] != names: + errors.append("field_order != ordre des fields") + if len(names) != len(set(names)): + errors.append("fieldname duplique") + if dt["title_field"] not in by_name: + errors.append(f"title_field {dt['title_field']!r} absent des champs") + for sf in dt["search_fields"].split(","): + if sf and sf not in by_name: + errors.append(f"search_field {sf!r} absent des champs") + + # 13 · tous les champs metier du spec sont presents (aucune perte). + spec_fields = {f["fieldname"] for g in spec["field_groups"] for f in g["fields"]} + missing = spec_fields - set(by_name) + if missing: + errors.append(f"champs du spec absents du DocType : {sorted(missing)}") + + # 14 · comptes du manifeste coherents. + data_fields = [f for f in fields if f["fieldtype"] not in frappe.LAYOUT_FIELDTYPES] + sections = [f for f in fields if f["fieldtype"] == "Section Break"] + checks = { + "fields": len(fields), + "data_fields": len(data_fields), + "sections": len(sections), + "permissions": len(dt["permissions"]), + "roles": len(targeting), + "depot_events": len(de), + } + for k, v in checks.items(): + if m["counts"][k] != v: + errors.append(f"counts.{k} incoherent ({m['counts'][k]} != {v})") + + return errors + + +def cmd_build(args: argparse.Namespace) -> int: + bundle, spec, wf_spec, contract, resolver = _build() + errors = _validate(bundle, spec, wf_spec, contract, resolver) + 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, "doctype_confotur_application.json"), bundle["doctype"]) + _write_json(os.path.join(out, "MANIFEST.json"), bundle["manifest"]) + + m = bundle["manifest"] + print(f"✅ DocType CONFOTUR Application genere dans {out}") + print(f" doctype_confotur_application.json : {m['counts']['fields']} champs " + f"({m['counts']['data_fields']} de donnee) · {m['counts']['sections']} sections · " + f"{m['counts']['permissions']} roles · soumissible={m['is_submittable']} · " + f"{m['counts']['depot_events']} evenements de depot") + print(" ⚠ Module `OTOV7 CONFOTUR` + import DocType + cablage evenements cote " + "agent ONAPI/Legal (VPS · #8). Taux/loi/montants : data_room P05/P07 (#6).") + return 0 + + +def cmd_validate(args: argparse.Namespace) -> int: + bundle, spec, wf_spec, contract, resolver = _build() + errors = _validate(bundle, spec, wf_spec, contract, resolver) + if errors: + _eprint("❌ Validation KO :") + for e in errors: + _eprint(f" - {e}") + return 1 + m = bundle["manifest"] + print(f"✅ Validation OK — DocType {m['doctype_name']!r} : " + f"{m['counts']['permissions']} roles RBAC, {m['counts']['depot_events']} " + f"evenements de depot, schema + 14 invariants verts.") + return 0 + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="Generateur du DocType CONFOTUR Application (ONAPI/Legal).") + sub = p.add_subparsers(dest="cmd", required=True) + + pb = sub.add_parser("build", help="genere doctype_confotur_application.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 + 14 invariants) sans ecrire") + 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/legal/confotur/confotur_spec.json b/05_deliverables_mvp/legal/confotur/confotur_spec.json new file mode 100644 index 0000000..4fe897b --- /dev/null +++ b/05_deliverables_mvp/legal/confotur/confotur_spec.json @@ -0,0 +1,59 @@ +{ + "version": "1.0.0", + "_comment": "Sprint 5 · ONAPI/Legal (roadmap L55 « Refactor oto_module_confotur_application.py -> depots automatiques »). Structure metier du DocType custom porteur `CONFOTUR Application` (incitation touristique Ley 158-01 / CONFOTUR, RD). ZERO chiffre OTO (#6) : ce fichier ne porte que des libelles, types de champ et drapeaux de mise en page. Le NOM du DocType, ses PERMISSIONS et son caractere soumissible sont DERIVES du contrat rbac_50_roles.json (roles referencant le doctype `CONFOTUR Application`) -> jamais reecrits en dur. Les options des champs `entite_porteuse` (Select), `dossier_vente` (Link) et `estado` (Select) sont DERIVEES via `options_source` (entites CLAUDE.md / document_type et etats confotur du workflow_vente) -> anti-derive. Aucun taux d'incitation, article de loi, montant ou reference d'autorite n'est encode ; ces valeurs restent `null` cote VPS (agent ONAPI, depuis data_room P05/P07).", + "doctype": { + "module": "OTOV7 CONFOTUR", + "naming_series": "CONF-.YYYY.-", + "title_field": "projet", + "search_fields": "projet,dossier_vente,referencia_autoridad", + "track_changes": 1, + "track_seen": 1 + }, + "doctype_name_source": "rbac_50_roles.json#roles[].permissions_cibles[].doctype == 'CONFOTUR Application'", + "entites": [ + "WAF", + "WA SRL", + "AC Arias Cuevas", + "Consortium ECR DR", + "Helios RD", + "Ploutos", + "9060 QC" + ], + "entites_source": "CLAUDE.md #Entites (WAF holding · WA SRL · AC Arias Cuevas · Consortium ECR DR paymaster · Helios RD sous WAG · Ploutos · 9060 QC)", + "field_groups": [ + { + "section": "Identification", + "fields": [ + {"fieldname": "projet", "fieldtype": "Link", "label": "Projet", "options": "Project", "reqd": 1, "in_list_view": 1, "in_standard_filter": 1}, + {"fieldname": "dossier_vente", "fieldtype": "Link", "label": "Dossier de vente", "options_source": "workflow_document_type", "in_standard_filter": 1}, + {"fieldname": "cliente", "fieldtype": "Link", "label": "Client", "options": "Customer"}, + {"fieldname": "entite_porteuse", "fieldtype": "Select", "label": "Entite porteuse", "options_source": "entites", "reqd": 1, "in_standard_filter": 1} + ] + }, + { + "section": "Incitation CONFOTUR (Ley 158-01)", + "fields": [ + {"fieldname": "referencia_autoridad", "fieldtype": "Data", "label": "Reference dossier (autorite CONFOTUR)", "read_only": 1, "in_list_view": 1}, + {"fieldname": "fecha_deposito", "fieldtype": "Date", "label": "Date de depot", "read_only": 1}, + {"fieldname": "fecha_aprobacion", "fieldtype": "Date", "label": "Date d'approbation", "read_only": 1} + ] + }, + { + "section": "Pieces du dossier (suivi interne)", + "fields": [ + {"fieldname": "piece_titularidad", "fieldtype": "Check", "label": "Titre de propriete / titularite"}, + {"fieldname": "piece_faisabilite", "fieldtype": "Check", "label": "Faisabilite 4 volets jointe"}, + {"fieldname": "piece_identidad_cliente", "fieldtype": "Check", "label": "Pieces d'identite client"}, + {"fieldname": "piece_descriptivo_proyecto", "fieldtype": "Check", "label": "Descriptif du projet touristique"} + ] + }, + { + "section": "Statut & suivi", + "fields": [ + {"fieldname": "estado", "fieldtype": "Select", "label": "Statut du depot", "options_source": "workflow_confotur_states", "in_list_view": 1, "in_standard_filter": 1}, + {"fieldname": "notas", "fieldtype": "Small Text", "label": "Notes"} + ] + } + ], + "pieces_note": "Les cases `piece_*` sont un suivi interne NON normatif. La liste legale exacte des pieces d'un dossier CONFOTUR (Ley 158-01) est confirmee par l'agent ONAPI/Legal depuis data_room P05/P07 (#6) — aucune exigence legale n'est affirmee ici." +} diff --git a/05_deliverables_mvp/legal/confotur/out/MANIFEST.json b/05_deliverables_mvp/legal/confotur/out/MANIFEST.json new file mode 100644 index 0000000..e0818f5 --- /dev/null +++ b/05_deliverables_mvp/legal/confotur/out/MANIFEST.json @@ -0,0 +1,82 @@ +{ + "generated_from": "confotur_spec.json", + "rbac_source": "rbac_50_roles.json", + "workflow_source": "crm/workflow_vente/workflow_vente_spec.json", + "source_version": "1.0.0", + "rbac_version": "1.0.0", + "workflow_version": "1.0.0", + "doctype_name": "CONFOTUR Application", + "custom": true, + "is_submittable": true, + "counts": { + "fields": 18, + "data_fields": 14, + "sections": 4, + "permissions": 3, + "roles": 3, + "depot_events": 2 + }, + "module_a_confirmer": "OTOV7 CONFOTUR", + "doctypes_lies_a_confirmer": [ + "Customer", + "OTO Dossier Vente", + "Project" + ], + "roles_rbac_utilises": [ + { + "role_id": "legal-directeur", + "erpnext_role_name": "OTO Legal Directeur", + "portail": "direction", + "permissions": [ + "read", + "report", + "submit", + "write" + ], + "custom": true + }, + { + "role_id": "legal-onapi", + "erpnext_role_name": "OTO Legal ONAPI", + "portail": "direction", + "permissions": [ + "create", + "read", + "write" + ], + "custom": true + }, + { + "role_id": "ventes-confotur", + "erpnext_role_name": "OTO Ventes CONFOTUR", + "portail": "ventes", + "permissions": [ + "create", + "print", + "read", + "write" + ], + "custom": true + } + ], + "depot_events": [ + { + "action": "Approuver CONFOTUR", + "next_state": "CONFOTUR approuvé", + "update_value": "confotur_approuve", + "role_id": "ventes-confotur", + "erpnext_role_name": "OTO Ventes CONFOTUR", + "portail": "ventes" + }, + { + "action": "Déposer CONFOTUR", + "next_state": "CONFOTUR déposé", + "update_value": "confotur_depose", + "role_id": "ventes-confotur", + "erpnext_role_name": "OTO Ventes CONFOTUR", + "portail": "ventes" + } + ], + "note_anti_invention": "Aucun taux d'incitation, article de loi, montant ni reference d'autorite CONFOTUR n'est encode (#6). Ces valeurs restent vides jusqu'a confirmation de l'agent ONAPI/Legal depuis data_room P05/P07.", + "hand_off_vps": "Creer le module Frappe 'OTOV7 CONFOTUR' puis importer ce DocType (bench migrate / import-fixtures) AVANT de cabler les evenements de depot du Workflow 'OTO Vente Pipeline' (crm/workflow_vente/out/)." +} diff --git a/05_deliverables_mvp/legal/confotur/out/doctype_confotur_application.json b/05_deliverables_mvp/legal/confotur/out/doctype_confotur_application.json new file mode 100644 index 0000000..be30f70 --- /dev/null +++ b/05_deliverables_mvp/legal/confotur/out/doctype_confotur_application.json @@ -0,0 +1,172 @@ +{ + "doctype": "DocType", + "name": "CONFOTUR Application", + "module": "OTOV7 CONFOTUR", + "custom": 1, + "is_submittable": 1, + "naming_rule": "By \"Naming Series\" field", + "autoname": "naming_series:", + "title_field": "projet", + "search_fields": "projet,dossier_vente,referencia_autoridad", + "track_changes": 1, + "track_seen": 1, + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "naming_series", + "sb_identification_0", + "projet", + "dossier_vente", + "cliente", + "entite_porteuse", + "sb_incitation_confotur__ley_158_01_1", + "referencia_autoridad", + "fecha_deposito", + "fecha_aprobacion", + "sb_pieces_du_dossier__suivi_interne_2", + "piece_titularidad", + "piece_faisabilite", + "piece_identidad_cliente", + "piece_descriptivo_proyecto", + "sb_statut___suivi_3", + "estado", + "notas" + ], + "fields": [ + { + "fieldname": "naming_series", + "fieldtype": "Select", + "label": "Serie de nommage", + "options": "CONF-.YYYY.-", + "default": "CONF-.YYYY.-", + "read_only": 1 + }, + { + "fieldname": "sb_identification_0", + "fieldtype": "Section Break", + "label": "Identification" + }, + { + "fieldname": "projet", + "fieldtype": "Link", + "label": "Projet", + "options": "Project", + "reqd": 1, + "in_list_view": 1, + "in_standard_filter": 1 + }, + { + "fieldname": "dossier_vente", + "fieldtype": "Link", + "label": "Dossier de vente", + "options": "OTO Dossier Vente", + "in_standard_filter": 1 + }, + { + "fieldname": "cliente", + "fieldtype": "Link", + "label": "Client", + "options": "Customer" + }, + { + "fieldname": "entite_porteuse", + "fieldtype": "Select", + "label": "Entite porteuse", + "options": "WAF\nWA SRL\nAC Arias Cuevas\nConsortium ECR DR\nHelios RD\nPloutos\n9060 QC", + "reqd": 1, + "in_standard_filter": 1 + }, + { + "fieldname": "sb_incitation_confotur__ley_158_01_1", + "fieldtype": "Section Break", + "label": "Incitation CONFOTUR (Ley 158-01)" + }, + { + "fieldname": "referencia_autoridad", + "fieldtype": "Data", + "label": "Reference dossier (autorite CONFOTUR)", + "read_only": 1, + "in_list_view": 1 + }, + { + "fieldname": "fecha_deposito", + "fieldtype": "Date", + "label": "Date de depot", + "read_only": 1 + }, + { + "fieldname": "fecha_aprobacion", + "fieldtype": "Date", + "label": "Date d'approbation", + "read_only": 1 + }, + { + "fieldname": "sb_pieces_du_dossier__suivi_interne_2", + "fieldtype": "Section Break", + "label": "Pieces du dossier (suivi interne)" + }, + { + "fieldname": "piece_titularidad", + "fieldtype": "Check", + "label": "Titre de propriete / titularite" + }, + { + "fieldname": "piece_faisabilite", + "fieldtype": "Check", + "label": "Faisabilite 4 volets jointe" + }, + { + "fieldname": "piece_identidad_cliente", + "fieldtype": "Check", + "label": "Pieces d'identite client" + }, + { + "fieldname": "piece_descriptivo_proyecto", + "fieldtype": "Check", + "label": "Descriptif du projet touristique" + }, + { + "fieldname": "sb_statut___suivi_3", + "fieldtype": "Section Break", + "label": "Statut & suivi" + }, + { + "fieldname": "estado", + "fieldtype": "Select", + "label": "Statut du depot", + "options": "\nconfotur_depose\nconfotur_approuve", + "in_list_view": 1, + "in_standard_filter": 1 + }, + { + "fieldname": "notas", + "fieldtype": "Small Text", + "label": "Notes" + } + ], + "permissions": [ + { + "role": "OTO Legal Directeur", + "permlevel": 0, + "read": 1, + "write": 1, + "submit": 1, + "report": 1 + }, + { + "role": "OTO Legal ONAPI", + "permlevel": 0, + "read": 1, + "write": 1, + "create": 1 + }, + { + "role": "OTO Ventes CONFOTUR", + "permlevel": 0, + "read": 1, + "write": 1, + "create": 1, + "print": 1 + } + ] +} diff --git a/05_deliverables_mvp/legal/confotur/tests/test_confotur.py b/05_deliverables_mvp/legal/confotur/tests/test_confotur.py new file mode 100644 index 0000000..e60f3d5 --- /dev/null +++ b/05_deliverables_mvp/legal/confotur/tests/test_confotur.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python3 +"""Tests du generateur du DocType `CONFOTUR Application` (Sprint 5 · ONAPI/Legal). + +Stdlib pur (`unittest`) -> aucune installation pip requise sur le runner Gitea. +La bibliotheque `jsonschema` sert d'*oracle* quand elle est presente, pour se +premunir d'un ecart entre le validateur maison et draft-07. + +Axe central : la CROSS-COHERENCE DocType <-> RBAC <-> workflow. Les permissions +du DocType DOIVENT etre, mot pour mot, les `permissions_cibles` du contrat RBAC ; +le caractere soumissible en decoule ; `estado` et `dossier_vente` derivent du +workflow ; aucun chiffre CONFOTUR n'est fabrique (#6). +""" + +from __future__ import annotations + +import copy +import json +import os +import subprocess +import sys +import unittest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_MODULE = os.path.normpath(os.path.join(_HERE, "..")) +_LEGAL = os.path.normpath(os.path.join(_MODULE, "..")) +_DELIVERABLES = os.path.normpath(os.path.join(_LEGAL, "..")) +_CRM = os.path.join(_DELIVERABLES, "crm") + +sys.path.insert(0, _MODULE) +sys.path.insert(0, _CRM) +sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste")) + +from cflib import builder, frappe, rbac_scan # noqa: E402 +from workflow_vente.wflib.rbac import RoleResolver # noqa: E402 +from lib import validator as maison # type: ignore # noqa: E402 +import confotur_application_gen as gen # noqa: E402 + +try: + import jsonschema # type: ignore + + _HAS_JSONSCHEMA = True +except Exception: # pragma: no cover + _HAS_JSONSCHEMA = False + + +def _load(path: str) -> dict: + with open(path, encoding="utf-8") as fh: + return json.load(fh) + + +class BaseFixture(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.spec = _load(gen._SPEC_PATH) + cls.wf_spec = _load(gen._WF_SPEC_PATH) + cls.schema = _load(gen._SCHEMA_PATH) + cls.contract = rbac_scan.load_contract() + cls.resolver = RoleResolver.from_path() + cls.bundle = builder.build_bundle(cls.spec, cls.wf_spec, cls.contract, cls.resolver) + cls.dt = cls.bundle["doctype"] + cls.manifest = cls.bundle["manifest"] + cls.fields = {f["fieldname"]: f for f in cls.dt["fields"]} + + +class TestBundleValid(BaseFixture): + def test_validate_green(self) -> None: + errors = gen._validate(self.bundle, self.spec, self.wf_spec, self.contract, self.resolver) + self.assertEqual(errors, [], f"invariants: {errors}") + + def test_schema_maison(self) -> None: + self.assertEqual(maison.validate(self.bundle, self.schema), []) + + @unittest.skipUnless(_HAS_JSONSCHEMA, "jsonschema absent") + def test_schema_oracle(self) -> None: + jsonschema.validate(self.bundle, self.schema) # type: ignore + + def test_deterministe(self) -> None: + again = builder.build_bundle(self.spec, self.wf_spec, self.contract, self.resolver) + self.assertEqual( + json.dumps(self.bundle, sort_keys=True, ensure_ascii=False), + json.dumps(again, sort_keys=True, ensure_ascii=False), + ) + + def test_top_level_keys(self) -> None: + self.assertEqual(set(self.bundle), {"manifest", "doctype"}) + + +class TestDocTypeIdentity(BaseFixture): + def test_name_is_target(self) -> None: + self.assertEqual(self.dt["name"], "CONFOTUR Application") + self.assertEqual(self.manifest["doctype_name"], "CONFOTUR Application") + + def test_custom(self) -> None: + self.assertEqual(self.dt["custom"], 1) + self.assertTrue(self.manifest["custom"]) + + def test_native_envelope(self) -> None: + self.assertEqual(self.dt["doctype"], "DocType") + self.assertEqual(self.dt["engine"], "InnoDB") + self.assertEqual(self.dt["autoname"], "naming_series:") + + def test_naming_series_readonly(self) -> None: + ns = self.fields["naming_series"] + self.assertEqual(ns["options"], self.spec["doctype"]["naming_series"]) + self.assertEqual(ns["read_only"], 1) + + def test_field_order_matches_fields(self) -> None: + self.assertEqual(self.dt["field_order"], [f["fieldname"] for f in self.dt["fields"]]) + + def test_unique_fieldnames(self) -> None: + names = [f["fieldname"] for f in self.dt["fields"]] + self.assertEqual(len(names), len(set(names))) + + def test_title_and_search_fields_real(self) -> None: + self.assertIn(self.dt["title_field"], self.fields) + for sf in self.dt["search_fields"].split(","): + self.assertIn(sf, self.fields) + + +class TestPermissionsFromRBAC(BaseFixture): + """Coeur du livrable : les DocPerm SONT les permissions_cibles RBAC.""" + + def setUp(self) -> None: + self.targeting = rbac_scan.roles_targeting("CONFOTUR Application", self.contract) + self.by_name = {self.resolver.erpnext_name(r["role_id"]): r for r in self.targeting} + + def test_three_roles(self) -> None: + self.assertEqual(len(self.targeting), 3) + self.assertEqual( + {r["role_id"] for r in self.targeting}, + {"ventes-confotur", "legal-onapi", "legal-directeur"}, + ) + + def test_exhaustive_and_no_extra(self) -> None: + perm_roles = {p["role"] for p in self.dt["permissions"]} + self.assertEqual(perm_roles, set(self.by_name)) + + def test_actions_word_for_word(self) -> None: + for row in self.dt["permissions"]: + granted = {p for p in frappe.VALID_PERMS if row.get(p) == 1} + self.assertEqual(granted, set(self.by_name[row["role"]]["actions"])) + + def test_permlevel_zero(self) -> None: + for row in self.dt["permissions"]: + self.assertEqual(row["permlevel"], 0) + + def test_role_names_prefixed(self) -> None: + for row in self.dt["permissions"]: + self.assertTrue(row["role"].startswith("OTO ")) + + def test_report_permission_supported(self) -> None: + # legal-directeur porte `report` (specifique a ce module vs dossier_vente). + directeur = next(p for p in self.dt["permissions"] if p["role"] == "OTO Legal Directeur") + self.assertEqual(directeur.get("report"), 1) + + +class TestSubmittable(BaseFixture): + def test_submittable_because_directeur_submits(self) -> None: + self.assertEqual(self.dt["is_submittable"], 1) + self.assertTrue(self.manifest["is_submittable"]) + + def test_confotur_states_are_submitted(self) -> None: + ds = {s["update_value"]: s["doc_status"] for s in self.wf_spec["states"]} + for uv in builder.confotur_state_values(self.wf_spec): + self.assertEqual(ds[uv], "1") + + +class TestDerivedOptions(BaseFixture): + def test_dossier_vente_link_from_workflow(self) -> None: + self.assertEqual(self.fields["dossier_vente"]["options"], self.wf_spec["document_type"]) + + def test_projet_and_client_links(self) -> None: + self.assertEqual(self.fields["projet"]["options"], "Project") + self.assertEqual(self.fields["cliente"]["options"], "Customer") + + def test_entite_options_from_spec(self) -> None: + self.assertEqual( + self.fields["entite_porteuse"]["options"].split("\n"), self.spec["entites"] + ) + self.assertEqual(self.fields["entite_porteuse"]["reqd"], 1) + + def test_estado_options_from_workflow(self) -> None: + opts = self.fields["estado"]["options"].split("\n") + self.assertEqual(opts[0], "") # facultatif a la creation + self.assertEqual([o for o in opts if o], builder.confotur_state_values(self.wf_spec)) + + def test_linked_doctypes_manifest(self) -> None: + linked = sorted({f.get("options") for f in self.dt["fields"] + if f["fieldtype"] == "Link" and f.get("options")}) + self.assertEqual(self.manifest["doctypes_lies_a_confirmer"], linked) + + +class TestDepotEvents(BaseFixture): + def test_two_events_deposer_approuver(self) -> None: + de = self.manifest["depot_events"] + self.assertEqual(len(de), 2) + self.assertEqual({e["update_value"] for e in de}, + {"confotur_depose", "confotur_approuve"}) + + def test_events_roles_have_doctype_access(self) -> None: + role_ids = {r["role_id"] for r in + rbac_scan.roles_targeting("CONFOTUR Application", self.contract)} + for e in self.manifest["depot_events"]: + self.assertIn(e["role_id"], role_ids) + + def test_events_resolved_correctly(self) -> None: + for e in self.manifest["depot_events"]: + self.assertEqual(e["erpnext_role_name"], self.resolver.erpnext_name(e["role_id"])) + self.assertEqual(e["portail"], self.resolver.portail(e["role_id"])) + + +class TestAntiInvention(BaseFixture): + def test_no_money_fieldtype(self) -> None: + money = {"Currency", "Float", "Int", "Percent"} + for f in self.dt["fields"]: + self.assertNotIn(f["fieldtype"], money) + + def test_no_default_except_naming_series(self) -> None: + for f in self.dt["fields"]: + if f["fieldname"] != "naming_series": + self.assertNotIn("default", f) + + def test_note_present(self) -> None: + self.assertIn("data_room", self.manifest["note_anti_invention"]) + self.assertRegex(self.manifest["note_anti_invention"], r"P05/P07") + + +class TestManifestCounts(BaseFixture): + def test_counts(self) -> None: + c = self.manifest["counts"] + fields = self.dt["fields"] + data = [f for f in fields if f["fieldtype"] not in frappe.LAYOUT_FIELDTYPES] + sections = [f for f in fields if f["fieldtype"] == "Section Break"] + self.assertEqual(c["fields"], len(fields)) + self.assertEqual(c["data_fields"], len(data)) + self.assertEqual(c["sections"], len(sections)) + self.assertEqual(c["permissions"], len(self.dt["permissions"])) + self.assertEqual(c["roles"], 3) + self.assertEqual(c["depot_events"], 2) + + +class TestNegative(BaseFixture): + """Le validateur DOIT rejeter toute derive introduite volontairement.""" + + def _val(self, bundle) -> list[str]: + return gen._validate(bundle, self.spec, self.wf_spec, self.contract, self.resolver) + + def test_reject_tampered_permission(self) -> None: + b = copy.deepcopy(self.bundle) + # On ajoute `delete` a un role -> ne correspond plus au contrat RBAC. + b["doctype"]["permissions"][0]["delete"] = 1 + self.assertTrue(self._val(b)) + + def test_reject_extra_role(self) -> None: + b = copy.deepcopy(self.bundle) + b["doctype"]["permissions"].append({"role": "OTO Faux Role", "permlevel": 0, "read": 1}) + self.assertTrue(self._val(b)) + + def test_reject_wrong_submittable(self) -> None: + b = copy.deepcopy(self.bundle) + b["doctype"]["is_submittable"] = 0 + self.assertTrue(self._val(b)) + + def test_reject_hardcoded_dossier_link(self) -> None: + b = copy.deepcopy(self.bundle) + for f in b["doctype"]["fields"]: + if f["fieldname"] == "dossier_vente": + f["options"] = "Some Other DocType" + self.assertTrue(self._val(b)) + + def test_reject_money_field(self) -> None: + b = copy.deepcopy(self.bundle) + b["doctype"]["fields"].append({"fieldname": "montant", "fieldtype": "Currency"}) + b["doctype"]["field_order"].append("montant") + self.assertTrue(self._val(b)) + + def test_reject_smuggled_default(self) -> None: + b = copy.deepcopy(self.bundle) + for f in b["doctype"]["fields"]: + if f["fieldname"] == "estado": + f["default"] = "confotur_approuve" + self.assertTrue(self._val(b)) + + def test_reject_estado_extra_option(self) -> None: + b = copy.deepcopy(self.bundle) + for f in b["doctype"]["fields"]: + if f["fieldname"] == "estado": + f["options"] = f["options"] + "\netat_invente" + self.assertTrue(self._val(b)) + + def test_reject_bad_counts(self) -> None: + b = copy.deepcopy(self.bundle) + b["manifest"]["counts"]["roles"] = 99 + self.assertTrue(self._val(b)) + + +class TestBuilderGuards(BaseFixture): + def test_missing_target_role_raises(self) -> None: + contract = copy.deepcopy(self.contract) + for role in contract["roles"]: + role["permissions_cibles"] = [ + pc for pc in role.get("permissions_cibles", []) + if pc.get("doctype") != "CONFOTUR Application" + ] + with self.assertRaises(ValueError): + builder.build_bundle(self.spec, self.wf_spec, contract, self.resolver) + + def test_duplicate_doctype_in_role_raises(self) -> None: + contract = copy.deepcopy(self.contract) + for role in contract["roles"]: + if role["id"] == "legal-onapi": + role["permissions_cibles"].append( + {"doctype": "CONFOTUR Application", "actions": ["read"]} + ) + with self.assertRaises(ValueError): + rbac_scan.roles_targeting("CONFOTUR Application", contract) + + def test_unknown_options_source_raises(self) -> None: + spec = copy.deepcopy(self.spec) + spec["field_groups"][0]["fields"][0]["options_source"] = "inexistant" + with self.assertRaises(ValueError): + builder.build_bundle(spec, self.wf_spec, self.contract, self.resolver) + + +class TestCLI(unittest.TestCase): + def test_build_and_validate_exit_zero(self) -> None: + for cmd in ("validate", "build"): + r = subprocess.run( + [sys.executable, os.path.join(_MODULE, "confotur_application_gen.py"), cmd], + capture_output=True, text=True, + ) + self.assertEqual(r.returncode, 0, r.stderr) + + +if __name__ == "__main__": + unittest.main(verbosity=2)