[DTP-Worker] Sprint 5 · Générateur Audit 5D conformité (QA · roadmap L58)
Audit de second niveau : lit les hand-off out/ des livrables (workflow vente, Dossier Vente, commissions, e-CF DGII, CONFOTUR) et vérifie 17 contrôles en 5 dimensions (D1 Traçabilité/ISA 500 · D2 AML-UAF/Ley 155-17 · D3 Fiscal e-CF/Ley 32-23 · D4 Intégrité/IFRS · D5 Gouvernance-SoD/ISA 315). Anti-invention #6 : paramètre réglementaire non confirmé → A_CONFIRMER (open item assigné au métier), jamais fabriqué. Verdict PASS_WITH_OPEN_ITEMS (13 PASS, 0 FAIL, 4 à confirmer). Réutilise validateur Publiciste + RoleResolver CRM + roles_targeting CONFOTUR (zéro duplication). 37 tests · 15 invariants · build déterministe · régression 341 tests verts. Job CI qa-audit-5d-tests + gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Générateur de l'audit 5D de conformité · Sprint 5 · QA.
|
||||
|
||||
Roadmap Sprint 5 · QA : « Audit UAF + normes ISA/IFRS 5D ». Ce générateur est un
|
||||
audit de SECOND NIVEAU : il lit les hand-off `out/` déjà commités par les
|
||||
générateurs amont (workflow vente, DocType Dossier Vente, barème commissions,
|
||||
plan e-CF DGII, DocType CONFOTUR) et vérifie 17 contrôles répartis en 5
|
||||
dimensions (Traçabilité ISA 500 · AML/UAF Ley 155-17 · Fiscal e-CF Ley 32-23 ·
|
||||
Intégrité IFRS · Gouvernance ISA 315).
|
||||
|
||||
ANTI-INVENTION (#6) : l'audit ne fabrique AUCUN paramètre réglementaire. Un
|
||||
paramètre réel non confirmé (taux commission, RNC/ITBIS/TipoCambio, seuil UAF)
|
||||
produit un statut A_CONFIRMER — un « open item » remonté au métier propriétaire —
|
||||
jamais une valeur inventée « pour faire PASS ». Un FAIL signale une incohérence
|
||||
inter-livrables ou une valeur chiffrée présente SANS `source`.
|
||||
|
||||
Sous-commandes :
|
||||
build [-o OUT] → écrit audit_report.json + MANIFEST.json
|
||||
validate → (re)génère en mémoire, valide schéma + 15 invariants ;
|
||||
sort en erreur si un contrôle FAIL ou un invariant casse.
|
||||
|
||||
Sortie déterministe (tri stable, aucun horodatage) → diffable + re-générable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, _HERE)
|
||||
|
||||
from qalib import artifacts, builder, controls # noqa: E402
|
||||
from qalib.deps import validate as maison_validate # noqa: E402
|
||||
|
||||
_SPEC_PATH = os.path.join(_HERE, "audit_spec.json")
|
||||
_SCHEMA_PATH = os.path.join(_HERE, "audit.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 _manifest(spec: dict, report: dict) -> dict:
|
||||
return {
|
||||
"generated_from": "audit_spec.json",
|
||||
"audited_artifacts": sorted(artifacts.ARTIFACT_PATHS.values()),
|
||||
"dimensions": [d["id"] for d in report["dimensions"]],
|
||||
"controls_total": report["totals"]["controls"],
|
||||
"verdict": report["verdict"],
|
||||
"open_items": [oi["control"] for oi in report["open_items"]],
|
||||
"note_anti_invention": (
|
||||
"Audit de second niveau : lit les hand-off out/ des livrables, ne "
|
||||
"fabrique aucun paramètre réglementaire (#6). A_CONFIRMER = open "
|
||||
"item à confirmer par le métier, jamais une valeur inventée."
|
||||
),
|
||||
"hand_off_vps": (
|
||||
"Confirmer les paramètres réglementaires (taux, RNC/ITBIS/"
|
||||
"TipoCambio, seuil UAF) + exécuter les tests E2E Playwright sur le "
|
||||
"desk réel → agents Direction/Compta/ONAPI-Legal (#8)."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _validate(spec: dict, report: dict) -> list[str]:
|
||||
"""Schéma de sortie + 15 invariants de cohérence audit/contrôles."""
|
||||
errors = list(maison_validate(report, _load(_SCHEMA_PATH)))
|
||||
|
||||
code_ids = controls.control_ids()
|
||||
spec_ids = sorted(c["id"] for c in spec["controls"])
|
||||
report_ids = sorted(c["id"] for c in report["controls"])
|
||||
code_dims = [d["id"] for d in controls.DIMENSIONS]
|
||||
spec_dims = [d["id"] for d in spec["dimensions"]]
|
||||
|
||||
spec_by_id = {c["id"]: c for c in spec["controls"]}
|
||||
code_by_id = {c["id"]: c for c in controls.CONTROLS}
|
||||
known_art = set(artifacts.ARTIFACT_PATHS)
|
||||
|
||||
# 1 · parité des id de contrôle : code == spec == rapport (aucune dérive).
|
||||
if not (code_ids == spec_ids == report_ids):
|
||||
errors.append(f"id de contrôle désalignés — code={code_ids} spec={spec_ids} report={report_ids}")
|
||||
|
||||
# 2 · parité des dimensions : code == spec, ordre identique.
|
||||
if code_dims != spec_dims:
|
||||
errors.append(f"dimensions désalignées — code={code_dims} spec={spec_dims}")
|
||||
|
||||
# 3 · chaque contrôle vise une dimension déclarée.
|
||||
for c in report["controls"]:
|
||||
if c["dimension"] not in code_dims:
|
||||
errors.append(f"contrôle {c['id']} → dimension inconnue {c['dimension']!r}")
|
||||
|
||||
# 4 · artefacts déclarés (spec) == artefacts du code, tous connus.
|
||||
for cid in code_ids:
|
||||
sart = spec_by_id.get(cid, {}).get("artifacts")
|
||||
cart = code_by_id[cid]["artifacts"]
|
||||
if sart != cart:
|
||||
errors.append(f"contrôle {cid} : artefacts spec {sart} ≠ code {cart}")
|
||||
for a in cart:
|
||||
if a not in known_art:
|
||||
errors.append(f"contrôle {cid} : artefact inconnu {a!r}")
|
||||
|
||||
# 5 · parité des propriétaires (owner) code ↔ spec.
|
||||
for cid in code_ids:
|
||||
if code_by_id[cid]["owner"] != spec_by_id.get(cid, {}).get("owner"):
|
||||
errors.append(f"contrôle {cid} : owner spec ≠ code")
|
||||
|
||||
# 6 · aucun id de contrôle dupliqué dans le rapport.
|
||||
if len(report_ids) != len(set(report_ids)):
|
||||
errors.append("id de contrôle dupliqué dans le rapport")
|
||||
|
||||
# 7 · totaux = somme des statuts, cohérents avec le nombre de contrôles.
|
||||
t = report["totals"]
|
||||
if t["pass"] + t["fail"] + t["a_confirmer"] != t["controls"]:
|
||||
errors.append("totals: pass+fail+a_confirmer != controls")
|
||||
if t["controls"] != len(report["controls"]):
|
||||
errors.append("totals.controls != nombre de contrôles")
|
||||
|
||||
# 8 · totaux = somme des synthèses par dimension.
|
||||
for key in ("pass", "fail", "a_confirmer"):
|
||||
s = sum(d[key] for d in report["dimensions"])
|
||||
if s != t[key]:
|
||||
errors.append(f"somme dimension.{key} ({s}) != totals.{key} ({t[key]})")
|
||||
|
||||
# 9 · verdict cohérent avec les totaux.
|
||||
expected = ("FAIL" if t["fail"] else
|
||||
"PASS_WITH_OPEN_ITEMS" if t["a_confirmer"] else "PASS")
|
||||
if report["verdict"] != expected:
|
||||
errors.append(f"verdict {report['verdict']!r} != attendu {expected!r}")
|
||||
|
||||
# 10 · chaque contrôle A_CONFIRMER a un owner non nul et un open_item.
|
||||
oi_by_ctrl = {oi["control"]: oi for oi in report["open_items"]}
|
||||
for c in report["controls"]:
|
||||
if c["statut"] == controls.A_CONFIRMER:
|
||||
if not c["owner"]:
|
||||
errors.append(f"contrôle A_CONFIRMER {c['id']} sans owner")
|
||||
oi = oi_by_ctrl.get(c["id"])
|
||||
if oi is None:
|
||||
errors.append(f"contrôle A_CONFIRMER {c['id']} absent des open_items")
|
||||
elif oi["owner"] != c["owner"]:
|
||||
errors.append(f"open_item {c['id']} : owner incohérent")
|
||||
|
||||
# 11 · tout open_item correspond à un contrôle A_CONFIRMER (pas d'orphelin).
|
||||
ac_ids = {c["id"] for c in report["controls"] if c["statut"] == controls.A_CONFIRMER}
|
||||
for oi in report["open_items"]:
|
||||
if oi["control"] not in ac_ids:
|
||||
errors.append(f"open_item orphelin : {oi['control']}")
|
||||
|
||||
# 12 · statut de dimension cohérent avec ses compteurs.
|
||||
for d in report["dimensions"]:
|
||||
exp = ("FAIL" if d["fail"] else
|
||||
"PASS_WITH_OPEN_ITEMS" if d["a_confirmer"] else "PASS")
|
||||
if d["statut"] != exp:
|
||||
errors.append(f"dimension {d['id']} statut {d['statut']!r} != {exp!r}")
|
||||
if d["controls_total"] != d["pass"] + d["fail"] + d["a_confirmer"]:
|
||||
errors.append(f"dimension {d['id']} : controls_total incohérent")
|
||||
if d["controls_total"] < 1:
|
||||
errors.append(f"dimension {d['id']} : aucun contrôle")
|
||||
|
||||
# 13 · les 5 dimensions sont présentes.
|
||||
if len(report["dimensions"]) != 5:
|
||||
errors.append(f"{len(report['dimensions'])} dimensions (attendu 5)")
|
||||
|
||||
# 14 · AUCUN contrôle FAIL sur les livrables courants (cohérence structurelle).
|
||||
fails = [c["id"] for c in report["controls"] if c["statut"] == controls.FAIL]
|
||||
if fails:
|
||||
errors.append(f"contrôle(s) FAIL sur les livrables : {fails}")
|
||||
|
||||
# 15 · déterminisme : re-générer donne un rapport identique.
|
||||
if builder.build(spec) != report:
|
||||
errors.append("build non déterministe (deux exécutions divergent)")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def cmd_build(args) -> int:
|
||||
spec = _load(_SPEC_PATH)
|
||||
report = builder.build(spec)
|
||||
errors = _validate(spec, report)
|
||||
if errors:
|
||||
_eprint("ÉCHEC validation — build refusé :")
|
||||
for e in errors:
|
||||
_eprint(" -", e)
|
||||
return 1
|
||||
out = args.out or _DEFAULT_OUT
|
||||
os.makedirs(out, exist_ok=True)
|
||||
_write_json(os.path.join(out, "audit_report.json"), report)
|
||||
_write_json(os.path.join(out, "MANIFEST.json"), _manifest(spec, report))
|
||||
print(f"OK · audit 5D → {out}")
|
||||
print(f" verdict={report['verdict']} · {report['totals']['controls']} contrôles "
|
||||
f"({report['totals']['pass']} PASS / {report['totals']['fail']} FAIL / "
|
||||
f"{report['totals']['a_confirmer']} À CONFIRMER)")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_validate(args) -> int:
|
||||
spec = _load(_SPEC_PATH)
|
||||
report = builder.build(spec)
|
||||
errors = _validate(spec, report)
|
||||
if errors:
|
||||
_eprint("ÉCHEC validation :")
|
||||
for e in errors:
|
||||
_eprint(" -", e)
|
||||
return 1
|
||||
print(f"OK · {report['totals']['controls']} contrôles, 15 invariants — "
|
||||
f"verdict {report['verdict']}")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
p = argparse.ArgumentParser(description="Audit 5D de conformité (QA · Sprint 5).")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
b = sub.add_parser("build", help="écrit audit_report.json + MANIFEST.json")
|
||||
b.add_argument("-o", "--out", help="dossier de sortie (défaut : ./out)")
|
||||
b.set_defaults(func=cmd_build)
|
||||
v = sub.add_parser("validate", help="valide schéma + 15 invariants")
|
||||
v.set_defaults(func=cmd_validate)
|
||||
args = p.parse_args(argv)
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user