Files
Claude Code DTP Worker 35a20247f8 [DTP-Worker] Sprint 5 · Générateur DocType CONFOTUR Application (ONAPI/Legal · roadmap L55)
DocType custom porteur des dossiers d'incitation touristique CONFOTUR (Ley
158-01, RD), référencé par RBAC (3 rôles) et le workflow vente mais jamais
généré. Permissions = permissions_cibles RBAC mot pour mot ; is_submittable
déduit de l'action submit ; estado/dossier_vente dérivés du workflow ; aucun
taux/loi/montant fabriqué (#6). 14 invariants, 44 tests, hand-off dans out/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 08:09:41 +00:00

304 lines
14 KiB
Python

#!/usr/bin/env python3
"""Generateur du DocType porteur `CONFOTUR Application` · Sprint 5 · ONAPI/Legal.
Roadmap ligne 55 : « Refactor oto_module_confotur_application.py -> depots
automatiques ». Produit le fixture DocType custom qui porte les dossiers
d'incitation touristique CONFOTUR (Ley 158-01, RD), cross-coherent avec les
contrats deja livres :
- le contrat RBAC (`rbac_50_roles.json`) -> nom, permissions, submit ;
- le pipeline vente (`workflow_vente_spec.json`) -> etats `estado`, DocType
lie `dossier_vente`, et evenements de depot (confotur_depose/_approuve).
Ce worker n'ecrit JAMAIS sur le VPS (#8) : il emet les fichiers de hand-off ;
la creation du module Frappe `OTOV7 CONFOTUR`, l'import du DocType et le cablage
des evenements restent cote agent ONAPI/Legal / ERPNext Backend.
ANTI-INVENTION (#6) : aucun taux d'incitation, article de loi, montant ni
reference d'autorite n'est fabrique. Le DocType est une STRUCTURE ; ses champs
substantiels restent vides, renseignes cote VPS depuis data_room P05/P07. Un
invariant refuse tout `default` de champ (hors serie de nommage) et tout type de
champ porteur de montant.
Sous-commandes :
build [-o OUT] -> ecrit doctype_confotur_application.json + MANIFEST.json
validate -> (re)genere en memoire, valide schema + 14 invariants de
cross-coherence DocType<->RBAC<->workflow ; sort en erreur
sinon.
Sortie deterministe (tri stable, aucun horodatage) -> diffable + re-generable.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
_DELIVERABLES = os.path.normpath(os.path.join(_HERE, "..", "..")) # 05_deliverables_mvp/
_CRM = os.path.join(_DELIVERABLES, "crm")
sys.path.insert(0, _HERE)
sys.path.insert(0, _CRM)
sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste"))
from cflib import builder, frappe, rbac_scan # noqa: E402
from workflow_vente.wflib.rbac import RoleResolver # noqa: E402
from lib import validator as maison # type: ignore # noqa: E402
_SPEC_PATH = os.path.join(_HERE, "confotur_spec.json")
_WF_SPEC_PATH = os.path.join(_CRM, "workflow_vente", "workflow_vente_spec.json")
_SCHEMA_PATH = os.path.join(_HERE, "confotur.schema.json")
_DEFAULT_OUT = os.path.join(_HERE, "out")
def _eprint(*args) -> None:
print(*args, file=sys.stderr)
def _load(path: str) -> dict:
with open(path, encoding="utf-8") as fh:
return json.load(fh)
def _write_json(path: str, data) -> None:
with open(path, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, indent=2)
fh.write("\n")
def _build() -> tuple[dict, dict, dict, dict, RoleResolver]:
spec = _load(_SPEC_PATH)
wf_spec = _load(_WF_SPEC_PATH)
contract = rbac_scan.load_contract()
resolver = RoleResolver.from_path()
bundle = builder.build_bundle(spec, wf_spec, contract, resolver)
return bundle, spec, wf_spec, contract, resolver
def _granted_perms(perm_row: dict) -> set[str]:
"""Reconstruit l'ensemble d'actions accordees depuis une ligne DocPerm."""
return {p for p in frappe.VALID_PERMS if perm_row.get(p) == 1}
def _validate(bundle: dict, spec: dict, wf_spec: dict, contract: dict,
resolver: RoleResolver) -> list[str]:
"""Schema de sortie + 14 invariants de cross-coherence (3 contrats)."""
schema = _load(_SCHEMA_PATH)
errors = list(maison.validate(bundle, schema))
dt = bundle["doctype"]
m = bundle["manifest"]
fields = dt["fields"]
by_name = {f["fieldname"]: f for f in fields}
targeting = rbac_scan.roles_targeting(builder.TARGET_DOCTYPE, contract)
tgt_by_name = {resolver.erpnext_name(r["role_id"]): r for r in targeting}
confotur_vals = builder.confotur_state_values(wf_spec)
docstatus_by_val = {s["update_value"]: s["doc_status"] for s in wf_spec["states"]}
# 1 · nom du DocType coherent partout (identite du generateur).
if dt["name"] != builder.TARGET_DOCTYPE or m["doctype_name"] != builder.TARGET_DOCTYPE:
errors.append(f"nom du DocType != {builder.TARGET_DOCTYPE!r}")
# 2 · permissions == permissions_cibles RBAC, mot pour mot + exhaustif.
perm_roles = [p["role"] for p in dt["permissions"]]
if len(perm_roles) != len(set(perm_roles)):
errors.append("role duplique dans doctype.permissions")
if set(perm_roles) != set(tgt_by_name):
errors.append("ensemble des roles du DocType != roles RBAC visant le DocType")
for row in dt["permissions"]:
r = tgt_by_name.get(row["role"])
if r is None:
errors.append(f"permission pour role {row['role']!r} absent du contrat RBAC")
continue
granted = _granted_perms(row)
if granted != set(r["actions"]):
errors.append(
f"permissions de {row['role']!r} = {sorted(granted)} != RBAC "
f"{sorted(set(r['actions']))}"
)
if row["permlevel"] != 0:
errors.append(f"permlevel != 0 pour {row['role']!r}")
# 3 · custom : DocType custom ET tous les roles le declarent custom (#1).
if dt["custom"] != 1 or m["custom"] is not True:
errors.append("doctype.custom doit valoir 1")
if not all(r["custom"] for r in targeting):
errors.append("un role RBAC visant le DocType ne le marque pas `custom`")
# 4 · is_submittable deduit de l'action `submit` cote RBAC.
expect_sub = 1 if any("submit" in r["actions"] for r in targeting) else 0
if dt["is_submittable"] != expect_sub or m["is_submittable"] is not bool(expect_sub):
errors.append(f"is_submittable={dt['is_submittable']} != attendu {expect_sub}")
# 5 · coherence avec le workflow : les etats CONFOTUR sont soumis (doc_status 1)
# -> un DocType soumissible porte un pipeline soumissible.
if not confotur_vals:
errors.append("aucun etat `confotur*` dans le workflow vente")
for uv in confotur_vals:
if docstatus_by_val.get(uv) != "1":
errors.append(f"etat confotur {uv!r} n'est pas soumis (doc_status != 1)")
# 6 · liens : options des champs Link = DocTypes attendus (dossier_vente derive
# du workflow, jamais en dur) + manifeste `doctypes_lies_a_confirmer` aligne.
if by_name.get("projet", {}).get("options") != "Project":
errors.append("champ `projet` : options != 'Project'")
if by_name.get("cliente", {}).get("options") != "Customer":
errors.append("champ `cliente` : options != 'Customer'")
if by_name.get("dossier_vente", {}).get("options") != wf_spec["document_type"]:
errors.append("champ `dossier_vente` : options != workflow document_type")
linked = sorted({f.get("options") for f in fields
if f["fieldtype"] == "Link" and f.get("options")})
if m["doctypes_lies_a_confirmer"] != linked:
errors.append("manifest.doctypes_lies_a_confirmer incoherent")
# 7 · `estado` : options Select derivees des etats CONFOTUR (exhaustif, sans
# extra) avec ligne vide en tete (facultatif a la creation).
est = by_name.get("estado", {})
est_opts = est.get("options", "").split("\n")
if est_opts[:1] != [""]:
errors.append("`estado` : ligne vide en tete attendue (Select facultatif)")
if [o for o in est_opts if o] != confotur_vals:
errors.append("`estado` : options != etats confotur du workflow")
# 8 · `entite_porteuse` : options = entites CLAUDE.md du spec (Select requis).
ent = by_name.get("entite_porteuse", {})
if ent.get("options", "").split("\n") != spec["entites"]:
errors.append("`entite_porteuse` : options != entites du spec")
if ent.get("reqd") != 1:
errors.append("`entite_porteuse` doit etre reqd")
# 9 · depot_events derives des transitions confotur du workflow.
de = m["depot_events"]
wf_confotur_tr = [t for t in wf_spec["transitions"]
if {s["state"]: s["update_value"] for s in wf_spec["states"]}
.get(t["next_state"], "").startswith("confotur")]
if len(de) != len(wf_confotur_tr):
errors.append("depot_events : compte != transitions confotur du workflow")
for ev in de:
if not ev["update_value"].startswith("confotur"):
errors.append(f"depot_event {ev['action']!r} : update_value non confotur")
if ev["update_value"] not in confotur_vals:
errors.append(f"depot_event {ev['action']!r} : etat absent du workflow")
if ev["role_id"] not in {r["role_id"] for r in targeting}:
errors.append(
f"depot_event {ev['action']!r} : role {ev['role_id']!r} sans acces "
f"au DocType CONFOTUR Application (RBAC)"
)
if ev["erpnext_role_name"] != resolver.erpnext_name(ev["role_id"]):
errors.append(f"depot_event {ev['action']!r} : erpnext_role_name incoherent")
if ev["portail"] != resolver.portail(ev["role_id"]):
errors.append(f"depot_event {ev['action']!r} : portail incoherent")
# 10 · ANTI-INVENTION (#6) : aucun champ ne porte de montant ni de `default`
# (hors serie de nommage) -> aucun chiffre OTO smuggle dans la structure.
money_types = {"Currency", "Float", "Int", "Percent"}
for f in fields:
if f["fieldtype"] in money_types:
errors.append(f"champ {f['fieldname']!r} de type montant interdit (#6)")
if "default" in f and f["fieldname"] != "naming_series":
errors.append(f"champ {f['fieldname']!r} porte un `default` (interdit #6)")
# 11 · serie de nommage native (autoname naming_series + champ read_only).
ns = by_name.get("naming_series", {})
if dt["autoname"] != "naming_series:":
errors.append("autoname != 'naming_series:'")
if ns.get("options") != spec["doctype"]["naming_series"] or ns.get("read_only") != 1:
errors.append("champ naming_series absent / non read_only / options != serie")
# 12 · field_order == fieldnames, unicite, title/search fields reels.
names = [f["fieldname"] for f in fields]
if dt["field_order"] != names:
errors.append("field_order != ordre des fields")
if len(names) != len(set(names)):
errors.append("fieldname duplique")
if dt["title_field"] not in by_name:
errors.append(f"title_field {dt['title_field']!r} absent des champs")
for sf in dt["search_fields"].split(","):
if sf and sf not in by_name:
errors.append(f"search_field {sf!r} absent des champs")
# 13 · tous les champs metier du spec sont presents (aucune perte).
spec_fields = {f["fieldname"] for g in spec["field_groups"] for f in g["fields"]}
missing = spec_fields - set(by_name)
if missing:
errors.append(f"champs du spec absents du DocType : {sorted(missing)}")
# 14 · comptes du manifeste coherents.
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"]
checks = {
"fields": len(fields),
"data_fields": len(data_fields),
"sections": len(sections),
"permissions": len(dt["permissions"]),
"roles": len(targeting),
"depot_events": len(de),
}
for k, v in checks.items():
if m["counts"][k] != v:
errors.append(f"counts.{k} incoherent ({m['counts'][k]} != {v})")
return errors
def cmd_build(args: argparse.Namespace) -> int:
bundle, spec, wf_spec, contract, resolver = _build()
errors = _validate(bundle, spec, wf_spec, contract, resolver)
if errors:
_eprint("❌ Bundle invalide — generation refusee (anti-regression) :")
for e in errors:
_eprint(f" - {e}")
return 1
out = os.path.abspath(args.out)
os.makedirs(out, exist_ok=True)
_write_json(os.path.join(out, "doctype_confotur_application.json"), bundle["doctype"])
_write_json(os.path.join(out, "MANIFEST.json"), bundle["manifest"])
m = bundle["manifest"]
print(f"✅ DocType CONFOTUR Application genere dans {out}")
print(f" doctype_confotur_application.json : {m['counts']['fields']} champs "
f"({m['counts']['data_fields']} de donnee) · {m['counts']['sections']} sections · "
f"{m['counts']['permissions']} roles · soumissible={m['is_submittable']} · "
f"{m['counts']['depot_events']} evenements de depot")
print(" ⚠ Module `OTOV7 CONFOTUR` + import DocType + cablage evenements cote "
"agent ONAPI/Legal (VPS · #8). Taux/loi/montants : data_room P05/P07 (#6).")
return 0
def cmd_validate(args: argparse.Namespace) -> int:
bundle, spec, wf_spec, contract, resolver = _build()
errors = _validate(bundle, spec, wf_spec, contract, resolver)
if errors:
_eprint("❌ Validation KO :")
for e in errors:
_eprint(f" - {e}")
return 1
m = bundle["manifest"]
print(f"✅ Validation OK — DocType {m['doctype_name']!r} : "
f"{m['counts']['permissions']} roles RBAC, {m['counts']['depot_events']} "
f"evenements de depot, schema + 14 invariants verts.")
return 0
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="Generateur du DocType CONFOTUR Application (ONAPI/Legal).")
sub = p.add_subparsers(dest="cmd", required=True)
pb = sub.add_parser("build", help="genere doctype_confotur_application.json / MANIFEST.json")
pb.add_argument("-o", "--out", default=_DEFAULT_OUT, help="dossier de sortie (defaut: ./out)")
pb.set_defaults(func=cmd_build)
pv = sub.add_parser("validate", help="valide le bundle (schema + 14 invariants) sans ecrire")
pv.set_defaults(func=cmd_validate)
args = p.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())