fix: distingue les montants cumulables des soldes à date

Chaque compte rendu reporte la dette du précédent dans une ligne
solde_anterieur. Les agrégats sommaient ces lignes comme le reste : la
même créance était recomptée à chaque document, et un remboursement ne
pouvait jamais s'inscrire — une dette soldée restait affichée à vie.

Sur la base réelle, la page Revenus annonçait ainsi 247 354 € d'impayés
pour une dette de 49 374 €, et désignait comme deuxième et troisième
débiteurs deux locataires à jour depuis avril (SURBECK 690,10 € et
GUINAIS 445,81 €, tous deux soldés).

Deux natures cohabitent, et c'est la colonne qui la porte, pas la ligne :
une ligne de report a un `total` déjà compté le mois d'avant, mais ses
`regles` sont un encaissement bien réel de la période. Écarter la ligne
entière ferait disparaître de l'argent reçu (1 298,81 € ici).

- flux (facturé, encaissé) : cumulés sur la période, report exclu ;
- stock (restant dû) : lu dans le dernier compte rendu de chaque immeuble ;
- taux de recouvrement : réglé sur facturé, report exclu des deux côtés,
  sans quoi rattraper une vieille dette ferait dépasser 100 %.

Résultat : 85 748 € facturés, 98,2 % de recouvrement, 49 374 € encore
dus. Les règles vivent dans services/revenus_query.py, pour que le
dashboard s'y branche au lieu de les réinventer.

/summary borne désormais tous ses blocs à la période demandée : les KPIs
et by_immeuble ignoraient `months` alors que by_month le respectait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 10:50:15 +02:00
parent 1c817a4ac9
commit 3e2d103929
4 changed files with 465 additions and 183 deletions

View File

