"""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}