[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:
@@ -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}
|
||||
Reference in New Issue
Block a user