[DTP-Worker 20260803_090713] Auto exec · session 20260803_090713
CI / Contraintes NON-NÉGOCIABLES (CLAUDE.md) (push) Has been cancelled
CI / Validation JSON (schémas Faisabilité) (push) Has been cancelled
CI / Qualité documentaire (liens + 4Big) (push) Has been cancelled
CI / Reproductibilité des artefacts out/ (build == commité) (push) Has been cancelled
CI / Fraîcheur matrice de régression (run == commité) (push) Has been cancelled
CI / Intégrité du câblage CI (gate agrège tout · gates statiques verrouillés) (push) Has been cancelled
CI / Intégrité des chiffres du README (valeur == artefact cité · (push) Has been cancelled
CI / Publiciste · parser + schéma + generator (unittest) (push) Has been cancelled
CI / RBAC · 50 rôles + schéma (unittest) (push) Has been cancelled
CI / Faisabilité · générateur 4 volets + round-trip (unittest) (push) Has been cancelled
CI / RBAC · fixtures ERPNext (Role + Custom DocPerm) (push) Has been cancelled
CI / RBAC · plan User Permission (row-level) (push) Has been cancelled
CI / RBAC · Role Profile (bundles par portail) (push) Has been cancelled
CI / RBAC · run-book d'application unifié (agrégat 3 volets) (push) Has been cancelled
CI / Faisabilité · dossier bancable trilingue FR/EN/ES (push) Has been cancelled
CI / CRM · workflow vente ERPNext (lead → CONFOTUR) (push) Has been cancelled
CI / CRM · DocType porteur OTO Dossier Vente (push) Has been cancelled
CI / CRM · barème commissions vendeurs (push) Has been cancelled
CI / CRM · Financement Bancaire (gate hypothécaire RD) (push) Has been cancelled
CI / Fiscal · e-CF DGII (Compupar) (push) Has been cancelled
CI / Frontend · Workspaces 5 portails rôle (push) Has been cancelled
CI / Legal · DocType CONFOTUR Application (push) Has been cancelled
CI / QA · Audit 5D conformité (push) Has been cancelled
CI / SEO · mots-clés trilingues + schema.org + hreflang (push) Has been cancelled
CI / Chat OTOIA · montage par portail (Custom Block) (push) Has been cancelled
CI / QA · Audit 4Big (95+/100 sur 100% deliverables) (push) Has been cancelled
CI / Démo · Scénarios (run-sheet P07 banquier / P05 client) (push) Has been cancelled
CI / QA · Matrice de régression exhaustive (Sprint 8) (push) Has been cancelled
CI / DevOps · Run-book de déploiement VPS unifié (Sprint 8) (push) Has been cancelled
CI / QA · Matrice d'acceptation / traçabilité MVP (Sprint 8) (push) Has been cancelled
CI / Mobile · config app Expo/EAS (navigation par rôle) (push) Has been cancelled
CI / E2E baseline Playwright (manuel) (push) Has been cancelled
CI / Gate qualité (agrégat) (push) Has been cancelled
CI / Contraintes NON-NÉGOCIABLES (CLAUDE.md) (push) Has been cancelled
CI / Validation JSON (schémas Faisabilité) (push) Has been cancelled
CI / Qualité documentaire (liens + 4Big) (push) Has been cancelled
CI / Reproductibilité des artefacts out/ (build == commité) (push) Has been cancelled
CI / Fraîcheur matrice de régression (run == commité) (push) Has been cancelled
CI / Intégrité du câblage CI (gate agrège tout · gates statiques verrouillés) (push) Has been cancelled
CI / Intégrité des chiffres du README (valeur == artefact cité · (push) Has been cancelled
CI / Publiciste · parser + schéma + generator (unittest) (push) Has been cancelled
CI / RBAC · 50 rôles + schéma (unittest) (push) Has been cancelled
CI / Faisabilité · générateur 4 volets + round-trip (unittest) (push) Has been cancelled
CI / RBAC · fixtures ERPNext (Role + Custom DocPerm) (push) Has been cancelled
CI / RBAC · plan User Permission (row-level) (push) Has been cancelled
CI / RBAC · Role Profile (bundles par portail) (push) Has been cancelled
CI / RBAC · run-book d'application unifié (agrégat 3 volets) (push) Has been cancelled
CI / Faisabilité · dossier bancable trilingue FR/EN/ES (push) Has been cancelled
CI / CRM · workflow vente ERPNext (lead → CONFOTUR) (push) Has been cancelled
CI / CRM · DocType porteur OTO Dossier Vente (push) Has been cancelled
CI / CRM · barème commissions vendeurs (push) Has been cancelled
CI / CRM · Financement Bancaire (gate hypothécaire RD) (push) Has been cancelled
CI / Fiscal · e-CF DGII (Compupar) (push) Has been cancelled
CI / Frontend · Workspaces 5 portails rôle (push) Has been cancelled
CI / Legal · DocType CONFOTUR Application (push) Has been cancelled
CI / QA · Audit 5D conformité (push) Has been cancelled
CI / SEO · mots-clés trilingues + schema.org + hreflang (push) Has been cancelled
CI / Chat OTOIA · montage par portail (Custom Block) (push) Has been cancelled
CI / QA · Audit 4Big (95+/100 sur 100% deliverables) (push) Has been cancelled
CI / Démo · Scénarios (run-sheet P07 banquier / P05 client) (push) Has been cancelled
CI / QA · Matrice de régression exhaustive (Sprint 8) (push) Has been cancelled
CI / DevOps · Run-book de déploiement VPS unifié (Sprint 8) (push) Has been cancelled
CI / QA · Matrice d'acceptation / traçabilité MVP (Sprint 8) (push) Has been cancelled
CI / Mobile · config app Expo/EAS (navigation par rôle) (push) Has been cancelled
CI / E2E baseline Playwright (manuel) (push) Has been cancelled
CI / Gate qualité (agrégat) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
# Caches Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
# Le dossier out/ EST commité (hand-off frontend/backend direct) — voir README.
|
||||
@@ -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.
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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)."
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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é)."
|
||||
]
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user