[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,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,
}