fix: corrige le sur-comptage des revenus par immeuble

Les agrégats par immeuble joignaient revenus et locataires à partir du
lot. Comme un lot accumule ses occupants successifs, chaque ligne de
revenu était comptée autant de fois qu'il y a eu de locataires : aucune
erreur levée, juste des montants faux qui dérivent avec l'ancienneté du
parc.

Sur la base réelle (40 lots, 46 locataires), /api/revenus/immeubles et
le bloc by_immeuble de /api/revenus/summary annonçaient :

    total   348 111,95 € au lieu de 332 899,45 €   (+4,6 %)
    réglés  100 697,66 € au lieu de  85 545,16 €   (+17,7 %)
    taux           28,9 % au lieu de       25,7 %

Les montants sont désormais agrégés par immeuble AVANT la jointure des
locataires, qui ne sert plus qu'au dénombrement. Les deux endpoints
partagent ces sous-requêtes et la construction de la réponse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 09:44:11 +02:00
parent 2ac7855cd5
commit 1b319dff4e
3 changed files with 202 additions and 59 deletions

View File

@@ -44,6 +44,27 @@ def db_session(tmp_path, monkeypatch):
connection.reset_connection()
@pytest.fixture
def api_client(db_session):
"""Client HTTP sur l'application FastAPI, branché sur la base de test.
La dépendance `get_session` est surchargée pour partager la session du test :
les données créées dans le test sont visibles par les endpoints, sans passer
par la base réelle de l'utilisateur.
"""
from fastapi.testclient import TestClient
from plesna_gerance.api.app import app
from plesna_gerance.database import get_session
app.dependency_overrides[get_session] = lambda: db_session
try:
with TestClient(app) as client:
yield client
finally:
app.dependency_overrides.clear()
@pytest.fixture
def sample_data():
"""Données extraites minimales mais complètes pour save_document."""

View File

@@ -0,0 +1,99 @@
"""Tests des agregats de revenus par immeuble.
Ces endpoints joignent revenus et locataires a partir du lot. Comme un lot
accumule les locataires successifs, une jointure naive multiplie chaque ligne de
revenu par le nombre d'occupants passes et gonfle silencieusement les totaux :
aucune erreur, juste des montants faux qui derivent avec l'anciennete du parc.
"""
import copy
import pytest
from plesna_gerance.database.models import Locataire, Revenu
from plesna_gerance.database.service import DatabaseService
@pytest.fixture
def lot_a_deux_locataires(db_session, sample_data):
"""Un lot occupe successivement par deux locataires, un revenu chacun.
Reproduit la situation courante d'une relocation : meme immeuble, meme lot,
deux noms differents sur deux comptes rendus.
"""
service = DatabaseService(db_session)
service.save_document(data=sample_data)
suivant = copy.deepcopy(sample_data)
suivant["metadata"]["document"]["reference"] = "REF002"
suivant["metadata"]["document"]["date"] = "2024-02-15"
suivant["situation_locataires"][0]["locataire"]["nom"] = "MARTIN"
suivant["situation_locataires"][0]["lignes"][0]["loyers"] = 600.0
suivant["situation_locataires"][0]["lignes"][0]["total"] = 600.0
suivant["situation_locataires"][0]["lignes"][0]["regles"] = 400.0
suivant["situation_locataires"][0]["lignes"][0]["impayes"] = 200.0
service.save_document(data=suivant)
# Le decor doit bien etre celui qu'on veut tester, sinon le test ne prouve rien.
assert db_session.query(Locataire).count() == 2
assert db_session.query(Revenu).count() == 2
return db_session
def _totaux_reels(session) -> tuple[float, float, float]:
revenus = session.query(Revenu).all()
return (
sum(r.total for r in revenus),
sum(r.regles for r in revenus),
sum(r.impayes for r in revenus),
)
def test_immeubles_ne_compte_pas_les_revenus_en_double(
api_client, lot_a_deux_locataires
):
"""/api/revenus/immeubles somme chaque revenu une seule fois."""
total, regles, impayes = _totaux_reels(lot_a_deux_locataires)
response = api_client.get("/api/revenus/immeubles")
assert response.status_code == 200
(immeuble,) = response.json()
assert immeuble["total_revenus"] == total
assert immeuble["total_regles"] == regles
assert immeuble["total_impayes"] == impayes
def test_summary_ne_compte_pas_les_revenus_en_double(
api_client, lot_a_deux_locataires
):
"""Le bloc by_immeuble de /api/revenus/summary somme comme les KPIs."""
total, regles, impayes = _totaux_reels(lot_a_deux_locataires)
response = api_client.get("/api/revenus/summary")
assert response.status_code == 200
corps = response.json()
(immeuble,) = corps["by_immeuble"]
assert immeuble["total_revenus"] == total
assert immeuble["total_regles"] == regles
assert immeuble["total_impayes"] == impayes
# Les KPIs globaux n'ont jamais eu le defaut : les deux doivent concorder.
assert corps["kpis"]["total_revenus"] == immeuble["total_revenus"]
def test_immeubles_compte_lots_et_locataires(api_client, lot_a_deux_locataires):
"""Les effectifs restent justes : un lot, ses deux occupants successifs."""
(immeuble,) = api_client.get("/api/revenus/immeubles").json()
assert immeuble["nb_lots"] == 1
assert immeuble["nb_locataires"] == 2
def test_taux_recouvrement_reste_coherent(api_client, lot_a_deux_locataires):
"""Le taux se deduit des totaux : il doit suivre la meme correction."""
total, regles, _ = _totaux_reels(lot_a_deux_locataires)
(immeuble,) = api_client.get("/api/revenus/immeubles").json()
assert immeuble["taux_recouvrement"] == round(regles / total * 100, 1)