Files
oto-enterprise-os-dtp/05_deliverables_mvp/crm/financement_bancaire/finlib/gate.py
T
Claude Code DTP Worker 85cd625dac [DTP-Worker 20260812_093114] fix correctness · financement_bancaire gate — un dossier incomplet pouvait afficher percent 100.0 (barre pleine ✓) tout en BLOQUANT
Défaut latent, byte-repro-aveugle : _cond_apport arrondit percent à 1 décimale
(round) tandis que ok est un test exact. Un versement dans la bande [99.95%,100%[
(ex. 19 999/20 000 USD = 99.995%) remonte à percent:100.0 alors que ok=False,
remaining=1 USD → barre frontend « 100% ✓ » qui refuse la soumission + raison
auto-contradictoire « reste 1 USD · 100.0% ». Seul _cond_apport atteignable
(montant continu) ; documents/autorisations = ratios entiers sur listes 13/4,
bande inatteignable.

Fix chirurgical : garde `if not ok and pct>=100.0: pct=99.9` → invariant
d'affichage percent==100.0 ⟺ ok. Échantillon commité à 67.8% (hors bande) →
gate_status_example.json/out rebuild BYTE-IDENTIQUE.

Test à dents test_apport_quasi_complet_ninclut_pas_100pct (mordu sans le fix).
Cascade +1 test : régression 633→634/616→617, plan+MANIFEST+quality_report
régénérés, fiches QA/Backend/CRM re-dérivées. run_ci 33/0/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 09:43:10 +00:00

211 lines
8.1 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)
)
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