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