[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:
Claude Code DTP Worker
2026-07-30 06:09:36 +00:00
parent 34f202af40
commit c23dfc24a5
14 changed files with 1668 additions and 1 deletions
@@ -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).