@@ -7,6 +7,7 @@ aucune erreur, juste des montants faux qui derivent avec l'anciennete du parc.
"""
import copy
from datetime import date, timedelta
import pytest
@@ -14,6 +15,19 @@ from plesna_gerance.database.models import Locataire, Revenu
from plesna_gerance.database.service import DatabaseService
def _mois_glissant(recul: int) -> str:
"""Date du 15 du mois, `recul` mois en arriere.
Les resumes ne portent que sur les derniers mois : des dates fixes
sortiraient de la fenetre avec le temps et videraient les tests de leur
substance sans jamais les faire echouer.
"""
jour = date.today().replace(day=15)
for _ in range(recul):
jour = (jour.replace(day=1) - timedelta(days=1)).replace(day=15)
return jour.isoformat()
@pytest.fixture
def lot_a_deux_locataires(db_session, sample_data):
"""Un lot occupe successivement par deux locataires, un revenu chacun.
@@ -22,11 +36,13 @@ def lot_a_deux_locataires(db_session, sample_data):
deux noms differents sur deux comptes rendus.
"""
service = DatabaseService(db_session)
service.save_document(data=sample_data)
premier = copy.deepcopy(sample_data)
premier["metadata"]["document"]["date"] = _mois_glissant(2)
service.save_document(data=premier)
suivant = copy.deepcopy(sample_data)
suivant["metadata"]["document"]["reference"] = "REF002"
suivant["metadata"]["document"]["date"] = "2024-02-15"
suivant["metadata"]["document"]["date"] = _mois_glissant(1)
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
@@ -40,46 +56,40 @@ def lot_a_deux_locataires(db_session, sample_data):
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),
)
#: Le decor : 500 puis 600 factures, 500 puis 400 regles, 200 encore dus.
FACTURE = 1100.0
ENCAISSE = 900.0
RESTANT_DU = 200.0
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
assert immeuble["total_revenus"] == FACTURE
assert immeuble["total_regles"] == ENCAISSE
assert immeuble["total_impayes"] == RESTANT_DU
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 immeuble["total_revenus"] == FACTURE
assert immeuble["total_regles"] == ENCAISSE
assert immeuble["total_impayes"] == RESTANT_DU
# Les deux blocs doivent raconter la meme chose.
assert corps["kpis"]["total_revenus"] == immeuble["total_revenus"]
assert corps["kpis"]["total_impayes"] == immeuble["total_impayes"]
def test_immeubles_compte_lots_et_locataires(api_client, lot_a_deux_locataires):
@@ -92,8 +102,6 @@ def test_immeubles_compte_lots_et_locataires(api_client, lot_a_deux_locataires):
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)
assert immeuble["taux_recouvrement"] == round(ENCAISSE / FACTURE * 100, 1)

View File

@@ -0,0 +1,148 @@
"""Tests de la distinction flux / stock dans les agregats de revenus.
Chaque compte rendu reporte la dette du precedent dans une ligne
`solde_anterieur`. Cumuler ces lignes recompte la meme creance a chaque
document et, surtout, empeche un remboursement de s'inscrire : une dette soldee
resterait affichee a vie.
Le decor rejoue le cycle observe en production : un locataire laisse un impaye,
le compte rendu suivant le reporte, il le solde, puis le reporte disparait.
"""
import copy
from datetime import date, timedelta
import pytest
from plesna_gerance.database.service import DatabaseService
def _mois_glissant(recul: int) -> str:
jour = date.today().replace(day=15)
for _ in range(recul):
jour = (jour.replace(day=1) - timedelta(days=1)).replace(day=15)
return jour.isoformat()
def _ligne(type_ligne: str, **montants) -> dict:
base = {
"type": type_ligne,
"periode": {"debut": None, "fin": None},
"loyers": 0.0,
"taxes": 0.0,
"provisions": 0.0,
"total": 0.0,
"regles": 0.0,
"impayes": 0.0,
}
base.update(montants)
return base
def _compte_rendu(sample_data: dict, reference: str, recul: int, lignes: list) -> dict:
data = copy.deepcopy(sample_data)
data["metadata"]["document"]["reference"] = reference
data["metadata"]["document"]["date"] = _mois_glissant(recul)
data["situation_locataires"][0]["lignes"] = lignes
data["recapitulatif_operations"] = []
return data
@pytest.fixture
def dette_reportee_puis_soldee(db_session, sample_data):
"""Trois mois : un impaye nait, il est reporte, il est solde.
Mois 1 : loyer de 800 dont 300 impayes.
Mois 2 : les 300 sont reportes et regles ; loyer de 800 regle en entier.
Mois 3 : plus aucun report ; loyer de 800 regle en entier.
"""
service = DatabaseService(db_session)
service.save_document(
data=_compte_rendu(
sample_data,
"M1",
2,
[_ligne("loyer", loyers=800.0, total=800.0, regles=500.0, impayes=300.0)],
)
)
service.save_document(
data=_compte_rendu(
sample_data,
"M2",
1,
[
_ligne("solde_anterieur", loyers=300.0, total=300.0, regles=300.0),
_ligne("loyer", loyers=800.0, total=800.0, regles=800.0),
],
)
)
service.save_document(
data=_compte_rendu(
sample_data,
"M3",
0,
[_ligne("loyer", loyers=800.0, total=800.0, regles=800.0)],
)
)
return db_session
def test_le_restant_du_est_celui_du_dernier_compte_rendu(
api_client, dette_reportee_puis_soldee
):
"""La dette soldee disparait : le cumul afficherait encore 300."""
kpis = api_client.get("/api/revenus/summary").json()["kpis"]
assert kpis["total_impayes"] == 0.0
def test_le_facture_ignore_le_report(api_client, dette_reportee_puis_soldee):
"""3 loyers de 800 : le report de 300 n'est pas un revenu de plus."""
kpis = api_client.get("/api/revenus/summary").json()["kpis"]
assert kpis["total_revenus"] == 2400.0
def test_l_encaisse_retient_le_reglement_d_une_vieille_dette(
api_client, dette_reportee_puis_soldee
):
"""Les 300 regles sur la ligne de report sont de l'argent bien recu.
C'est ce qui interdit d'ecarter la ligne de report en bloc : sa colonne
`total` est un stock deja compte, mais sa colonne `regles` est un flux.
"""
kpis = api_client.get("/api/revenus/summary").json()["kpis"]
assert kpis["total_regles"] == 500.0 + 300.0 + 800.0 + 800.0
def test_le_taux_compare_un_perimetre_homogene(
api_client, dette_reportee_puis_soldee
):
"""Regle sur facture, report exclu des deux cotes : 2100 / 2400.
Rapporter l'encaisse (2400, rattrapage compris) au facture ferait afficher
un taux de 100 % alors qu'un impaye est ne sur la periode.
"""
kpis = api_client.get("/api/revenus/summary").json()["kpis"]
assert kpis["taux_recouvrement"] == round(2100.0 / 2400.0 * 100, 1)
def test_le_classement_des_impayes_oublie_qui_a_paye(
api_client, dette_reportee_puis_soldee
):
"""Un locataire a jour ne doit plus figurer parmi les debiteurs."""
top = api_client.get("/api/revenus/summary").json()["top_impayes"]
assert top == []
def test_les_mois_restent_comparables(api_client, dette_reportee_puis_soldee):
"""Chaque mois vaut son loyer : le report ne gonfle pas le mois 2."""
by_month = api_client.get("/api/revenus/summary").json()["by_month"]
assert [point["total"] for point in by_month] == [800.0, 800.0, 800.0]
# L'impaye reste le solde constate ce mois-la, pas un cumul.
assert [point["impayes"] for point in by_month] == [300.0, 0.0, 0.0]