diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 15a99da..550039b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -299,10 +299,21 @@ jobs: - name: Tests générateur CONFOTUR Application run: python3 -m unittest discover -s tests -v + qa-audit-5d-tests: + name: QA · Audit 5D conformité + runs-on: ubuntu-latest + defaults: + run: + working-directory: 05_deliverables_mvp/qa/audit_5d + steps: + - uses: actions/checkout@v4 + - name: Tests générateur Audit 5D + run: python3 -m unittest discover -s tests -v + gate: name: Gate qualité (agrégat) runs-on: ubuntu-latest - needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests, rbac-userperm-tests, rbac-roleprofile-tests, rbac-applyplan-tests, bancable-tests, crm-workflow-vente-tests, crm-dossier-vente-tests, crm-commissions-tests, fiscal-ecf-tests, frontend-portails-tests, legal-confotur-tests] + needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests, rbac-userperm-tests, rbac-roleprofile-tests, rbac-applyplan-tests, bancable-tests, crm-workflow-vente-tests, crm-dossier-vente-tests, crm-commissions-tests, fiscal-ecf-tests, frontend-portails-tests, legal-confotur-tests, qa-audit-5d-tests] steps: - name: Résultat run: echo "✅ Gate qualité 4Big franchi — tous les checks verts." diff --git a/05_activity_log/2026-07-30.md b/05_activity_log/2026-07-30.md index ad2a0ba..f43c779 100644 --- a/05_activity_log/2026-07-30.md +++ b/05_activity_log/2026-07-30.md @@ -1,5 +1,60 @@ # Activity Log · 2026-07-30 · Claude Code DTP +## Session `20260730_082714` (session 17) + +**Tâche** : **Sprint 5 · QA** — Générateur de l'**Audit 5D de conformité** +(roadmap Sprint 5 · QA « **Audit UAF + normes ISA/IFRS 5D** »). Dernier volet +Sprint 5 réalisable en repo : ONAPI/Legal livré (session 16) et Mobile +(builds/submit stores) dépend d'API externes / VPS (hors périmètre worker · #8). + +**Décision d'architecture** : audit de **second niveau** — sa matière première +est le hand-off `out/` **déjà commité** par les générateurs amont (workflow +vente, DocType Dossier Vente, barème commissions, plan e-CF DGII, DocType +CONFOTUR). Il ne relance rien et ne fabrique aucune donnée : il vérifie la +conformité + la cohérence croisée sur **17 contrôles** en **5 dimensions** : +D1 Traçabilité (ISA 500) · D2 AML/UAF (Ley 155-17) · D3 Fiscal e-CF (Ley 32-23 · +DGII · Cardnet) · D4 Intégrité IFRS · D5 Gouvernance/SoD (ISA 315). + +**Fichiers créés** — `05_deliverables_mvp/qa/audit_5d/` : +- `audit_spec.json` (catalogue des 17 contrôles + bloc UAF déclaratif · + `seuil_operacion: null`) +- `qalib/{__init__,deps,artifacts,controls,builder}.py` (`deps` **réutilise** le + validateur maison Publiciste, `is_filled`, le `RoleResolver` du CRM et + `roles_targeting` de CONFOTUR · `controls` = 17 contrôles purs · `builder` + déterministe) +- `audit_5d_gen.py` (CLI `build`/`validate` · **15 invariants**) +- `audit.schema.json` (contrat de sortie draft-07) +- `out/{audit_report,MANIFEST}.json` (hand-off) · `tests/test_audit_5d.py` + (**37 tests** · une injection négative par contrôle) · `README.md` · `.gitignore` + +**Fichiers modifiés** : +- `.gitea/workflows/ci.yml` : job `qa-audit-5d-tests` + ajout au `gate`. + +**Anti-invention (cœur · #6)** : l'audit **remonte, ne fabrique pas**. Un +paramètre réglementaire non confirmé produit **A_CONFIRMER** (open item assigné au +métier), jamais une valeur inventée « pour faire PASS ». **FAIL** = incohérence +inter-livrables OU valeur chiffrée **sans `source`**. Un invariant refuse tout +FAIL sur les livrables courants ; un test injecte une fabrication par contrôle +et vérifie le basculement en FAIL. + +**Résultat** : verdict `PASS_WITH_OPEN_ITEMS` — **13 PASS · 0 FAIL · 4 à +confirmer** (D1.1 taux → Direction ; D1.2 RNC émetteur → Compta ; D1.3 +ITBIS/TipoCambio → Fiscaliste eCF ; D2.3 seuil UAF → Oficial de Cumplimiento). +Ce sont les 4 mêmes paramètres `null` des générateurs amont, consolidés en une +check-list unique de confirmation VPS. + +**Vérifs** : 37/37 tests ; gate CI local vert (guard + JSON + docs + YAML) ; +régression **341 tests verts** au total ; build déterministe. + +**Hors périmètre worker (VPS · #8)** : confirmation des 4 paramètres +réglementaires (avec `source`, dans `data_room PXX`) + tests E2E Playwright sur +le desk réel → métiers propriétaires / agent QA VPS. + +**Détail complet** : voir +[`05_deliverables_mvp/daily_reports/2026-07-30-session17.md`](../05_deliverables_mvp/daily_reports/2026-07-30-session17.md). + +**Auto-score 4Big** : 96/100. + ## Session `20260730_075712` (session 16) **Tâche** : **Sprint 5 · ONAPI/Legal** — Générateur du **DocType porteur diff --git a/05_deliverables_mvp/daily_reports/2026-07-30-session17.md b/05_deliverables_mvp/daily_reports/2026-07-30-session17.md new file mode 100644 index 0000000..3cac696 --- /dev/null +++ b/05_deliverables_mvp/daily_reports/2026-07-30-session17.md @@ -0,0 +1,85 @@ +# Rapport détaillé · 2026-07-30 · Session 17 (`20260730_082714`) + +## Sprint 5 · QA — Générateur **Audit 5D de conformité** + +### Contexte roadmap +Sprint 5 · volet **QA** : « **Audit UAF + normes ISA/IFRS 5D** ». Les autres +volets Sprint 5 sont soit livrés (ONAPI/Legal · DocType CONFOTUR, session 16), +soit hors périmètre worker (Mobile : builds/submit stores = API externes + VPS, +#8). L'audit qualité 5D est le volet QA réalisable en repo. + +### Décision d'architecture +L'audit est un **audit de second niveau** : sa matière première est le hand-off +`out/` **déjà commité** par les générateurs amont (workflow vente, DocType +Dossier Vente, barème commissions, plan e-CF DGII, DocType CONFOTUR). Il ne +relance aucun générateur et **ne fabrique aucune donnée** — il vérifie la +conformité + la cohérence croisée des livrables. + +### Les 5 dimensions (5D) et leurs normes +| # | Dimension | Norme | +|---|-----------|-------| +| D1 | Traçabilité & anti-invention | ISA 500 | +| D2 | Conformité AML / UAF | Ley 155-17 (sujeto obligado · KYC) | +| D3 | Conformité fiscale e-CF | Ley 32-23 · DGII · Cardnet (#10) | +| D4 | Intégrité référentielle | IFRS (cohérence documentaire) | +| D5 | Gouvernance & SoD | ISA 315 (contrôle interne) | + +**17 contrôles** au total (D1×4, D2×3, D3×3, D4×4, D5×3). + +### Fichiers créés — `05_deliverables_mvp/qa/audit_5d/` +- `audit_spec.json` — catalogue humain des 17 contrôles + bloc réglementaire UAF + déclaratif (`seuil_operacion: null`, a_confirmer, source null). +- `qalib/{__init__,deps,artifacts,controls,builder}.py` : + - `deps.py` **réutilise** (zéro duplication · #6) le validateur maison + Publiciste, `is_filled`, le `RoleResolver` du module CRM et `roles_targeting` + du module CONFOTUR ; + - `artifacts.py` charge les 5 hand-off `out/` (erreur explicite si absent) ; + - `controls.py` = les 17 contrôles (fonctions pures) + les 5 dimensions ; + - `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/{audit_report,MANIFEST}.json` — hand-off commité. +- `tests/test_audit_5d.py` — **37 tests** (`unittest`, zéro pip), une injection + négative par contrôle. +- `README.md` · `.gitignore`. + +### Fichiers modifiés +- `.gitea/workflows/ci.yml` : job `qa-audit-5d-tests` + ajout au `gate`. + +### Anti-invention (cœur · #6) +Le principe même de l'audit : **remonter, pas fabriquer**. Un paramètre +réglementaire réel non confirmé produit le statut **A_CONFIRMER** (un « open +item » assigné au métier propriétaire), jamais une valeur inventée « pour faire +PASS ». Un statut **FAIL** signale soit une incohérence inter-livrables, soit une +valeur chiffrée présente **sans `source`** (fabrication). Un invariant refuse tout +FAIL sur les livrables courants ; un test injecte une valeur fabriquée par +contrôle D1/D2 et vérifie qu'elle bascule bien en FAIL. + +### Verdict courant +`PASS_WITH_OPEN_ITEMS` — **13 PASS · 0 FAIL · 4 à confirmer** : + +| Open item | Propriétaire | +|-----------|--------------| +| D1.1 · taux de commission | Direction | +| D1.2 · RNC / raison sociale émetteur | Compta | +| D1.3 · ITBIS + TipoCambio | Compta Fiscaliste eCF | +| D2.3 · seuil de déclaration UAF | Oficial de Cumplimiento / UAF | + +Ce sont les **4 mêmes paramètres** 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** de confirmation pour le VPS. + +### Vérifications +- 37/37 tests du module ; `validate` : schéma + 15 invariants verts. +- Gate CI local vert : `guard_constraints` + `validate_json` + `check_docs` + + YAML `ci.yml` valide. +- **Régression : 341 tests verts** au total (304 prior + 37). +- Build déterministe (invariant #15 : deux exécutions identiques). + +### 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). + +**Auto-score 4Big : 96/100.** diff --git a/05_deliverables_mvp/qa/audit_5d/.gitignore b/05_deliverables_mvp/qa/audit_5d/.gitignore new file mode 100644 index 0000000..ac42912 --- /dev/null +++ b/05_deliverables_mvp/qa/audit_5d/.gitignore @@ -0,0 +1,4 @@ +# Caches Python +__pycache__/ +*.pyc +# Le dossier out/ EST commité (hand-off audit + suivi verdict) — voir README. diff --git a/05_deliverables_mvp/qa/audit_5d/README.md b/05_deliverables_mvp/qa/audit_5d/README.md new file mode 100644 index 0000000..b11a38e --- /dev/null +++ b/05_deliverables_mvp/qa/audit_5d/README.md @@ -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. diff --git a/05_deliverables_mvp/qa/audit_5d/audit.schema.json b/05_deliverables_mvp/qa/audit_5d/audit.schema.json new file mode 100644 index 0000000..4a40df7 --- /dev/null +++ b/05_deliverables_mvp/qa/audit_5d/audit.schema.json @@ -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 } + } + } +} diff --git a/05_deliverables_mvp/qa/audit_5d/audit_5d_gen.py b/05_deliverables_mvp/qa/audit_5d/audit_5d_gen.py new file mode 100644 index 0000000..c994b3f --- /dev/null +++ b/05_deliverables_mvp/qa/audit_5d/audit_5d_gen.py @@ -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()) diff --git a/05_deliverables_mvp/qa/audit_5d/audit_spec.json b/05_deliverables_mvp/qa/audit_5d/audit_spec.json new file mode 100644 index 0000000..569ff6c --- /dev/null +++ b/05_deliverables_mvp/qa/audit_5d/audit_spec.json @@ -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." + ] +} diff --git a/05_deliverables_mvp/qa/audit_5d/out/MANIFEST.json b/05_deliverables_mvp/qa/audit_5d/out/MANIFEST.json new file mode 100644 index 0000000..c27c611 --- /dev/null +++ b/05_deliverables_mvp/qa/audit_5d/out/MANIFEST.json @@ -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)." +} diff --git a/05_deliverables_mvp/qa/audit_5d/out/audit_report.json b/05_deliverables_mvp/qa/audit_5d/out/audit_report.json new file mode 100644 index 0000000..8d203d7 --- /dev/null +++ b/05_deliverables_mvp/qa/audit_5d/out/audit_report.json @@ -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." + ] +} diff --git a/05_deliverables_mvp/qa/audit_5d/qalib/__init__.py b/05_deliverables_mvp/qa/audit_5d/qalib/__init__.py new file mode 100644 index 0000000..e5ae0de --- /dev/null +++ b/05_deliverables_mvp/qa/audit_5d/qalib/__init__.py @@ -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. +""" diff --git a/05_deliverables_mvp/qa/audit_5d/qalib/artifacts.py b/05_deliverables_mvp/qa/audit_5d/qalib/artifacts.py new file mode 100644 index 0000000..1f7887f --- /dev/null +++ b/05_deliverables_mvp/qa/audit_5d/qalib/artifacts.py @@ -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) diff --git a/05_deliverables_mvp/qa/audit_5d/qalib/builder.py b/05_deliverables_mvp/qa/audit_5d/qalib/builder.py new file mode 100644 index 0000000..adff7da --- /dev/null +++ b/05_deliverables_mvp/qa/audit_5d/qalib/builder.py @@ -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", []), + } diff --git a/05_deliverables_mvp/qa/audit_5d/qalib/controls.py b/05_deliverables_mvp/qa/audit_5d/qalib/controls.py new file mode 100644 index 0000000..9aec3ad --- /dev/null +++ b/05_deliverables_mvp/qa/audit_5d/qalib/controls.py @@ -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) diff --git a/05_deliverables_mvp/qa/audit_5d/qalib/deps.py b/05_deliverables_mvp/qa/audit_5d/qalib/deps.py new file mode 100644 index 0000000..2940367 --- /dev/null +++ b/05_deliverables_mvp/qa/audit_5d/qalib/deps.py @@ -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", +] diff --git a/05_deliverables_mvp/qa/audit_5d/tests/test_audit_5d.py b/05_deliverables_mvp/qa/audit_5d/tests/test_audit_5d.py new file mode 100644 index 0000000..e3a1082 --- /dev/null +++ b/05_deliverables_mvp/qa/audit_5d/tests/test_audit_5d.py @@ -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()