[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,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)
|
||||
Reference in New Issue
Block a user