Files
oto-enterprise-os-dtp/05_deliverables_mvp/crm/financement_bancaire/finlib/gate.py
T

219 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)
ok = paid + 1e-9 >= required
pct = _round1(min(100.0, (paid / required * 100.0) if required > 0 else 100.0))
# Un versement dans la bande [99.95 %, 100 %[ (p. ex. 19 999/20 000 USD)
# remonterait à 100.0 par l'arrondi 1-décimale, alors que le gate BLOQUE
# (ok=False, remaining>0). La barre de progression afficherait « 100 % ✓ »
# tout en refusant la soumission, et la raison lirait « reste 1 USD · 100 %».
# Invariant d'affichage : percent == 100.0 ⟺ ok — un dossier incomplet
# plafonne à 99.9 (plus grande valeur 1-décimale strictement < 100).
if not ok and pct >= 100.0:
pct = 99.9
return {
"ok": ok,
"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)
)
# Même invariant qu'au niveau condition (cf. _cond_apport) mais à l'AGRÉGAT :
# la moyenne de 4 percents peut arrondir à 100.0 alors qu'une condition reste
# bloquante — p. ex. apport plafonné à 99.9 (non ok) + les 3 autres à 100.0 →
# (99.9 + 300) / 4 = 99.975 → round1 = 100.0. La barre GLOBALE afficherait
# « 100 % » tout en refusant la soumission (403). On plafonne donc à 99.9.
# Invariant d'affichage : overall_percent == 100.0 ⟺ can_submit.
if not can_submit and overall >= 100.0:
overall = 99.9
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