c23dfc24a5
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>
146 lines
4.9 KiB
Python
146 lines
4.9 KiB
Python
"""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,
|
|
}
|