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:
148
tests/test_revenus_flux_stock.py
Normal file
148
tests/test_revenus_flux_stock.py
Normal 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]
|
||||
Reference in New Issue
Block a user