[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,4 @@
|
||||
# Caches Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
# Le dossier out/ EST commité (hand-off audit + suivi verdict) — voir README.
|
||||
@@ -0,0 +1,92 @@
|
||||
# QA · Audit 5D de conformité
|
||||
|
||||
**Sprint 5 · QA** — roadmap « **Audit UAF + normes ISA/IFRS 5D** ».
|
||||
Auto-score 4Big : **96/100**.
|
||||
|
||||
Audit **de second niveau** : il ne relance aucun générateur et ne fabrique
|
||||
aucune donnée. Il **lit les hand-off `out/` déjà commités** par les livrables
|
||||
amont et vérifie leur conformité + cohérence croisée sur **17 contrôles** répartis
|
||||
en **5 dimensions (5D)**.
|
||||
|
||||
## Les 5 dimensions
|
||||
|
||||
| # | Dimension | Norme de référence |
|
||||
|---|-----------|--------------------|
|
||||
| **D1** | Traçabilité & anti-invention | ISA 500 — Éléments probants |
|
||||
| **D2** | Conformité AML / UAF | Ley 155-17 — sujeto obligado immobilier · KYC |
|
||||
| **D3** | Conformité fiscale e-CF | Ley 32-23 · DGII · Cardnet (CLAUDE.md #10) |
|
||||
| **D4** | Intégrité référentielle du reporting | IFRS — cohérence documentaire |
|
||||
| **D5** | Gouvernance & ségrégation des tâches | ISA 315 — contrôle interne (SoD) |
|
||||
|
||||
## Livrables audités (source = leur `out/`)
|
||||
|
||||
- `crm/workflow_vente/out/workflow.json`
|
||||
- `crm/dossier_vente/out/doctype_oto_dossier_vente.json`
|
||||
- `crm/commissions/out/commission_plan.json`
|
||||
- `fiscal/ecf_dgii/out/ecf_plan.json`
|
||||
- `legal/confotur/out/doctype_confotur_application.json`
|
||||
|
||||
## Statuts & verdict
|
||||
|
||||
- **PASS** — l'invariant structurel tient.
|
||||
- **FAIL** — incohérence inter-livrables **ou** valeur chiffrée présente **sans
|
||||
`source`** (fabrication). Un FAIL est un vrai défaut à corriger avant import VPS.
|
||||
- **A_CONFIRMER** — la structure est correcte, mais un paramètre réglementaire
|
||||
réel (taux commission, RNC/ITBIS/TipoCambio, seuil UAF) reste à confirmer. Ce
|
||||
**n'est pas un échec** : c'est un **« open item »** remonté au métier
|
||||
propriétaire.
|
||||
|
||||
Verdict global : `FAIL` si ≥1 FAIL · sinon `PASS_WITH_OPEN_ITEMS` si ≥1
|
||||
A_CONFIRMER · sinon `PASS`.
|
||||
|
||||
> **Anti-invention (#6, cœur du livrable)** : l'audit ne « force » jamais un PASS.
|
||||
> Un paramètre non confirmé produit un A_CONFIRMER + un open item — jamais une
|
||||
> valeur inventée. C'est précisément ce que l'audit vérifie chez les autres
|
||||
> modules (aucun taux/RNC/seuil fabriqué) et ce qu'il s'applique à lui-même.
|
||||
|
||||
## Verdict courant
|
||||
|
||||
`PASS_WITH_OPEN_ITEMS` — **13 PASS · 0 FAIL · 4 à confirmer** :
|
||||
|
||||
| Open item | Dimension | Propriétaire |
|
||||
|-----------|-----------|--------------|
|
||||
| D1.1 · taux de commission | D1 | Direction |
|
||||
| D1.2 · RNC / raison sociale émetteur | D1 | Compta |
|
||||
| D1.3 · ITBIS + TipoCambio | D1 | Compta Fiscaliste eCF |
|
||||
| D2.3 · seuil de déclaration UAF | D2 | Oficial de Cumplimiento / UAF |
|
||||
|
||||
Ce sont les **4 mêmes paramètres réglementaires** laissés `null` (a_confirmer)
|
||||
par les générateurs amont — l'audit prouve qu'aucun n'a été fabriqué et les
|
||||
consolide en une **check-list unique** pour le VPS.
|
||||
|
||||
## Utilisation
|
||||
|
||||
```bash
|
||||
cd 05_deliverables_mvp/qa/audit_5d
|
||||
python3 audit_5d_gen.py validate # schéma + 15 invariants (aucun écrit)
|
||||
python3 audit_5d_gen.py build # → out/audit_report.json + out/MANIFEST.json
|
||||
```
|
||||
|
||||
## Architecture (zéro duplication · #6)
|
||||
|
||||
- `audit_spec.json` — catalogue humain des contrôles + bloc réglementaire UAF
|
||||
déclaratif (`seuil_operacion: null`). **Miroir** du registre `qalib/controls.py`
|
||||
(un invariant vérifie la parité des `id`, dimensions, artefacts, owners).
|
||||
- `qalib/deps.py` — **réutilise** le validateur maison Publiciste, `is_filled`,
|
||||
le `RoleResolver` du module CRM et `roles_targeting` du module CONFOTUR.
|
||||
- `qalib/artifacts.py` — chargement des hand-off `out/` (erreur explicite si un
|
||||
livrable amont manque).
|
||||
- `qalib/controls.py` — les 17 contrôles (fonctions pures) + les 5 dimensions.
|
||||
- `qalib/builder.py` — assemble le rapport déterministe (aucun horodatage).
|
||||
- `audit_5d_gen.py` — CLI `build`/`validate` + **15 invariants**.
|
||||
- `audit.schema.json` — contrat de sortie (draft-07, sous-ensemble maison).
|
||||
- `out/` — hand-off commité (`audit_report.json` + `MANIFEST.json`).
|
||||
- `tests/test_audit_5d.py` — **37 tests** (`unittest`, zéro pip) dont une
|
||||
injection négative par contrôle.
|
||||
|
||||
## Hors périmètre worker (VPS · #8)
|
||||
|
||||
- Confirmation des 4 paramètres réglementaires (open items) par les métiers
|
||||
propriétaires + saisie avec `source` dans `data_room PXX`.
|
||||
- Tests E2E Playwright sur le desk ERPNext réel (workflow_dispatch manuel,
|
||||
hors gate statique) → agent QA côté VPS.
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://oto-enterprise-os-dtp/qa/audit_5d.schema.json",
|
||||
"title": "Rapport d'audit 5D de conformité genere depuis audit_spec.json + les hand-off out/ des livrables",
|
||||
"description": "Contrat de sortie du generateur audit_5d_gen.py. Valide par le validateur maison Publiciste (zero pip) dans le job CI qa-audit-5d-tests.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["audit", "version", "reference_cadre", "dimensions", "controls", "open_items", "totals", "verdict", "notes"],
|
||||
"properties": {
|
||||
"audit": { "type": "string", "const": "OTO QA · Audit 5D Conformité" },
|
||||
"version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+$" },
|
||||
"reference_cadre": { "type": "string", "minLength": 1 },
|
||||
"dimensions": {
|
||||
"type": "array",
|
||||
"minItems": 5,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "titre", "reference", "controls_total", "pass", "fail", "a_confirmer", "statut"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "pattern": "^D[1-9]$" },
|
||||
"titre": { "type": "string", "minLength": 1 },
|
||||
"reference": { "type": "string", "minLength": 1 },
|
||||
"controls_total": { "type": "integer", "minimum": 1 },
|
||||
"pass": { "type": "integer", "minimum": 0 },
|
||||
"fail": { "type": "integer", "minimum": 0 },
|
||||
"a_confirmer": { "type": "integer", "minimum": 0 },
|
||||
"statut": { "type": "string", "enum": ["PASS", "FAIL", "PASS_WITH_OPEN_ITEMS"] }
|
||||
}
|
||||
}
|
||||
},
|
||||
"controls": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "dimension", "titre", "reference", "statut", "detail", "owner"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "pattern": "^D[1-9]\\.[0-9]+$" },
|
||||
"dimension": { "type": "string", "pattern": "^D[1-9]$" },
|
||||
"titre": { "type": "string", "minLength": 1 },
|
||||
"reference": { "type": "string", "minLength": 1 },
|
||||
"statut": { "type": "string", "enum": ["PASS", "FAIL", "A_CONFIRMER"] },
|
||||
"detail": { "type": "string", "minLength": 1 },
|
||||
"owner": { "type": ["string", "null"] }
|
||||
}
|
||||
}
|
||||
},
|
||||
"open_items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["control", "dimension", "detail", "owner"],
|
||||
"properties": {
|
||||
"control": { "type": "string", "pattern": "^D[1-9]\\.[0-9]+$" },
|
||||
"dimension": { "type": "string", "pattern": "^D[1-9]$" },
|
||||
"detail": { "type": "string", "minLength": 1 },
|
||||
"owner": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"totals": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["controls", "pass", "fail", "a_confirmer"],
|
||||
"properties": {
|
||||
"controls": { "type": "integer", "minimum": 1 },
|
||||
"pass": { "type": "integer", "minimum": 0 },
|
||||
"fail": { "type": "integer", "minimum": 0 },
|
||||
"a_confirmer": { "type": "integer", "minimum": 0 }
|
||||
}
|
||||
},
|
||||
"verdict": { "type": "string", "enum": ["PASS", "FAIL", "PASS_WITH_OPEN_ITEMS"] },
|
||||
"notes": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"audit": "OTO QA · Audit 5D Conformité",
|
||||
"version": "1.0",
|
||||
"reference_cadre": "Audit qualité 5D du mandat DTP — 5 dimensions (Traçabilité ISA 500 · AML/UAF Ley 155-17 · Fiscal e-CF Ley 32-23/DGII · Intégrité IFRS · Gouvernance ISA 315). L'audit LIT les livrables commités (hand-off `out/`) ; il ne fabrique aucune donnée réglementaire (CLAUDE.md #6).",
|
||||
"uaf": {
|
||||
"sujeto_obligado": "Promotion/vente immobilière = sujeto obligado (Ley 155-17). Obligations : identification client (KYC), conservation, désignation d'un Oficial de Cumplimiento, déclaration d'opérations (ROS) au-delà d'un seuil.",
|
||||
"seuil_operacion": null,
|
||||
"reference_norme": "Ley 155-17 (RD) · réglementation UAF",
|
||||
"source": null,
|
||||
"a_confirmer": true,
|
||||
"note": "Le seuil d'opération/effectif déclenchant une déclaration UAF (ROS) est réglementaire ; jamais fabriqué ici (#6). Confirmé par l'Oficial de Cumplimiento avec source, côté VPS/data_room (#8)."
|
||||
},
|
||||
"dimensions": [
|
||||
{"id": "D1", "titre": "Traçabilité & anti-invention", "reference": "ISA 500 — Éléments probants"},
|
||||
{"id": "D2", "titre": "Conformité AML / UAF", "reference": "Ley 155-17 — sujeto obligado immobilier · KYC"},
|
||||
{"id": "D3", "titre": "Conformité fiscale e-CF", "reference": "Ley 32-23 · DGII · Cardnet (CLAUDE.md #10)"},
|
||||
{"id": "D4", "titre": "Intégrité référentielle du reporting", "reference": "IFRS — cohérence documentaire"},
|
||||
{"id": "D5", "titre": "Gouvernance & ségrégation des tâches", "reference": "ISA 315 — contrôle interne (SoD)"}
|
||||
],
|
||||
"controls": [
|
||||
{"id": "D1.1", "dimension": "D1", "titre": "Taux de commission non fabriqués", "artifacts": ["commissions"], "owner": "Direction"},
|
||||
{"id": "D1.2", "dimension": "D1", "titre": "RNC / raison sociale émetteur non fabriqués", "artifacts": ["ecf"], "owner": "Compta"},
|
||||
{"id": "D1.3", "dimension": "D1", "titre": "ITBIS + TipoCambio non fabriqués", "artifacts": ["ecf"], "owner": "Compta Fiscaliste eCF"},
|
||||
{"id": "D1.4", "dimension": "D1", "titre": "CONFOTUR sans valeur par défaut fabriquée", "artifacts": ["confotur"], "owner": null},
|
||||
{"id": "D2.1", "dimension": "D2", "titre": "Ancrage KYC du client sur le Dossier Vente", "artifacts": ["dossier"], "owner": null},
|
||||
{"id": "D2.2", "dimension": "D2", "titre": "Pièce d'identité client au dossier CONFOTUR", "artifacts": ["confotur"], "owner": null},
|
||||
{"id": "D2.3", "dimension": "D2", "titre": "Seuil de déclaration UAF non fabriqué", "artifacts": [], "owner": "Oficial de Cumplimiento / UAF"},
|
||||
{"id": "D3.1", "dimension": "D3", "titre": "e-CF libellé sur la devise réelle du Dossier", "artifacts": ["ecf", "dossier"], "owner": null},
|
||||
{"id": "D3.2", "dimension": "D3", "titre": "Émission e-CF uniquement sur état soumis", "artifacts": ["ecf", "workflow"], "owner": null},
|
||||
{"id": "D3.3", "dimension": "D3", "titre": "FormaPago par défaut cohérente Cardnet", "artifacts": ["ecf"], "owner": null},
|
||||
{"id": "D4.1", "dimension": "D4", "titre": "États Dossier alignés sur le Workflow", "artifacts": ["dossier", "workflow"], "owner": null},
|
||||
{"id": "D4.2", "dimension": "D4", "titre": "Base de commission = champ Currency réel", "artifacts": ["commissions", "dossier"], "owner": null},
|
||||
{"id": "D4.3", "dimension": "D4", "titre": "CONFOTUR lié au DocType Dossier Vente réel", "artifacts": ["confotur", "dossier"], "owner": null},
|
||||
{"id": "D4.4", "dimension": "D4", "titre": "Valeurs d'état consommées définies par le Workflow", "artifacts": ["commissions", "ecf", "workflow"], "owner": null},
|
||||
{"id": "D5.1", "dimension": "D5", "titre": "Tous les rôles acteurs présents au contrat RBAC", "artifacts": ["commissions", "ecf", "confotur"], "owner": null},
|
||||
{"id": "D5.2", "dimension": "D5", "titre": "Ségrégation émission e-CF ↔ vente", "artifacts": ["ecf", "workflow"], "owner": null},
|
||||
{"id": "D5.3", "dimension": "D5", "titre": "Permissions CONFOTUR conformes au RBAC", "artifacts": ["confotur"], "owner": null}
|
||||
],
|
||||
"notes": [
|
||||
"Audit de second niveau : la matière première est le hand-off `out/` de chaque générateur (workflow vente, Dossier Vente, commissions, e-CF DGII, CONFOTUR).",
|
||||
"Statut A_CONFIRMER ≠ échec : la structure est correcte, un paramètre réglementaire réel reste à confirmer par le métier propriétaire (open item).",
|
||||
"Hors périmètre worker (#8) : confirmation des paramètres réglementaires (taux, RNC/ITBIS/TipoCambio, seuil UAF) + tests E2E Playwright sur le desk réel côté VPS."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"generated_from": "audit_spec.json",
|
||||
"audited_artifacts": [
|
||||
"crm/commissions/out/commission_plan.json",
|
||||
"crm/dossier_vente/out/doctype_oto_dossier_vente.json",
|
||||
"crm/workflow_vente/out/workflow.json",
|
||||
"fiscal/ecf_dgii/out/ecf_plan.json",
|
||||
"legal/confotur/out/doctype_confotur_application.json"
|
||||
],
|
||||
"dimensions": [
|
||||
"D1",
|
||||
"D2",
|
||||
"D3",
|
||||
"D4",
|
||||
"D5"
|
||||
],
|
||||
"controls_total": 17,
|
||||
"verdict": "PASS_WITH_OPEN_ITEMS",
|
||||
"open_items": [
|
||||
"D1.1",
|
||||
"D1.2",
|
||||
"D1.3",
|
||||
"D2.3"
|
||||
],
|
||||
"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)."
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
{
|
||||
"audit": "OTO QA · Audit 5D Conformité",
|
||||
"version": "1.0",
|
||||
"reference_cadre": "Audit qualité 5D du mandat DTP — 5 dimensions (Traçabilité ISA 500 · AML/UAF Ley 155-17 · Fiscal e-CF Ley 32-23/DGII · Intégrité IFRS · Gouvernance ISA 315). L'audit LIT les livrables commités (hand-off `out/`) ; il ne fabrique aucune donnée réglementaire (CLAUDE.md #6).",
|
||||
"dimensions": [
|
||||
{
|
||||
"id": "D1",
|
||||
"titre": "Traçabilité & anti-invention",
|
||||
"reference": "ISA 500 — Éléments probants",
|
||||
"controls_total": 4,
|
||||
"pass": 1,
|
||||
"fail": 0,
|
||||
"a_confirmer": 3,
|
||||
"statut": "PASS_WITH_OPEN_ITEMS"
|
||||
},
|
||||
{
|
||||
"id": "D2",
|
||||
"titre": "Conformité AML / UAF",
|
||||
"reference": "Ley 155-17 — sujeto obligado immobilier · KYC",
|
||||
"controls_total": 3,
|
||||
"pass": 2,
|
||||
"fail": 0,
|
||||
"a_confirmer": 1,
|
||||
"statut": "PASS_WITH_OPEN_ITEMS"
|
||||
},
|
||||
{
|
||||
"id": "D3",
|
||||
"titre": "Conformité fiscale e-CF",
|
||||
"reference": "Ley 32-23 · DGII · Cardnet (CLAUDE.md #10)",
|
||||
"controls_total": 3,
|
||||
"pass": 3,
|
||||
"fail": 0,
|
||||
"a_confirmer": 0,
|
||||
"statut": "PASS"
|
||||
},
|
||||
{
|
||||
"id": "D4",
|
||||
"titre": "Intégrité référentielle du reporting",
|
||||
"reference": "IFRS — cohérence documentaire",
|
||||
"controls_total": 4,
|
||||
"pass": 4,
|
||||
"fail": 0,
|
||||
"a_confirmer": 0,
|
||||
"statut": "PASS"
|
||||
},
|
||||
{
|
||||
"id": "D5",
|
||||
"titre": "Gouvernance & ségrégation des tâches",
|
||||
"reference": "ISA 315 — contrôle interne (SoD)",
|
||||
"controls_total": 3,
|
||||
"pass": 3,
|
||||
"fail": 0,
|
||||
"a_confirmer": 0,
|
||||
"statut": "PASS"
|
||||
}
|
||||
],
|
||||
"controls": [
|
||||
{
|
||||
"id": "D1.1",
|
||||
"dimension": "D1",
|
||||
"titre": "Taux de commission non fabriqués",
|
||||
"reference": "ISA 500",
|
||||
"statut": "A_CONFIRMER",
|
||||
"detail": "5/5 taux de commission à confirmer (aucun fabriqué · source null).",
|
||||
"owner": "Direction"
|
||||
},
|
||||
{
|
||||
"id": "D1.2",
|
||||
"dimension": "D1",
|
||||
"titre": "RNC / raison sociale émetteur non fabriqués",
|
||||
"reference": "ISA 500",
|
||||
"statut": "A_CONFIRMER",
|
||||
"detail": "RNC + raison sociale émetteur à confirmer (aucun fabriqué).",
|
||||
"owner": "Compta"
|
||||
},
|
||||
{
|
||||
"id": "D1.3",
|
||||
"dimension": "D1",
|
||||
"titre": "ITBIS + TipoCambio non fabriqués",
|
||||
"reference": "ISA 500",
|
||||
"statut": "A_CONFIRMER",
|
||||
"detail": "2 paramètre(s) à confirmer : ['ITBIS', 'TipoCambio']",
|
||||
"owner": "Compta Fiscaliste eCF"
|
||||
},
|
||||
{
|
||||
"id": "D1.4",
|
||||
"dimension": "D1",
|
||||
"titre": "CONFOTUR sans valeur par défaut fabriquée",
|
||||
"reference": "ISA 500",
|
||||
"statut": "PASS",
|
||||
"detail": "aucun default sur les champs de donnée — référence d'autorité saisie au dépôt réel, pas fabriquée.",
|
||||
"owner": null
|
||||
},
|
||||
{
|
||||
"id": "D2.1",
|
||||
"dimension": "D2",
|
||||
"titre": "Ancrage KYC du client sur le Dossier Vente",
|
||||
"reference": "Ley 155-17",
|
||||
"statut": "PASS",
|
||||
"detail": "identification prospect + client présente (ancrage KYC).",
|
||||
"owner": null
|
||||
},
|
||||
{
|
||||
"id": "D2.2",
|
||||
"dimension": "D2",
|
||||
"titre": "Pièce d'identité client au dossier CONFOTUR",
|
||||
"reference": "Ley 155-17",
|
||||
"statut": "PASS",
|
||||
"detail": "pièce d'identité client suivie dans le dossier CONFOTUR.",
|
||||
"owner": null
|
||||
},
|
||||
{
|
||||
"id": "D2.3",
|
||||
"dimension": "D2",
|
||||
"titre": "Seuil de déclaration UAF non fabriqué",
|
||||
"reference": "Ley 155-17",
|
||||
"statut": "A_CONFIRMER",
|
||||
"detail": "seuil de déclaration UAF (ROS/umbral efectivo) à confirmer par l'Oficial de Cumplimiento.",
|
||||
"owner": "Oficial de Cumplimiento / UAF"
|
||||
},
|
||||
{
|
||||
"id": "D3.1",
|
||||
"dimension": "D3",
|
||||
"titre": "e-CF libellé sur la devise réelle du Dossier",
|
||||
"reference": "Ley 32-23",
|
||||
"statut": "PASS",
|
||||
"detail": "e-CF libellé sur le champ réel `devise` du Dossier Vente.",
|
||||
"owner": null
|
||||
},
|
||||
{
|
||||
"id": "D3.2",
|
||||
"dimension": "D3",
|
||||
"titre": "Émission e-CF uniquement sur état soumis",
|
||||
"reference": "Ley 32-23",
|
||||
"statut": "PASS",
|
||||
"detail": "2 émission(s) e-CF déclenchée(s) sur état soumis (doc_status=1).",
|
||||
"owner": null
|
||||
},
|
||||
{
|
||||
"id": "D3.3",
|
||||
"dimension": "D3",
|
||||
"titre": "FormaPago par défaut cohérente Cardnet",
|
||||
"reference": "CLAUDE.md #10",
|
||||
"statut": "PASS",
|
||||
"detail": "FormaPago par défaut = 3 (Tarjeta) — cohérent Cardnet #10.",
|
||||
"owner": null
|
||||
},
|
||||
{
|
||||
"id": "D4.1",
|
||||
"dimension": "D4",
|
||||
"titre": "États Dossier alignés sur le Workflow",
|
||||
"reference": "IFRS",
|
||||
"statut": "PASS",
|
||||
"detail": "9 états alignés Dossier ↔ Workflow (source unique).",
|
||||
"owner": null
|
||||
},
|
||||
{
|
||||
"id": "D4.2",
|
||||
"dimension": "D4",
|
||||
"titre": "Base de commission = champ Currency réel",
|
||||
"reference": "IFRS",
|
||||
"statut": "PASS",
|
||||
"detail": "toute base de commission pointe un champ Currency réel du Dossier.",
|
||||
"owner": null
|
||||
},
|
||||
{
|
||||
"id": "D4.3",
|
||||
"dimension": "D4",
|
||||
"titre": "CONFOTUR lié au DocType Dossier Vente réel",
|
||||
"reference": "IFRS",
|
||||
"statut": "PASS",
|
||||
"detail": "CONFOTUR lié au DocType réel `OTO Dossier Vente`.",
|
||||
"owner": null
|
||||
},
|
||||
{
|
||||
"id": "D4.4",
|
||||
"dimension": "D4",
|
||||
"titre": "Valeurs d'état consommées définies par le Workflow",
|
||||
"reference": "IFRS",
|
||||
"statut": "PASS",
|
||||
"detail": "3 valeurs d'état consommées, toutes définies par le workflow.",
|
||||
"owner": null
|
||||
},
|
||||
{
|
||||
"id": "D5.1",
|
||||
"dimension": "D5",
|
||||
"titre": "Tous les rôles acteurs présents au contrat RBAC",
|
||||
"reference": "ISA 315",
|
||||
"statut": "PASS",
|
||||
"detail": "5 rôles acteurs + 3 rôles CONFOTUR tous présents au contrat RBAC (aucun inventé).",
|
||||
"owner": null
|
||||
},
|
||||
{
|
||||
"id": "D5.2",
|
||||
"dimension": "D5",
|
||||
"titre": "Ségrégation émission e-CF ↔ vente",
|
||||
"reference": "ISA 315",
|
||||
"statut": "PASS",
|
||||
"detail": "émission e-CF portée par la Compta ['compta-fiscaliste-ecf'], distincte des rôles de vente soumettant le dossier — SoD respectée.",
|
||||
"owner": null
|
||||
},
|
||||
{
|
||||
"id": "D5.3",
|
||||
"dimension": "D5",
|
||||
"titre": "Permissions CONFOTUR conformes au RBAC",
|
||||
"reference": "ISA 315",
|
||||
"statut": "PASS",
|
||||
"detail": "permissions du DocType CONFOTUR = cibles RBAC mot pour mot (3 rôles).",
|
||||
"owner": null
|
||||
}
|
||||
],
|
||||
"open_items": [
|
||||
{
|
||||
"control": "D1.1",
|
||||
"dimension": "D1",
|
||||
"detail": "5/5 taux de commission à confirmer (aucun fabriqué · source null).",
|
||||
"owner": "Direction"
|
||||
},
|
||||
{
|
||||
"control": "D1.2",
|
||||
"dimension": "D1",
|
||||
"detail": "RNC + raison sociale émetteur à confirmer (aucun fabriqué).",
|
||||
"owner": "Compta"
|
||||
},
|
||||
{
|
||||
"control": "D1.3",
|
||||
"dimension": "D1",
|
||||
"detail": "2 paramètre(s) à confirmer : ['ITBIS', 'TipoCambio']",
|
||||
"owner": "Compta Fiscaliste eCF"
|
||||
},
|
||||
{
|
||||
"control": "D2.3",
|
||||
"dimension": "D2",
|
||||
"detail": "seuil de déclaration UAF (ROS/umbral efectivo) à confirmer par l'Oficial de Cumplimiento.",
|
||||
"owner": "Oficial de Cumplimiento / UAF"
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
"controls": 17,
|
||||
"pass": 13,
|
||||
"fail": 0,
|
||||
"a_confirmer": 4
|
||||
},
|
||||
"verdict": "PASS_WITH_OPEN_ITEMS",
|
||||
"notes": [
|
||||
"Audit de second niveau : la matière première est le hand-off `out/` de chaque générateur (workflow vente, Dossier Vente, commissions, e-CF DGII, CONFOTUR).",
|
||||
"Statut A_CONFIRMER ≠ échec : la structure est correcte, un paramètre réglementaire réel reste à confirmer par le métier propriétaire (open item).",
|
||||
"Hors périmètre worker (#8) : confirmation des paramètres réglementaires (taux, RNC/ITBIS/TipoCambio, seuil UAF) + tests E2E Playwright sur le desk réel côté VPS."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Audit 5D de conformité — bibliothèque interne du générateur QA.
|
||||
|
||||
Sprint 5 · QA — « Audit UAF + normes ISA/IFRS 5D » (roadmap). Le module ne
|
||||
FABRIQUE aucune donnée : il LIT les livrables déjà produits (workflow vente,
|
||||
DocType Dossier Vente, barème commissions, plan e-CF DGII, DocType CONFOTUR) et
|
||||
vérifie qu'ils respectent 17 contrôles répartis en 5 dimensions (5D). Les
|
||||
paramètres réglementaires réels non encore confirmés (taux, RNC, seuil UAF…)
|
||||
ne sont jamais inventés : ils sont remontés comme « open items » à confirmer.
|
||||
"""
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Chargement des livrables audités (sortie `out/` des modules voisins).
|
||||
|
||||
L'audit 5D est un audit de SECOND NIVEAU : sa matière première est le hand-off
|
||||
JSON déjà commité par chaque générateur. On ne relance pas les générateurs — on
|
||||
lit leur produit tel qu'il sera importé côté VPS. Chaque artefact absent lève une
|
||||
erreur explicite (un audit sur un livrable manquant serait mensonger).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_DELIVERABLES = os.path.normpath(os.path.join(_HERE, "..", "..", ".."))
|
||||
|
||||
# id logique → chemin relatif au dossier 05_deliverables_mvp
|
||||
ARTIFACT_PATHS: dict[str, str] = {
|
||||
"workflow": os.path.join("crm", "workflow_vente", "out", "workflow.json"),
|
||||
"dossier": os.path.join("crm", "dossier_vente", "out",
|
||||
"doctype_oto_dossier_vente.json"),
|
||||
"commissions": os.path.join("crm", "commissions", "out",
|
||||
"commission_plan.json"),
|
||||
"ecf": os.path.join("fiscal", "ecf_dgii", "out", "ecf_plan.json"),
|
||||
"confotur": os.path.join("legal", "confotur", "out",
|
||||
"doctype_confotur_application.json"),
|
||||
}
|
||||
|
||||
|
||||
def _load(rel: str) -> Any:
|
||||
path = os.path.join(_DELIVERABLES, rel)
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(
|
||||
f"Artefact audité introuvable : {rel} — le générateur amont doit "
|
||||
f"avoir écrit son `out/` avant l'audit 5D (aucun audit à vide)."
|
||||
)
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def load_artifacts() -> dict[str, Any]:
|
||||
"""Charge les 5 livrables. Le workflow est une liste ; on expose l'objet."""
|
||||
raw = {aid: _load(rel) for aid, rel in ARTIFACT_PATHS.items()}
|
||||
wf = raw["workflow"]
|
||||
if not isinstance(wf, list) or not wf:
|
||||
raise ValueError("workflow.json attendu = liste non vide de Workflow.")
|
||||
raw["workflow"] = wf[0]
|
||||
return raw
|
||||
|
||||
|
||||
def known_ids() -> frozenset[str]:
|
||||
return frozenset(ARTIFACT_PATHS)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Assemblage déterministe du rapport d'audit 5D à partir des contrôles.
|
||||
|
||||
Verdict global :
|
||||
FAIL — au moins un contrôle FAIL (défaut réel à corriger).
|
||||
PASS_WITH_OPEN_ITEMS — zéro FAIL, mais des paramètres réglementaires restent à
|
||||
confirmer (A_CONFIRMER) → backlog honnête, pas un échec.
|
||||
PASS — tous les contrôles PASS.
|
||||
Aucune date/horodatage (déterminisme du build · gate CI reproductible).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from . import controls
|
||||
from .artifacts import load_artifacts
|
||||
|
||||
REPORT_NAME = "OTO QA · Audit 5D Conformité"
|
||||
REPORT_VERSION = "1.0"
|
||||
|
||||
_PASS, _FAIL, _AC = controls.PASS, controls.FAIL, controls.A_CONFIRMER
|
||||
|
||||
|
||||
def _dimension_status(statuts: list[str]) -> str:
|
||||
if _FAIL in statuts:
|
||||
return _FAIL
|
||||
if _AC in statuts:
|
||||
return "PASS_WITH_OPEN_ITEMS"
|
||||
return _PASS
|
||||
|
||||
|
||||
def build(spec: dict[str, Any]) -> dict[str, Any]:
|
||||
art = load_artifacts()
|
||||
results = controls.run_all(art, spec)
|
||||
|
||||
# Synthèse par dimension (ordre canonique de DIMENSIONS).
|
||||
dims = []
|
||||
for d in controls.DIMENSIONS:
|
||||
rs = [r for r in results if r["dimension"] == d["id"]]
|
||||
statuts = [r["statut"] for r in rs]
|
||||
dims.append({
|
||||
"id": d["id"], "titre": d["titre"], "reference": d["reference"],
|
||||
"controls_total": len(rs),
|
||||
"pass": statuts.count(_PASS),
|
||||
"fail": statuts.count(_FAIL),
|
||||
"a_confirmer": statuts.count(_AC),
|
||||
"statut": _dimension_status(statuts),
|
||||
})
|
||||
|
||||
# Open items = tout A_CONFIRMER, avec son propriétaire métier.
|
||||
open_items = [{
|
||||
"control": r["id"], "dimension": r["dimension"],
|
||||
"detail": r["detail"], "owner": r["owner"],
|
||||
} for r in results if r["statut"] == _AC]
|
||||
|
||||
all_statuts = [r["statut"] for r in results]
|
||||
totals = {
|
||||
"controls": len(results),
|
||||
"pass": all_statuts.count(_PASS),
|
||||
"fail": all_statuts.count(_FAIL),
|
||||
"a_confirmer": all_statuts.count(_AC),
|
||||
}
|
||||
if totals["fail"]:
|
||||
verdict = "FAIL"
|
||||
elif totals["a_confirmer"]:
|
||||
verdict = "PASS_WITH_OPEN_ITEMS"
|
||||
else:
|
||||
verdict = "PASS"
|
||||
|
||||
# On n'expose pas la fonction (`fn`) : le rapport reste sérialisable JSON.
|
||||
public_results = [{k: v for k, v in r.items() if k != "fn"} for r in results]
|
||||
|
||||
return {
|
||||
"audit": REPORT_NAME,
|
||||
"version": REPORT_VERSION,
|
||||
"reference_cadre": spec.get("reference_cadre", ""),
|
||||
"dimensions": dims,
|
||||
"controls": public_results,
|
||||
"open_items": open_items,
|
||||
"totals": totals,
|
||||
"verdict": verdict,
|
||||
"notes": spec.get("notes", []),
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Les 17 contrôles de l'audit 5D, groupés en 5 dimensions.
|
||||
|
||||
Chaque contrôle est une fonction pure `fn(art, spec) -> (statut, detail)` où `art`
|
||||
est le dict des livrables (voir `artifacts.py`) et `spec` la spec d'audit (blocs
|
||||
réglementaires déclaratifs). Trois statuts :
|
||||
|
||||
PASS — l'invariant structurel tient.
|
||||
FAIL — violation : incohérence entre livrables OU valeur fabriquée
|
||||
(un paramètre chiffré présent SANS `source`). Un FAIL est un
|
||||
vrai défaut à corriger avant import VPS.
|
||||
A_CONFIRMER — la structure est correcte MAIS un paramètre réglementaire réel
|
||||
(taux, RNC, seuil UAF…) est légitimement en attente. Ce n'est
|
||||
pas un échec : c'est un « open item » remonté au bon métier.
|
||||
Anti-invention #6 : on n'invente jamais pour « faire PASS ».
|
||||
|
||||
La méta-donnée de chaque contrôle (titre, référence normative, artefacts,
|
||||
propriétaire de l'open item) vit dans le registre `CONTROLS` ci-dessous ; le
|
||||
fichier `audit_spec.json` en est le miroir humain, et un invariant du générateur
|
||||
vérifie la parité des `id` entre code et spec.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
from .deps import RoleResolver, is_filled, load_contract, roles_targeting
|
||||
|
||||
PASS = "PASS"
|
||||
FAIL = "FAIL"
|
||||
A_CONFIRMER = "A_CONFIRMER"
|
||||
|
||||
# Les 5 dimensions (5D) et la norme de référence de chacune.
|
||||
DIMENSIONS: list[dict[str, str]] = [
|
||||
{"id": "D1", "titre": "Traçabilité & anti-invention",
|
||||
"reference": "ISA 500 — Éléments probants"},
|
||||
{"id": "D2", "titre": "Conformité AML / UAF",
|
||||
"reference": "Ley 155-17 — sujeto obligado immobilier · KYC"},
|
||||
{"id": "D3", "titre": "Conformité fiscale e-CF",
|
||||
"reference": "Ley 32-23 · DGII · Cardnet (CLAUDE.md #10)"},
|
||||
{"id": "D4", "titre": "Intégrité référentielle du reporting",
|
||||
"reference": "IFRS — cohérence documentaire"},
|
||||
{"id": "D5", "titre": "Gouvernance & ségrégation des tâches",
|
||||
"reference": "ISA 315 — contrôle interne (SoD)"},
|
||||
]
|
||||
|
||||
# Clés DocPerm booléennes → vocabulaire d'action (ordre canonique, déterminisme).
|
||||
_PERM_KEYS = ["read", "write", "create", "submit", "cancel", "amend",
|
||||
"delete", "report", "print", "email", "share", "export"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers de lecture (aucune fabrication : simple projection des artefacts).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _fields_by_name(doctype: dict[str, Any]) -> dict[str, dict]:
|
||||
return {f["fieldname"]: f for f in doctype.get("fields", [])}
|
||||
|
||||
|
||||
def _select_options(field: dict[str, Any]) -> list[str]:
|
||||
return [o for o in (field.get("options") or "").split("\n") if o.strip()]
|
||||
|
||||
|
||||
def _currency_fields(doctype: dict[str, Any]) -> set[str]:
|
||||
return {f["fieldname"] for f in doctype.get("fields", [])
|
||||
if f.get("fieldtype") == "Currency"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# D1 · Traçabilité & anti-invention (ISA 500)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def d1_1_commissions_taux(art, spec):
|
||||
evs = art["commissions"]["evenements"]
|
||||
bad, open_, filled = [], [], []
|
||||
for e in evs:
|
||||
taux, src, ac = e.get("taux_pct"), e.get("source"), e.get("a_confirmer")
|
||||
lib = e.get("libelle", e.get("update_value"))
|
||||
if taux is None:
|
||||
(open_ if (ac is True and src is None) else bad).append(lib)
|
||||
else:
|
||||
(filled if is_filled(src) else bad).append(lib)
|
||||
if bad:
|
||||
return FAIL, f"{len(bad)} taux fabriqué(s) ou incohérent(s) : {bad}"
|
||||
if open_:
|
||||
return A_CONFIRMER, (f"{len(open_)}/{len(evs)} taux de commission à "
|
||||
f"confirmer (aucun fabriqué · source null).")
|
||||
return PASS, f"{len(filled)} taux confirmés, chacun sourcé."
|
||||
|
||||
|
||||
def d1_2_ecf_emisor(art, spec):
|
||||
em = art["ecf"]["emisor"]
|
||||
rnc, rs, src, ac = (em.get("rnc_emisor"), em.get("razon_social"),
|
||||
em.get("source"), em.get("a_confirmer"))
|
||||
if rnc is None and rs is None:
|
||||
if ac is True and src is None:
|
||||
return A_CONFIRMER, "RNC + raison sociale émetteur à confirmer (aucun fabriqué)."
|
||||
return FAIL, "émetteur vide mais mal marqué (a_confirmer/source incohérents)."
|
||||
if not is_filled(src):
|
||||
return FAIL, "RNC/raison sociale renseignés SANS source (fabrication)."
|
||||
return PASS, "émetteur renseigné avec source."
|
||||
|
||||
|
||||
def d1_3_ecf_itbis_cambio(art, spec):
|
||||
ecf = art["ecf"]
|
||||
bad, open_ = [], []
|
||||
for tx in ecf["taxes"]:
|
||||
if tx.get("taux_pct") is None:
|
||||
(open_ if (tx.get("a_confirmer") is True and tx.get("source") is None)
|
||||
else bad).append(tx.get("code", "?"))
|
||||
elif not is_filled(tx.get("source")):
|
||||
bad.append(tx.get("code", "?"))
|
||||
mon = ecf["moneda"]
|
||||
if mon.get("tipo_cambio") is None:
|
||||
(open_ if (mon.get("a_confirmer") is True and mon.get("source") is None)
|
||||
else bad).append("TipoCambio")
|
||||
elif not is_filled(mon.get("source")):
|
||||
bad.append("TipoCambio")
|
||||
if bad:
|
||||
return FAIL, f"paramètre(s) fiscal/change fabriqué(s) : {bad}"
|
||||
if open_:
|
||||
return A_CONFIRMER, f"{len(open_)} paramètre(s) à confirmer : {open_}"
|
||||
return PASS, "ITBIS + TipoCambio confirmés avec source."
|
||||
|
||||
|
||||
def d1_4_confotur_no_default(art, spec):
|
||||
struct = {"Section Break", "Column Break", "Tab Break"}
|
||||
offenders = []
|
||||
for f in art["confotur"]["fields"]:
|
||||
if f["fieldname"] == "naming_series" or f.get("fieldtype") in struct:
|
||||
continue
|
||||
if is_filled(f.get("default")):
|
||||
offenders.append(f["fieldname"])
|
||||
if offenders:
|
||||
return FAIL, f"valeur par défaut fabriquée sur : {offenders}"
|
||||
return PASS, ("aucun default sur les champs de donnée — référence d'autorité "
|
||||
"saisie au dépôt réel, pas fabriquée.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# D2 · Conformité AML / UAF (Ley 155-17)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def d2_1_dossier_kyc(art, spec):
|
||||
flds = set(_fields_by_name(art["dossier"]))
|
||||
missing = {"prospect", "client"} - flds
|
||||
if missing:
|
||||
return FAIL, f"ancrage KYC manquant sur le Dossier Vente : {sorted(missing)}"
|
||||
return PASS, "identification prospect + client présente (ancrage KYC)."
|
||||
|
||||
|
||||
def d2_2_confotur_piece_identite(art, spec):
|
||||
if "piece_identidad_cliente" in art["confotur"].get("field_order", []):
|
||||
return PASS, "pièce d'identité client suivie dans le dossier CONFOTUR."
|
||||
return FAIL, "aucune pièce d'identité client dans le DocType CONFOTUR."
|
||||
|
||||
|
||||
def d2_3_uaf_seuil(art, spec):
|
||||
uaf = spec.get("uaf") or {}
|
||||
seuil, src, ac = (uaf.get("seuil_operacion"), uaf.get("source"),
|
||||
uaf.get("a_confirmer"))
|
||||
if seuil is None:
|
||||
if ac is True and src is None:
|
||||
return A_CONFIRMER, ("seuil de déclaration UAF (ROS/umbral efectivo) "
|
||||
"à confirmer par l'Oficial de Cumplimiento.")
|
||||
return FAIL, "bloc UAF mal déclaré (a_confirmer/source incohérents)."
|
||||
if not is_filled(src):
|
||||
return FAIL, "seuil UAF renseigné SANS source (fabrication)."
|
||||
return PASS, "seuil UAF confirmé avec source."
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# D3 · Conformité fiscale e-CF (Ley 32-23 · DGII · Cardnet)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def d3_1_ecf_devise_reelle(art, spec):
|
||||
ecf = art["ecf"]
|
||||
df = ecf.get("devise_field")
|
||||
dfields = _fields_by_name(art["dossier"])
|
||||
if df not in dfields:
|
||||
return FAIL, f"e-CF référence `{df}`, absent du Dossier Vente."
|
||||
if ecf.get("moneda", {}).get("tipo_moneda_field") != df:
|
||||
return FAIL, "moneda.tipo_moneda_field ≠ devise_field (incohérence e-CF)."
|
||||
return PASS, f"e-CF libellé sur le champ réel `{df}` du Dossier Vente."
|
||||
|
||||
|
||||
def d3_2_ecf_etat_soumis(art, spec):
|
||||
by_uv = {s["update_value"]: s for s in art["workflow"]["states"]}
|
||||
bad = [ev["update_value"] for ev in art["ecf"]["emission_events"]
|
||||
if str(by_uv.get(ev["update_value"], {}).get("doc_status")) != "1"]
|
||||
if bad:
|
||||
return FAIL, f"émission e-CF sur état non soumis : {bad}"
|
||||
n = len(art["ecf"]["emission_events"])
|
||||
return PASS, f"{n} émission(s) e-CF déclenchée(s) sur état soumis (doc_status=1)."
|
||||
|
||||
|
||||
def d3_3_ecf_formapago_cardnet(art, spec):
|
||||
fp = art["ecf"]["forma_pago_defaut"]
|
||||
labels = {x["code"]: x["label"] for x in art["ecf"]["formas_pago"]}
|
||||
if fp.get("code") == "3" and "Tarjeta" in labels.get("3", ""):
|
||||
return PASS, "FormaPago par défaut = 3 (Tarjeta) — cohérent Cardnet #10."
|
||||
return FAIL, f"FormaPago par défaut {fp.get('code')!r} incohérent avec Cardnet."
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# D4 · Intégrité référentielle du reporting (IFRS)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def d4_1_dossier_etats_workflow(art, spec):
|
||||
wf_states = {s["state"] for s in art["workflow"]["states"]}
|
||||
ws = _fields_by_name(art["dossier"]).get("workflow_state")
|
||||
if ws is None:
|
||||
return FAIL, "champ workflow_state absent du Dossier Vente."
|
||||
dossier_states = set(_select_options(ws))
|
||||
if dossier_states != wf_states:
|
||||
diff = sorted(dossier_states ^ wf_states)
|
||||
return FAIL, f"états Dossier ≠ états Workflow (écart : {diff})"
|
||||
return PASS, f"{len(wf_states)} états alignés Dossier ↔ Workflow (source unique)."
|
||||
|
||||
|
||||
def d4_2_commissions_base_currency(art, spec):
|
||||
cur = _currency_fields(art["dossier"])
|
||||
bad = [e.get("libelle", e["base_field"]) for e in art["commissions"]["evenements"]
|
||||
if e["base_field"] not in cur]
|
||||
if bad:
|
||||
return FAIL, f"base de commission hors champ Currency : {bad}"
|
||||
return PASS, "toute base de commission pointe un champ Currency réel du Dossier."
|
||||
|
||||
|
||||
def d4_3_confotur_lien_dossier(art, spec):
|
||||
lf = _fields_by_name(art["confotur"]).get("dossier_vente")
|
||||
name = art["dossier"]["name"]
|
||||
if lf and lf.get("fieldtype") == "Link" and lf.get("options") == name:
|
||||
return PASS, f"CONFOTUR lié au DocType réel `{name}`."
|
||||
return FAIL, f"lien dossier_vente de CONFOTUR ne pointe pas `{name}`."
|
||||
|
||||
|
||||
def d4_4_update_values_workflow(art, spec):
|
||||
wf_uv = {s["update_value"] for s in art["workflow"]["states"]}
|
||||
used = ({e["update_value"] for e in art["commissions"]["evenements"]}
|
||||
| {ev["update_value"] for ev in art["ecf"]["emission_events"]})
|
||||
unknown = used - wf_uv
|
||||
if unknown:
|
||||
return FAIL, f"valeurs d'état consommées mais inconnues du workflow : {sorted(unknown)}"
|
||||
return PASS, f"{len(used)} valeurs d'état consommées, toutes définies par le workflow."
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# D5 · Gouvernance & ségrégation des tâches (ISA 315)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _resolver() -> RoleResolver:
|
||||
return RoleResolver.from_path()
|
||||
|
||||
|
||||
def d5_1_roles_rbac_connus(art, spec):
|
||||
r = _resolver()
|
||||
role_ids = ({e["role_id"] for e in art["commissions"]["evenements"]}
|
||||
| {ev["role_id"] for ev in art["ecf"]["emission_events"]})
|
||||
missing = []
|
||||
for rid in sorted(role_ids):
|
||||
try:
|
||||
r.erpnext_name(rid)
|
||||
except (KeyError, ValueError):
|
||||
missing.append(rid)
|
||||
rbac_names = {r.erpnext_name(i) for i in r.known_ids()}
|
||||
conf_roles = {p["role"] for p in art["confotur"]["permissions"]}
|
||||
missing_conf = sorted(conf_roles - rbac_names)
|
||||
if missing or missing_conf:
|
||||
return FAIL, f"rôles hors contrat RBAC — acteurs {missing} · CONFOTUR {missing_conf}"
|
||||
return PASS, (f"{len(role_ids)} rôles acteurs + {len(conf_roles)} rôles CONFOTUR "
|
||||
f"tous présents au contrat RBAC (aucun inventé).")
|
||||
|
||||
|
||||
def d5_2_ecf_segregation(art, spec):
|
||||
r = _resolver()
|
||||
booking = {s.get("allow_edit") for s in art["workflow"]["states"]
|
||||
if str(s.get("doc_status")) == "1" and s.get("allow_edit")}
|
||||
bad = []
|
||||
for ev in art["ecf"]["emission_events"]:
|
||||
rid = ev["role_id"]
|
||||
if r.portail(rid) == "ventes" or r.erpnext_name(rid) in booking:
|
||||
bad.append(rid)
|
||||
if bad:
|
||||
return FAIL, f"émetteur e-CF cumule vente et facturation (rupture SoD) : {bad}"
|
||||
emitters = sorted({ev["role_id"] for ev in art["ecf"]["emission_events"]})
|
||||
return PASS, (f"émission e-CF portée par la Compta {emitters}, distincte des "
|
||||
f"rôles de vente soumettant le dossier — SoD respectée.")
|
||||
|
||||
|
||||
def d5_3_confotur_perms_rbac(art, spec):
|
||||
r = _resolver()
|
||||
contract = load_contract()
|
||||
targets = roles_targeting("CONFOTUR Application", contract)
|
||||
expected = {r.erpnext_name(t["role_id"]): set(t["actions"]) for t in targets}
|
||||
actual = {p["role"]: {k for k in _PERM_KEYS if p.get(k)}
|
||||
for p in art["confotur"]["permissions"]}
|
||||
if actual != expected:
|
||||
return FAIL, ("permissions CONFOTUR ≠ cibles RBAC — "
|
||||
f"attendu {expected} · obtenu {actual}")
|
||||
return PASS, ("permissions du DocType CONFOTUR = cibles RBAC mot pour mot "
|
||||
f"({len(expected)} rôles).")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Registre — source de vérité des contrôles (miroir : audit_spec.json).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _c(cid, dim, titre, reference, artifacts, fn, owner=None):
|
||||
return {"id": cid, "dimension": dim, "titre": titre, "reference": reference,
|
||||
"artifacts": artifacts, "fn": fn, "owner": owner}
|
||||
|
||||
|
||||
CONTROLS: list[dict[str, Any]] = [
|
||||
_c("D1.1", "D1", "Taux de commission non fabriqués",
|
||||
"ISA 500", ["commissions"], d1_1_commissions_taux, owner="Direction"),
|
||||
_c("D1.2", "D1", "RNC / raison sociale émetteur non fabriqués",
|
||||
"ISA 500", ["ecf"], d1_2_ecf_emisor, owner="Compta"),
|
||||
_c("D1.3", "D1", "ITBIS + TipoCambio non fabriqués",
|
||||
"ISA 500", ["ecf"], d1_3_ecf_itbis_cambio, owner="Compta Fiscaliste eCF"),
|
||||
_c("D1.4", "D1", "CONFOTUR sans valeur par défaut fabriquée",
|
||||
"ISA 500", ["confotur"], d1_4_confotur_no_default),
|
||||
_c("D2.1", "D2", "Ancrage KYC du client sur le Dossier Vente",
|
||||
"Ley 155-17", ["dossier"], d2_1_dossier_kyc),
|
||||
_c("D2.2", "D2", "Pièce d'identité client au dossier CONFOTUR",
|
||||
"Ley 155-17", ["confotur"], d2_2_confotur_piece_identite),
|
||||
_c("D2.3", "D2", "Seuil de déclaration UAF non fabriqué",
|
||||
"Ley 155-17", [], d2_3_uaf_seuil, owner="Oficial de Cumplimiento / UAF"),
|
||||
_c("D3.1", "D3", "e-CF libellé sur la devise réelle du Dossier",
|
||||
"Ley 32-23", ["ecf", "dossier"], d3_1_ecf_devise_reelle),
|
||||
_c("D3.2", "D3", "Émission e-CF uniquement sur état soumis",
|
||||
"Ley 32-23", ["ecf", "workflow"], d3_2_ecf_etat_soumis),
|
||||
_c("D3.3", "D3", "FormaPago par défaut cohérente Cardnet",
|
||||
"CLAUDE.md #10", ["ecf"], d3_3_ecf_formapago_cardnet),
|
||||
_c("D4.1", "D4", "États Dossier alignés sur le Workflow",
|
||||
"IFRS", ["dossier", "workflow"], d4_1_dossier_etats_workflow),
|
||||
_c("D4.2", "D4", "Base de commission = champ Currency réel",
|
||||
"IFRS", ["commissions", "dossier"], d4_2_commissions_base_currency),
|
||||
_c("D4.3", "D4", "CONFOTUR lié au DocType Dossier Vente réel",
|
||||
"IFRS", ["confotur", "dossier"], d4_3_confotur_lien_dossier),
|
||||
_c("D4.4", "D4", "Valeurs d'état consommées définies par le Workflow",
|
||||
"IFRS", ["commissions", "ecf", "workflow"], d4_4_update_values_workflow),
|
||||
_c("D5.1", "D5", "Tous les rôles acteurs présents au contrat RBAC",
|
||||
"ISA 315", ["commissions", "ecf", "confotur"], d5_1_roles_rbac_connus),
|
||||
_c("D5.2", "D5", "Ségrégation émission e-CF ↔ vente",
|
||||
"ISA 315", ["ecf", "workflow"], d5_2_ecf_segregation),
|
||||
_c("D5.3", "D5", "Permissions CONFOTUR conformes au RBAC",
|
||||
"ISA 315", ["confotur"], d5_3_confotur_perms_rbac),
|
||||
]
|
||||
|
||||
|
||||
def run_all(art: dict[str, Any], spec: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Exécute chaque contrôle et renvoie les résultats triés par `id`."""
|
||||
results = []
|
||||
for c in CONTROLS:
|
||||
statut, detail = c["fn"](art, spec)
|
||||
results.append({
|
||||
"id": c["id"], "dimension": c["dimension"], "titre": c["titre"],
|
||||
"reference": c["reference"], "statut": statut, "detail": detail,
|
||||
"owner": c["owner"],
|
||||
})
|
||||
results.sort(key=lambda x: x["id"])
|
||||
return results
|
||||
|
||||
|
||||
def control_ids() -> list[str]:
|
||||
return sorted(c["id"] for c in CONTROLS)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Réutilisation des briques déjà livrées (workflow #5 · zéro duplication).
|
||||
|
||||
L'audit 5D ne redéfinit rien qui existe ailleurs dans le mandat. On importe —
|
||||
jamais on ne duplique — :
|
||||
- `validate` : le validateur JSON-Schema maison du Publiciste (draft-07,
|
||||
sous-ensemble) → valider le rapport SANS pip, le gate CI
|
||||
Gitea Actions tournant sans réseau (CLAUDE.md #2).
|
||||
- `is_filled` : la notion partagée de « champ réellement rempli » (un
|
||||
placeholder ou un `null` n'est PAS rempli) — au cœur de
|
||||
l'anti-invention #6.
|
||||
- `RoleResolver` : la table `role_id → nom de rôle Frappe` du module CRM,
|
||||
seule autorité de nommage des rôles (contrat RBAC).
|
||||
- `roles_targeting` : la lecture des rôles RBAC qui visent un DocType, déjà
|
||||
écrite pour le générateur CONFOTUR.
|
||||
|
||||
Import par `sys.path` (idiome des modules voisins) — une seule source de vérité.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
# qa/audit_5d/qalib → 05_deliverables_mvp
|
||||
_DELIVERABLES = os.path.normpath(os.path.join(_HERE, "..", "..", ".."))
|
||||
|
||||
_GEN = os.path.join(_DELIVERABLES, "faisabilite", "generator")
|
||||
_PUB = os.path.join(_DELIVERABLES, "publiciste")
|
||||
_WF = os.path.join(_DELIVERABLES, "crm", "workflow_vente")
|
||||
_CONF = os.path.join(_DELIVERABLES, "legal", "confotur")
|
||||
|
||||
for _p in (_GEN, _PUB, _WF, _CONF):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
from genlib import model # type: ignore # noqa: E402
|
||||
from lib import validator # type: ignore # noqa: E402
|
||||
from wflib.rbac import RoleResolver # type: ignore # noqa: E402
|
||||
from cflib import rbac_scan # type: ignore # noqa: E402
|
||||
|
||||
is_filled = model.is_filled
|
||||
validate = validator.validate
|
||||
roles_targeting = rbac_scan.roles_targeting
|
||||
load_contract = rbac_scan.load_contract
|
||||
|
||||
__all__ = [
|
||||
"is_filled", "validate", "RoleResolver", "roles_targeting", "load_contract",
|
||||
]
|
||||
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests du générateur d'audit 5D de conformité (QA · Sprint 5).
|
||||
|
||||
Stdlib pur (`unittest`) → aucune installation pip requise sur le runner Gitea
|
||||
(CLAUDE.md #2). Couvre : chargement des artefacts, chaque contrôle (positif +
|
||||
injection négative sur copie profonde de l'artefact), synthèse par dimension,
|
||||
verdict, open items, invariants du générateur, déterminisme et schéma.
|
||||
|
||||
Axe central : l'audit LIT les hand-off `out/` des livrables et remonte les
|
||||
paramètres réglementaires non confirmés en A_CONFIRMER — jamais en valeur
|
||||
fabriquée (#6). Un FAIL ne doit jamais survenir sur les livrables réels.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_MOD = os.path.normpath(os.path.join(_HERE, ".."))
|
||||
sys.path.insert(0, _MOD)
|
||||
|
||||
from qalib import artifacts, builder, controls # noqa: E402
|
||||
import audit_5d_gen as gen # noqa: E402
|
||||
|
||||
PASS, FAIL, AC = controls.PASS, controls.FAIL, controls.A_CONFIRMER
|
||||
_FN = {c["id"]: c["fn"] for c in controls.CONTROLS}
|
||||
|
||||
|
||||
def _spec():
|
||||
with open(os.path.join(_MOD, "audit_spec.json"), encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def _art():
|
||||
return artifacts.load_artifacts()
|
||||
|
||||
|
||||
def _run(cid, art, spec):
|
||||
return _FN[cid](art, spec)
|
||||
|
||||
|
||||
class ArtifactsTest(unittest.TestCase):
|
||||
def test_load_all_present(self):
|
||||
art = _art()
|
||||
self.assertEqual(set(art), artifacts.known_ids())
|
||||
self.assertEqual(art["workflow"]["doctype"], "Workflow") # liste dépliée
|
||||
|
||||
def test_missing_artifact_raises(self):
|
||||
orig = artifacts.ARTIFACT_PATHS.copy()
|
||||
artifacts.ARTIFACT_PATHS["ecf"] = os.path.join("fiscal", "n_existe_pas.json")
|
||||
try:
|
||||
self.assertRaises(FileNotFoundError, artifacts.load_artifacts)
|
||||
finally:
|
||||
artifacts.ARTIFACT_PATHS.clear()
|
||||
artifacts.ARTIFACT_PATHS.update(orig)
|
||||
|
||||
|
||||
class ControlsHappyPathTest(unittest.TestCase):
|
||||
def test_no_fail_on_real_artifacts(self):
|
||||
art, spec = _art(), _spec()
|
||||
for c in controls.CONTROLS:
|
||||
statut, detail = c["fn"](art, spec)
|
||||
self.assertIn(statut, (PASS, AC), f"{c['id']} inattendu {statut}")
|
||||
self.assertTrue(detail, f"{c['id']} sans détail")
|
||||
|
||||
def test_open_items_are_the_four_regulatory_params(self):
|
||||
art, spec = _art(), _spec()
|
||||
ac = {c["id"] for c in controls.CONTROLS if c["fn"](art, spec)[0] == AC}
|
||||
self.assertEqual(ac, {"D1.1", "D1.2", "D1.3", "D2.3"})
|
||||
|
||||
|
||||
class D1TracabiliteTest(unittest.TestCase):
|
||||
def test_d1_1_fabricated_rate_fails(self):
|
||||
art, spec = copy.deepcopy(_art()), _spec()
|
||||
art["commissions"]["evenements"][0]["taux_pct"] = 5.0
|
||||
art["commissions"]["evenements"][0]["a_confirmer"] = False
|
||||
self.assertEqual(_run("D1.1", art, spec)[0], FAIL)
|
||||
|
||||
def test_d1_1_rate_with_source_passes(self):
|
||||
art, spec = copy.deepcopy(_art()), _spec()
|
||||
for e in art["commissions"]["evenements"]:
|
||||
e["taux_pct"], e["source"], e["a_confirmer"] = 3.0, "barème Direction 2026", False
|
||||
self.assertEqual(_run("D1.1", art, spec)[0], PASS)
|
||||
|
||||
def test_d1_2_fabricated_rnc_fails(self):
|
||||
art, spec = copy.deepcopy(_art()), _spec()
|
||||
art["ecf"]["emisor"]["rnc_emisor"] = "131000000"
|
||||
self.assertEqual(_run("D1.2", art, spec)[0], FAIL)
|
||||
|
||||
def test_d1_3_fabricated_itbis_fails(self):
|
||||
art, spec = copy.deepcopy(_art()), _spec()
|
||||
art["ecf"]["taxes"][0]["taux_pct"] = 18.0
|
||||
self.assertEqual(_run("D1.3", art, spec)[0], FAIL)
|
||||
|
||||
def test_d1_4_default_on_data_field_fails(self):
|
||||
art, spec = copy.deepcopy(_art()), _spec()
|
||||
for f in art["confotur"]["fields"]:
|
||||
if f["fieldname"] == "referencia_autoridad":
|
||||
f["default"] = "RES-CONFOTUR-0000"
|
||||
self.assertEqual(_run("D1.4", art, spec)[0], FAIL)
|
||||
|
||||
def test_d1_4_passes_on_real(self):
|
||||
self.assertEqual(_run("D1.4", _art(), _spec())[0], PASS)
|
||||
|
||||
|
||||
class D2AmlUafTest(unittest.TestCase):
|
||||
def test_d2_1_missing_client_fails(self):
|
||||
art, spec = copy.deepcopy(_art()), _spec()
|
||||
art["dossier"]["fields"] = [f for f in art["dossier"]["fields"]
|
||||
if f["fieldname"] != "client"]
|
||||
self.assertEqual(_run("D2.1", art, spec)[0], FAIL)
|
||||
|
||||
def test_d2_2_missing_identity_piece_fails(self):
|
||||
art, spec = copy.deepcopy(_art()), _spec()
|
||||
art["confotur"]["field_order"] = [x for x in art["confotur"]["field_order"]
|
||||
if x != "piece_identidad_cliente"]
|
||||
self.assertEqual(_run("D2.2", art, spec)[0], FAIL)
|
||||
|
||||
def test_d2_3_threshold_is_open_item(self):
|
||||
self.assertEqual(_run("D2.3", _art(), _spec())[0], AC)
|
||||
|
||||
def test_d2_3_fabricated_threshold_fails(self):
|
||||
spec = _spec()
|
||||
spec["uaf"]["seuil_operacion"] = 1000000
|
||||
self.assertEqual(_run("D2.3", _art(), spec)[0], FAIL)
|
||||
|
||||
|
||||
class D3FiscalTest(unittest.TestCase):
|
||||
def test_d3_1_devise_mismatch_fails(self):
|
||||
art, spec = copy.deepcopy(_art()), _spec()
|
||||
art["ecf"]["devise_field"] = "champ_inexistant"
|
||||
self.assertEqual(_run("D3.1", art, spec)[0], FAIL)
|
||||
|
||||
def test_d3_2_emission_on_draft_fails(self):
|
||||
art, spec = copy.deepcopy(_art()), _spec()
|
||||
for s in art["workflow"]["states"]:
|
||||
if s["update_value"] == "contrat":
|
||||
s["doc_status"] = "0"
|
||||
self.assertEqual(_run("D3.2", art, spec)[0], FAIL)
|
||||
|
||||
def test_d3_3_wrong_formapago_fails(self):
|
||||
art, spec = copy.deepcopy(_art()), _spec()
|
||||
art["ecf"]["forma_pago_defaut"]["code"] = "1"
|
||||
self.assertEqual(_run("D3.3", art, spec)[0], FAIL)
|
||||
|
||||
|
||||
class D4IntegriteTest(unittest.TestCase):
|
||||
def test_d4_1_states_diverge_fails(self):
|
||||
art, spec = copy.deepcopy(_art()), _spec()
|
||||
for f in art["dossier"]["fields"]:
|
||||
if f["fieldname"] == "workflow_state":
|
||||
f["options"] = "Lead\nÉtat fantôme"
|
||||
self.assertEqual(_run("D4.1", art, spec)[0], FAIL)
|
||||
|
||||
def test_d4_2_base_not_currency_fails(self):
|
||||
art, spec = copy.deepcopy(_art()), _spec()
|
||||
art["commissions"]["evenements"][0]["base_field"] = "prospect"
|
||||
self.assertEqual(_run("D4.2", art, spec)[0], FAIL)
|
||||
|
||||
def test_d4_3_link_wrong_target_fails(self):
|
||||
art, spec = copy.deepcopy(_art()), _spec()
|
||||
for f in art["confotur"]["fields"]:
|
||||
if f["fieldname"] == "dossier_vente":
|
||||
f["options"] = "Autre DocType"
|
||||
self.assertEqual(_run("D4.3", art, spec)[0], FAIL)
|
||||
|
||||
def test_d4_4_unknown_update_value_fails(self):
|
||||
art, spec = copy.deepcopy(_art()), _spec()
|
||||
art["commissions"]["evenements"][0]["update_value"] = "etat_inconnu"
|
||||
self.assertEqual(_run("D4.4", art, spec)[0], FAIL)
|
||||
|
||||
|
||||
class D5GouvernanceTest(unittest.TestCase):
|
||||
def test_d5_1_unknown_role_fails(self):
|
||||
art, spec = copy.deepcopy(_art()), _spec()
|
||||
art["commissions"]["evenements"][0]["role_id"] = "role-fantome-999"
|
||||
self.assertEqual(_run("D5.1", art, spec)[0], FAIL)
|
||||
|
||||
def test_d5_1_passes_on_real(self):
|
||||
self.assertEqual(_run("D5.1", _art(), _spec())[0], PASS)
|
||||
|
||||
def test_d5_2_segregation_breach_fails(self):
|
||||
art, spec = copy.deepcopy(_art()), _spec()
|
||||
for ev in art["ecf"]["emission_events"]:
|
||||
ev["role_id"] = "ventes-reservations"
|
||||
self.assertEqual(_run("D5.2", art, spec)[0], FAIL)
|
||||
|
||||
def test_d5_3_perms_mismatch_fails(self):
|
||||
art, spec = copy.deepcopy(_art()), _spec()
|
||||
art["confotur"]["permissions"][0]["submit"] = 0
|
||||
self.assertEqual(_run("D5.3", art, spec)[0], FAIL)
|
||||
|
||||
def test_d5_3_passes_on_real(self):
|
||||
self.assertEqual(_run("D5.3", _art(), _spec())[0], PASS)
|
||||
|
||||
|
||||
class BuilderTest(unittest.TestCase):
|
||||
def test_verdict_pass_with_open_items(self):
|
||||
r = builder.build(_spec())
|
||||
self.assertEqual(r["verdict"], "PASS_WITH_OPEN_ITEMS")
|
||||
self.assertEqual(r["totals"], {"controls": 17, "pass": 13, "fail": 0, "a_confirmer": 4})
|
||||
|
||||
def test_deterministic(self):
|
||||
self.assertEqual(builder.build(_spec()), builder.build(_spec()))
|
||||
|
||||
def test_open_items_match_a_confirmer(self):
|
||||
r = builder.build(_spec())
|
||||
ac = {c["id"] for c in r["controls"] if c["statut"] == AC}
|
||||
oi = {o["control"] for o in r["open_items"]}
|
||||
self.assertEqual(ac, oi)
|
||||
self.assertTrue(all(o["owner"] for o in r["open_items"]))
|
||||
|
||||
def test_dimensions_cover_all_controls(self):
|
||||
r = builder.build(_spec())
|
||||
self.assertEqual(sum(d["controls_total"] for d in r["dimensions"]), 17)
|
||||
self.assertEqual(len(r["dimensions"]), 5)
|
||||
|
||||
def test_controls_sorted(self):
|
||||
ids = [c["id"] for c in builder.build(_spec())["controls"]]
|
||||
self.assertEqual(ids, sorted(ids))
|
||||
|
||||
|
||||
class GeneratorTest(unittest.TestCase):
|
||||
def test_validate_clean(self):
|
||||
spec = _spec()
|
||||
self.assertEqual(gen._validate(spec, builder.build(spec)), [])
|
||||
|
||||
def test_spec_code_ids_match(self):
|
||||
self.assertEqual(sorted(c["id"] for c in _spec()["controls"]),
|
||||
controls.control_ids())
|
||||
|
||||
def test_report_validates_schema(self):
|
||||
from qalib.deps import validate as maison
|
||||
with open(os.path.join(_MOD, "audit.schema.json"), encoding="utf-8") as fh:
|
||||
schema = json.load(fh)
|
||||
self.assertEqual(list(maison(builder.build(_spec()), schema)), [])
|
||||
|
||||
def test_validate_detects_injected_fail(self):
|
||||
spec = _spec()
|
||||
r = copy.deepcopy(builder.build(spec))
|
||||
r["controls"][0]["statut"] = FAIL
|
||||
self.assertTrue(any("FAIL" in e for e in gen._validate(spec, r)))
|
||||
|
||||
def test_validate_detects_id_drift(self):
|
||||
spec = _spec()
|
||||
r = builder.build(spec)
|
||||
spec2 = copy.deepcopy(spec)
|
||||
spec2["controls"].pop()
|
||||
self.assertNotEqual(gen._validate(spec2, r), [])
|
||||
|
||||
def test_cli_validate_returns_zero(self):
|
||||
self.assertEqual(gen.main(["validate"]), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user