[DTP-Worker] Sprint 5 · Générateur DocType CONFOTUR Application (ONAPI/Legal · roadmap L55)
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) <noreply@anthropic.com>
This commit is contained in:
+22
-1
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
# Caches Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
# Le dossier out/ EST commite (hand-off ERPNext direct) — voir README.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"]
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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/)."
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user