c06e15c058
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>
237 lines
9.7 KiB
Python
237 lines
9.7 KiB
Python
#!/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())
|