diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index a0a3106..0f17081 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -199,10 +199,29 @@ jobs: - name: Tests générateur workflow vente 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: 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] + 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: - name: Résultat run: echo "✅ Gate qualité 4Big franchi — tous les checks verts." diff --git a/05_activity_log/2026-07-30.md b/05_activity_log/2026-07-30.md index dca2e13..0cba9a8 100644 --- a/05_activity_log/2026-07-30.md +++ b/05_activity_log/2026-07-30.md @@ -1,5 +1,37 @@ # 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) **Tâche** : Sprint 4 · CRM — Générateur de workflow vente ERPNext diff --git a/05_deliverables_mvp/crm/dossier_vente/.gitignore b/05_deliverables_mvp/crm/dossier_vente/.gitignore new file mode 100644 index 0000000..cece918 --- /dev/null +++ b/05_deliverables_mvp/crm/dossier_vente/.gitignore @@ -0,0 +1,4 @@ +# Caches Python +__pycache__/ +*.pyc +# Le dossier out/ EST commité (hand-off ERPNext direct) — voir README. diff --git a/05_deliverables_mvp/crm/dossier_vente/README.md b/05_deliverables_mvp/crm/dossier_vente/README.md new file mode 100644 index 0000000..7686473 --- /dev/null +++ b/05_deliverables_mvp/crm/dossier_vente/README.md @@ -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). diff --git a/05_deliverables_mvp/crm/dossier_vente/doctype.schema.json b/05_deliverables_mvp/crm/dossier_vente/doctype.schema.json new file mode 100644 index 0000000..81e0666 --- /dev/null +++ b/05_deliverables_mvp/crm/dossier_vente/doctype.schema.json @@ -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" } + } + } + } +} diff --git a/05_deliverables_mvp/crm/dossier_vente/doctype_dossier_vente_gen.py b/05_deliverables_mvp/crm/dossier_vente/doctype_dossier_vente_gen.py new file mode 100644 index 0000000..0969c74 --- /dev/null +++ b/05_deliverables_mvp/crm/dossier_vente/doctype_dossier_vente_gen.py @@ -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()) diff --git a/05_deliverables_mvp/crm/dossier_vente/doctype_spec.json b/05_deliverables_mvp/crm/dossier_vente/doctype_spec.json new file mode 100644 index 0000000..e1deb7e --- /dev/null +++ b/05_deliverables_mvp/crm/dossier_vente/doctype_spec.json @@ -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" } + ] + } + ] +} diff --git a/05_deliverables_mvp/crm/dossier_vente/dvlib/__init__.py b/05_deliverables_mvp/crm/dossier_vente/dvlib/__init__.py new file mode 100644 index 0000000..46465bc --- /dev/null +++ b/05_deliverables_mvp/crm/dossier_vente/dvlib/__init__.py @@ -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. +""" diff --git a/05_deliverables_mvp/crm/dossier_vente/dvlib/builder.py b/05_deliverables_mvp/crm/dossier_vente/dvlib/builder.py new file mode 100644 index 0000000..4ed7418 --- /dev/null +++ b/05_deliverables_mvp/crm/dossier_vente/dvlib/builder.py @@ -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} diff --git a/05_deliverables_mvp/crm/dossier_vente/dvlib/frappe.py b/05_deliverables_mvp/crm/dossier_vente/dvlib/frappe.py new file mode 100644 index 0000000..f4110a9 --- /dev/null +++ b/05_deliverables_mvp/crm/dossier_vente/dvlib/frappe.py @@ -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, + } diff --git a/05_deliverables_mvp/crm/dossier_vente/out/MANIFEST.json b/05_deliverables_mvp/crm/dossier_vente/out/MANIFEST.json new file mode 100644 index 0000000..e42c6db --- /dev/null +++ b/05_deliverables_mvp/crm/dossier_vente/out/MANIFEST.json @@ -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/)." +} diff --git a/05_deliverables_mvp/crm/dossier_vente/out/doctype_oto_dossier_vente.json b/05_deliverables_mvp/crm/dossier_vente/out/doctype_oto_dossier_vente.json new file mode 100644 index 0000000..2caf849 --- /dev/null +++ b/05_deliverables_mvp/crm/dossier_vente/out/doctype_oto_dossier_vente.json @@ -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 + } + ] +} diff --git a/05_deliverables_mvp/crm/dossier_vente/tests/test_dossier_vente.py b/05_deliverables_mvp/crm/dossier_vente/tests/test_dossier_vente.py new file mode 100644 index 0000000..79227e6 --- /dev/null +++ b/05_deliverables_mvp/crm/dossier_vente/tests/test_dossier_vente.py @@ -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) diff --git a/05_deliverables_mvp/daily_reports/2026-07-30-session12.md b/05_deliverables_mvp/daily_reports/2026-07-30-session12.md new file mode 100644 index 0000000..d0a42e9 --- /dev/null +++ b/05_deliverables_mvp/daily_reports/2026-07-30-session12.md @@ -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).