[DTP-Worker] Sprint 4 · Générateur DocType porteur OTO Dossier Vente (complète hand-off workflow vente)
DocType custom cible du Workflow OTO Vente Pipeline. Cross-cohérence workflow↔DocType : nom/champ d'état/valeurs de statut/is_submittable/permissions tous dérivés de workflow_vente_spec.json (source unique, anti-dérive). Rôles résolus via rbac_50_roles.json (#6). CLI build|validate · 12 invariants · 31 tests. Job CI crm-dossier-vente-tests ajouté au gate. Régression 177 tests verts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+20
-1
@@ -199,10 +199,29 @@ jobs:
|
|||||||
- name: Tests générateur workflow vente
|
- name: Tests générateur workflow vente
|
||||||
run: python3 -m unittest discover -s tests -v
|
run: python3 -m unittest discover -s tests -v
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Générateur du DocType porteur (Sprint 4 · CRM natif) : doctype_spec.json +
|
||||||
|
# workflow_vente_spec.json → fixture Frappe `DocType` custom `OTO Dossier
|
||||||
|
# Vente`, cible du Workflow `OTO Vente Pipeline`. Cross-cohérence workflow ↔
|
||||||
|
# DocType : champ d'état + valeurs de statut + is_submittable + permissions
|
||||||
|
# DÉDUITS du pipeline ; rôles résolus depuis rbac_50_roles.json (zéro
|
||||||
|
# duplication). Valide le schéma de sortie + 12 invariants. stdlib pur, zéro pip.
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
crm-dossier-vente-tests:
|
||||||
|
name: CRM · DocType porteur OTO Dossier Vente
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: 05_deliverables_mvp/crm/dossier_vente
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Tests générateur DocType Dossier Vente
|
||||||
|
run: python3 -m unittest discover -s tests -v
|
||||||
|
|
||||||
gate:
|
gate:
|
||||||
name: Gate qualité (agrégat)
|
name: Gate qualité (agrégat)
|
||||||
runs-on: ubuntu-latest
|
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]
|
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]
|
||||||
steps:
|
steps:
|
||||||
- name: Résultat
|
- name: Résultat
|
||||||
run: echo "✅ Gate qualité 4Big franchi — tous les checks verts."
|
run: echo "✅ Gate qualité 4Big franchi — tous les checks verts."
|
||||||
|
|||||||
@@ -1,5 +1,37 @@
|
|||||||
# Activity Log · 2026-07-30 · Claude Code DTP
|
# Activity Log · 2026-07-30 · Claude Code DTP
|
||||||
|
|
||||||
|
## Session `20260730_055704` (session 12)
|
||||||
|
|
||||||
|
**Tâche** : Sprint 4 · CRM — Générateur du **DocType porteur `OTO Dossier Vente`**
|
||||||
|
(complète le hand-off du workflow vente : le document réel que le Workflow pilote).
|
||||||
|
|
||||||
|
**Fichiers créés** — `05_deliverables_mvp/crm/dossier_vente/` :
|
||||||
|
- `doctype_spec.json` (structure métier · zéro chiffre · Projet P01..P09 · USD/DOP)
|
||||||
|
- `dvlib/{__init__,frappe,builder}.py` (connaissance Frappe + assemblage
|
||||||
|
cross-cohérent · réutilise `_UPDATE_FIELD` + `RoleResolver` de `workflow_vente`)
|
||||||
|
- `doctype_dossier_vente_gen.py` (CLI `build`/`validate` · 12 invariants)
|
||||||
|
- `doctype.schema.json` (contrat de sortie draft-07)
|
||||||
|
- `out/{doctype_oto_dossier_vente,MANIFEST}.json` (hand-off)
|
||||||
|
- `tests/test_dossier_vente.py` (31 tests) · `README.md` · `.gitignore`
|
||||||
|
|
||||||
|
**Fichiers modifiés** :
|
||||||
|
- `.gitea/workflows/ci.yml` : job `crm-dossier-vente-tests` + ajout au `gate`.
|
||||||
|
|
||||||
|
**Cross-cohérence (cœur du livrable)** : nom / champ d'état / valeurs de statut /
|
||||||
|
`is_submittable` / permissions tous **dérivés** de `workflow_vente_spec.json`
|
||||||
|
(source unique · anti-dérive) ; rôles résolus depuis `rbac_50_roles.json` (#6).
|
||||||
|
|
||||||
|
**Vérifs** : 31/31 tests ; gate CI local vert (guard + JSON + docs + YAML) ;
|
||||||
|
régression **177 tests verts** au total.
|
||||||
|
|
||||||
|
**Hors périmètre worker (VPS · #8)** : création module `OTO Ventes` + import réel
|
||||||
|
DocType puis Workflow → agent ERPNext Backend.
|
||||||
|
|
||||||
|
**Détail complet** : voir
|
||||||
|
[`05_deliverables_mvp/daily_reports/2026-07-30-session12.md`](../05_deliverables_mvp/daily_reports/2026-07-30-session12.md).
|
||||||
|
|
||||||
|
**Auto-score 4Big** : 96/100.
|
||||||
|
|
||||||
## Session `20260730_052701` (session 11)
|
## Session `20260730_052701` (session 11)
|
||||||
|
|
||||||
**Tâche** : Sprint 4 · CRM — Générateur de workflow vente ERPNext
|
**Tâche** : Sprint 4 · CRM — Générateur de workflow vente ERPNext
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Caches Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
# Le dossier out/ EST commité (hand-off ERPNext direct) — voir README.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# DocType porteur · `OTO Dossier Vente`
|
||||||
|
|
||||||
|
**Sprint 4 · CRM natif ERPNext.** Génère le **DocType custom porteur** du
|
||||||
|
pipeline vente : le document réel qui circule dans le Workflow `OTO Vente
|
||||||
|
Pipeline` produit par [`../workflow_vente/`](../workflow_vente/README.md). Sans
|
||||||
|
lui, le Workflow n'a rien à quoi s'attacher — ce module **complète le hand-off**
|
||||||
|
de la session workflow (lead → visite → devis → réservation → contrat →
|
||||||
|
CONFOTUR).
|
||||||
|
|
||||||
|
> Ce worker **n'écrit jamais sur le VPS** (contrainte #8). Il produit le fichier
|
||||||
|
> de fixture en-repo ; l'application réelle (`bench migrate` /
|
||||||
|
> `import-fixtures`) reste côté serveur (agent ERPNext Backend). Le DocType
|
||||||
|
> doit être importé **AVANT** le Workflow qui le cible.
|
||||||
|
|
||||||
|
## Ce qui est généré (`out/`, commité — hand-off direct)
|
||||||
|
|
||||||
|
| Fichier | DocType Frappe | Rôle |
|
||||||
|
|---|---|---|
|
||||||
|
| `doctype_oto_dossier_vente.json` | `DocType` (custom) | Le document porteur : champs métier + champs de pilotage workflow + permissions. |
|
||||||
|
| `MANIFEST.json` | — | Traçabilité (sources, comptes) + **module & DocTypes liés à confirmer VPS** + rôles RBAC utilisés. |
|
||||||
|
|
||||||
|
## Cross-cohérence workflow ↔ DocType (le cœur du livrable)
|
||||||
|
|
||||||
|
Le DocType n'est **pas** rédigé indépendamment : ses facettes structurantes sont
|
||||||
|
**dérivées** du contrat pipeline
|
||||||
|
[`workflow_vente_spec.json`](../workflow_vente/workflow_vente_spec.json), source
|
||||||
|
unique (anti-dérive · zéro duplication · workflow #5) :
|
||||||
|
|
||||||
|
- **Nom du DocType** = `document_type` du workflow (`OTO Dossier Vente`).
|
||||||
|
- **Champ d'état** `workflow_state` (Select, `read_only`) — options = les **9
|
||||||
|
états** du pipeline, dans l'ordre.
|
||||||
|
- **Champ de valeur machine** `statut_pipeline` — options = les `update_value`
|
||||||
|
du workflow. Le nom du champ est **importé** de `workflow_vente/wflib/builder.py`
|
||||||
|
(`_UPDATE_FIELD`) : le renommer côté workflow renomme ici automatiquement.
|
||||||
|
- **`is_submittable`** est **déduit** des `doc_status` (présence de 1/2 ⇒ le
|
||||||
|
document est soumissible — indispensable au moteur Workflow).
|
||||||
|
- **Permissions** **déduites** des rôles réellement cités par le workflow :
|
||||||
|
éditer un état ⇒ `write` (+ `create` si état brouillon) ; transition vers un
|
||||||
|
état soumis ⇒ `submit` ; vers un état annulé ⇒ `cancel`+`amend`. Les noms de
|
||||||
|
rôle Frappe sont résolus depuis
|
||||||
|
[`rbac_50_roles.json`](../../rbac/rbac_50_roles.json) via le `RoleResolver`
|
||||||
|
réutilisé du module workflow (jamais de nom en dur · #6).
|
||||||
|
|
||||||
|
## Champs métier (structure seule, zéro chiffre · #6)
|
||||||
|
|
||||||
|
Prospect/Client (`Link` Lead/Customer/User), Projet (`Select` **P01..P09**
|
||||||
|
ancré sur CLAUDE.md), Unité/Typologie, bloc **Financier** (`Currency` sans aucune
|
||||||
|
valeur par défaut — devise `USD`/`DOP` · #10), jalons de dates, bloc CONFOTUR,
|
||||||
|
motif de clôture. Un fixture DocType est un **schéma** : il ne porte aucun
|
||||||
|
montant fabriqué.
|
||||||
|
|
||||||
|
## Utilisation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 doctype_dossier_vente_gen.py build # écrit out/ (refuse si invalide)
|
||||||
|
python3 doctype_dossier_vente_gen.py validate # schéma + 12 invariants, sans écrire
|
||||||
|
python3 -m unittest discover -s tests -v # 31 tests (stdlib pur)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Les 12 invariants (le CLI refuse d'écrire si l'un casse)
|
||||||
|
|
||||||
|
1. Conformité au [schéma de sortie](doctype.schema.json). 2. Nom == `document_type`
|
||||||
|
du workflow. 3. `custom` cohérent avec le workflow. 4. Champ d'état = Select
|
||||||
|
`read_only`, options == noms d'états. 5. Champ `statut_pipeline` = options ==
|
||||||
|
`update_value`. 6. `is_submittable` == (doc_status max ≥ 1). 7. `field_order` ==
|
||||||
|
champs, noms uniques. 8. `naming_series` + `autoname` cohérents. 9. Tout rôle du
|
||||||
|
workflow présent en permission. 10. Capacités déduites correctes (write/create/
|
||||||
|
submit/cancel). 11. Anti-invention : aucun `Currency` avec défaut. 12. Comptes du
|
||||||
|
manifeste cohérents.
|
||||||
|
|
||||||
|
## Hand-off VPS (agent ERPNext Backend · hors périmètre worker · #8)
|
||||||
|
|
||||||
|
1. Créer le **module** `OTO Ventes` (ou remapper) et confirmer les DocTypes liés
|
||||||
|
natifs (`Lead`, `Customer`, `User`).
|
||||||
|
2. Importer `doctype_oto_dossier_vente.json`.
|
||||||
|
3. **Ensuite** importer le Workflow `crm/workflow_vente/out/workflow.json` (il
|
||||||
|
cible ce DocType).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Auto-score 4Big : 96/100.** Réserve −4 : création du module + import réel côté
|
||||||
|
VPS (agent ERPNext Backend · #8) ; ce module valide statiquement en-repo (31
|
||||||
|
tests verts + schéma + 12 invariants de cross-cohérence + gate CI).
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"$id": "https://oto-enterprise-os-dtp/crm/doctype_dossier_vente.schema.json",
|
||||||
|
"title": "Bundle fixture DocType 'OTO Dossier Vente' généré depuis doctype_spec.json + workflow_vente_spec.json",
|
||||||
|
"description": "Contrat de sortie du générateur doctype_dossier_vente_gen.py. Validé par le validateur maison Publiciste (zéro pip) dans le job CI crm-dossier-vente-tests.",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["manifest", "doctype"],
|
||||||
|
"properties": {
|
||||||
|
"manifest": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"generated_from",
|
||||||
|
"workflow_source",
|
||||||
|
"rbac_source",
|
||||||
|
"source_version",
|
||||||
|
"workflow_version",
|
||||||
|
"doctype_name",
|
||||||
|
"custom",
|
||||||
|
"is_submittable",
|
||||||
|
"workflow_state_field",
|
||||||
|
"pipeline_value_field",
|
||||||
|
"counts",
|
||||||
|
"module_a_confirmer",
|
||||||
|
"doctypes_lies_a_confirmer",
|
||||||
|
"roles_rbac_utilises",
|
||||||
|
"hand_off_vps"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"generated_from": { "type": "string", "const": "doctype_spec.json" },
|
||||||
|
"workflow_source": { "type": "string", "const": "workflow_vente_spec.json" },
|
||||||
|
"rbac_source": { "type": "string", "const": "rbac_50_roles.json" },
|
||||||
|
"source_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", "minLength": 1 },
|
||||||
|
"custom": { "type": "boolean" },
|
||||||
|
"is_submittable": { "type": "boolean" },
|
||||||
|
"workflow_state_field": { "type": "string", "minLength": 1 },
|
||||||
|
"pipeline_value_field": { "type": "string", "minLength": 1 },
|
||||||
|
"counts": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["fields", "data_fields", "sections", "permissions", "pipeline_states", "pipeline_values"],
|
||||||
|
"properties": {
|
||||||
|
"fields": { "type": "integer", "minimum": 3 },
|
||||||
|
"data_fields": { "type": "integer", "minimum": 2 },
|
||||||
|
"sections": { "type": "integer", "minimum": 1 },
|
||||||
|
"permissions": { "type": "integer", "minimum": 1 },
|
||||||
|
"pipeline_states": { "type": "integer", "minimum": 2 },
|
||||||
|
"pipeline_values": { "type": "integer", "minimum": 2 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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", "permissions"],
|
||||||
|
"properties": {
|
||||||
|
"role_id": { "type": "string", "minLength": 1 },
|
||||||
|
"erpnext_role_name": { "type": "string", "pattern": "^OTO " },
|
||||||
|
"permissions": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": { "type": "string", "enum": ["read", "write", "create", "submit", "cancel", "amend", "delete", "print", "email", "export"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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", "minLength": 1 },
|
||||||
|
"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", "Currency", "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" },
|
||||||
|
"export": { "$ref": "#/definitions/flag" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Générateur du DocType porteur `OTO Dossier Vente` · Sprint 4 · CRM ERPNext.
|
||||||
|
|
||||||
|
Complète le hand-off du générateur workflow vente (`crm/workflow_vente/`) : le
|
||||||
|
Workflow `OTO Vente Pipeline` cible un `document_type` custom qui doit exister
|
||||||
|
AVANT son import. Ce module produit ce DocType porteur en-repo (fixture Frappe
|
||||||
|
custom), cross-cohérent avec le contrat pipeline.
|
||||||
|
|
||||||
|
Ce worker n'écrit JAMAIS sur le VPS (#8) : il émet les fichiers ; l'application
|
||||||
|
réelle (`bench migrate` / `import-fixtures`) reste côté agent ERPNext Backend.
|
||||||
|
|
||||||
|
Sous-commandes :
|
||||||
|
build [-o OUT] → écrit doctype_oto_dossier_vente.json + MANIFEST.json
|
||||||
|
validate → (re)génère en mémoire, valide schéma + 12 invariants de
|
||||||
|
cross-cohérence workflow↔DocType ; sort en erreur sinon.
|
||||||
|
|
||||||
|
Sortie déterministe (tri stable, aucun horodatage) → diffable + re-générable.
|
||||||
|
Anti-invention (#6) : nom du DocType, champ d'état, valeurs de statut et rôles
|
||||||
|
proviennent des contrats (workflow + RBAC) ; aucun chiffre n'est peuplé.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_CRM = os.path.normpath(os.path.join(_HERE, "..")) # crm/
|
||||||
|
_DELIVERABLES = os.path.normpath(os.path.join(_CRM, "..")) # 05_deliverables_mvp/
|
||||||
|
|
||||||
|
sys.path.insert(0, _HERE)
|
||||||
|
# Réutilisation (workflow #5, zéro duplication) du validateur maison Publiciste.
|
||||||
|
sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste"))
|
||||||
|
|
||||||
|
from dvlib import builder, frappe # noqa: E402
|
||||||
|
from dvlib.builder import _UPDATE_FIELD # 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, "doctype_spec.json")
|
||||||
|
_WF_SPEC_PATH = os.path.join(_CRM, "workflow_vente", "workflow_vente_spec.json")
|
||||||
|
_SCHEMA_PATH = os.path.join(_HERE, "doctype.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, RoleResolver]:
|
||||||
|
spec = _load(_SPEC_PATH)
|
||||||
|
wf_spec = _load(_WF_SPEC_PATH)
|
||||||
|
resolver = RoleResolver.from_path()
|
||||||
|
bundle = builder.build_bundle(spec, wf_spec, resolver)
|
||||||
|
return bundle, wf_spec, resolver
|
||||||
|
|
||||||
|
|
||||||
|
def _select_options(field: dict) -> list[str]:
|
||||||
|
"""Options d'un Select Frappe : lignes non vides de `options`."""
|
||||||
|
return [ln for ln in field.get("options", "").split("\n") if ln]
|
||||||
|
|
||||||
|
|
||||||
|
def _validate(bundle: dict, wf_spec: dict, resolver: RoleResolver) -> list[str]:
|
||||||
|
"""Schéma de sortie + 12 invariants de cross-cohérence workflow ↔ DocType."""
|
||||||
|
schema = _load(_SCHEMA_PATH)
|
||||||
|
errors = list(maison.validate(bundle, schema))
|
||||||
|
|
||||||
|
dt = bundle["doctype"]
|
||||||
|
m = bundle["manifest"]
|
||||||
|
fields = {f["fieldname"]: f for f in dt["fields"]}
|
||||||
|
|
||||||
|
# 1 · Le nom du DocType == document_type du workflow (source unique).
|
||||||
|
if dt["name"] != wf_spec["document_type"]:
|
||||||
|
errors.append(f"nom DocType {dt['name']!r} ≠ workflow.document_type {wf_spec['document_type']!r}")
|
||||||
|
|
||||||
|
# 2 · `custom` cohérent avec document_type_custom du workflow.
|
||||||
|
if bool(dt["custom"]) != bool(wf_spec.get("document_type_custom")):
|
||||||
|
errors.append("drapeau `custom` incohérent avec workflow.document_type_custom")
|
||||||
|
|
||||||
|
# 3 · Le champ d'état existe, est un Select, options == noms d'états.
|
||||||
|
state_field = wf_spec["workflow_state_field"]
|
||||||
|
state_names = builder._ordered_unique([s["state"] for s in wf_spec["states"]])
|
||||||
|
fsf = fields.get(state_field)
|
||||||
|
if fsf is None:
|
||||||
|
errors.append(f"champ d'état {state_field!r} absent du DocType")
|
||||||
|
else:
|
||||||
|
if fsf["fieldtype"] != "Select":
|
||||||
|
errors.append(f"champ d'état {state_field!r} doit être Select, trouvé {fsf['fieldtype']!r}")
|
||||||
|
if _select_options(fsf) != state_names:
|
||||||
|
errors.append(f"options de {state_field!r} ≠ noms d'états du workflow")
|
||||||
|
if not fsf.get("read_only"):
|
||||||
|
errors.append(f"champ d'état {state_field!r} doit être read_only (piloté par le moteur)")
|
||||||
|
|
||||||
|
# 4 · Le champ de valeur machine existe, Select, options == update_value.
|
||||||
|
pipeline_values = builder._ordered_unique([s["update_value"] for s in wf_spec["states"]])
|
||||||
|
pvf = fields.get(_UPDATE_FIELD)
|
||||||
|
if pvf is None:
|
||||||
|
errors.append(f"champ de statut {_UPDATE_FIELD!r} absent du DocType")
|
||||||
|
elif _select_options(pvf) != pipeline_values:
|
||||||
|
errors.append(f"options de {_UPDATE_FIELD!r} ≠ update_value du workflow")
|
||||||
|
|
||||||
|
# 5 · is_submittable DÉDUIT des doc_status (1/2 présents ⇒ soumissible).
|
||||||
|
max_ds = max(s["doc_status"] for s in wf_spec["states"])
|
||||||
|
expected_sub = 1 if max_ds >= "1" else 0
|
||||||
|
if dt["is_submittable"] != expected_sub:
|
||||||
|
errors.append(f"is_submittable={dt['is_submittable']} ≠ attendu {expected_sub} (doc_status max {max_ds})")
|
||||||
|
|
||||||
|
# 6 · field_order == fieldnames, tous uniques.
|
||||||
|
order = dt["field_order"]
|
||||||
|
names = [f["fieldname"] for f in dt["fields"]]
|
||||||
|
if order != names:
|
||||||
|
errors.append("field_order ≠ ordre réel des champs")
|
||||||
|
if len(names) != len(set(names)):
|
||||||
|
errors.append("fieldname dupliqué dans le DocType")
|
||||||
|
|
||||||
|
# 7 · Nommage : champ naming_series + autoname cohérents.
|
||||||
|
if dt["autoname"] != "naming_series:":
|
||||||
|
errors.append("autoname doit être 'naming_series:'")
|
||||||
|
if "naming_series" not in fields:
|
||||||
|
errors.append("champ `naming_series` absent alors que autoname='naming_series:'")
|
||||||
|
|
||||||
|
# 8 · Tout rôle cité par le workflow est présent en permission (≥ read).
|
||||||
|
perm_roles = {p["role"] for p in dt["permissions"]}
|
||||||
|
wf_role_ids = {s["role_id"] for s in wf_spec["states"]} | {t["role_id"] for t in wf_spec["transitions"]}
|
||||||
|
for rid in sorted(wf_role_ids):
|
||||||
|
rn = resolver.erpnext_name(rid)
|
||||||
|
if rn not in perm_roles:
|
||||||
|
errors.append(f"rôle workflow {rid!r} ({rn}) absent des permissions du DocType")
|
||||||
|
|
||||||
|
# 9 · Cohérence des capacités déduites (édition brouillon / submit / cancel).
|
||||||
|
perm_by_role = {p["role"]: p for p in dt["permissions"]}
|
||||||
|
docstatus_by_state = {s["state"]: s["doc_status"] for s in wf_spec["states"]}
|
||||||
|
for s in wf_spec["states"]:
|
||||||
|
p = perm_by_role.get(resolver.erpnext_name(s["role_id"]), {})
|
||||||
|
if not p.get("write"):
|
||||||
|
errors.append(f"rôle éditant l'état {s['state']!r} sans droit write")
|
||||||
|
if s["doc_status"] == "0" and not p.get("create"):
|
||||||
|
errors.append(f"rôle éditant l'état brouillon {s['state']!r} sans droit create")
|
||||||
|
for t in wf_spec["transitions"]:
|
||||||
|
p = perm_by_role.get(resolver.erpnext_name(t["role_id"]), {})
|
||||||
|
nxt = docstatus_by_state[t["next_state"]]
|
||||||
|
if nxt == "1" and not p.get("submit"):
|
||||||
|
errors.append(f"transition {t['action']!r} vers état soumis sans droit submit")
|
||||||
|
if nxt == "2" and not p.get("cancel"):
|
||||||
|
errors.append(f"transition {t['action']!r} vers état annulé sans droit cancel")
|
||||||
|
|
||||||
|
# 10 · Anti-invention (#6) : aucun champ Currency ne porte de valeur par défaut.
|
||||||
|
for f in dt["fields"]:
|
||||||
|
if f["fieldtype"] == "Currency" and "default" in f:
|
||||||
|
errors.append(f"champ Currency {f['fieldname']!r} porte un défaut ({f['default']!r}) — chiffre inventé")
|
||||||
|
|
||||||
|
# 11 · Tout champ Link cible un DocType listé « à confirmer VPS ».
|
||||||
|
declared_links = set(m["doctypes_lies_a_confirmer"])
|
||||||
|
for f in dt["fields"]:
|
||||||
|
if f["fieldtype"] == "Link" and f.get("options") and f["options"] not in declared_links:
|
||||||
|
errors.append(f"Link {f['fieldname']!r} → {f['options']!r} absent de doctypes_lies_a_confirmer")
|
||||||
|
|
||||||
|
# 12 · Cohérence des comptes du manifeste.
|
||||||
|
data_fields = [f for f in dt["fields"] if f["fieldtype"] not in frappe.LAYOUT_FIELDTYPES]
|
||||||
|
if m["counts"]["fields"] != len(dt["fields"]):
|
||||||
|
errors.append("counts.fields incohérent")
|
||||||
|
if m["counts"]["data_fields"] != len(data_fields):
|
||||||
|
errors.append("counts.data_fields incohérent")
|
||||||
|
if m["counts"]["permissions"] != len(dt["permissions"]):
|
||||||
|
errors.append("counts.permissions incohérent")
|
||||||
|
if m["counts"]["pipeline_states"] != len(state_names):
|
||||||
|
errors.append("counts.pipeline_states incohérent")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_build(args: argparse.Namespace) -> int:
|
||||||
|
bundle, wf_spec, resolver = _build()
|
||||||
|
errors = _validate(bundle, wf_spec, resolver)
|
||||||
|
if errors:
|
||||||
|
_eprint("❌ Bundle invalide — génération refusée (anti-régression) :")
|
||||||
|
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_oto_dossier_vente.json"), bundle["doctype"])
|
||||||
|
_write_json(os.path.join(out, "MANIFEST.json"), bundle["manifest"])
|
||||||
|
|
||||||
|
m = bundle["manifest"]
|
||||||
|
print(f"✅ Fixture DocType générée dans {out}")
|
||||||
|
print(f" doctype_oto_dossier_vente.json : {m['counts']['data_fields']} champs · "
|
||||||
|
f"{m['counts']['permissions']} rôles · submittable={m['is_submittable']}")
|
||||||
|
print(f" ⚠ À créer/confirmer VPS avant import : module {m['module_a_confirmer']!r} "
|
||||||
|
f"+ DocTypes liés {m['doctypes_lies_a_confirmer']}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_validate(args: argparse.Namespace) -> int:
|
||||||
|
bundle, wf_spec, resolver = _build()
|
||||||
|
errors = _validate(bundle, wf_spec, 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} : {m['counts']['data_fields']} champs, "
|
||||||
|
f"{m['counts']['permissions']} rôles, schéma + 12 invariants verts.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
p = argparse.ArgumentParser(description="Générateur du DocType porteur OTO Dossier Vente.")
|
||||||
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
pb = sub.add_parser("build", help="génère doctype_oto_dossier_vente.json / MANIFEST.json")
|
||||||
|
pb.add_argument("-o", "--out", default=_DEFAULT_OUT, help="dossier de sortie (défaut: ./out)")
|
||||||
|
pb.set_defaults(func=cmd_build)
|
||||||
|
|
||||||
|
pv = sub.add_parser("validate", help="valide le bundle (schéma + 12 invariants) sans écrire")
|
||||||
|
pv.set_defaults(func=cmd_validate)
|
||||||
|
|
||||||
|
args = p.parse_args(argv)
|
||||||
|
return args.func(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"doctype": {
|
||||||
|
"module": "OTO Ventes",
|
||||||
|
"naming_series": "OTO-DV-.YYYY.-",
|
||||||
|
"title_field": "prospect",
|
||||||
|
"search_fields": "prospect,projet,workflow_state",
|
||||||
|
"track_changes": 1,
|
||||||
|
"track_seen": 1
|
||||||
|
},
|
||||||
|
"_comment": "Contrat métier du DocType porteur du pipeline vente (roadmap Sprint 4 · CRM natif ERPNext #3). Ne definit QUE la structure (libelles + types de champ) : aucun chiffre n'est peuple (#6). Les champs de pilotage du workflow (`workflow_state` + le champ de valeur machine `statut_pipeline`) NE sont PAS listes ici : le builder les injecte depuis workflow_vente_spec.json (source unique, anti-derive). Les Select `projet`/`devise` sont ancres sur CLAUDE.md (P01..P09 · USD/DOP), pas inventes.",
|
||||||
|
"field_groups": [
|
||||||
|
{
|
||||||
|
"section": "Prospect & Client",
|
||||||
|
"fields": [
|
||||||
|
{ "fieldname": "prospect", "label": "Prospect", "fieldtype": "Link", "options": "Lead", "reqd": 1, "in_list_view": 1, "in_standard_filter": 1 },
|
||||||
|
{ "fieldname": "client", "label": "Client (après conversion)", "fieldtype": "Link", "options": "Customer" },
|
||||||
|
{ "fieldname": "conseiller", "label": "Conseiller ventes", "fieldtype": "Link", "options": "User", "in_standard_filter": 1 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"section": "Projet & Unité",
|
||||||
|
"fields": [
|
||||||
|
{ "fieldname": "projet", "label": "Projet", "fieldtype": "Select", "options": "\nP01 Structure\nP02 Coral del Sur\nP03 Nakua\nP04 Xamaná Cantiles\nP05 Las Colinas Najayo Arriba\nP06 Coco Real\nP07 Aqua Terra Las Terrenas\nP08 Fasano Espirilla\nP09 1069 Crisfer", "reqd": 1, "in_list_view": 1, "in_standard_filter": 1 },
|
||||||
|
{ "fieldname": "unite", "label": "Unité (code)", "fieldtype": "Data", "in_list_view": 1 },
|
||||||
|
{ "fieldname": "typologie", "label": "Typologie", "fieldtype": "Data" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"section": "Financier",
|
||||||
|
"fields": [
|
||||||
|
{ "fieldname": "devise", "label": "Devise", "fieldtype": "Select", "options": "USD\nDOP", "default": "USD", "reqd": 1 },
|
||||||
|
{ "fieldname": "prix_liste", "label": "Prix de liste", "fieldtype": "Currency", "options": "devise" },
|
||||||
|
{ "fieldname": "montant_reservation", "label": "Dépôt de réservation", "fieldtype": "Currency", "options": "devise" },
|
||||||
|
{ "fieldname": "montant_contrat", "label": "Montant du contrat", "fieldtype": "Currency", "options": "devise" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"section": "Jalons (dates)",
|
||||||
|
"fields": [
|
||||||
|
{ "fieldname": "date_lead", "label": "Date lead", "fieldtype": "Date" },
|
||||||
|
{ "fieldname": "date_visite", "label": "Date visite", "fieldtype": "Date" },
|
||||||
|
{ "fieldname": "date_devis", "label": "Date devis", "fieldtype": "Date" },
|
||||||
|
{ "fieldname": "date_reservation", "label": "Date réservation", "fieldtype": "Date" },
|
||||||
|
{ "fieldname": "date_contrat", "label": "Date contrat", "fieldtype": "Date" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"section": "CONFOTUR",
|
||||||
|
"fields": [
|
||||||
|
{ "fieldname": "confotur_reference", "label": "Référence CONFOTUR", "fieldtype": "Data" },
|
||||||
|
{ "fieldname": "date_confotur_depot", "label": "Date dépôt CONFOTUR", "fieldtype": "Date" },
|
||||||
|
{ "fieldname": "date_confotur_approbation", "label": "Date approbation CONFOTUR", "fieldtype": "Date" },
|
||||||
|
{ "fieldname": "confotur_notes", "label": "Notes CONFOTUR", "fieldtype": "Small Text" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"section": "Clôture",
|
||||||
|
"fields": [
|
||||||
|
{ "fieldname": "motif_perte", "label": "Motif de perte / abandon", "fieldtype": "Small Text" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Bibliothèque du générateur DocType porteur `OTO Dossier Vente` (Sprint 4).
|
||||||
|
|
||||||
|
- `frappe` : connaissance Frappe/ERPNext v15 native (types de champ, modèle de
|
||||||
|
permission, assemblage d'un fixture DocType). Aucun accès VPS.
|
||||||
|
- `builder` : assemblage déterministe + cross-cohérence avec le workflow vente.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""Assemblage du fixture DocType `OTO Dossier Vente` — cross-cohérent workflow.
|
||||||
|
|
||||||
|
Entrées :
|
||||||
|
- `doctype_spec.json` : structure métier (libellés + types de champ).
|
||||||
|
- `workflow_vente_spec.json` : le pipeline (source UNIQUE du nom du DocType,
|
||||||
|
du champ d'état, des valeurs de statut, des rôles). Réutilisé, jamais copié.
|
||||||
|
- `RoleResolver` (rbac_50_roles.json) : `role_id → erpnext_role_name`.
|
||||||
|
|
||||||
|
Sortie : un bundle déterministe `{manifest, doctype}` reproductible bit-à-bit.
|
||||||
|
|
||||||
|
Anti-dérive (#6, zéro invention / zéro duplication · workflow #5) :
|
||||||
|
- Le NOM du DocType, le champ d'état (`workflow_state`) et le champ de valeur
|
||||||
|
machine (`statut_pipeline`) proviennent du contrat workflow — jamais réécrits
|
||||||
|
en dur ici. Si le workflow renomme le champ, ce DocType suit automatiquement.
|
||||||
|
- `is_submittable` est DÉDUIT des `doc_status` du workflow (1/2 ⇒ soumissible).
|
||||||
|
- Les permissions sont DÉDUITES des rôles réellement cités par le workflow
|
||||||
|
(édition de brouillon ⇒ write/create ; transition vers soumis ⇒ submit ;
|
||||||
|
vers annulé ⇒ cancel). Aucun rôle ni chiffre fabriqué.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from . import frappe
|
||||||
|
|
||||||
|
# Réutilisation (workflow #5) du module CRM voisin : le champ de valeur machine
|
||||||
|
# (`_UPDATE_FIELD`) et le résolveur de rôles sont importés, jamais redéfinis.
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_CRM = os.path.normpath(os.path.join(_HERE, "..", "..")) # 05_deliverables_mvp/crm/
|
||||||
|
sys.path.insert(0, _CRM)
|
||||||
|
|
||||||
|
from workflow_vente.wflib.builder import _UPDATE_FIELD # noqa: E402
|
||||||
|
from workflow_vente.wflib.rbac import RoleResolver # noqa: E402
|
||||||
|
|
||||||
|
WORKFLOW_SPEC_REL = os.path.join("..", "workflow_vente", "workflow_vente_spec.json")
|
||||||
|
|
||||||
|
# Drapeaux de docfield que le spec métier peut porter (le reste = mise en page).
|
||||||
|
_SPEC_FLAGS = ("reqd", "read_only", "in_list_view", "in_standard_filter", "hidden", "bold")
|
||||||
|
|
||||||
|
|
||||||
|
def _ordered_unique(seq: list[str]) -> list[str]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
out: list[str] = []
|
||||||
|
for x in seq:
|
||||||
|
if x not in seen:
|
||||||
|
seen.add(x)
|
||||||
|
out.append(x)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _spec_docfield(f: 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=f.get("options"),
|
||||||
|
default=f.get("default"),
|
||||||
|
flags=flags,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_permissions(wf_spec: dict, resolver: RoleResolver) -> tuple[list[dict], dict[str, set[str]]]:
|
||||||
|
"""Déduit les DocPerm des rôles cités par le workflow.
|
||||||
|
|
||||||
|
Retour : (lignes de permission triées, capabilities par role_id) — la 2e
|
||||||
|
valeur alimente le manifeste de traçabilité.
|
||||||
|
"""
|
||||||
|
docstatus_by_state = {s["state"]: s["doc_status"] for s in wf_spec["states"]}
|
||||||
|
caps: dict[str, set[str]] = {}
|
||||||
|
|
||||||
|
def grant(role_id: str, perms: set[str]) -> None:
|
||||||
|
caps.setdefault(role_id, set()).update(perms)
|
||||||
|
|
||||||
|
# Un rôle `allow_edit` sur un état édite le dossier dans cet état ⇒ read+write
|
||||||
|
# (Frappe exige write pour modifier, y compris un document soumis). Seuls les
|
||||||
|
# états BROUILLON (doc_status 0) sont créables ⇒ + create.
|
||||||
|
for s in wf_spec["states"]:
|
||||||
|
grant(s["role_id"], {"read", "write"})
|
||||||
|
if s["doc_status"] == "0":
|
||||||
|
grant(s["role_id"], {"create"})
|
||||||
|
for t in wf_spec["transitions"]:
|
||||||
|
grant(t["role_id"], {"read"})
|
||||||
|
nxt = docstatus_by_state[t["next_state"]]
|
||||||
|
if nxt == "1":
|
||||||
|
grant(t["role_id"], {"submit"})
|
||||||
|
elif nxt == "2":
|
||||||
|
grant(t["role_id"], {"cancel", "amend"})
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
frappe.permission_row(resolver.erpnext_name(rid), perms)
|
||||||
|
for rid, perms in sorted(caps.items(), key=lambda kv: resolver.erpnext_name(kv[0]))
|
||||||
|
]
|
||||||
|
return rows, caps
|
||||||
|
|
||||||
|
|
||||||
|
def build_bundle(spec: dict, wf_spec: dict, resolver: RoleResolver) -> dict[str, Any]:
|
||||||
|
"""Transforme les contrats en bundle `{manifest, doctype}` déterministe."""
|
||||||
|
dt_cfg = spec["doctype"]
|
||||||
|
|
||||||
|
# --- 1. Facettes dérivées du workflow (source unique, anti-dérive) --------
|
||||||
|
doctype_name = wf_spec["document_type"]
|
||||||
|
state_field = wf_spec["workflow_state_field"] # p.ex. "workflow_state"
|
||||||
|
state_names = _ordered_unique([s["state"] for s in wf_spec["states"]])
|
||||||
|
pipeline_values = _ordered_unique([s["update_value"] for s in wf_spec["states"]])
|
||||||
|
max_docstatus = max(s["doc_status"] for s in wf_spec["states"]) # "0" < "1" < "2"
|
||||||
|
is_submittable = 1 if max_docstatus >= "1" else 0
|
||||||
|
|
||||||
|
# --- 2. Champs de pilotage (injectés, non re-saisis dans le spec métier) --
|
||||||
|
fields: list[dict] = [
|
||||||
|
frappe.naming_series_field(dt_cfg["naming_series"]),
|
||||||
|
frappe.section_break("sb_pipeline", "Pipeline"),
|
||||||
|
frappe.docfield(
|
||||||
|
state_field, "Select",
|
||||||
|
label="État du workflow",
|
||||||
|
options="\n" + "\n".join(state_names),
|
||||||
|
flags={"read_only": 1, "in_list_view": 1, "in_standard_filter": 1},
|
||||||
|
),
|
||||||
|
frappe.docfield(
|
||||||
|
_UPDATE_FIELD, "Select",
|
||||||
|
label="Statut pipeline (machine)",
|
||||||
|
options="\n" + "\n".join(pipeline_values),
|
||||||
|
flags={"read_only": 1, "hidden": 1},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- 3. Champs métier (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"]:
|
||||||
|
fields.append(_spec_docfield(f))
|
||||||
|
if f["fieldtype"] == "Link" and f.get("options"):
|
||||||
|
linked_doctypes.add(f["options"])
|
||||||
|
|
||||||
|
# --- 4. Permissions déduites du workflow ----------------------------------
|
||||||
|
permissions, caps = _derive_permissions(wf_spec, resolver)
|
||||||
|
|
||||||
|
# --- 5. Document DocType ---------------------------------------------------
|
||||||
|
doctype = frappe.doctype_doc(
|
||||||
|
name=doctype_name,
|
||||||
|
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. Manifeste de traçabilité ------------------------------------------
|
||||||
|
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": "doctype_spec.json",
|
||||||
|
"workflow_source": "workflow_vente_spec.json",
|
||||||
|
"rbac_source": "rbac_50_roles.json",
|
||||||
|
"source_version": spec["version"],
|
||||||
|
"workflow_version": wf_spec["version"],
|
||||||
|
"doctype_name": doctype_name,
|
||||||
|
"custom": bool(doctype["custom"]),
|
||||||
|
"is_submittable": bool(is_submittable),
|
||||||
|
"workflow_state_field": state_field,
|
||||||
|
"pipeline_value_field": _UPDATE_FIELD,
|
||||||
|
"counts": {
|
||||||
|
"fields": len(fields),
|
||||||
|
"data_fields": len(data_fields),
|
||||||
|
"sections": len(sections),
|
||||||
|
"permissions": len(permissions),
|
||||||
|
"pipeline_states": len(state_names),
|
||||||
|
"pipeline_values": len(pipeline_values),
|
||||||
|
},
|
||||||
|
# Non natif : à créer / confirmer sur le VPS avant import (SPEC §7).
|
||||||
|
"module_a_confirmer": dt_cfg["module"],
|
||||||
|
"doctypes_lies_a_confirmer": sorted(linked_doctypes),
|
||||||
|
"roles_rbac_utilises": [
|
||||||
|
{
|
||||||
|
"role_id": rid,
|
||||||
|
"erpnext_role_name": resolver.erpnext_name(rid),
|
||||||
|
"permissions": sorted(perms),
|
||||||
|
}
|
||||||
|
for rid, perms in sorted(caps.items())
|
||||||
|
],
|
||||||
|
"hand_off_vps": (
|
||||||
|
"Importer ce DocType (bench migrate / import-fixtures) AVANT le "
|
||||||
|
"Workflow 'OTO Vente Pipeline' qui le cible (crm/workflow_vente/out/)."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"manifest": manifest, "doctype": doctype}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Modèle Frappe/ERPNext v15 : structure native d'un fixture `DocType`.
|
||||||
|
|
||||||
|
Sépare la CONNAISSANCE FRAPPE (types de champ légitimes, drapeaux de docfield,
|
||||||
|
modèle de permission, enveloppe du document DocType) de l'assemblage métier
|
||||||
|
(`builder.py`). Contrainte #1 « ERPNext natif = priorité absolue » : on produit
|
||||||
|
le DocType standard du moteur Frappe (aucune structure inventée), porteur du
|
||||||
|
Workflow `OTO Vente Pipeline` généré par `crm/workflow_vente/`.
|
||||||
|
|
||||||
|
Aucun accès VPS : chaque fonction renvoie un dict sérialisable que l'agent
|
||||||
|
ERPNext Backend importera via `bench` (SPEC §7).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Types de champ Frappe utilisés par ce DocType. Une valeur hors de cet ensemble
|
||||||
|
# = invention → refusée par `docfield()`. (Sous-ensemble volontaire : on n'ouvre
|
||||||
|
# que ce que le contrat métier emploie réellement.)
|
||||||
|
VALID_FIELDTYPES: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"Section Break",
|
||||||
|
"Column Break",
|
||||||
|
"Data",
|
||||||
|
"Select",
|
||||||
|
"Link",
|
||||||
|
"Currency",
|
||||||
|
"Date",
|
||||||
|
"Datetime",
|
||||||
|
"Small Text",
|
||||||
|
"Text",
|
||||||
|
"Check",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Types « de mise en page » : ni requis, ni porteurs de donnée.
|
||||||
|
LAYOUT_FIELDTYPES: frozenset[str] = frozenset({"Section Break", "Column Break"})
|
||||||
|
|
||||||
|
# Permissions natives du child table `DocPerm` d'un DocType Frappe.
|
||||||
|
VALID_PERMS: frozenset[str] = frozenset(
|
||||||
|
{"read", "write", "create", "submit", "cancel", "amend", "delete", "print", "email", "export"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Drapeaux de docfield connus (défaut 0) — on ne sérialise que ceux à 1 pour un
|
||||||
|
# diff propre, sauf `reqd`/`read_only`/`in_list_view`/`in_standard_filter` qui
|
||||||
|
# portent l'intention métier.
|
||||||
|
_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 booléen : {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 intégré) d'un DocType.
|
||||||
|
|
||||||
|
On n'émet que les drapeaux à 1 (défaut sûr = refus), triés implicitement par
|
||||||
|
l'ordre de `VALID_PERMS` via le dict de sortie — diff stable.
|
||||||
|
"""
|
||||||
|
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 ("read", "write", "create", "submit", "cancel", "amend", "delete", "print", "email", "export"):
|
||||||
|
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(s)
|
||||||
|
motif(s) de série. On le rend `read_only` (série imposée, pas saisie libre).
|
||||||
|
"""
|
||||||
|
return docfield(
|
||||||
|
"naming_series",
|
||||||
|
"Select",
|
||||||
|
label="Série 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 prêt 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,93 @@
|
|||||||
|
{
|
||||||
|
"generated_from": "doctype_spec.json",
|
||||||
|
"workflow_source": "workflow_vente_spec.json",
|
||||||
|
"rbac_source": "rbac_50_roles.json",
|
||||||
|
"source_version": "1.0.0",
|
||||||
|
"workflow_version": "1.0.0",
|
||||||
|
"doctype_name": "OTO Dossier Vente",
|
||||||
|
"custom": true,
|
||||||
|
"is_submittable": true,
|
||||||
|
"workflow_state_field": "workflow_state",
|
||||||
|
"pipeline_value_field": "statut_pipeline",
|
||||||
|
"counts": {
|
||||||
|
"fields": 30,
|
||||||
|
"data_fields": 23,
|
||||||
|
"sections": 7,
|
||||||
|
"permissions": 7,
|
||||||
|
"pipeline_states": 9,
|
||||||
|
"pipeline_values": 9
|
||||||
|
},
|
||||||
|
"module_a_confirmer": "OTO Ventes",
|
||||||
|
"doctypes_lies_a_confirmer": [
|
||||||
|
"Customer",
|
||||||
|
"Lead",
|
||||||
|
"User"
|
||||||
|
],
|
||||||
|
"roles_rbac_utilises": [
|
||||||
|
{
|
||||||
|
"role_id": "direction-cco",
|
||||||
|
"erpnext_role_name": "OTO Direction Commerciale",
|
||||||
|
"permissions": [
|
||||||
|
"amend",
|
||||||
|
"cancel",
|
||||||
|
"read"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role_id": "ventes-chef-equipe",
|
||||||
|
"erpnext_role_name": "OTO Ventes Chef Équipe",
|
||||||
|
"permissions": [
|
||||||
|
"create",
|
||||||
|
"read",
|
||||||
|
"write"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role_id": "ventes-confotur",
|
||||||
|
"erpnext_role_name": "OTO Ventes CONFOTUR",
|
||||||
|
"permissions": [
|
||||||
|
"read",
|
||||||
|
"submit",
|
||||||
|
"write"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role_id": "ventes-conseiller",
|
||||||
|
"erpnext_role_name": "OTO Ventes Conseiller",
|
||||||
|
"permissions": [
|
||||||
|
"create",
|
||||||
|
"read",
|
||||||
|
"write"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role_id": "ventes-contrats",
|
||||||
|
"erpnext_role_name": "OTO Ventes Contrats",
|
||||||
|
"permissions": [
|
||||||
|
"read",
|
||||||
|
"submit",
|
||||||
|
"write"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role_id": "ventes-directeur",
|
||||||
|
"erpnext_role_name": "OTO Ventes Directeur",
|
||||||
|
"permissions": [
|
||||||
|
"amend",
|
||||||
|
"cancel",
|
||||||
|
"read",
|
||||||
|
"write"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role_id": "ventes-reservations",
|
||||||
|
"erpnext_role_name": "OTO Ventes Réservations",
|
||||||
|
"permissions": [
|
||||||
|
"read",
|
||||||
|
"submit",
|
||||||
|
"write"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hand_off_vps": "Importer ce DocType (bench migrate / import-fixtures) AVANT le Workflow 'OTO Vente Pipeline' qui le cible (crm/workflow_vente/out/)."
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
{
|
||||||
|
"doctype": "DocType",
|
||||||
|
"name": "OTO Dossier Vente",
|
||||||
|
"module": "OTO Ventes",
|
||||||
|
"custom": 1,
|
||||||
|
"is_submittable": 1,
|
||||||
|
"naming_rule": "By \"Naming Series\" field",
|
||||||
|
"autoname": "naming_series:",
|
||||||
|
"title_field": "prospect",
|
||||||
|
"search_fields": "prospect,projet,workflow_state",
|
||||||
|
"track_changes": 1,
|
||||||
|
"track_seen": 1,
|
||||||
|
"editable_grid": 1,
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"naming_series",
|
||||||
|
"sb_pipeline",
|
||||||
|
"workflow_state",
|
||||||
|
"statut_pipeline",
|
||||||
|
"sb_prospect___client_0",
|
||||||
|
"prospect",
|
||||||
|
"client",
|
||||||
|
"conseiller",
|
||||||
|
"sb_projet___unit_1",
|
||||||
|
"projet",
|
||||||
|
"unite",
|
||||||
|
"typologie",
|
||||||
|
"sb_financier_2",
|
||||||
|
"devise",
|
||||||
|
"prix_liste",
|
||||||
|
"montant_reservation",
|
||||||
|
"montant_contrat",
|
||||||
|
"sb_jalons__dates_3",
|
||||||
|
"date_lead",
|
||||||
|
"date_visite",
|
||||||
|
"date_devis",
|
||||||
|
"date_reservation",
|
||||||
|
"date_contrat",
|
||||||
|
"sb_confotur_4",
|
||||||
|
"confotur_reference",
|
||||||
|
"date_confotur_depot",
|
||||||
|
"date_confotur_approbation",
|
||||||
|
"confotur_notes",
|
||||||
|
"sb_cl_ture_5",
|
||||||
|
"motif_perte"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "naming_series",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"label": "Série de nommage",
|
||||||
|
"options": "OTO-DV-.YYYY.-",
|
||||||
|
"default": "OTO-DV-.YYYY.-",
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "sb_pipeline",
|
||||||
|
"fieldtype": "Section Break",
|
||||||
|
"label": "Pipeline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "workflow_state",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"label": "État du workflow",
|
||||||
|
"options": "\nLead\nVisite planifiée\nDevis émis\nRéservation confirmée\nContrat signé\nCONFOTUR déposé\nCONFOTUR approuvé\nAbandonné\nPerdu",
|
||||||
|
"read_only": 1,
|
||||||
|
"in_list_view": 1,
|
||||||
|
"in_standard_filter": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "statut_pipeline",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"label": "Statut pipeline (machine)",
|
||||||
|
"options": "\nlead\nvisite\ndevis\nreservation\ncontrat\nconfotur_depose\nconfotur_approuve\nabandonne\nperdu",
|
||||||
|
"read_only": 1,
|
||||||
|
"hidden": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "sb_prospect___client_0",
|
||||||
|
"fieldtype": "Section Break",
|
||||||
|
"label": "Prospect & Client"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "prospect",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"label": "Prospect",
|
||||||
|
"options": "Lead",
|
||||||
|
"reqd": 1,
|
||||||
|
"in_list_view": 1,
|
||||||
|
"in_standard_filter": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "client",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"label": "Client (après conversion)",
|
||||||
|
"options": "Customer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "conseiller",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"label": "Conseiller ventes",
|
||||||
|
"options": "User",
|
||||||
|
"in_standard_filter": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "sb_projet___unit_1",
|
||||||
|
"fieldtype": "Section Break",
|
||||||
|
"label": "Projet & Unité"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "projet",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"label": "Projet",
|
||||||
|
"options": "\nP01 Structure\nP02 Coral del Sur\nP03 Nakua\nP04 Xamaná Cantiles\nP05 Las Colinas Najayo Arriba\nP06 Coco Real\nP07 Aqua Terra Las Terrenas\nP08 Fasano Espirilla\nP09 1069 Crisfer",
|
||||||
|
"reqd": 1,
|
||||||
|
"in_list_view": 1,
|
||||||
|
"in_standard_filter": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "unite",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Unité (code)",
|
||||||
|
"in_list_view": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "typologie",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Typologie"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "sb_financier_2",
|
||||||
|
"fieldtype": "Section Break",
|
||||||
|
"label": "Financier"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "devise",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"label": "Devise",
|
||||||
|
"options": "USD\nDOP",
|
||||||
|
"default": "USD",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "prix_liste",
|
||||||
|
"fieldtype": "Currency",
|
||||||
|
"label": "Prix de liste",
|
||||||
|
"options": "devise"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "montant_reservation",
|
||||||
|
"fieldtype": "Currency",
|
||||||
|
"label": "Dépôt de réservation",
|
||||||
|
"options": "devise"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "montant_contrat",
|
||||||
|
"fieldtype": "Currency",
|
||||||
|
"label": "Montant du contrat",
|
||||||
|
"options": "devise"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "sb_jalons__dates_3",
|
||||||
|
"fieldtype": "Section Break",
|
||||||
|
"label": "Jalons (dates)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "date_lead",
|
||||||
|
"fieldtype": "Date",
|
||||||
|
"label": "Date lead"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "date_visite",
|
||||||
|
"fieldtype": "Date",
|
||||||
|
"label": "Date visite"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "date_devis",
|
||||||
|
"fieldtype": "Date",
|
||||||
|
"label": "Date devis"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "date_reservation",
|
||||||
|
"fieldtype": "Date",
|
||||||
|
"label": "Date réservation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "date_contrat",
|
||||||
|
"fieldtype": "Date",
|
||||||
|
"label": "Date contrat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "sb_confotur_4",
|
||||||
|
"fieldtype": "Section Break",
|
||||||
|
"label": "CONFOTUR"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "confotur_reference",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Référence CONFOTUR"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "date_confotur_depot",
|
||||||
|
"fieldtype": "Date",
|
||||||
|
"label": "Date dépôt CONFOTUR"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "date_confotur_approbation",
|
||||||
|
"fieldtype": "Date",
|
||||||
|
"label": "Date approbation CONFOTUR"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "confotur_notes",
|
||||||
|
"fieldtype": "Small Text",
|
||||||
|
"label": "Notes CONFOTUR"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "sb_cl_ture_5",
|
||||||
|
"fieldtype": "Section Break",
|
||||||
|
"label": "Clôture"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "motif_perte",
|
||||||
|
"fieldtype": "Small Text",
|
||||||
|
"label": "Motif de perte / abandon"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"role": "OTO Direction Commerciale",
|
||||||
|
"permlevel": 0,
|
||||||
|
"read": 1,
|
||||||
|
"cancel": 1,
|
||||||
|
"amend": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "OTO Ventes CONFOTUR",
|
||||||
|
"permlevel": 0,
|
||||||
|
"read": 1,
|
||||||
|
"write": 1,
|
||||||
|
"submit": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "OTO Ventes Chef Équipe",
|
||||||
|
"permlevel": 0,
|
||||||
|
"read": 1,
|
||||||
|
"write": 1,
|
||||||
|
"create": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "OTO Ventes Conseiller",
|
||||||
|
"permlevel": 0,
|
||||||
|
"read": 1,
|
||||||
|
"write": 1,
|
||||||
|
"create": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "OTO Ventes Contrats",
|
||||||
|
"permlevel": 0,
|
||||||
|
"read": 1,
|
||||||
|
"write": 1,
|
||||||
|
"submit": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "OTO Ventes Directeur",
|
||||||
|
"permlevel": 0,
|
||||||
|
"read": 1,
|
||||||
|
"write": 1,
|
||||||
|
"cancel": 1,
|
||||||
|
"amend": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "OTO Ventes Réservations",
|
||||||
|
"permlevel": 0,
|
||||||
|
"read": 1,
|
||||||
|
"write": 1,
|
||||||
|
"submit": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests du générateur du DocType porteur `OTO Dossier Vente` (Sprint 4 · CRM).
|
||||||
|
|
||||||
|
Stdlib pur (`unittest`) → aucune installation pip requise sur le runner Gitea.
|
||||||
|
La bibliothèque `jsonschema` sert d'*oracle* quand elle est présente, pour se
|
||||||
|
prémunir d'un écart entre le validateur maison et draft-07.
|
||||||
|
|
||||||
|
Axe central : la CROSS-COHÉRENCE workflow ↔ DocType (le DocType doit porter
|
||||||
|
exactement l'état, les valeurs de statut, la soumissibilité et les rôles du
|
||||||
|
pipeline `crm/workflow_vente/`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
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, ".."))
|
||||||
|
_CRM = os.path.normpath(os.path.join(_MODULE, ".."))
|
||||||
|
_DELIVERABLES = os.path.normpath(os.path.join(_CRM, ".."))
|
||||||
|
|
||||||
|
sys.path.insert(0, _MODULE)
|
||||||
|
sys.path.insert(0, _CRM)
|
||||||
|
sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste"))
|
||||||
|
|
||||||
|
from dvlib import builder, frappe # noqa: E402
|
||||||
|
from dvlib.builder import _UPDATE_FIELD # noqa: E402
|
||||||
|
from workflow_vente.wflib.rbac import RoleResolver # noqa: E402
|
||||||
|
from lib import validator as maison # type: ignore # noqa: E402
|
||||||
|
import doctype_dossier_vente_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.resolver = RoleResolver.from_path()
|
||||||
|
cls.bundle = builder.build_bundle(cls.spec, cls.wf_spec, cls.resolver)
|
||||||
|
cls.dt = cls.bundle["doctype"]
|
||||||
|
cls.manifest = cls.bundle["manifest"]
|
||||||
|
cls.fields = {f["fieldname"]: f for f in cls.dt["fields"]}
|
||||||
|
|
||||||
|
def _opts(self, fieldname: str) -> list[str]:
|
||||||
|
return [ln for ln in self.fields[fieldname].get("options", "").split("\n") if ln]
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchema(BaseFixture):
|
||||||
|
def test_maison_schema_ok(self):
|
||||||
|
self.assertEqual(maison.validate(self.bundle, self.schema), [])
|
||||||
|
|
||||||
|
@unittest.skipUnless(_HAS_JSONSCHEMA, "jsonschema absent")
|
||||||
|
def test_oracle_jsonschema_ok(self):
|
||||||
|
jsonschema.validate(self.bundle, self.schema)
|
||||||
|
|
||||||
|
def test_validate_returns_empty(self):
|
||||||
|
self.assertEqual(gen._validate(self.bundle, self.wf_spec, self.resolver), [])
|
||||||
|
|
||||||
|
|
||||||
|
class TestCrossCoherence(BaseFixture):
|
||||||
|
def test_name_equals_workflow_document_type(self):
|
||||||
|
self.assertEqual(self.dt["name"], self.wf_spec["document_type"])
|
||||||
|
|
||||||
|
def test_custom_matches_workflow(self):
|
||||||
|
self.assertEqual(bool(self.dt["custom"]), bool(self.wf_spec["document_type_custom"]))
|
||||||
|
|
||||||
|
def test_state_field_options_equal_state_names(self):
|
||||||
|
field = self.wf_spec["workflow_state_field"]
|
||||||
|
names = [s["state"] for s in self.wf_spec["states"]]
|
||||||
|
self.assertEqual(self.fields[field]["fieldtype"], "Select")
|
||||||
|
self.assertEqual(self._opts(field), names)
|
||||||
|
self.assertEqual(self.fields[field]["read_only"], 1)
|
||||||
|
|
||||||
|
def test_pipeline_value_field_options_equal_update_values(self):
|
||||||
|
values = [s["update_value"] for s in self.wf_spec["states"]]
|
||||||
|
self.assertIn(_UPDATE_FIELD, self.fields)
|
||||||
|
self.assertEqual(self._opts(_UPDATE_FIELD), values)
|
||||||
|
|
||||||
|
def test_is_submittable_derived_from_docstatus(self):
|
||||||
|
max_ds = max(s["doc_status"] for s in self.wf_spec["states"])
|
||||||
|
self.assertEqual(self.dt["is_submittable"], 1 if max_ds >= "1" else 0)
|
||||||
|
|
||||||
|
def test_update_field_comes_from_workflow_builder(self):
|
||||||
|
# Anti-dérive : le champ de valeur machine est importé du builder voisin,
|
||||||
|
# pas réécrit en dur ici.
|
||||||
|
self.assertEqual(_UPDATE_FIELD, "statut_pipeline")
|
||||||
|
|
||||||
|
|
||||||
|
class TestPermissions(BaseFixture):
|
||||||
|
def setUp(self):
|
||||||
|
self.perm = {p["role"]: p for p in self.dt["permissions"]}
|
||||||
|
self.docstatus = {s["state"]: s["doc_status"] for s in self.wf_spec["states"]}
|
||||||
|
|
||||||
|
def test_every_workflow_role_present(self):
|
||||||
|
role_ids = {s["role_id"] for s in self.wf_spec["states"]}
|
||||||
|
role_ids |= {t["role_id"] for t in self.wf_spec["transitions"]}
|
||||||
|
for rid in role_ids:
|
||||||
|
self.assertIn(self.resolver.erpnext_name(rid), self.perm)
|
||||||
|
|
||||||
|
def test_allow_edit_roles_have_write(self):
|
||||||
|
for s in self.wf_spec["states"]:
|
||||||
|
p = self.perm[self.resolver.erpnext_name(s["role_id"])]
|
||||||
|
self.assertEqual(p.get("write"), 1, s["state"])
|
||||||
|
if s["doc_status"] == "0":
|
||||||
|
self.assertEqual(p.get("create"), 1, s["state"])
|
||||||
|
|
||||||
|
def test_submit_and_cancel_derivation(self):
|
||||||
|
for t in self.wf_spec["transitions"]:
|
||||||
|
p = self.perm[self.resolver.erpnext_name(t["role_id"])]
|
||||||
|
nxt = self.docstatus[t["next_state"]]
|
||||||
|
if nxt == "1":
|
||||||
|
self.assertEqual(p.get("submit"), 1, t["action"])
|
||||||
|
if nxt == "2":
|
||||||
|
self.assertEqual(p.get("cancel"), 1, t["action"])
|
||||||
|
|
||||||
|
def test_no_role_name_hardcoded_in_spec(self):
|
||||||
|
# #6 : aucun NOM DE RÔLE Frappe en dur dans le contrat métier ; les rôles
|
||||||
|
# ne viennent que du workflow (role_id) résolu via le RBAC.
|
||||||
|
blob = json.dumps(self.spec, ensure_ascii=False)
|
||||||
|
for rid in self.resolver.known_ids():
|
||||||
|
self.assertNotIn(self.resolver.erpnext_name(rid), blob)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFrappeKnowledge(unittest.TestCase):
|
||||||
|
def test_docfield_rejects_unknown_fieldtype(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
frappe.docfield("x", "Rocket")
|
||||||
|
|
||||||
|
def test_permission_row_rejects_unknown_perm(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
frappe.permission_row("OTO X", {"read", "teleport"})
|
||||||
|
|
||||||
|
def test_docfield_only_emits_set_flags(self):
|
||||||
|
f = frappe.docfield("x", "Data", flags={"reqd": 1})
|
||||||
|
self.assertEqual(f.get("reqd"), 1)
|
||||||
|
self.assertNotIn("hidden", f)
|
||||||
|
|
||||||
|
def test_naming_series_field_is_readonly_select(self):
|
||||||
|
f = frappe.naming_series_field("OTO-DV-.YYYY.-")
|
||||||
|
self.assertEqual(f["fieldtype"], "Select")
|
||||||
|
self.assertEqual(f["read_only"], 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestInvariantsAndStructure(BaseFixture):
|
||||||
|
def test_field_order_matches_fields(self):
|
||||||
|
self.assertEqual(self.dt["field_order"], [f["fieldname"] for f in self.dt["fields"]])
|
||||||
|
|
||||||
|
def test_fieldnames_unique(self):
|
||||||
|
names = [f["fieldname"] for f in self.dt["fields"]]
|
||||||
|
self.assertEqual(len(names), len(set(names)))
|
||||||
|
|
||||||
|
def test_no_currency_default(self):
|
||||||
|
for f in self.dt["fields"]:
|
||||||
|
if f["fieldtype"] == "Currency":
|
||||||
|
self.assertNotIn("default", f, f["fieldname"])
|
||||||
|
|
||||||
|
def test_currency_options_reference_devise(self):
|
||||||
|
for f in self.dt["fields"]:
|
||||||
|
if f["fieldtype"] == "Currency":
|
||||||
|
self.assertEqual(f.get("options"), "devise")
|
||||||
|
|
||||||
|
def test_links_declared_in_manifest(self):
|
||||||
|
declared = set(self.manifest["doctypes_lies_a_confirmer"])
|
||||||
|
for f in self.dt["fields"]:
|
||||||
|
if f["fieldtype"] == "Link":
|
||||||
|
self.assertIn(f["options"], declared)
|
||||||
|
|
||||||
|
def test_naming_series_present(self):
|
||||||
|
self.assertIn("naming_series", self.fields)
|
||||||
|
self.assertEqual(self.dt["autoname"], "naming_series:")
|
||||||
|
|
||||||
|
def test_manifest_counts_coherent(self):
|
||||||
|
c = self.manifest["counts"]
|
||||||
|
self.assertEqual(c["fields"], len(self.dt["fields"]))
|
||||||
|
self.assertEqual(c["permissions"], len(self.dt["permissions"]))
|
||||||
|
self.assertEqual(c["pipeline_states"], len({s["state"] for s in self.wf_spec["states"]}))
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeterminismAndAntiRegression(BaseFixture):
|
||||||
|
def test_build_is_deterministic(self):
|
||||||
|
again = builder.build_bundle(self.spec, self.wf_spec, self.resolver)
|
||||||
|
self.assertEqual(json.dumps(again, sort_keys=True), json.dumps(self.bundle, sort_keys=True))
|
||||||
|
|
||||||
|
def test_committed_out_matches_regeneration(self):
|
||||||
|
out = os.path.join(_MODULE, "out", "doctype_oto_dossier_vente.json")
|
||||||
|
man = os.path.join(_MODULE, "out", "MANIFEST.json")
|
||||||
|
if not (os.path.exists(out) and os.path.exists(man)):
|
||||||
|
self.skipTest("out/ non commité")
|
||||||
|
self.assertEqual(_load(out), self.dt)
|
||||||
|
self.assertEqual(_load(man), self.manifest)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTamperDetection(BaseFixture):
|
||||||
|
def test_break_is_submittable_detected(self):
|
||||||
|
bad = copy.deepcopy(self.bundle)
|
||||||
|
bad["doctype"]["is_submittable"] = 0
|
||||||
|
self.assertTrue(gen._validate(bad, self.wf_spec, self.resolver))
|
||||||
|
|
||||||
|
def test_drop_state_option_detected(self):
|
||||||
|
bad = copy.deepcopy(self.bundle)
|
||||||
|
for f in bad["doctype"]["fields"]:
|
||||||
|
if f["fieldname"] == self.wf_spec["workflow_state_field"]:
|
||||||
|
f["options"] = "\n".join(f["options"].split("\n")[:-1])
|
||||||
|
self.assertTrue(gen._validate(bad, self.wf_spec, self.resolver))
|
||||||
|
|
||||||
|
def test_rename_doctype_detected(self):
|
||||||
|
bad = copy.deepcopy(self.bundle)
|
||||||
|
bad["doctype"]["name"] = "Autre Chose"
|
||||||
|
self.assertTrue(gen._validate(bad, self.wf_spec, self.resolver))
|
||||||
|
|
||||||
|
def test_inject_currency_default_detected(self):
|
||||||
|
bad = copy.deepcopy(self.bundle)
|
||||||
|
for f in bad["doctype"]["fields"]:
|
||||||
|
if f["fieldtype"] == "Currency":
|
||||||
|
f["default"] = "150000"
|
||||||
|
break
|
||||||
|
self.assertTrue(gen._validate(bad, self.wf_spec, self.resolver))
|
||||||
|
|
||||||
|
|
||||||
|
class TestCLI(unittest.TestCase):
|
||||||
|
def test_cli_validate_exit_zero(self):
|
||||||
|
r = subprocess.run(
|
||||||
|
[sys.executable, os.path.join(_MODULE, "doctype_dossier_vente_gen.py"), "validate"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(r.returncode, 0, r.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Daily Report · 2026-07-30 · Claude Code DTP Worker (session 12)
|
||||||
|
|
||||||
|
**Session** : `20260730_055704`
|
||||||
|
|
||||||
|
## Tâche exécutée
|
||||||
|
**Sprint 4 · CRM — Générateur du DocType porteur `OTO Dossier Vente`.** Complète
|
||||||
|
le hand-off du générateur workflow vente (session 11) : le Workflow `OTO Vente
|
||||||
|
Pipeline` cible un `document_type` **custom** qui doit exister AVANT son import.
|
||||||
|
Ce module produit ce DocType porteur en-repo (fixture Frappe custom),
|
||||||
|
**cross-cohérent** avec le contrat pipeline. C'est exactement la « prochaine
|
||||||
|
tâche suggérée » de la session 11 (DocType porteur `OTO Dossier Vente`).
|
||||||
|
|
||||||
|
## Contexte / analyse
|
||||||
|
- Relu `CLAUDE.md`, `ROADMAP_8_WEEKS_OR_LESS.md`, daily report session 11, et
|
||||||
|
l'idiome des générateurs CRM/RBAC (`wflib`, `fixtures_gen` : `frappe.py` +
|
||||||
|
`builder.py` + schéma + CLI `build|validate` + tests + job CI + gate).
|
||||||
|
- Le workflow vente (session 11) référence `document_type: "OTO Dossier Vente"`
|
||||||
|
marqué `document_type_custom: true` + `custom_doctypes_a_confirmer` → il
|
||||||
|
manquait le DocType porteur. Livrable 100 % autorable sans VPS (structure
|
||||||
|
seule, zéro chiffre · #6).
|
||||||
|
|
||||||
|
## Réalisé — module `05_deliverables_mvp/crm/dossier_vente/`
|
||||||
|
- `doctype_spec.json` — structure métier (libellés + types de champ) : Prospect/
|
||||||
|
Client (`Link`), Projet (`Select` **P01..P09** ancré CLAUDE.md), Financier
|
||||||
|
(`Currency` **sans défaut** · devise USD/DOP · #10), jalons de dates, CONFOTUR,
|
||||||
|
clôture. Les champs de pilotage workflow n'y figurent PAS (injectés).
|
||||||
|
- `dvlib/frappe.py` — connaissance Frappe v15 native : types de champ légitimes,
|
||||||
|
modèle de permission (DocPerm intégré), champ `naming_series`, enveloppe
|
||||||
|
`DocType` custom.
|
||||||
|
- `dvlib/builder.py` — assemblage **déterministe** + **cross-cohérence** :
|
||||||
|
dérive du workflow le nom du DocType, le champ d'état, les valeurs de statut,
|
||||||
|
`is_submittable` et les permissions. Réutilise (`#5`) le `_UPDATE_FIELD` et le
|
||||||
|
`RoleResolver` du module `workflow_vente` (zéro duplication).
|
||||||
|
- `doctype.schema.json` — contrat de sortie draft-07 (validateur maison
|
||||||
|
Publiciste, zéro pip).
|
||||||
|
- `doctype_dossier_vente_gen.py` — CLI `build`/`validate`. **Refuse d'écrire** si
|
||||||
|
un des **12 invariants** de cross-cohérence casse.
|
||||||
|
- `out/` (commité, hand-off direct) : `doctype_oto_dossier_vente.json`,
|
||||||
|
`MANIFEST.json`.
|
||||||
|
- `tests/test_dossier_vente.py` — **31 tests `unittest` (stdlib pur)**.
|
||||||
|
- **CI** : job `crm-dossier-vente-tests` ajouté au **gate** (`.gitea/workflows/ci.yml`).
|
||||||
|
|
||||||
|
## Cross-cohérence workflow ↔ DocType (le cœur du livrable)
|
||||||
|
Le DocType n'est pas rédigé indépendamment ; il **dérive** du contrat
|
||||||
|
`workflow_vente_spec.json` (source unique · anti-dérive) :
|
||||||
|
- Nom == `document_type` du workflow ; `custom` == `document_type_custom`.
|
||||||
|
- Champ `workflow_state` (Select `read_only`) : options == les 9 états, ordonnés.
|
||||||
|
- Champ `statut_pipeline` : options == les `update_value` ; nom **importé** de
|
||||||
|
`workflow_vente/wflib/builder.py::_UPDATE_FIELD` (renommage propagé).
|
||||||
|
- `is_submittable` **déduit** des `doc_status` (1/2 ⇒ soumissible).
|
||||||
|
- Permissions **déduites** des rôles du workflow (édition ⇒ write/+create si
|
||||||
|
brouillon ; transition→soumis ⇒ submit ; →annulé ⇒ cancel/amend). Noms de rôle
|
||||||
|
résolus depuis `rbac_50_roles.json` (jamais en dur · #6).
|
||||||
|
|
||||||
|
## Vérifications effectuées (en-repo, sans toucher au VPS)
|
||||||
|
- **31/31 tests verts** (validateur maison + oracle `jsonschema`) ; génération :
|
||||||
|
30 champs (23 porteurs de donnée) · 7 sections · 7 rôles · submittable=1 ;
|
||||||
|
`out/` commité == régénération bit-à-bit.
|
||||||
|
- **Gate CI local vert (exit 0)** : `guard_constraints.sh`, `validate_json.sh`,
|
||||||
|
`check_docs.sh` (score présent, 0 lien cassé), YAML `ci.yml` valide.
|
||||||
|
- **Régression** : 23 Publiciste + 10 RBAC + 16 Faisabilité + 11 fixtures + 12
|
||||||
|
userperm + 11 roleprofile + 16 apply_plan + 22 bancable + 25 workflow vente +
|
||||||
|
31 dossier vente = **177 tests verts** au total.
|
||||||
|
|
||||||
|
## Non fait (hors périmètre worker · VPS · #8)
|
||||||
|
- Création du **module** `OTO Ventes` + import réel (`bench migrate`) du DocType,
|
||||||
|
puis du Workflow qui le cible → agent ERPNext Backend.
|
||||||
|
|
||||||
|
## Prochaine tâche suggérée
|
||||||
|
- Sprint 4 CRM : générateur des **Notification/Email Alert** par transition du
|
||||||
|
pipeline (relances lead, alerte réservation, dépôt CONFOTUR).
|
||||||
|
- Sprint 4 ERPNext Backend : barème **commissions vendeurs** (calcul traçable
|
||||||
|
façon module `finance.py` du bancable · #6) + plan e-CF DGII (Compupar).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Auto-score 4Big du livrable DocType porteur : 96/100.** Réserve −4 : création
|
||||||
|
du module + import réel = côté VPS (agent ERPNext Backend, hors périmètre worker
|
||||||
|
· #8). Validé statiquement en-repo (31 tests verts + schéma conforme + 12
|
||||||
|
invariants de cross-cohérence + gate CI · 177 tests de régression au total).
|
||||||
Reference in New Issue
Block a user