[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>
This commit is contained in:
Claude Code DTP Worker
2026-08-12 09:43:10 +00:00
parent cb8069b83c
commit 85cd625dac
10 changed files with 60 additions and 13 deletions
@@ -94,9 +94,18 @@ def _cond_apport(dossier: dict, cfg: GateConfig) -> dict:
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": paid + 1e-9 >= required,
"ok": ok,
"required": required,
"paid": paid,
"remaining": remaining,
@@ -118,6 +118,21 @@ class GateCheckTest(unittest.TestCase):
self.assertTrue(status["conditions"]["apport_initial_complet"]["ok"])
self.assertEqual(status["conditions"]["apport_initial_complet"]["percent"], 100.0)
def test_apport_quasi_complet_ninclut_pas_100pct(self):
# Bande [99.95 %, 100 %[ : l'arrondi 1-décimale de 99.995 % remonte à
# 100.0, mais le gate BLOQUE (ok=False, reste 1 USD). Un dossier
# incomplet ne doit JAMAIS afficher 100 % (barre pleine ⇔ soumissible).
d = _dossier_complet(self.cfg, self.spec)
d["paiements"] = [{"montant_verse_usd": 19999}] # 20 000 requis → 99.995 %
c = gate.gate_status(d, self.cfg)["conditions"]["apport_initial_complet"]
self.assertFalse(c["ok"])
self.assertAlmostEqual(c["remaining"], 1.0)
self.assertLess(c["percent"], 100.0)
self.assertEqual(c["percent"], 99.9)
# La raison de blocage ne doit pas dire « 100 % ».
_, reasons = gate.can_submit_dossier(d, self.cfg)
self.assertTrue(any("Apport initial incomplet" in r and "100" not in r for r in reasons))
def test_apport_manquant_bloque(self):
d = _dossier_complet(self.cfg, self.spec)
d["paiements"] = [{"montant_verse_usd": 19999}]