diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e0a6295..a91b392 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -311,6 +311,27 @@ jobs: - name: Tests générateur barème commissions run: python3 -m unittest discover -s tests -v + # -------------------------------------------------------------------------- + # Financement Bancaire (Sprint 5 · CRM natif · roadmap L52 « workflow vente + # end-to-end » · volet financement · DIRECTIVE_FINANCEMENT_BANCAIRE_COMPLET_ + # 20260803.md). Parcours hypothécaire immobilier RD : contrat banques/documents/ + # autorisations/sections + CŒUR MÉTIER du gate check 4 conditions (apport initial + # 20/30 · documents validés WAG · autorisations signées · validation WAG). Anti- + # invention (#6) : taux d'apport et rôles depuis le contrat + RBAC (jamais codés) ; + # taux/LTV bancaires non énoncés = null a_confirmer. Exemple de gate CALCULÉ, + # auditable. Schéma de sortie + 12 invariants. stdlib pur, zéro pip. + # -------------------------------------------------------------------------- + crm-financement-bancaire-tests: + name: CRM · Financement Bancaire (gate hypothécaire RD) + runs-on: ubuntu-latest + defaults: + run: + working-directory: 05_deliverables_mvp/crm/financement_bancaire + steps: + - uses: actions/checkout@v4 + - name: Tests générateur Financement Bancaire + run: python3 -m unittest discover -s tests -v + # -------------------------------------------------------------------------- # Intégration e-CF DGII (Sprint 4 · ERPNext Backend « e-CF DGII intégration # (Compupar) »). Génère un plan de configuration de facturation électronique @@ -524,7 +545,7 @@ jobs: gate: name: Gate qualité (agrégat) runs-on: ubuntu-latest - needs: [constraints-guard, validate-json, check-docs, check-artifacts, check-regression, check-ci-integrity, check-readme-claims, 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, seo-tests, chat-otoia-tests, qa-audit-4big-tests, demo-scenario-tests, qa-regression-tests, devops-deploy-runbook-tests, qa-acceptance-tests, mobile-app-config-tests] + needs: [constraints-guard, validate-json, check-docs, check-artifacts, check-regression, check-ci-integrity, check-readme-claims, 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, crm-financement-bancaire-tests, fiscal-ecf-tests, frontend-portails-tests, legal-confotur-tests, qa-audit-5d-tests, seo-tests, chat-otoia-tests, qa-audit-4big-tests, demo-scenario-tests, qa-regression-tests, devops-deploy-runbook-tests, qa-acceptance-tests, mobile-app-config-tests] steps: - name: Résultat run: echo "✅ Gate qualité 4Big franchi — tous les checks verts." diff --git a/03_agents/devops/AGENT.md b/03_agents/devops/AGENT.md index a2239f1..3c66312 100644 --- a/03_agents/devops/AGENT.md +++ b/03_agents/devops/AGENT.md @@ -21,7 +21,7 @@ et vérifications HTTP restent côté **agent DevOps/ERPNext Backend**. | Module | Sprint | Rôle | Entrée | Tests | |---|---|---|---|---| -| [`.gitea/workflows/ci.yml`](../../.gitea/workflows/ci.yml) + [`ci/`](../../ci/README.md) | 1 (roadmap L29) | **CI/CD Gitea Actions** : 3 guards blocants (contraintes / JSON / docs) + un job de tests par livrable gated + `gate` agrégateur + `e2e-baseline` | déclenché sur `push`/`pull_request` → `main` + `workflow_dispatch` | 22 suites gated | +| [`.gitea/workflows/ci.yml`](../../.gitea/workflows/ci.yml) + [`ci/`](../../ci/README.md) | 1 (roadmap L29) | **CI/CD Gitea Actions** : 3 guards blocants (contraintes / JSON / docs) + un job de tests par livrable gated + `gate` agrégateur + `e2e-baseline` | déclenché sur `push`/`pull_request` → `main` + `workflow_dispatch` | 23 suites gated | | [`05_deliverables_mvp/devops/deploy_runbook/`](../../05_deliverables_mvp/devops/deploy_runbook/README.md) | 8 (roadmap L73) | **Run-book VPS unifié** : graphe de **7 phases ordonnées** couvrant TOUS les modules gated, dépendances inter-phases + confirmations préalables sourcées | `deploy_runbook_gen.py build\|validate` | 29 (dont 14 injections négatives) | | [`run_ci.sh`](../../run_ci.sh) | 8 (roadmap L73 · « regression exhaustifs ») | **Miroir LOCAL du gate** : rejoue en une commande, hors runner et hors réseau, l'ensemble exact des checks de `gate.needs` | `bash run_ci.sh` (outil, non un gate) | — (voir ci-dessous) | diff --git a/03_agents/erpnext_backend/AGENT.md b/03_agents/erpnext_backend/AGENT.md index 1721b39..ee58d28 100644 --- a/03_agents/erpnext_backend/AGENT.md +++ b/03_agents/erpnext_backend/AGENT.md @@ -37,7 +37,7 @@ le VPS reçoit — jamais l'inverse. vente, le barème commissions, le DocType Dossier Vente et le plan e-CF **résolvent** leurs rôles depuis ce fichier, jamais un nom Frappe en dur. Total backend **RBAC 60 tests** (10 + 11 + 12 + 11 + 16) + **e-CF 39 tests**, tous gated dans le CI (matrice -de régression du repo : **569 tests · 22 suites** · verdict `PASS`, source +de régression du repo : **604 tests · 23 suites** · verdict `PASS`, source `qa/regression/out/regression_run.json` — jamais compté à la main · #6). ## Hand-off reçus (à appliquer sur le VPS, dans l'ordre) diff --git a/03_agents/qa/AGENT.md b/03_agents/qa/AGENT.md index 171e043..d5935e7 100644 --- a/03_agents/qa/AGENT.md +++ b/03_agents/qa/AGENT.md @@ -32,7 +32,7 @@ périmètre — un auditeur ne s'audite jamais. **conformité** normative des hand-off. ## Verdict agrégé courant (source `qa/regression/out/regression_run.json`) -**22 suites gated · 569 tests · 569 passés · 0 échec · 0 erreur → verdict `PASS`.** +**23 suites gated · 604 tests · 604 passés · 0 échec · 0 erreur → verdict `PASS`.** Ce compte est **découvert automatiquement depuis le CI** (`q4lib.registry.parse_ci`, réutilisé sans duplication) — jamais compté à la main. diff --git a/05_deliverables_mvp/crm/financement_bancaire/.gitignore b/05_deliverables_mvp/crm/financement_bancaire/.gitignore new file mode 100644 index 0000000..88b9765 --- /dev/null +++ b/05_deliverables_mvp/crm/financement_bancaire/.gitignore @@ -0,0 +1,4 @@ +# Caches Python +__pycache__/ +*.pyc +# Le dossier out/ EST commité (hand-off frontend/backend direct) — voir README. diff --git a/05_deliverables_mvp/crm/financement_bancaire/README.md b/05_deliverables_mvp/crm/financement_bancaire/README.md new file mode 100644 index 0000000..3592138 --- /dev/null +++ b/05_deliverables_mvp/crm/financement_bancaire/README.md @@ -0,0 +1,111 @@ +# Financement Bancaire · parcours hypothécaire immobilier RD + +**Sprint 5 · CRM natif ERPNext · roadmap L52 (workflow vente end-to-end · volet +financement).** Matérialise la +[`DIRECTIVE_FINANCEMENT_BANCAIRE_COMPLET_20260803.md`](../../../DIRECTIVE_FINANCEMENT_BANCAIRE_COMPLET_20260803.md) +(Michel, 2026-08-03) : un module **complet** de financement bancaire pour un vrai +parcours hypothécaire dominicain, à la place du module « trop simpliste » +antérieur. Livrable **Phase 1 (P0 · MVP)** : le contrat de données + le **cœur +métier du gate check** + la structure des 6 sections + la bannière critique. + +> Ce worker **n'écrit jamais sur le VPS** (contrainte #8). Il produit le contrat +> et les artefacts en-repo ; le backend `/api/hypotheque/*` et le frontend +> `renderHypotheque` réels sont appliqués côté serveur (agents ERPNext Backend / +> Frontend Console). La **Phase 2** (vrais formulaires PDF des 6 banques) attend +> les démarches relationship-manager de Michel — hors périmètre worker. + +## Le cœur du livrable · le gate check 4 conditions + +L'exigence non-négociable de Michel : **aucun document n'est transmis à la banque** +tant que **les 4 conditions** ne sont pas remplies. La logique vit dans +[`finlib/gate.py`](finlib/gate.py) — fonctions **pures**, testées, sans I/O : + +| # | Condition (`key`) | Règle vérifiée | +|---|---|---| +| 1 | `apport_initial_complet` | `Σ paiements ≥ prix × taux` — **20 % résident RD · 30 % étranger** (amendement Michel · aussi exigence des banques RD · Ley 189-11). | +| 2 | `documents_exiges` | Tous les documents `is_required` de la banque choisie au statut **« Validé WAG »** ou **« Envoyé banque »**. | +| 3 | `autorisations_signees` | Toutes les autorisations signées (`signature_date` non nulle). | +| 4 | `validation_wag` | Un conseiller WAG référent a validé (`wag_validated_by`). | + +```python +from finlib import gate +cfg = gate.GateConfig.from_spec(spec) +ok, reasons = gate.can_submit_dossier(dossier, cfg) # (False, ["Apport initial incomplet : …", …]) +status = gate.gate_status(dossier, cfg) # forme de l'endpoint gate-status (barres 0-100 %) +``` + +Si **une** condition manque, `can_submit` est `False` et `reasons` liste chaque +blocage (ordre canonique). Le backend `POST /…/submit` doit renvoyer **403 + ces +raisons** ; le frontend grise le bouton « Envoyer à la banque » et affiche une +barre de progression par condition. Tant que l'**apport initial** est incomplet, +toutes les sections aval sont **grisées** (section `apport_initial` = seule +section `gating`, en position 1). + +## Ce qui est généré (`out/`, commité — hand-off direct) + +| Fichier | Contenu | +|---|---| +| `banques.json` | Profils des banques partenaires (type, taux/LTV/durée, spécialités, devises). | +| `sections.json` | Structure accordéon (apport → info achat → choix banque → formulaires → exigences → autorisations → envoi). | +| `documents.json` | Catalogue des documents exigés par catégorie (client / immobilier / étrangers). | +| `autorisations.json` | Autorisations à signer (OTO Sign) + base légale. | +| `gate_spec.json` | Les 4 conditions + **bannière critique** + endpoints + rôle validation résolu + workflow suivi banque. | +| `gate_status_example.json` | Exemple **calculé** (dossier P07 volontairement bloqué) — auditable, jamais fabriqué. | +| `MANIFEST.json` | Traçabilité (sources, comptes dérivés, rôles RBAC utilisés). | + +Comptes courants (source de vérité = `MANIFEST.json`) : **6 banques**, +**7 sections**, **15 documents** (requis **9** résident · **13** étranger), +**4 autorisations**, **4 conditions** de gate. + +## Anti-invention (#6) + +- **Aucun taux d'apport codé en dur** : 20/30 viennent du contrat + (`apport.residence_types`), sourcés de la DIRECTIVE. Changer le contrat change + le gate, sans toucher au code. +- **Aucun taux/LTV bancaire fabriqué** : les fourchettes non données par la + DIRECTIVE restent `null` (`a_confirmer` · démarche relationship manager). Seules + les valeurs que la DIRECTIVE énonce (ex. Scotiabank LTV 70 %, BHD León 30 ans) + sont encodées. +- **Aucun nom de rôle Frappe en dur** : les rôles (`ventes-conseiller` pour la + validation WAG, etc.) sont résolus depuis + [`rbac_50_roles.json`](../../rbac/rbac_50_roles.json) via `finlib/rbac.py` + (source unique · zéro duplication · #5). + +## Validation · schéma + 12 invariants + +`validate` re-génère le bundle en mémoire et vérifie le schéma de sortie +([`financement.schema.json`](financement.schema.json)) **plus 12 invariants** +métier (unicité des ids ; `0 < résident < étranger ≤ 100` ; fourchettes banque +`min ≤ max` ; rôles résolus dans RBAC ; sections contiguës + section gating en +tête ; gate = 4 conditions alignées sur la bannière ; documents étrangers +strictement additionnels ; statuts recevables ⊆ statuts ; exemple de gate +recalculé cohérent ; comptes du manifeste cohérents). Un invariant qui casse +**refuse** le build (anti-régression). + +## Utilisation + +```bash +cd 05_deliverables_mvp/crm/financement_bancaire +python3 financement_bancaire_gen.py validate # schéma + 12 invariants +python3 financement_bancaire_gen.py build # écrit les 7 artefacts out/ +python3 -m unittest discover -s tests -v # suite (gate + générateur) +``` + +Sortie **déterministe** (tri stable, aucun horodatage) → l'artefact commité est +reproductible bit-à-bit et vérifié par `ci/check_artifacts.sh`. + +## Endpoints backend visés (Phase 1 · à implémenter côté serveur) + +Contrat exposé par `gate_spec.json` : + +- `GET /api/hypotheque/banques` · profils banques (⇐ `banques.json`) +- `GET /api/hypotheque/dossier/{id}/gate-status` · état des 4 conditions (forme de `gate_status_example.json`) +- `POST /api/hypotheque/dossier/{id}/submit` · **403 + raisons** tant que le gate n'est pas vert +- `GET /api/hypotheque/dossier/{id}/journal` · historique horodaté + +## Périmètre + +- ✅ **Phase 1 (ce module)** : contrat, gate check, sections, bannière, exemple calculé. +- ⏳ **Phase 2 (hors worker)** : vrais formulaires PDF officiels des 6 banques + (démarche relationship-manager Michel) + implémentation runtime des endpoints / + du `renderHypotheque` côté VPS. diff --git a/05_deliverables_mvp/crm/financement_bancaire/financement.schema.json b/05_deliverables_mvp/crm/financement_bancaire/financement.schema.json new file mode 100644 index 0000000..ddc373c --- /dev/null +++ b/05_deliverables_mvp/crm/financement_bancaire/financement.schema.json @@ -0,0 +1,165 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Bundle Financement Bancaire (out/)", + "type": "object", + "additionalProperties": true, + "required": ["manifest", "banques", "sections", "documents", "autorisations", "gate_spec", "gate_status_example"], + "properties": { + "manifest": { + "type": "object", + "required": ["generated_from", "source_directive", "rbac_source", "module", "counts", "roles_rbac_utilises"], + "properties": { + "generated_from": {"const": "financement_spec.json"}, + "module": {"const": "financement_bancaire"}, + "counts": { + "type": "object", + "required": ["banques", "sections", "documents", "autorisations", "gate_conditions", "banniere_lignes", "workflow_suivi_etapes"], + "properties": { + "banques": {"type": "integer", "minimum": 1}, + "sections": {"type": "integer", "minimum": 1}, + "documents": {"type": "integer", "minimum": 1}, + "autorisations": {"type": "integer", "minimum": 1}, + "gate_conditions": {"type": "integer", "minimum": 4, "maximum": 4}, + "banniere_lignes": {"type": "integer", "minimum": 4, "maximum": 4}, + "workflow_suivi_etapes": {"type": "integer", "minimum": 1} + } + }, + "roles_rbac_utilises": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["role_id", "erpnext_role_name"], + "properties": { + "role_id": {"type": "string", "minLength": 1}, + "erpnext_role_name": {"type": "string", "minLength": 1} + } + } + } + } + }, + "banques": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "nom", "type", "specialites", "devises"], + "properties": { + "id": {"type": "string", "pattern": "^[a-z0-9_]+$"}, + "nom": {"type": "string", "minLength": 1}, + "type": {"type": "string", "minLength": 1}, + "taux_min_pct": {"type": ["number", "null"]}, + "taux_max_pct": {"type": ["number", "null"]}, + "ltv_min_pct": {"type": ["number", "null"]}, + "ltv_max_pct": {"type": ["number", "null"]}, + "duree_max_ans": {"type": ["integer", "null"]}, + "specialites": {"type": "array", "items": {"type": "string"}}, + "devises": {"type": "array", "minItems": 1, "items": {"type": "string"}} + } + } + }, + "sections": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "titre", "ordre", "gating"], + "properties": { + "id": {"type": "string", "pattern": "^[a-z0-9_]+$"}, + "titre": {"type": "string", "minLength": 1}, + "ordre": {"type": "integer", "minimum": 1}, + "gating": {"type": "boolean"} + } + } + }, + "documents": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "label", "categorie", "applies_to", "is_required"], + "properties": { + "id": {"type": "string", "pattern": "^[a-z0-9_]+$"}, + "label": {"type": "string", "minLength": 1}, + "categorie": {"type": "string", "enum": ["client", "immobilier", "etrangers"]}, + "applies_to": {"type": "string", "enum": ["all", "resident_rd", "etranger"]}, + "is_required": {"type": "boolean"} + } + } + }, + "autorisations": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "label", "base_legale", "signature_method"], + "properties": { + "id": {"type": "string", "pattern": "^[a-z0-9_]+$"}, + "label": {"type": "string", "minLength": 1}, + "base_legale": {"type": "string", "minLength": 1}, + "signature_method": {"type": "string", "minLength": 1} + } + } + }, + "gate_spec": { + "type": "object", + "required": ["endpoint", "submit_endpoint", "condition_keys", "conditions", "banniere", "role_validation", "workflow_suivi_banque"], + "properties": { + "condition_keys": {"type": "array", "minItems": 4, "maxItems": 4, "items": {"type": "string"}}, + "conditions": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "items": { + "type": "object", + "required": ["key", "ordre", "label"], + "properties": { + "key": {"type": "string", "minLength": 1}, + "ordre": {"type": "integer", "minimum": 1}, + "label": {"type": "string", "minLength": 1} + } + } + }, + "banniere": { + "type": "object", + "required": ["niveau", "persistante", "titre", "lignes", "consequence"], + "properties": { + "niveau": {"const": "critique"}, + "persistante": {"type": "boolean"}, + "titre": {"type": "string", "minLength": 1}, + "lignes": {"type": "array", "minItems": 4, "maxItems": 4, "items": {"type": "string"}}, + "consequence": {"type": "string", "minLength": 1} + } + }, + "role_validation": { + "type": "object", + "required": ["role_id", "erpnext_role_name"], + "properties": { + "role_id": {"type": "string", "minLength": 1}, + "erpnext_role_name": {"type": "string", "minLength": 1} + } + }, + "workflow_suivi_banque": {"type": "array", "minItems": 1, "items": {"type": "string"}} + } + }, + "gate_status_example": { + "type": "object", + "required": ["dossier_id", "gate_status", "can_submit", "reasons"], + "properties": { + "dossier_id": {"type": "string", "minLength": 1}, + "can_submit": {"type": "boolean"}, + "reasons": {"type": "array", "items": {"type": "string"}}, + "gate_status": { + "type": "object", + "required": ["can_submit", "conditions_total", "conditions_completed", "overall_percent", "conditions"], + "properties": { + "can_submit": {"type": "boolean"}, + "conditions_total": {"type": "integer", "minimum": 4, "maximum": 4}, + "conditions_completed": {"type": "integer", "minimum": 0, "maximum": 4}, + "overall_percent": {"type": "number", "minimum": 0, "maximum": 100} + } + } + } + } + } +} diff --git a/05_deliverables_mvp/crm/financement_bancaire/financement_bancaire_gen.py b/05_deliverables_mvp/crm/financement_bancaire/financement_bancaire_gen.py new file mode 100644 index 0000000..cf3ad43 --- /dev/null +++ b/05_deliverables_mvp/crm/financement_bancaire/financement_bancaire_gen.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +"""Générateur du module Financement Bancaire — parcours hypothécaire RD. + +Sprint 5 · CRM natif · roadmap L52 (« workflow vente end-to-end » — volet +financement) + DIRECTIVE_FINANCEMENT_BANCAIRE_COMPLET_20260803.md (Michel). + +Transforme le contrat `financement_spec.json` en artefacts prêts à consommer +par le frontend `renderHypotheque` et le backend `/api/hypotheque/*` : + - banques.json → profils des banques partenaires (taux/LTV/durée) + - sections.json → structure accordéon (apport → info → … → envoi) + - documents.json → catalogue des documents exigés par catégorie + - autorisations.json → autorisations à signer (OTO Sign) + - gate_spec.json → gate check 4 conditions + bannière critique + - gate_status_example.json → exemple CALCULÉ (dossier bloqué) auditable + - MANIFEST.json → traçabilité + comptes dérivés + +Le CŒUR métier (calcul d'apport 20/30 + gate check 4 conditions) vit dans +`finlib/gate.py` (fonctions pures, testées). Ce fichier ne fait qu'orchestrer : +charger le contrat, assembler le bundle, valider (schéma + invariants), écrire. + +Anti-invention (#6) : aucune valeur (taux d'apport, documents, autorisations, +noms de rôle) n'est codée en dur — tout vient du contrat + du contrat RBAC. Ce +worker n'écrit JAMAIS sur le VPS (l'application réelle est côté serveur). + +Sortie déterministe (tri stable, aucun horodatage) → diffable + re-générable. + +Sous-commandes : + build [-o OUT] → écrit les 7 fichiers d'artefacts + validate → (re)génère en mémoire, valide schéma + 12 invariants ; + sort en erreur si un invariant casse (anti-régression). +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_DELIVERABLES = os.path.normpath(os.path.join(_HERE, "..", "..")) + +sys.path.insert(0, _HERE) +# Réutilisation (#5 : zéro duplication) du validateur JSON-Schema maison Publiciste. +sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste")) + +from finlib import builder, gate # noqa: E402 +from finlib.rbac import RoleResolver # noqa: E402 +from lib import validator as maison # type: ignore # noqa: E402 + +_SPEC_PATH = os.path.join(_HERE, "financement_spec.json") +_SCHEMA_PATH = os.path.join(_HERE, "financement.schema.json") +_DEFAULT_OUT = os.path.join(_HERE, "out") + +# Fichiers écrits par `build` (nom → clé du bundle). +_ARTIFACTS = [ + ("banques.json", "banques"), + ("sections.json", "sections"), + ("documents.json", "documents"), + ("autorisations.json", "autorisations"), + ("gate_spec.json", "gate_spec"), + ("gate_status_example.json", "gate_status_example"), + ("MANIFEST.json", "manifest"), +] + + +def _eprint(*args) -> None: + print(*args, file=sys.stderr) + + +def _load(path: str) -> dict: + with open(path, encoding="utf-8") as fh: + return json.load(fh) + + +def _write_json(path: str, data) -> None: + with open(path, "w", encoding="utf-8") as fh: + json.dump(data, fh, ensure_ascii=False, indent=2) + fh.write("\n") + + +def _build_bundle() -> tuple[dict, dict]: + spec = _load(_SPEC_PATH) + resolver = RoleResolver.from_path() + return spec, builder.build_bundle(spec, resolver) + + +def _dups(ids: list[str]) -> list[str]: + seen: set[str] = set() + dups: set[str] = set() + for i in ids: + if i in seen: + dups.add(i) + seen.add(i) + return sorted(dups) + + +def _validate_bundle(spec: dict, bundle: dict) -> list[str]: + """Schéma de sortie + 12 invariants métier du module Financement Bancaire.""" + schema = _load(_SCHEMA_PATH) + errors = list(maison.validate(bundle, schema)) + + banques = bundle["banques"] + sections = bundle["sections"] + documents = bundle["documents"] + autorisations = bundle["autorisations"] + gate_spec = bundle["gate_spec"] + manifest = bundle["manifest"] + cfg = gate.GateConfig.from_spec(spec) + + # Invariant 1 · unicité des identifiants (banques, documents, autorisations, sections). + for coll_name, coll in ( + ("banques", banques), + ("documents", documents), + ("autorisations", autorisations), + ("sections", sections), + ): + d = _dups([x["id"] for x in coll]) + if d: + errors.append(f"Identifiant dupliqué dans {coll_name} : {d}") + + # Invariant 2 · taux d'apport : 0 < résident < étranger ≤ 100 (amendement Michel). + taux = cfg.taux_apport_pct + tr = taux.get("resident_rd") + te = taux.get("etranger") + if tr is None or te is None: + errors.append("Taux d'apport résident/étranger manquant dans le contrat.") + elif not (0 < tr < te <= 100): + errors.append( + f"Taux d'apport invalides : résident={tr}, étranger={te} " + f"(attendu 0 < résident < étranger ≤ 100)." + ) + + # Invariant 3 · fourchettes banque cohérentes (min ≤ max quand les deux présents). + for b in banques: + for lo, hi, nom in ( + ("taux_min_pct", "taux_max_pct", "taux"), + ("ltv_min_pct", "ltv_max_pct", "LTV"), + ): + a, z = b.get(lo), b.get(hi) + if a is not None and z is not None and a > z: + errors.append(f"Banque {b['id']!r} : {nom} min {a} > max {z}.") + + # Invariant 4 · rôles du contrat résolus dans RBAC (anti-invention #6). + resolver = RoleResolver.from_path() + for cle, rid in spec["roles"].items(): + try: + resolver.erpnext_name(rid) + except (KeyError, ValueError) as exc: + errors.append(f"Rôle {cle}={rid!r} irrésoluble dans RBAC : {exc}") + + # Invariant 5 · sections : `ordre` contigu 1..N, unique. + ordres = [s["ordre"] for s in sections] + if sorted(ordres) != list(range(1, len(sections) + 1)): + errors.append(f"Ordres de sections non contigus 1..N : {sorted(ordres)}") + + # Invariant 6 · exactement UNE section `gating`, en position 1 (apport_initial). + gating = [s for s in sections if s.get("gating")] + if len(gating) != 1: + errors.append(f"Attendu exactement 1 section gating, trouvé {len(gating)}.") + elif gating[0]["ordre"] != 1 or gating[0]["id"] != "apport_initial": + errors.append( + "La section gating doit être `apport_initial` en position 1 " + f"(trouvé id={gating[0]['id']!r}, ordre={gating[0]['ordre']})." + ) + + # Invariant 7 · gate : exactement 4 conditions, clés == CONDITION_KEYS, ordre 1..4. + conds = gate_spec["conditions"] + keys = [c["key"] for c in conds] + if set(keys) != set(gate.CONDITION_KEYS): + errors.append( + f"Clés de condition {sorted(set(keys))} ≠ clés canoniques " + f"{sorted(gate.CONDITION_KEYS)}." + ) + if [c["ordre"] for c in conds] != [1, 2, 3, 4]: + errors.append(f"Ordres des conditions du gate ≠ 1..4 : {[c['ordre'] for c in conds]}.") + if gate_spec["condition_keys"] != list(gate.CONDITION_KEYS): + errors.append("gate_spec.condition_keys ≠ ordre canonique CONDITION_KEYS.") + + # Invariant 8 · bannière : 4 lignes, alignées 1-pour-1 sur les 4 conditions. + lignes = gate_spec["banniere"]["lignes"] + if len(lignes) != len(gate.CONDITION_KEYS): + errors.append( + f"Bannière : {len(lignes)} lignes ≠ {len(gate.CONDITION_KEYS)} conditions." + ) + + # Invariant 9 · documents « étrangers » exclusifs + cohérence categorie⟺applies_to. + req_res = set(gate.required_document_ids("resident_rd", cfg)) + req_etr = set(gate.required_document_ids("etranger", cfg)) + if not req_res < req_etr: + errors.append( + "Les documents requis résident doivent être un sous-ensemble STRICT " + "des documents requis étranger (les pièces étrangers ajoutent des exigences)." + ) + for d in documents: + etr_cat = d["categorie"] == "etrangers" + etr_app = d["applies_to"] == "etranger" + if etr_cat != etr_app: + errors.append( + f"Document {d['id']!r} : categorie/applies_to incohérents " + f"(categorie={d['categorie']}, applies_to={d['applies_to']})." + ) + + # Invariant 10 · statuts recevables ⊆ statuts déclarés. + statuses = set(spec["document_statuses"]) + recevables = set(spec["document_statuses_recevables"]) + if not recevables <= statuses: + errors.append( + f"document_statuses_recevables {sorted(recevables)} ⊄ " + f"document_statuses {sorted(statuses)}." + ) + + # Invariant 11 · exemple de gate CALCULÉ cohérent (auto-audit du cœur métier). + ex = bundle["gate_status_example"] + gs = ex["gate_status"] + recomputed_can = gate.gate_status(spec["dossier_exemple"], cfg)["can_submit"] + if ex["can_submit"] != recomputed_can or gs["can_submit"] != recomputed_can: + errors.append("gate_status_example.can_submit incohérent avec le recalcul.") + if bool(ex["reasons"]) == ex["can_submit"]: + errors.append("Incohérence : `reasons` non vide DOIT équivaloir à ¬can_submit.") + completed = sum(1 for k in gate.CONDITION_KEYS if gs["conditions"][k]["ok"]) + if gs["conditions_completed"] != completed: + errors.append("gate_status_example.conditions_completed mal compté.") + + # Invariant 12 · cohérence des comptes du manifeste avec les collections. + c = manifest["counts"] + expected = { + "banques": len(banques), + "sections": len(sections), + "documents": len(documents), + "documents_requis_resident": len(req_res), + "documents_requis_etranger": len(req_etr), + "autorisations": len(autorisations), + "gate_conditions": len(conds), + "banniere_lignes": len(lignes), + "workflow_suivi_etapes": len(gate_spec["workflow_suivi_banque"]), + } + for k, v in expected.items(): + if c.get(k) != v: + errors.append(f"manifest.counts.{k}={c.get(k)} ≠ {v} (dérive de compte).") + + return errors + + +def cmd_build(args: argparse.Namespace) -> int: + spec, bundle = _build_bundle() + errors = _validate_bundle(spec, bundle) + if errors: + _eprint("❌ Bundle invalide — génération refusée (anti-régression) :") + for e in errors: + _eprint(f" - {e}") + return 1 + + out = os.path.abspath(args.out) + os.makedirs(out, exist_ok=True) + for fname, key in _ARTIFACTS: + _write_json(os.path.join(out, fname), bundle[key]) + + m = bundle["manifest"] + c = m["counts"] + print(f"✅ Artefacts Financement Bancaire générés dans {out}") + print(f" banques.json : {c['banques']} banques partenaires") + print(f" sections.json : {c['sections']} sections (accordéon)") + print( + f" documents.json : {c['documents']} documents " + f"(requis résident {c['documents_requis_resident']} · étranger {c['documents_requis_etranger']})" + ) + print(f" autorisations.json : {c['autorisations']} autorisations (OTO Sign)") + print(f" gate_spec.json : {c['gate_conditions']} conditions + bannière critique") + ex = bundle["gate_status_example"] + print( + f" gate_status_example: dossier {ex['dossier_id']} · " + f"can_submit={ex['can_submit']} · {len(ex['reasons'])} raison(s) de blocage" + ) + return 0 + + +def cmd_validate(args: argparse.Namespace) -> int: + spec, bundle = _build_bundle() + errors = _validate_bundle(spec, bundle) + if errors: + _eprint("❌ Validation KO :") + for e in errors: + _eprint(f" - {e}") + return 1 + c = bundle["manifest"]["counts"] + print( + f"✅ Validation OK — {c['banques']} banques, {c['sections']} sections, " + f"{c['documents']} documents, {c['autorisations']} autorisations, " + f"gate 4 conditions, schéma + 12 invariants verts." + ) + return 0 + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser( + description="Générateur du module Financement Bancaire (parcours hypothécaire RD)." + ) + sub = p.add_subparsers(dest="cmd", required=True) + + pb = sub.add_parser("build", help="génère les 7 artefacts out/") + pb.add_argument("-o", "--out", default=_DEFAULT_OUT, help="dossier de sortie (défaut: ./out)") + pb.set_defaults(func=cmd_build) + + pv = sub.add_parser("validate", help="valide le bundle (schéma + 12 invariants) sans écrire") + pv.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/crm/financement_bancaire/financement_spec.json b/05_deliverables_mvp/crm/financement_bancaire/financement_spec.json new file mode 100644 index 0000000..ced7629 --- /dev/null +++ b/05_deliverables_mvp/crm/financement_bancaire/financement_spec.json @@ -0,0 +1,215 @@ +{ + "version": "1.0.0", + "module": "financement_bancaire", + "titre": "Financement Bancaire — parcours hypothécaire immobilier RD", + "source_directive": "DIRECTIVE_FINANCEMENT_BANCAIRE_COMPLET_20260803.md", + "roles": { + "conseiller_wag": "ventes-conseiller", + "validation_dossier": "ventes-conseiller", + "escalade": "ventes-directeur", + "tresorerie_apport": "compta-tresorier" + }, + "apport": { + "note": "Apport initial minimum exigé AVANT toute démarche bancaire (amendement Michel 2026-08-03). Source des taux : la DIRECTIVE (§1 plan de paiement + amendement) — jamais un taux inventé (#6). Ce sont aussi des exigences des banques dominicaines (Ley 189-11).", + "residence_types": [ + { + "code": "resident_rd", + "label": "Résident RD", + "taux_apport_pct": 20, + "source": "DIRECTIVE §1 + amendement" + }, + { + "code": "etranger", + "label": "Étranger", + "taux_apport_pct": 30, + "source": "DIRECTIVE §1 + amendement" + } + ] + }, + "document_statuses": ["Non déposé", "Déposé", "Validé WAG", "Envoyé banque"], + "document_statuses_recevables": ["Validé WAG", "Envoyé banque"], + "banques": [ + { + "id": "banreservas", + "nom": "Banreservas", + "type": "banque d'État", + "taux_min_pct": null, + "taux_max_pct": null, + "ltv_min_pct": null, + "ltv_max_pct": 80, + "duree_max_ans": 25, + "specialites": ["residents", "taux_preferentiels"], + "devises": ["DOP"], + "note": "Taux préférentiels résidents. Taux exacts à confirmer (relationship manager · #6)." + }, + { + "id": "popular", + "nom": "Banco Popular Dominicano", + "type": "banque privée", + "taux_min_pct": null, + "taux_max_pct": null, + "ltv_min_pct": 75, + "ltv_max_pct": 85, + "duree_max_ans": null, + "specialites": ["reseau_large", "rapide", "confotur"], + "devises": ["DOP"], + "note": "Réseau large, CONFOTUR-friendly. Taux et durée à confirmer (#6)." + }, + { + "id": "bhd_leon", + "nom": "BHD León", + "type": "banque privée", + "taux_min_pct": null, + "taux_max_pct": null, + "ltv_min_pct": null, + "ltv_max_pct": null, + "duree_max_ans": 30, + "specialites": ["haut_de_gamme", "etrangers", "bilingue"], + "devises": ["DOP", "USD"], + "note": "Haut de gamme, étrangers acceptés, bilingue. Taux/LTV à confirmer (#6)." + }, + { + "id": "santa_cruz", + "nom": "Banco Santa Cruz", + "type": "banque privée", + "taux_min_pct": null, + "taux_max_pct": null, + "ltv_min_pct": null, + "ltv_max_pct": null, + "duree_max_ans": null, + "specialites": ["taux_competitifs", "prequalif_rapide"], + "devises": ["DOP"], + "note": "Taux compétitifs, pré-qualif rapide. Chiffres à confirmer (#6)." + }, + { + "id": "scotiabank_rd", + "nom": "Scotiabank República Dominicana", + "type": "banque privée", + "taux_min_pct": null, + "taux_max_pct": null, + "ltv_min_pct": null, + "ltv_max_pct": 70, + "duree_max_ans": null, + "specialites": ["etrangers", "devise_usd"], + "devises": ["DOP", "USD"], + "note": "Spécialistes étrangers, devise USD acceptée. Taux/durée à confirmer (#6)." + }, + { + "id": "lopez_de_haro", + "nom": "López de Haro", + "type": "banque privée", + "taux_min_pct": null, + "taux_max_pct": null, + "ltv_min_pct": null, + "ltv_max_pct": null, + "duree_max_ans": null, + "specialites": ["haut_de_gamme", "niche"], + "devises": ["DOP"], + "note": "Niche haut de gamme. Profil complet à confirmer (#6)." + } + ], + "documents": [ + {"id": "cedula_passeport", "label": "Cédula ou Passeport (2 côtés)", "categorie": "client", "applies_to": "all", "is_required": true}, + {"id": "preuve_adresse", "label": "Preuve d'adresse récente (<3 mois)", "categorie": "client", "applies_to": "all", "is_required": true}, + {"id": "fiches_paye_3", "label": "3 dernières fiches de paye ou déclarations ITBIS/DGII", "categorie": "client", "applies_to": "all", "is_required": true}, + {"id": "etats_bancaires_6m", "label": "États bancaires 6 derniers mois", "categorie": "client", "applies_to": "all", "is_required": true}, + {"id": "lettre_employeur", "label": "Lettre employeur ou attestation profession libérale", "categorie": "client", "applies_to": "all", "is_required": true}, + {"id": "certif_solvabilite", "label": "Certification de solvabilité", "categorie": "client", "applies_to": "all", "is_required": true}, + {"id": "certif_datacredito", "label": "Certification DataCrédito (délivrée par la banque)", "categorie": "client", "applies_to": "all", "is_required": true}, + {"id": "promesa_notariee", "label": "Promesa Irrévocable signée notariée", "categorie": "immobilier", "applies_to": "all", "is_required": true}, + {"id": "fideicomiso", "label": "Fideicomiso Inmobiliario constitué (Ley 189-11)", "categorie": "immobilier", "applies_to": "all", "is_required": true}, + {"id": "titre_definitif", "label": "Titre définitif ou Certificado de Título (si disponible)", "categorie": "immobilier", "applies_to": "all", "is_required": false}, + {"id": "passeport_2cotes", "label": "Passeport valide (2 côtés)", "categorie": "etrangers", "applies_to": "etranger", "is_required": true}, + {"id": "visa_residence", "label": "Visa / résidence si applicable", "categorie": "etrangers", "applies_to": "etranger", "is_required": false}, + {"id": "lettre_origine_fonds", "label": "Lettre origine des fonds notariée (FinCEN GTO)", "categorie": "etrangers", "applies_to": "etranger", "is_required": true}, + {"id": "refs_bancaires_intl", "label": "Références bancaires internationales", "categorie": "etrangers", "applies_to": "etranger", "is_required": true}, + {"id": "refs_perso", "label": "Références commerciales / personnelles", "categorie": "etrangers", "applies_to": "etranger", "is_required": true} + ], + "autorisations": [ + {"id": "partage_donnees", "label": "Autorisation de partage de données personnelles avec la banque", "base_legale": "RGPD / Ley 172-13", "signature_method": "OTO Sign"}, + {"id": "investigation_datacredito", "label": "Autorisation d'investigation DataCrédito", "base_legale": "Ley 172-13", "signature_method": "OTO Sign"}, + {"id": "mandat_wag", "label": "Mandat WAG comme intermédiaire de financement", "base_legale": "mandat contractuel", "signature_method": "OTO Sign"}, + {"id": "acknowledgment_risques", "label": "Acknowledgment — risques de refus bancaire + politique annulation", "base_legale": "consentement éclairé", "signature_method": "OTO Sign"} + ], + "formulaires_types": [ + "Solicitud de Préstamo Hipotecario", + "Autorización de investigación crediticia (DataCrédito)", + "Declaración jurada de patrimonio", + "Autorización débito automático quote-part", + "Certificación de origen de fondos (étrangers)" + ], + "sections": [ + {"id": "apport_initial", "titre": "Statut apport initial", "ordre": 1, "gating": true, "resume": "Prix unité · statut résidence · apport requis (20%/30%) · apport versé · solde restant · barre 0-100%."}, + {"id": "info_achat", "titre": "Information de l'achat", "ordre": 2, "gating": false, "resume": "Résumé auto de la réservation : projet, unité, plan de paiement, apport, solde à financer, statuts Fideicomiso + CONFOTUR."}, + {"id": "choix_banque", "titre": "Choix de la banque partenaire", "ordre": 3, "gating": false, "resume": "Sélection d'UNE banque prioritaire (radio) + multi-dossiers possibles ; profils taux/LTV/durée/spécialités."}, + {"id": "formulaires", "titre": "Formulaires banque pré-remplis", "ordre": 4, "gating": false, "resume": "Formulaires officiels PDF pré-remplis, téléchargés, signés, ré-uploadés ; journal audit des signatures."}, + {"id": "exigences", "titre": "Exigences bancaires par banque", "ordre": 5, "gating": false, "resume": "Liste des documents exigés cochée au fil des uploads ; statut par item (Non déposé / Déposé / Validé WAG / Envoyé banque)."}, + {"id": "autorisations", "titre": "Autorisations à signer", "ordre": 6, "gating": false, "resume": "Autorisations à signer AVANT envoi (OTO Sign horodaté + IP + hash)."}, + {"id": "envoi", "titre": "Envoi à la banque", "ordre": 7, "gating": false, "resume": "Bouton « Envoyer à la banque » (gate 4 conditions) + timeline suivi banque + statut temps réel."} + ], + "gate": { + "endpoint": "GET /api/hypotheque/dossier/{id}/gate-status", + "submit_endpoint": "POST /api/hypotheque/dossier/{id}/submit", + "conditions": [ + {"key": "apport_initial_complet", "ordre": 1, "label": "Dépôt initial complet (20% résident RD · 30% étranger) versé et vérifié"}, + {"key": "documents_exiges", "ordre": 2, "label": "TOUS les documents exigés déposés et validés WAG"}, + {"key": "autorisations_signees", "ordre": 3, "label": "TOUTES les autorisations signées électroniquement"}, + {"key": "validation_wag", "ordre": 4, "label": "Validation manuelle WAG confirmée par le conseiller référent"} + ], + "banniere": { + "niveau": "critique", + "couleurs": ["rouge", "or"], + "persistante": true, + "titre": "⚠ AUCUN DOCUMENT N'EST TRANSMIS À LA BANQUE tant que :", + "lignes": [ + "Dépôt initial complet (20% résident RD · 30% étranger) versé et vérifié", + "TOUS les documents exigés ne sont pas déposés dans le portail", + "TOUTES les autorisations ne sont pas signées électroniquement", + "Validation manuelle WAG confirmée par le conseiller référent" + ], + "consequence": "Le bouton « Envoyer à la banque » reste désactivé tant que ces 4 conditions ne sont pas remplies. Progression affichée en temps réel avec barre 0-100% par condition." + } + }, + "workflow_suivi_banque": [ + "Prequalifié", + "Dossier soumis", + "Analyse crédit", + "Offre reçue", + "Contrat signé", + "Décaissement" + ], + "dossier_exemple": { + "id": "DEMO-P07-A312", + "projet": "P07 Aqua Terra Las Terrenas", + "unite": "A-312", + "prix_usd": 145000, + "residence_type": "etranger", + "banque_choisie": "scotiabank_rd", + "paiements": [ + {"date": "2026-06-15", "montant_verse_usd": 15000, "moyen": "Cardnet"}, + {"date": "2026-07-20", "montant_verse_usd": 14500, "moyen": "Cardnet"} + ], + "documents": [ + {"id": "cedula_passeport", "statut": "Validé WAG"}, + {"id": "preuve_adresse", "statut": "Validé WAG"}, + {"id": "fiches_paye_3", "statut": "Déposé"}, + {"id": "etats_bancaires_6m", "statut": "Validé WAG"}, + {"id": "lettre_employeur", "statut": "Non déposé"}, + {"id": "certif_solvabilite", "statut": "Non déposé"}, + {"id": "certif_datacredito", "statut": "Non déposé"}, + {"id": "promesa_notariee", "statut": "Validé WAG"}, + {"id": "fideicomiso", "statut": "Déposé"}, + {"id": "passeport_2cotes", "statut": "Validé WAG"}, + {"id": "lettre_origine_fonds", "statut": "Non déposé"}, + {"id": "refs_bancaires_intl", "statut": "Non déposé"}, + {"id": "refs_perso", "statut": "Non déposé"} + ], + "autorisations": [ + {"id": "partage_donnees", "signature_date": "2026-07-21"}, + {"id": "investigation_datacredito", "signature_date": "2026-07-21"}, + {"id": "mandat_wag", "signature_date": null}, + {"id": "acknowledgment_risques", "signature_date": null} + ], + "wag_validated_by": null + } +} diff --git a/05_deliverables_mvp/crm/financement_bancaire/finlib/__init__.py b/05_deliverables_mvp/crm/financement_bancaire/finlib/__init__.py new file mode 100644 index 0000000..98f69a9 --- /dev/null +++ b/05_deliverables_mvp/crm/financement_bancaire/finlib/__init__.py @@ -0,0 +1,7 @@ +"""finlib · logique du module Financement Bancaire (parcours hypothécaire RD). + +Sous-modules : + rbac — résolution des rôles depuis le contrat RBAC 50 rôles (zéro invention). + gate — calcul de l'apport + gate check 4 conditions (cœur métier, pur). + builder — assemblage du bundle d'artefacts déterministe depuis le contrat. +""" diff --git a/05_deliverables_mvp/crm/financement_bancaire/finlib/builder.py b/05_deliverables_mvp/crm/financement_bancaire/finlib/builder.py new file mode 100644 index 0000000..461df53 --- /dev/null +++ b/05_deliverables_mvp/crm/financement_bancaire/finlib/builder.py @@ -0,0 +1,100 @@ +"""Assemblage du bundle d'artefacts Financement Bancaire depuis le contrat. + +Entrée : `financement_spec.json` + un `RoleResolver` (id → nom Frappe issu de +rbac_50_roles.json). Sortie : un bundle déterministe (tri stable, aucun +horodatage) → reproductible bit-à-bit à contrat constant, donc diffable et +re-générable en CI (`ci/check_artifacts.sh`). + +Le builder n'invente rien (#6) : banques, documents, autorisations, sections, +conditions du gate proviennent du contrat ; les noms de rôle proviennent du +contrat RBAC ; l'exemple de gate-status est CALCULÉ par `finlib.gate` depuis le +dossier d'exemple du contrat (donc auditable, pas fabriqué). +""" + +from __future__ import annotations + +from typing import Any + +from . import gate +from .rbac import RoleResolver + + +def build_bundle(spec: dict, resolver: RoleResolver) -> dict[str, Any]: + """Transforme le contrat en bundle d'artefacts prêts à consommer.""" + cfg = gate.GateConfig.from_spec(spec) + + # --- rôles résolus (id → nom Frappe) -------------------------------------- + roles_resolved = { + cle: {"role_id": rid, "erpnext_role_name": resolver.erpnext_name(rid)} + for cle, rid in sorted(spec["roles"].items()) + } + + # --- banques (ordre du contrat = ordre d'affichage) ----------------------- + banques = list(spec["banques"]) + + # --- sections triées par `ordre` ----------------------------------------- + sections = sorted(spec["sections"], key=lambda s: s["ordre"]) + + documents = list(spec["documents"]) + autorisations = list(spec["autorisations"]) + + # --- spec du gate (conditions + bannière + endpoints + rôle validation) --- + gate_spec = { + "endpoint": spec["gate"]["endpoint"], + "submit_endpoint": spec["gate"]["submit_endpoint"], + "condition_keys": list(gate.CONDITION_KEYS), + "conditions": sorted(spec["gate"]["conditions"], key=lambda c: c["ordre"]), + "banniere": spec["gate"]["banniere"], + "role_validation": roles_resolved["validation_dossier"], + "workflow_suivi_banque": list(spec["workflow_suivi_banque"]), + } + + # --- exemple de gate-status CALCULÉ (auditable, pas inventé) --------------- + exemple = spec["dossier_exemple"] + can_submit, reasons = gate.can_submit_dossier(exemple, cfg) + gate_status_example = { + "dossier_id": exemple["id"], + "projet": exemple["projet"], + "unite": exemple["unite"], + "banque_choisie": exemple["banque_choisie"], + "gate_status": gate.gate_status(exemple, cfg), + "can_submit": can_submit, + "reasons": reasons, + } + + # --- comptes des documents requis par type de résidence ------------------- + docs_requis = { + rt["code"]: len(gate.required_document_ids(rt["code"], cfg)) + for rt in spec["apport"]["residence_types"] + } + + manifest = { + "generated_from": "financement_spec.json", + "source_directive": spec["source_directive"], + "rbac_source": "rbac_50_roles.json", + "source_version": spec["version"], + "module": spec["module"], + "titre": spec["titre"], + "counts": { + "banques": len(banques), + "sections": len(sections), + "documents": len(documents), + "documents_requis_resident": docs_requis.get("resident_rd", 0), + "documents_requis_etranger": docs_requis.get("etranger", 0), + "autorisations": len(autorisations), + "gate_conditions": len(gate_spec["conditions"]), + "banniere_lignes": len(gate_spec["banniere"]["lignes"]), + "workflow_suivi_etapes": len(gate_spec["workflow_suivi_banque"]), + }, + "roles_rbac_utilises": [roles_resolved[k] for k in sorted(roles_resolved)], + } + + return { + "manifest": manifest, + "banques": banques, + "sections": sections, + "documents": documents, + "autorisations": autorisations, + "gate_spec": gate_spec, + "gate_status_example": gate_status_example, + } diff --git a/05_deliverables_mvp/crm/financement_bancaire/finlib/gate.py b/05_deliverables_mvp/crm/financement_bancaire/finlib/gate.py new file mode 100644 index 0000000..a0d54f9 --- /dev/null +++ b/05_deliverables_mvp/crm/financement_bancaire/finlib/gate.py @@ -0,0 +1,201 @@ +"""Cœur métier du module Financement Bancaire : apport initial + gate check. + +Fonctions PURES (aucune I/O, aucun horodatage) → testables et déterministes. +Elles matérialisent l'exigence Michel (DIRECTIVE + amendement 2026-08-03) : + + AUCUN dossier n'est transmis à la banque tant que les 4 conditions ne sont pas + remplies : + 1. apport_initial_complet — dépôt initial (20 % résident RD · 30 % étranger) + intégralement versé et vérifié ; + 2. documents_exiges — tous les documents `is_required` de la banque + choisie au statut « Validé WAG » ou « Envoyé banque » ; + 3. autorisations_signees — toutes les autorisations signées (date non nulle) ; + 4. validation_wag — un conseiller WAG a validé (`wag_validated_by`). + +Anti-invention (#6) : les taux d'apport (20/30) et la liste des documents/ +autorisations ne sont JAMAIS codés en dur ici — ils proviennent du contrat +`financement_spec.json` via `GateConfig`. Changer le contrat change le gate, +sans toucher ce code. +""" + +from __future__ import annotations + +from typing import Any + +# Ordre canonique des 4 clés de condition du gate (= ordre du contrat / bannière). +CONDITION_KEYS = ( + "apport_initial_complet", + "documents_exiges", + "autorisations_signees", + "validation_wag", +) + + +class GateConfig: + """Vue en lecture du contrat, indexée pour le calcul du gate.""" + + def __init__(self, spec: dict) -> None: + self.taux_apport_pct: dict[str, float] = { + rt["code"]: float(rt["taux_apport_pct"]) + for rt in spec["apport"]["residence_types"] + } + self.documents: list[dict] = list(spec["documents"]) + self.autorisations: list[dict] = list(spec["autorisations"]) + self.statuts_recevables: set[str] = set(spec["document_statuses_recevables"]) + self.banques: dict[str, dict] = {b["id"]: b for b in spec["banques"]} + + @classmethod + def from_spec(cls, spec: dict) -> "GateConfig": + return cls(spec) + + +def _round1(x: float) -> float: + """Arrondi à 1 décimale, stable pour un artefact diffable.""" + return round(x + 0.0, 1) + + +def apport_requis_usd(prix_usd: float, residence_type: str, cfg: GateConfig) -> float: + """Apport requis = prix × taux (20 % résident RD, 30 % étranger).""" + if residence_type not in cfg.taux_apport_pct: + raise KeyError( + f"residence_type {residence_type!r} inconnu " + f"(attendu : {sorted(cfg.taux_apport_pct)})." + ) + return round(float(prix_usd) * cfg.taux_apport_pct[residence_type] / 100.0, 2) + + +def apport_verse_usd(dossier: dict) -> float: + """Somme des versements enregistrés (0 si aucun).""" + total = 0.0 + for p in dossier.get("paiements", []): + total += float(p.get("montant_verse_usd", 0) or 0) + return round(total, 2) + + +def required_document_ids(residence_type: str, cfg: GateConfig) -> list[str]: + """Documents `is_required` applicables au type de résidence du client. + + Un document est exigé si `is_required` ET (`applies_to` == "all" OU + `applies_to` == residence_type). Les documents « étrangers uniquement » ne + s'appliquent donc pas à un résident RD. + """ + out: list[str] = [] + for d in cfg.documents: + if not d.get("is_required"): + continue + applies = d.get("applies_to", "all") + if applies in ("all", residence_type): + out.append(d["id"]) + return out + + +def _cond_apport(dossier: dict, cfg: GateConfig) -> dict: + residence = dossier.get("residence_type") + required = apport_requis_usd(dossier.get("prix_usd", 0), residence, cfg) + paid = apport_verse_usd(dossier) + remaining = round(max(0.0, required - paid), 2) + pct = _round1(min(100.0, (paid / required * 100.0) if required > 0 else 100.0)) + return { + "ok": paid + 1e-9 >= required, + "required": required, + "paid": paid, + "remaining": remaining, + "percent": pct, + } + + +def _cond_documents(dossier: dict, cfg: GateConfig) -> dict: + residence = dossier.get("residence_type") + required_ids = required_document_ids(residence, cfg) + by_id = {d["id"]: d for d in dossier.get("documents", [])} + deposited = 0 + validated = 0 + for did in required_ids: + statut = (by_id.get(did) or {}).get("statut", "Non déposé") + if statut != "Non déposé": + deposited += 1 + if statut in cfg.statuts_recevables: + validated += 1 + total = len(required_ids) + pct = _round1((validated / total * 100.0) if total > 0 else 100.0) + return { + "ok": validated == total, + "total": total, + "deposited": deposited, + "validated": validated, + "percent": pct, + } + + +def _cond_autorisations(dossier: dict, cfg: GateConfig) -> dict: + required_ids = [a["id"] for a in cfg.autorisations] + by_id = {a["id"]: a for a in dossier.get("autorisations", [])} + signed = 0 + for aid in required_ids: + if (by_id.get(aid) or {}).get("signature_date"): + signed += 1 + total = len(required_ids) + pct = _round1((signed / total * 100.0) if total > 0 else 100.0) + return {"ok": signed == total, "total": total, "signed": signed, "percent": pct} + + +def _cond_validation_wag(dossier: dict) -> dict: + validated_by = dossier.get("wag_validated_by") + ok = bool(validated_by) + return {"ok": ok, "validated_by": validated_by, "percent": 100.0 if ok else 0.0} + + +def gate_status(dossier: dict, cfg: GateConfig) -> dict: + """État complet du gate — forme consommée par le frontend (barres 0-100 %). + + Correspond au contrat de l'endpoint + `GET /api/hypotheque/dossier/{id}/gate-status` (DIRECTIVE amendement). + """ + conditions = { + "apport_initial_complet": _cond_apport(dossier, cfg), + "documents_exiges": _cond_documents(dossier, cfg), + "autorisations_signees": _cond_autorisations(dossier, cfg), + "validation_wag": _cond_validation_wag(dossier), + } + can_submit = all(conditions[k]["ok"] for k in CONDITION_KEYS) + completed = sum(1 for k in CONDITION_KEYS if conditions[k]["ok"]) + overall = _round1( + sum(conditions[k]["percent"] for k in CONDITION_KEYS) / len(CONDITION_KEYS) + ) + return { + "can_submit": can_submit, + "conditions_total": len(CONDITION_KEYS), + "conditions_completed": completed, + "overall_percent": overall, + "conditions": conditions, + } + + +def can_submit_dossier(dossier: dict, cfg: GateConfig) -> tuple[bool, list[str]]: + """(peut_soumettre, raisons_de_blocage) — vide si soumissible. + + `submit_to_bank()` doit renvoyer 403 + ces raisons tant que la liste n'est + pas vide. Ordre des raisons = ordre canonique des conditions. + """ + status = gate_status(dossier, cfg) + reasons: list[str] = [] + c = status["conditions"] + if not c["apport_initial_complet"]["ok"]: + a = c["apport_initial_complet"] + reasons.append( + f"Apport initial incomplet : {a['paid']:.0f}/{a['required']:.0f} USD versés " + f"(reste {a['remaining']:.0f} USD · {a['percent']}%)." + ) + if not c["documents_exiges"]["ok"]: + d = c["documents_exiges"] + reasons.append( + f"Documents exigés incomplets : {d['validated']}/{d['total']} validés WAG." + ) + if not c["autorisations_signees"]["ok"]: + s = c["autorisations_signees"] + reasons.append( + f"Autorisations non signées : {s['signed']}/{s['total']} signées." + ) + if not c["validation_wag"]["ok"]: + reasons.append("Validation WAG manquante (aucun conseiller référent n'a validé).") + return status["can_submit"], reasons diff --git a/05_deliverables_mvp/crm/financement_bancaire/finlib/rbac.py b/05_deliverables_mvp/crm/financement_bancaire/finlib/rbac.py new file mode 100644 index 0000000..db1d736 --- /dev/null +++ b/05_deliverables_mvp/crm/financement_bancaire/finlib/rbac.py @@ -0,0 +1,56 @@ +"""Résolution des rôles du module Financement Bancaire depuis le contrat RBAC. + +Réutilisation (workflow #5 : zéro duplication de VALEUR) de l'unique source de +vérité des rôles ERPNext : `05_deliverables_mvp/rbac/rbac_50_roles.json` (validé +par `rbac.schema.json` dans le job CI `rbac-tests`). Le contrat du module +(`financement_spec.json`) ne cite JAMAIS un nom de rôle Frappe en dur : il +référence l'`id` stable d'un rôle RBAC → ici on résout `id → erpnext_role_name`. + +Conséquence anti-invention (#6) : un `role_id` absent du contrat RBAC lève une +erreur (aucun rôle fabriqué), et renommer un rôle côté RBAC se propage +automatiquement au module sans édition manuelle. +""" + +from __future__ import annotations + +import json +import os + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_DELIVERABLES = os.path.normpath(os.path.join(_HERE, "..", "..", "..")) +RBAC_CONTRACT_PATH = os.path.join(_DELIVERABLES, "rbac", "rbac_50_roles.json") + + +class RoleResolver: + """Table `role_id → erpnext_role_name` construite depuis le contrat RBAC.""" + + def __init__(self, contract: dict) -> None: + self._by_id: dict[str, dict] = {} + for role in contract.get("roles", []): + rid = role.get("id") + if rid is None: + raise ValueError("Rôle RBAC sans `id` — contrat corrompu.") + if rid in self._by_id: + raise ValueError(f"`id` de rôle RBAC dupliqué : {rid!r}") + self._by_id[rid] = role + + @classmethod + def from_path(cls, path: str = RBAC_CONTRACT_PATH) -> "RoleResolver": + with open(path, encoding="utf-8") as fh: + return cls(json.load(fh)) + + def erpnext_name(self, role_id: str) -> str: + """`id` RBAC → nom de rôle Frappe. Lève si l'`id` n'existe pas.""" + role = self._by_id.get(role_id) + if role is None: + raise KeyError( + f"role_id {role_id!r} introuvable dans rbac_50_roles.json " + f"(aucun rôle inventé · #6)." + ) + name = role.get("erpnext_role_name") + if not name: + raise ValueError(f"Rôle {role_id!r} sans `erpnext_role_name`.") + return name + + def known_ids(self) -> frozenset[str]: + return frozenset(self._by_id) diff --git a/05_deliverables_mvp/crm/financement_bancaire/out/MANIFEST.json b/05_deliverables_mvp/crm/financement_bancaire/out/MANIFEST.json new file mode 100644 index 0000000..1ee742f --- /dev/null +++ b/05_deliverables_mvp/crm/financement_bancaire/out/MANIFEST.json @@ -0,0 +1,37 @@ +{ + "generated_from": "financement_spec.json", + "source_directive": "DIRECTIVE_FINANCEMENT_BANCAIRE_COMPLET_20260803.md", + "rbac_source": "rbac_50_roles.json", + "source_version": "1.0.0", + "module": "financement_bancaire", + "titre": "Financement Bancaire — parcours hypothécaire immobilier RD", + "counts": { + "banques": 6, + "sections": 7, + "documents": 15, + "documents_requis_resident": 9, + "documents_requis_etranger": 13, + "autorisations": 4, + "gate_conditions": 4, + "banniere_lignes": 4, + "workflow_suivi_etapes": 6 + }, + "roles_rbac_utilises": [ + { + "role_id": "ventes-conseiller", + "erpnext_role_name": "OTO Ventes Conseiller" + }, + { + "role_id": "ventes-directeur", + "erpnext_role_name": "OTO Ventes Directeur" + }, + { + "role_id": "compta-tresorier", + "erpnext_role_name": "OTO Compta Trésorier" + }, + { + "role_id": "ventes-conseiller", + "erpnext_role_name": "OTO Ventes Conseiller" + } + ] +} diff --git a/05_deliverables_mvp/crm/financement_bancaire/out/autorisations.json b/05_deliverables_mvp/crm/financement_bancaire/out/autorisations.json new file mode 100644 index 0000000..5749498 --- /dev/null +++ b/05_deliverables_mvp/crm/financement_bancaire/out/autorisations.json @@ -0,0 +1,26 @@ +[ + { + "id": "partage_donnees", + "label": "Autorisation de partage de données personnelles avec la banque", + "base_legale": "RGPD / Ley 172-13", + "signature_method": "OTO Sign" + }, + { + "id": "investigation_datacredito", + "label": "Autorisation d'investigation DataCrédito", + "base_legale": "Ley 172-13", + "signature_method": "OTO Sign" + }, + { + "id": "mandat_wag", + "label": "Mandat WAG comme intermédiaire de financement", + "base_legale": "mandat contractuel", + "signature_method": "OTO Sign" + }, + { + "id": "acknowledgment_risques", + "label": "Acknowledgment — risques de refus bancaire + politique annulation", + "base_legale": "consentement éclairé", + "signature_method": "OTO Sign" + } +] diff --git a/05_deliverables_mvp/crm/financement_bancaire/out/banques.json b/05_deliverables_mvp/crm/financement_bancaire/out/banques.json new file mode 100644 index 0000000..bc999e5 --- /dev/null +++ b/05_deliverables_mvp/crm/financement_bancaire/out/banques.json @@ -0,0 +1,114 @@ +[ + { + "id": "banreservas", + "nom": "Banreservas", + "type": "banque d'État", + "taux_min_pct": null, + "taux_max_pct": null, + "ltv_min_pct": null, + "ltv_max_pct": 80, + "duree_max_ans": 25, + "specialites": [ + "residents", + "taux_preferentiels" + ], + "devises": [ + "DOP" + ], + "note": "Taux préférentiels résidents. Taux exacts à confirmer (relationship manager · #6)." + }, + { + "id": "popular", + "nom": "Banco Popular Dominicano", + "type": "banque privée", + "taux_min_pct": null, + "taux_max_pct": null, + "ltv_min_pct": 75, + "ltv_max_pct": 85, + "duree_max_ans": null, + "specialites": [ + "reseau_large", + "rapide", + "confotur" + ], + "devises": [ + "DOP" + ], + "note": "Réseau large, CONFOTUR-friendly. Taux et durée à confirmer (#6)." + }, + { + "id": "bhd_leon", + "nom": "BHD León", + "type": "banque privée", + "taux_min_pct": null, + "taux_max_pct": null, + "ltv_min_pct": null, + "ltv_max_pct": null, + "duree_max_ans": 30, + "specialites": [ + "haut_de_gamme", + "etrangers", + "bilingue" + ], + "devises": [ + "DOP", + "USD" + ], + "note": "Haut de gamme, étrangers acceptés, bilingue. Taux/LTV à confirmer (#6)." + }, + { + "id": "santa_cruz", + "nom": "Banco Santa Cruz", + "type": "banque privée", + "taux_min_pct": null, + "taux_max_pct": null, + "ltv_min_pct": null, + "ltv_max_pct": null, + "duree_max_ans": null, + "specialites": [ + "taux_competitifs", + "prequalif_rapide" + ], + "devises": [ + "DOP" + ], + "note": "Taux compétitifs, pré-qualif rapide. Chiffres à confirmer (#6)." + }, + { + "id": "scotiabank_rd", + "nom": "Scotiabank República Dominicana", + "type": "banque privée", + "taux_min_pct": null, + "taux_max_pct": null, + "ltv_min_pct": null, + "ltv_max_pct": 70, + "duree_max_ans": null, + "specialites": [ + "etrangers", + "devise_usd" + ], + "devises": [ + "DOP", + "USD" + ], + "note": "Spécialistes étrangers, devise USD acceptée. Taux/durée à confirmer (#6)." + }, + { + "id": "lopez_de_haro", + "nom": "López de Haro", + "type": "banque privée", + "taux_min_pct": null, + "taux_max_pct": null, + "ltv_min_pct": null, + "ltv_max_pct": null, + "duree_max_ans": null, + "specialites": [ + "haut_de_gamme", + "niche" + ], + "devises": [ + "DOP" + ], + "note": "Niche haut de gamme. Profil complet à confirmer (#6)." + } +] diff --git a/05_deliverables_mvp/crm/financement_bancaire/out/documents.json b/05_deliverables_mvp/crm/financement_bancaire/out/documents.json new file mode 100644 index 0000000..5e4b50f --- /dev/null +++ b/05_deliverables_mvp/crm/financement_bancaire/out/documents.json @@ -0,0 +1,107 @@ +[ + { + "id": "cedula_passeport", + "label": "Cédula ou Passeport (2 côtés)", + "categorie": "client", + "applies_to": "all", + "is_required": true + }, + { + "id": "preuve_adresse", + "label": "Preuve d'adresse récente (<3 mois)", + "categorie": "client", + "applies_to": "all", + "is_required": true + }, + { + "id": "fiches_paye_3", + "label": "3 dernières fiches de paye ou déclarations ITBIS/DGII", + "categorie": "client", + "applies_to": "all", + "is_required": true + }, + { + "id": "etats_bancaires_6m", + "label": "États bancaires 6 derniers mois", + "categorie": "client", + "applies_to": "all", + "is_required": true + }, + { + "id": "lettre_employeur", + "label": "Lettre employeur ou attestation profession libérale", + "categorie": "client", + "applies_to": "all", + "is_required": true + }, + { + "id": "certif_solvabilite", + "label": "Certification de solvabilité", + "categorie": "client", + "applies_to": "all", + "is_required": true + }, + { + "id": "certif_datacredito", + "label": "Certification DataCrédito (délivrée par la banque)", + "categorie": "client", + "applies_to": "all", + "is_required": true + }, + { + "id": "promesa_notariee", + "label": "Promesa Irrévocable signée notariée", + "categorie": "immobilier", + "applies_to": "all", + "is_required": true + }, + { + "id": "fideicomiso", + "label": "Fideicomiso Inmobiliario constitué (Ley 189-11)", + "categorie": "immobilier", + "applies_to": "all", + "is_required": true + }, + { + "id": "titre_definitif", + "label": "Titre définitif ou Certificado de Título (si disponible)", + "categorie": "immobilier", + "applies_to": "all", + "is_required": false + }, + { + "id": "passeport_2cotes", + "label": "Passeport valide (2 côtés)", + "categorie": "etrangers", + "applies_to": "etranger", + "is_required": true + }, + { + "id": "visa_residence", + "label": "Visa / résidence si applicable", + "categorie": "etrangers", + "applies_to": "etranger", + "is_required": false + }, + { + "id": "lettre_origine_fonds", + "label": "Lettre origine des fonds notariée (FinCEN GTO)", + "categorie": "etrangers", + "applies_to": "etranger", + "is_required": true + }, + { + "id": "refs_bancaires_intl", + "label": "Références bancaires internationales", + "categorie": "etrangers", + "applies_to": "etranger", + "is_required": true + }, + { + "id": "refs_perso", + "label": "Références commerciales / personnelles", + "categorie": "etrangers", + "applies_to": "etranger", + "is_required": true + } +] diff --git a/05_deliverables_mvp/crm/financement_bancaire/out/gate_spec.json b/05_deliverables_mvp/crm/financement_bancaire/out/gate_spec.json new file mode 100644 index 0000000..1dcadd4 --- /dev/null +++ b/05_deliverables_mvp/crm/financement_bancaire/out/gate_spec.json @@ -0,0 +1,60 @@ +{ + "endpoint": "GET /api/hypotheque/dossier/{id}/gate-status", + "submit_endpoint": "POST /api/hypotheque/dossier/{id}/submit", + "condition_keys": [ + "apport_initial_complet", + "documents_exiges", + "autorisations_signees", + "validation_wag" + ], + "conditions": [ + { + "key": "apport_initial_complet", + "ordre": 1, + "label": "Dépôt initial complet (20% résident RD · 30% étranger) versé et vérifié" + }, + { + "key": "documents_exiges", + "ordre": 2, + "label": "TOUS les documents exigés déposés et validés WAG" + }, + { + "key": "autorisations_signees", + "ordre": 3, + "label": "TOUTES les autorisations signées électroniquement" + }, + { + "key": "validation_wag", + "ordre": 4, + "label": "Validation manuelle WAG confirmée par le conseiller référent" + } + ], + "banniere": { + "niveau": "critique", + "couleurs": [ + "rouge", + "or" + ], + "persistante": true, + "titre": "⚠ AUCUN DOCUMENT N'EST TRANSMIS À LA BANQUE tant que :", + "lignes": [ + "Dépôt initial complet (20% résident RD · 30% étranger) versé et vérifié", + "TOUS les documents exigés ne sont pas déposés dans le portail", + "TOUTES les autorisations ne sont pas signées électroniquement", + "Validation manuelle WAG confirmée par le conseiller référent" + ], + "consequence": "Le bouton « Envoyer à la banque » reste désactivé tant que ces 4 conditions ne sont pas remplies. Progression affichée en temps réel avec barre 0-100% par condition." + }, + "role_validation": { + "role_id": "ventes-conseiller", + "erpnext_role_name": "OTO Ventes Conseiller" + }, + "workflow_suivi_banque": [ + "Prequalifié", + "Dossier soumis", + "Analyse crédit", + "Offre reçue", + "Contrat signé", + "Décaissement" + ] +} diff --git a/05_deliverables_mvp/crm/financement_bancaire/out/gate_status_example.json b/05_deliverables_mvp/crm/financement_bancaire/out/gate_status_example.json new file mode 100644 index 0000000..a353bcf --- /dev/null +++ b/05_deliverables_mvp/crm/financement_bancaire/out/gate_status_example.json @@ -0,0 +1,46 @@ +{ + "dossier_id": "DEMO-P07-A312", + "projet": "P07 Aqua Terra Las Terrenas", + "unite": "A-312", + "banque_choisie": "scotiabank_rd", + "gate_status": { + "can_submit": false, + "conditions_total": 4, + "conditions_completed": 0, + "overall_percent": 39.1, + "conditions": { + "apport_initial_complet": { + "ok": false, + "required": 43500.0, + "paid": 29500.0, + "remaining": 14000.0, + "percent": 67.8 + }, + "documents_exiges": { + "ok": false, + "total": 13, + "deposited": 7, + "validated": 5, + "percent": 38.5 + }, + "autorisations_signees": { + "ok": false, + "total": 4, + "signed": 2, + "percent": 50.0 + }, + "validation_wag": { + "ok": false, + "validated_by": null, + "percent": 0.0 + } + } + }, + "can_submit": false, + "reasons": [ + "Apport initial incomplet : 29500/43500 USD versés (reste 14000 USD · 67.8%).", + "Documents exigés incomplets : 5/13 validés WAG.", + "Autorisations non signées : 2/4 signées.", + "Validation WAG manquante (aucun conseiller référent n'a validé)." + ] +} diff --git a/05_deliverables_mvp/crm/financement_bancaire/out/sections.json b/05_deliverables_mvp/crm/financement_bancaire/out/sections.json new file mode 100644 index 0000000..172ebe3 --- /dev/null +++ b/05_deliverables_mvp/crm/financement_bancaire/out/sections.json @@ -0,0 +1,51 @@ +[ + { + "id": "apport_initial", + "titre": "Statut apport initial", + "ordre": 1, + "gating": true, + "resume": "Prix unité · statut résidence · apport requis (20%/30%) · apport versé · solde restant · barre 0-100%." + }, + { + "id": "info_achat", + "titre": "Information de l'achat", + "ordre": 2, + "gating": false, + "resume": "Résumé auto de la réservation : projet, unité, plan de paiement, apport, solde à financer, statuts Fideicomiso + CONFOTUR." + }, + { + "id": "choix_banque", + "titre": "Choix de la banque partenaire", + "ordre": 3, + "gating": false, + "resume": "Sélection d'UNE banque prioritaire (radio) + multi-dossiers possibles ; profils taux/LTV/durée/spécialités." + }, + { + "id": "formulaires", + "titre": "Formulaires banque pré-remplis", + "ordre": 4, + "gating": false, + "resume": "Formulaires officiels PDF pré-remplis, téléchargés, signés, ré-uploadés ; journal audit des signatures." + }, + { + "id": "exigences", + "titre": "Exigences bancaires par banque", + "ordre": 5, + "gating": false, + "resume": "Liste des documents exigés cochée au fil des uploads ; statut par item (Non déposé / Déposé / Validé WAG / Envoyé banque)." + }, + { + "id": "autorisations", + "titre": "Autorisations à signer", + "ordre": 6, + "gating": false, + "resume": "Autorisations à signer AVANT envoi (OTO Sign horodaté + IP + hash)." + }, + { + "id": "envoi", + "titre": "Envoi à la banque", + "ordre": 7, + "gating": false, + "resume": "Bouton « Envoyer à la banque » (gate 4 conditions) + timeline suivi banque + statut temps réel." + } +] diff --git a/05_deliverables_mvp/crm/financement_bancaire/tests/test_financement_bancaire.py b/05_deliverables_mvp/crm/financement_bancaire/tests/test_financement_bancaire.py new file mode 100644 index 0000000..6007dc5 --- /dev/null +++ b/05_deliverables_mvp/crm/financement_bancaire/tests/test_financement_bancaire.py @@ -0,0 +1,312 @@ +"""Tests du module Financement Bancaire — cœur métier (gate) + générateur. + +Deux familles : + 1. Cœur métier `finlib.gate` — calcul d'apport 20/30 + gate check 4 conditions. + Fonctions pures → scénarios exhaustifs (chaque condition qui bloque, cas + nominal qui passe, frontière apport). + 2. Générateur — validate/build, déterminisme (build == build), schéma + + 12 invariants, résolution RBAC réelle. +""" + +import copy +import json +import os +import subprocess +import sys +import tempfile +import unittest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_MODULE = os.path.normpath(os.path.join(_HERE, "..")) +_DELIVERABLES = os.path.normpath(os.path.join(_MODULE, "..", "..")) + +sys.path.insert(0, _MODULE) +sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste")) + +from finlib import gate # noqa: E402 +from finlib.builder import build_bundle # noqa: E402 +from finlib.rbac import RoleResolver # noqa: E402 + +_SPEC_PATH = os.path.join(_MODULE, "financement_spec.json") +_GEN = os.path.join(_MODULE, "financement_bancaire_gen.py") + + +def _load_spec(): + with open(_SPEC_PATH, encoding="utf-8") as fh: + return json.load(fh) + + +class ApportTest(unittest.TestCase): + def setUp(self): + self.cfg = gate.GateConfig.from_spec(_load_spec()) + + def test_taux_resident_20_etranger_30(self): + self.assertEqual(self.cfg.taux_apport_pct["resident_rd"], 20.0) + self.assertEqual(self.cfg.taux_apport_pct["etranger"], 30.0) + + def test_apport_requis_resident(self): + self.assertAlmostEqual(gate.apport_requis_usd(100000, "resident_rd", self.cfg), 20000.0) + + def test_apport_requis_etranger(self): + self.assertAlmostEqual(gate.apport_requis_usd(100000, "etranger", self.cfg), 30000.0) + + def test_apport_requis_type_inconnu_leve(self): + with self.assertRaises(KeyError): + gate.apport_requis_usd(100000, "martien", self.cfg) + + def test_apport_verse_somme_paiements(self): + dossier = {"paiements": [{"montant_verse_usd": 5000}, {"montant_verse_usd": 2500.5}]} + self.assertAlmostEqual(gate.apport_verse_usd(dossier), 7500.5) + + def test_apport_verse_aucun_paiement(self): + self.assertEqual(gate.apport_verse_usd({}), 0.0) + + +class RequiredDocumentsTest(unittest.TestCase): + def setUp(self): + self.cfg = gate.GateConfig.from_spec(_load_spec()) + + def test_etranger_exige_plus_que_resident(self): + res = set(gate.required_document_ids("resident_rd", self.cfg)) + etr = set(gate.required_document_ids("etranger", self.cfg)) + self.assertTrue(res < etr) # sous-ensemble strict + + def test_resident_ninclut_pas_docs_etrangers(self): + res = gate.required_document_ids("resident_rd", self.cfg) + self.assertNotIn("lettre_origine_fonds", res) + self.assertNotIn("passeport_2cotes", res) + + def test_etranger_inclut_docs_etrangers(self): + etr = gate.required_document_ids("etranger", self.cfg) + self.assertIn("lettre_origine_fonds", etr) + self.assertIn("refs_bancaires_intl", etr) + + def test_document_optionnel_exclu(self): + # titre_definitif is_required=false → jamais dans la liste des exigés. + self.assertNotIn("titre_definitif", gate.required_document_ids("etranger", self.cfg)) + + +def _dossier_complet(cfg, spec): + """Un dossier qui satisfait les 4 conditions (résident RD, 100 000 USD).""" + required = gate.required_document_ids("resident_rd", cfg) + return { + "id": "T-OK", + "prix_usd": 100000, + "residence_type": "resident_rd", + "banque_choisie": "banreservas", + "paiements": [{"montant_verse_usd": 20000}], # 20 % pile + "documents": [{"id": d, "statut": "Validé WAG"} for d in required], + "autorisations": [{"id": a["id"], "signature_date": "2026-08-01"} for a in spec["autorisations"]], + "wag_validated_by": "ventes-conseiller:amelie", + } + + +class GateCheckTest(unittest.TestCase): + def setUp(self): + self.spec = _load_spec() + self.cfg = gate.GateConfig.from_spec(self.spec) + + def test_dossier_complet_soumissible(self): + ok, reasons = gate.can_submit_dossier(_dossier_complet(self.cfg, self.spec), self.cfg) + self.assertTrue(ok) + self.assertEqual(reasons, []) + + def test_apport_pile_au_seuil_passe(self): + # Exactement 20 % → apport complet (frontière incluse). + d = _dossier_complet(self.cfg, self.spec) + status = gate.gate_status(d, self.cfg) + self.assertTrue(status["conditions"]["apport_initial_complet"]["ok"]) + self.assertEqual(status["conditions"]["apport_initial_complet"]["percent"], 100.0) + + def test_apport_manquant_bloque(self): + d = _dossier_complet(self.cfg, self.spec) + d["paiements"] = [{"montant_verse_usd": 19999}] + ok, reasons = gate.can_submit_dossier(d, self.cfg) + self.assertFalse(ok) + self.assertTrue(any("Apport initial incomplet" in r for r in reasons)) + c = gate.gate_status(d, self.cfg)["conditions"]["apport_initial_complet"] + self.assertAlmostEqual(c["remaining"], 1.0) + + def test_document_non_valide_bloque(self): + d = _dossier_complet(self.cfg, self.spec) + d["documents"][0]["statut"] = "Déposé" # déposé mais pas validé WAG + ok, reasons = gate.can_submit_dossier(d, self.cfg) + self.assertFalse(ok) + self.assertTrue(any("Documents exigés incomplets" in r for r in reasons)) + + def test_document_envoye_banque_est_recevable(self): + d = _dossier_complet(self.cfg, self.spec) + d["documents"][0]["statut"] = "Envoyé banque" + ok, _ = gate.can_submit_dossier(d, self.cfg) + self.assertTrue(ok) + + def test_document_manquant_compte_comme_non_depose(self): + d = _dossier_complet(self.cfg, self.spec) + d["documents"] = d["documents"][:-1] # retire un requis + c = gate.gate_status(d, self.cfg)["conditions"]["documents_exiges"] + self.assertFalse(c["ok"]) + self.assertEqual(c["validated"], c["total"] - 1) + + def test_autorisation_non_signee_bloque(self): + d = _dossier_complet(self.cfg, self.spec) + d["autorisations"][0]["signature_date"] = None + ok, reasons = gate.can_submit_dossier(d, self.cfg) + self.assertFalse(ok) + self.assertTrue(any("Autorisations non signées" in r for r in reasons)) + + def test_validation_wag_manquante_bloque(self): + d = _dossier_complet(self.cfg, self.spec) + d["wag_validated_by"] = None + ok, reasons = gate.can_submit_dossier(d, self.cfg) + self.assertFalse(ok) + self.assertTrue(any("Validation WAG manquante" in r for r in reasons)) + + def test_quatre_conditions_toutes_ko(self): + # Dossier vide → 4 raisons, 0 condition complétée. + d = {"id": "T-VIDE", "prix_usd": 100000, "residence_type": "etranger"} + status = gate.gate_status(d, self.cfg) + self.assertFalse(status["can_submit"]) + self.assertEqual(status["conditions_completed"], 0) + ok, reasons = gate.can_submit_dossier(d, self.cfg) + self.assertFalse(ok) + self.assertEqual(len(reasons), 4) + + def test_gate_status_forme_endpoint(self): + # Forme conforme au contrat de l'endpoint gate-status. + d = _dossier_complet(self.cfg, self.spec) + status = gate.gate_status(d, self.cfg) + self.assertIn("can_submit", status) + self.assertEqual(set(status["conditions"]), set(gate.CONDITION_KEYS)) + for k in gate.CONDITION_KEYS: + self.assertIn("percent", status["conditions"][k]) + + def test_reasons_ordre_canonique(self): + d = {"id": "T", "prix_usd": 100000, "residence_type": "etranger"} + _, reasons = gate.can_submit_dossier(d, self.cfg) + self.assertIn("Apport", reasons[0]) + self.assertIn("Documents", reasons[1]) + self.assertIn("Autorisations", reasons[2]) + self.assertIn("Validation WAG", reasons[3]) + + +class BuilderTest(unittest.TestCase): + def setUp(self): + self.spec = _load_spec() + self.resolver = RoleResolver.from_path() + self.bundle = build_bundle(self.spec, self.resolver) + + def test_bundle_cles(self): + for k in ("manifest", "banques", "sections", "documents", "autorisations", "gate_spec", "gate_status_example"): + self.assertIn(k, self.bundle) + + def test_role_validation_resolu(self): + rv = self.bundle["gate_spec"]["role_validation"] + self.assertEqual(rv["role_id"], "ventes-conseiller") + self.assertEqual(rv["erpnext_role_name"], "OTO Ventes Conseiller") + + def test_role_inconnu_leve(self): + bad = copy.deepcopy(self.spec) + bad["roles"]["validation_dossier"] = "role-inexistant" + with self.assertRaises(KeyError): + build_bundle(bad, self.resolver) + + def test_sections_triees_par_ordre(self): + ordres = [s["ordre"] for s in self.bundle["sections"]] + self.assertEqual(ordres, sorted(ordres)) + self.assertEqual(self.bundle["sections"][0]["id"], "apport_initial") + + def test_exemple_est_bloque(self): + # Le dossier d'exemple DOIT démontrer un blocage (valeur pédagogique). + self.assertFalse(self.bundle["gate_status_example"]["can_submit"]) + self.assertTrue(self.bundle["gate_status_example"]["reasons"]) + + def test_deterministe(self): + again = build_bundle(_load_spec(), RoleResolver.from_path()) + self.assertEqual( + json.dumps(self.bundle, sort_keys=True, ensure_ascii=False), + json.dumps(again, sort_keys=True, ensure_ascii=False), + ) + + +class GeneratorCliTest(unittest.TestCase): + def _run(self, *argv): + return subprocess.run( + [sys.executable, _GEN, *argv], + capture_output=True, text=True, cwd=_MODULE, + ) + + def test_validate_ok(self): + r = self._run("validate") + self.assertEqual(r.returncode, 0, r.stderr) + self.assertIn("12 invariants", r.stdout) + + @staticmethod + def _read(path): + with open(path, encoding="utf-8") as fh: + return fh.read() + + def test_build_reproductible(self): + with tempfile.TemporaryDirectory() as d: + r1 = self._run("build", "-o", d) + self.assertEqual(r1.returncode, 0, r1.stderr) + first = {f: self._read(os.path.join(d, f)) for f in os.listdir(d)} + r2 = self._run("build", "-o", d) + self.assertEqual(r2.returncode, 0, r2.stderr) + second = {f: self._read(os.path.join(d, f)) for f in os.listdir(d)} + self.assertEqual(first, second) + + def test_build_egale_commite(self): + # L'artefact commité out/ doit être identique à un build frais (miroir + # local du gate ci/check_artifacts.sh). + with tempfile.TemporaryDirectory() as d: + self.assertEqual(self._run("build", "-o", d).returncode, 0) + for fname in ("banques.json", "sections.json", "documents.json", + "autorisations.json", "gate_spec.json", + "gate_status_example.json", "MANIFEST.json"): + fresh = self._read(os.path.join(d, fname)) + committed_path = os.path.join(_MODULE, "out", fname) + if os.path.exists(committed_path): + self.assertEqual(fresh, self._read(committed_path), + f"{fname} a dérivé de sa source") + + +class InvariantGuardTest(unittest.TestCase): + """Chaque invariant du générateur DOIT mordre une mutation (anti-régression).""" + + def setUp(self): + sys.path.insert(0, _MODULE) + import financement_bancaire_gen as gen # noqa + self.gen = gen + self.spec = _load_spec() + self.resolver = RoleResolver.from_path() + + def _errors(self, spec): + bundle = build_bundle(spec, self.resolver) + return self.gen._validate_bundle(spec, bundle) + + def test_baseline_clean(self): + self.assertEqual(self._errors(self.spec), []) + + def test_taux_ordre_mordu(self): + bad = copy.deepcopy(self.spec) + bad["apport"]["residence_types"][0]["taux_apport_pct"] = 40 # résident > étranger + self.assertTrue(any("Taux d'apport" in e for e in self._errors(bad))) + + def test_section_gating_deplacee_mordue(self): + bad = copy.deepcopy(self.spec) + bad["sections"][0]["gating"] = False # plus aucune section gating + self.assertTrue(any("gating" in e for e in self._errors(bad))) + + def test_id_duplique_mordu(self): + bad = copy.deepcopy(self.spec) + bad["banques"].append(dict(bad["banques"][0])) + self.assertTrue(any("dupliqué" in e for e in self._errors(bad))) + + def test_ltv_incoherent_mordu(self): + bad = copy.deepcopy(self.spec) + bad["banques"][1]["ltv_min_pct"] = 99 # min > max (85) + self.assertTrue(any("LTV" in e for e in self._errors(bad))) + + +if __name__ == "__main__": + unittest.main() diff --git a/05_deliverables_mvp/devops/deploy_runbook/deploy_spec.json b/05_deliverables_mvp/devops/deploy_runbook/deploy_spec.json index 14642a7..e4f2834 100644 --- a/05_deliverables_mvp/devops/deploy_runbook/deploy_spec.json +++ b/05_deliverables_mvp/devops/deploy_runbook/deploy_spec.json @@ -79,6 +79,7 @@ "rbac/apply_plan": "rbac-apply", "crm/workflow_vente": "workflow-metier", "crm/commissions": "workflow-metier", + "crm/financement_bancaire": "workflow-metier", "fiscal/ecf_dgii": "workflow-metier", "frontend/portails": "frontend", "frontend/chat_otoia": "frontend", diff --git a/05_deliverables_mvp/devops/deploy_runbook/out/MANIFEST.json b/05_deliverables_mvp/devops/deploy_runbook/out/MANIFEST.json index 53b303d..7d3e6c5 100644 --- a/05_deliverables_mvp/devops/deploy_runbook/out/MANIFEST.json +++ b/05_deliverables_mvp/devops/deploy_runbook/out/MANIFEST.json @@ -5,14 +5,14 @@ "self_module": "devops/deploy_runbook", "counts": { "phases": 7, - "modules": 22, - "modules_mapped": 22, + "modules": 23, + "modules_mapped": 23, "confirmations": 7, "confirmations_referenced": 7 }, "coverage": { - "ci_modules_count": 22, - "mapped_modules_count": 22, + "ci_modules_count": 23, + "mapped_modules_count": 23, "bijective": true, "missing_in_map": [], "extra_in_map": [], diff --git a/05_deliverables_mvp/devops/deploy_runbook/out/deploy_runbook.json b/05_deliverables_mvp/devops/deploy_runbook/out/deploy_runbook.json index f64e30d..65f509d 100644 --- a/05_deliverables_mvp/devops/deploy_runbook/out/deploy_runbook.json +++ b/05_deliverables_mvp/devops/deploy_runbook/out/deploy_runbook.json @@ -93,6 +93,10 @@ "module": "crm/commissions", "ci_job": "crm-commissions-tests" }, + { + "module": "crm/financement_bancaire", + "ci_job": "crm-financement-bancaire-tests" + }, { "module": "crm/workflow_vente", "ci_job": "crm-workflow-vente-tests" diff --git a/05_deliverables_mvp/qa/acceptance/acceptance_spec.json b/05_deliverables_mvp/qa/acceptance/acceptance_spec.json index db3a402..17acc64 100644 --- a/05_deliverables_mvp/qa/acceptance/acceptance_spec.json +++ b/05_deliverables_mvp/qa/acceptance/acceptance_spec.json @@ -79,6 +79,7 @@ "crm/workflow_vente", "crm/dossier_vente", "crm/commissions", + "crm/financement_bancaire", "fiscal/ecf_dgii", "frontend/portails" ], @@ -87,6 +88,10 @@ { "item": "création module + import DocTypes/Workflow + transmission e-CF réelle DGII (Compupar) sur ERPNext LIVE", "source": "CLAUDE.md #8 (VPS) · #10 (Cardnet/DGII endpoints prod)" + }, + { + "item": "Financement Bancaire Phase 2 : vrais formulaires PDF officiels des 6 banques + implémentation runtime des endpoints /api/hypotheque/* et de renderHypotheque sur le VPS", + "source": "DIRECTIVE_FINANCEMENT_BANCAIRE_COMPLET_20260803.md (Phase 2 · démarche relationship-manager Michel) · CLAUDE.md #8" } ] }, diff --git a/05_deliverables_mvp/qa/acceptance/out/MANIFEST.json b/05_deliverables_mvp/qa/acceptance/out/MANIFEST.json index fc21bdd..8e1c6d5 100644 --- a/05_deliverables_mvp/qa/acceptance/out/MANIFEST.json +++ b/05_deliverables_mvp/qa/acceptance/out/MANIFEST.json @@ -10,13 +10,13 @@ "deliverables_total": 15, "in_repo": 15, "out_of_scope_full": 0, - "gated_modules": 22, - "cited_modules": 22, - "out_of_scope_items": 12 + "gated_modules": 23, + "cited_modules": 23, + "out_of_scope_items": 13 }, "coverage": { - "ci_modules_count": 22, - "cited_modules_count": 22, + "ci_modules_count": 23, + "cited_modules_count": 23, "bijective": true, "missing_in_matrix": [], "phantom_evidence": [], @@ -71,6 +71,7 @@ "expected": [ "crm/commissions", "crm/dossier_vente", + "crm/financement_bancaire", "crm/workflow_vente", "fiscal/ecf_dgii", "frontend/portails" @@ -78,6 +79,7 @@ "cited": [ "crm/commissions", "crm/dossier_vente", + "crm/financement_bancaire", "crm/workflow_vente", "fiscal/ecf_dgii", "frontend/portails" diff --git a/05_deliverables_mvp/qa/acceptance/out/acceptance_matrix.json b/05_deliverables_mvp/qa/acceptance/out/acceptance_matrix.json index 3105629..69c3798 100644 --- a/05_deliverables_mvp/qa/acceptance/out/acceptance_matrix.json +++ b/05_deliverables_mvp/qa/acceptance/out/acceptance_matrix.json @@ -130,6 +130,11 @@ "ci_job": "crm-commissions-tests", "gated": true }, + { + "module": "crm/financement_bancaire", + "ci_job": "crm-financement-bancaire-tests", + "gated": true + }, { "module": "fiscal/ecf_dgii", "ci_job": "fiscal-ecf-tests", @@ -146,9 +151,13 @@ { "item": "création module + import DocTypes/Workflow + transmission e-CF réelle DGII (Compupar) sur ERPNext LIVE", "source": "CLAUDE.md #8 (VPS) · #10 (Cardnet/DGII endpoints prod)" + }, + { + "item": "Financement Bancaire Phase 2 : vrais formulaires PDF officiels des 6 banques + implémentation runtime des endpoints /api/hypotheque/* et de renderHypotheque sur le VPS", + "source": "DIRECTIVE_FINANCEMENT_BANCAIRE_COMPLET_20260803.md (Phase 2 · démarche relationship-manager Michel) · CLAUDE.md #8" } ], - "n_modules": 5, + "n_modules": 6, "all_modules_gated": true, "all_artifacts_exist": true, "has_evidence": true diff --git a/05_deliverables_mvp/qa/audit_4big/README.md b/05_deliverables_mvp/qa/audit_4big/README.md index e1c05b2..4431ce9 100644 --- a/05_deliverables_mvp/qa/audit_4big/README.md +++ b/05_deliverables_mvp/qa/audit_4big/README.md @@ -65,7 +65,7 @@ Publiciste** (zéro `pip`, runner Gitea sans réseau · CLAUDE.md #2). ## Résultat courant -**Verdict `PASS`** — **22/22** modules à **100/100** (min 100), couverture +**Verdict `PASS`** — **23/23** modules à **100/100** (min 100), couverture **100 %** des livrables gated. L'audit servira surtout de **gate anti-régression** : retirer des tests, casser un `MANIFEST` ou supprimer un schéma fait chuter le module sous 95 → `FAIL`. diff --git a/05_deliverables_mvp/qa/audit_4big/out/MANIFEST.json b/05_deliverables_mvp/qa/audit_4big/out/MANIFEST.json index e3912b1..a9687bf 100644 --- a/05_deliverables_mvp/qa/audit_4big/out/MANIFEST.json +++ b/05_deliverables_mvp/qa/audit_4big/out/MANIFEST.json @@ -4,7 +4,7 @@ "audit": "OTO QA · Audit 4Big Qualité", "version": "1.0", "verdict": "PASS", - "modules_audited": 22, + "modules_audited": 23, "min_score": 100, "pass_score": 95, "coverage_ok": true, diff --git a/05_deliverables_mvp/qa/audit_4big/out/quality_report.json b/05_deliverables_mvp/qa/audit_4big/out/quality_report.json index a7980a9..e501e2e 100644 --- a/05_deliverables_mvp/qa/audit_4big/out/quality_report.json +++ b/05_deliverables_mvp/qa/audit_4big/out/quality_report.json @@ -5,8 +5,8 @@ "pass_score": 95, "coverage": { "ok": true, - "ci_modules_count": 22, - "registry_modules_count": 22, + "ci_modules_count": 23, + "registry_modules_count": 23, "missing_in_registry": [], "missing_in_ci": [], "not_in_gate": [], @@ -550,6 +550,48 @@ } ] }, + { + "id": "crm-financement-bancaire", + "path": "crm/financement_bancaire", + "sprint": "S5", + "archetype": "generator", + "applicable_weight": 100, + "earned_weight": 100, + "score": 100, + "verdict": "PASS", + "checks": [ + { + "criterion": "DOC", + "weight": 20, + "passed": true, + "evidence": "README.md (6280 octets)" + }, + { + "criterion": "CONTRAT", + "weight": 20, + "passed": true, + "evidence": "financement.schema.json" + }, + { + "criterion": "TESTS", + "weight": 25, + "passed": true, + "evidence": "35 méthodes test_* dans 1 fichier(s) (seuil 8)" + }, + { + "criterion": "CLI", + "weight": 15, + "passed": true, + "evidence": "financement_bancaire_gen.py (argparse + __main__)" + }, + { + "criterion": "HANDOFF", + "weight": 20, + "passed": true, + "evidence": "MANIFEST + 6 artefact(s) JSON valides" + } + ] + }, { "id": "legal-confotur", "path": "legal/confotur", @@ -930,8 +972,8 @@ } ], "totals": { - "modules": 22, - "pass": 22, + "modules": 23, + "pass": 23, "fail": 0, "min_score": 100, "max_score": 100, diff --git a/05_deliverables_mvp/qa/audit_4big/quality_spec.json b/05_deliverables_mvp/qa/audit_4big/quality_spec.json index 4ff991f..06fb437 100644 --- a/05_deliverables_mvp/qa/audit_4big/quality_spec.json +++ b/05_deliverables_mvp/qa/audit_4big/quality_spec.json @@ -72,6 +72,7 @@ { "id": "crm-workflow-vente", "path": "crm/workflow_vente", "sprint": "S4", "archetype": "generator", "source": "roadmap Sprint 4 · workflow vente lead→CONFOTUR · ci.yml crm-workflow-vente-tests" }, { "id": "crm-dossier-vente", "path": "crm/dossier_vente", "sprint": "S4", "archetype": "generator", "source": "roadmap Sprint 4 · DocType OTO Dossier Vente · ci.yml crm-dossier-vente-tests" }, { "id": "crm-commissions", "path": "crm/commissions", "sprint": "S4", "archetype": "generator", "source": "roadmap Sprint 4 · barème commissions vendeurs · ci.yml crm-commissions-tests" }, + { "id": "crm-financement-bancaire", "path": "crm/financement_bancaire", "sprint": "S4", "archetype": "generator", "source": "roadmap Sprint 4 · workflow vente end-to-end (volet financement bancaire) · DIRECTIVE_FINANCEMENT_BANCAIRE_COMPLET_20260803.md · ci.yml crm-financement-bancaire-tests" }, { "id": "fiscal-ecf", "path": "fiscal/ecf_dgii", "sprint": "S4", "archetype": "generator", "source": "roadmap Sprint 4 · e-CF DGII Compupar · ci.yml fiscal-ecf-tests" }, { "id": "frontend-portails", "path": "frontend/portails", "sprint": "S4", "archetype": "generator", "source": "roadmap Sprint 4 · Workspaces 5 portails rôle · ci.yml frontend-portails-tests" }, { "id": "legal-confotur", "path": "legal/confotur", "sprint": "S5", "archetype": "generator", "source": "roadmap Sprint 5 · DocType CONFOTUR Application · ci.yml legal-confotur-tests" }, diff --git a/05_deliverables_mvp/qa/regression/out/MANIFEST.json b/05_deliverables_mvp/qa/regression/out/MANIFEST.json index f098723..8e056bd 100644 --- a/05_deliverables_mvp/qa/regression/out/MANIFEST.json +++ b/05_deliverables_mvp/qa/regression/out/MANIFEST.json @@ -4,8 +4,8 @@ "matrix": "OTO QA · Matrice de régression exhaustive", "version": "1.0", "verdict": "PASS", - "suites": 22, - "test_methods": 569, + "suites": 23, + "test_methods": 604, "coverage_ok": true, "artifacts": [ "regression_plan.json" diff --git a/05_deliverables_mvp/qa/regression/out/regression_plan.json b/05_deliverables_mvp/qa/regression/out/regression_plan.json index d0fd9cc..c48e99a 100644 --- a/05_deliverables_mvp/qa/regression/out/regression_plan.json +++ b/05_deliverables_mvp/qa/regression/out/regression_plan.json @@ -17,8 +17,8 @@ ], "coverage": { "ok": true, - "suites_count": 22, - "gated_in_ci": 23, + "suites_count": 23, + "gated_in_ci": 24, "self_module_excluded": "qa/regression", "self_module_gated": true, "missing_tests_dir": [], @@ -48,6 +48,17 @@ "test_files": 1, "test_methods": 31 }, + { + "id": "crm.financement_bancaire", + "path": "crm/financement_bancaire", + "jobs": [ + "crm-financement-bancaire-tests" + ], + "in_gate": true, + "has_tests_dir": true, + "test_files": 1, + "test_methods": 35 + }, { "id": "crm.workflow_vente", "path": "crm/workflow_vente", @@ -271,9 +282,9 @@ ], "under_threshold": [], "totals": { - "suites": 22, - "test_files": 22, - "test_methods": 569, + "suites": 23, + "test_files": 23, + "test_methods": 604, "min_methods": 10, "under_threshold": 0 }, diff --git a/05_deliverables_mvp/qa/regression/out/regression_run.json b/05_deliverables_mvp/qa/regression/out/regression_run.json index 404c4c5..731b413 100644 --- a/05_deliverables_mvp/qa/regression/out/regression_run.json +++ b/05_deliverables_mvp/qa/regression/out/regression_run.json @@ -27,6 +27,18 @@ "ok": true, "returncode": 0 }, + { + "id": "crm.financement_bancaire", + "path": "crm/financement_bancaire", + "expected_methods": 35, + "ran": 35, + "passed": 35, + "failures": 0, + "errors": 0, + "skipped": 0, + "ok": true, + "returncode": 0 + }, { "id": "crm.workflow_vente", "path": "crm/workflow_vente", @@ -269,11 +281,11 @@ } ], "totals": { - "suites": 22, - "green": 22, + "suites": 23, + "green": 23, "red": 0, - "ran": 569, - "passed": 569, + "ran": 604, + "passed": 604, "failures": 0, "errors": 0, "skipped": 0 diff --git a/README.md b/README.md index dbc67a1..6cb9c82 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,9 @@ Transverses : [`devops`](03_agents/devops/AGENT.md) · [`qa`](03_agents/qa/AGENT ## État courant (sourcé) -- **Qualité 4Big** : **22/22 modules gated à 100/100** (seuil 95 · `CLAUDE.md` #5), verdict `PASS` +- **Qualité 4Big** : **23/23 modules gated à 100/100** (seuil 95 · `CLAUDE.md` #5), verdict `PASS` — source [`qa/audit_4big/out/quality_report.json`](05_deliverables_mvp/qa/audit_4big/out/quality_report.json). -- **Régression** : **22 suites gated** couvertes bijectivement vs CI. Le **compte de tests +- **Régression** : **23 suites gated** couvertes bijectivement vs CI. Le **compte de tests agrégé faisant autorité** vit dans [`qa/regression/out/regression_run.json`](05_deliverables_mvp/qa/regression/out/regression_run.json), artefact **commité et byte-gaté** : `ci/check_regression.sh` régénère un `run` frais et exige l'identité **byte-for-byte** avec la baseline commitée (verdict `PASS`). Recensement déterministe