fix: aligne le dashboard sur la règle flux / stock
All checks were successful
Build and Publish Docker Image / Build App Image (push) Successful in 22s
Build and Publish Docker Image / Build Summary (push) Successful in 3s

L'accueil et la page Revenus calculaient leurs totaux chacun de leur
côté : le premier lisait le dernier compte rendu, la seconde cumulait
tout. Les deux écrans affichaient donc deux impayés différents pour la
même notion — 49 374 € contre 247 354 €.

Les raccourcis immeubles cumulaient la dette, et les courbes mensuelles
réintégraient le report chaque mois, ce qui rendait deux mois
incomparables. Tous passent par les mêmes règles que la page Revenus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 10:50:15 +02:00
parent 3e2d103929
commit 11bd3ca539
2 changed files with 158 additions and 46 deletions

View File

@@ -17,6 +17,7 @@ from ...database.models import (
Lot,
Revenu,
)
from ...services.revenus_query import est_flux, flux_par, restant_du_par
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
@@ -114,10 +115,13 @@ async def get_financial_summary(
last_document_date = str(last_doc.date)
last_document_reference = last_doc.reference
# Revenus du dernier document
# Revenus du dernier document : le solde reporte du mois precedent n'est
# pas un revenu du mois, il est deja compte dans les impayes.
revenus = (
session.execute(
select(func.sum(Revenu.total)).where(Revenu.document_id == last_doc.id)
select(func.sum(Revenu.total))
.where(Revenu.document_id == last_doc.id)
.where(est_flux())
).scalar()
or 0.0
)
@@ -153,11 +157,13 @@ async def get_financial_summary(
impayes_by_month: dict[str, float] = defaultdict(float)
depenses_by_month: dict[str, float] = defaultdict(float)
# Recuperer revenus et impayes par mois
# Recuperer revenus et impayes par mois. Le revenu du mois exclut le report
# pour que les mois soient comparables ; l'impaye reste le solde constate ce
# mois-la, de sorte que la courbe suive la dette au lieu de l'empiler.
revenus_stmt = (
select(
Document.date,
func.sum(Revenu.total).label("total"),
func.sum(Revenu.total).filter(est_flux()).label("total"),
func.sum(Revenu.impayes).label("impayes"),
)
.join(Revenu, Revenu.document_id == Document.id)
@@ -277,9 +283,10 @@ async def get_monthly_trends(
today = date.today()
start_date = (today.replace(day=1) - timedelta(days=months * 31)).replace(day=1)
# Recuperer tous les revenus depuis start_date
# Recuperer tous les revenus depuis start_date, report exclu : la courbe
# compare des mois entre eux, pas des soldes cumules.
revenus_stmt = (
select(Document.date, func.sum(Revenu.total).label("total"))
select(Document.date, func.sum(Revenu.total).filter(est_flux()).label("total"))
.join(Revenu, Revenu.document_id == Document.id)
.where(Document.date >= start_date)
.group_by(Document.date)
@@ -333,51 +340,55 @@ async def get_immeubles_shortcuts(
- **limit**: Nombre maximum d'immeubles (defaut: 5)
"""
# Requete pour les immeubles avec stats
# Effectifs et activite, puis flux et restant du : trois granularites
# differentes, jointes plutot que melangees pour ne pas se multiplier.
effectifs = (
select(
Lot.immeuble_id.label("immeuble_id"),
func.count(func.distinct(Lot.id)).label("nb_lots"),
func.count(func.distinct(Locataire.id)).label("nb_locataires"),
)
.outerjoin(Locataire, Locataire.lot_id == Lot.id)
.group_by(Lot.immeuble_id)
.subquery()
)
activite = (
select(
Document.immeuble_id.label("immeuble_id"),
func.count(Document.id).label("nb_documents"),
)
.group_by(Document.immeuble_id)
.subquery()
)
flux = flux_par(Document.immeuble_id)
stock = restant_du_par(Document.immeuble_id)
stmt = (
select(
Immeuble,
func.count(func.distinct(Lot.id)).label("nb_lots"),
func.count(func.distinct(Locataire.id)).label("nb_locataires"),
func.count(func.distinct(Document.id)).label("nb_documents"),
effectifs.c.nb_lots,
effectifs.c.nb_locataires,
flux.c.facture,
stock.c.restant_du,
)
.outerjoin(Lot, Lot.immeuble_id == Immeuble.id)
.outerjoin(Locataire, Locataire.lot_id == Lot.id)
.outerjoin(Document, Document.immeuble_id == Immeuble.id)
.group_by(Immeuble.id)
.order_by(desc("nb_documents"))
.outerjoin(effectifs, effectifs.c.immeuble_id == Immeuble.id)
.outerjoin(activite, activite.c.immeuble_id == Immeuble.id)
.outerjoin(flux, flux.c.cle == Immeuble.id)
.outerjoin(stock, stock.c.cle == Immeuble.id)
.order_by(desc(activite.c.nb_documents))
.limit(limit)
)
result = session.execute(stmt)
immeubles = result.all()
# Pour chaque immeuble, recuperer les totaux revenus/impayes
shortcuts = []
for row in immeubles:
immeuble = row.Immeuble
# Revenus de cet immeuble
revenus_stmt = (
select(func.sum(Revenu.total), func.sum(Revenu.impayes))
.join(Lot, Revenu.lot_id == Lot.id)
.where(Lot.immeuble_id == immeuble.id)
return [
ImmeubleShortcutResponse(
id=row.Immeuble.id,
code=row.Immeuble.code,
adresse=row.Immeuble.adresse,
ville=row.Immeuble.ville,
nb_lots=row.nb_lots or 0,
nb_locataires=row.nb_locataires or 0,
total_revenus=row.facture or 0.0,
total_impayes=row.restant_du or 0.0,
)
rev_result = session.execute(revenus_stmt).first()
total_revenus = rev_result[0] or 0.0 if rev_result else 0.0
total_impayes = rev_result[1] or 0.0 if rev_result else 0.0
shortcuts.append(
ImmeubleShortcutResponse(
id=immeuble.id,
code=immeuble.code,
adresse=immeuble.adresse,
ville=immeuble.ville,
nb_lots=row.nb_lots or 0,
nb_locataires=row.nb_locataires or 0,
total_revenus=total_revenus,
total_impayes=total_impayes,
)
)
return shortcuts
for row in session.execute(stmt)
]

View File

@@ -0,0 +1,101 @@
"""Le dashboard et la page Revenus doivent annoncer les memes chiffres.
Les deux ecrans calculaient leurs totaux chacun de leur cote, avec des regles
differentes : l'accueil lisait le dernier compte rendu, la page Revenus cumulait
tout. Ils affichaient donc deux montants d'impayes pour la meme notion. Ces
tests verrouillent leur accord.
"""
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()
@pytest.fixture
def deux_comptes_rendus(db_session, sample_data):
"""Un impaye de 300 ne le mois dernier, reporte et non regle ce mois-ci."""
service = DatabaseService(db_session)
premier = copy.deepcopy(sample_data)
premier["metadata"]["document"]["reference"] = "M1"
premier["metadata"]["document"]["date"] = _mois_glissant(1)
premier["situation_locataires"][0]["lignes"] = [
{
"type": "loyer",
"periode": {"debut": None, "fin": None},
"loyers": 800.0,
"total": 800.0,
"regles": 500.0,
"impayes": 300.0,
}
]
service.save_document(data=premier)
second = copy.deepcopy(sample_data)
second["metadata"]["document"]["reference"] = "M2"
second["metadata"]["document"]["date"] = _mois_glissant(0)
second["situation_locataires"][0]["lignes"] = [
{
"type": "solde_anterieur",
"periode": {"debut": None, "fin": None},
"loyers": 300.0,
"total": 300.0,
"regles": 0.0,
"impayes": 300.0,
},
{
"type": "loyer",
"periode": {"debut": None, "fin": None},
"loyers": 800.0,
"total": 800.0,
"regles": 800.0,
"impayes": 0.0,
},
]
service.save_document(data=second)
return db_session
def test_les_deux_ecrans_annoncent_le_meme_impaye(api_client, deux_comptes_rendus):
"""300 dus, vus depuis l'accueil comme depuis la page Revenus."""
accueil = api_client.get("/api/dashboard/financial-summary").json()
revenus = api_client.get("/api/revenus/summary").json()
assert accueil["impayes"] == 300.0
assert revenus["kpis"]["total_impayes"] == 300.0
def test_le_revenu_du_dernier_compte_rendu_exclut_le_report(
api_client, deux_comptes_rendus
):
"""Le mois vaut son loyer de 800, pas 1100 report compris."""
accueil = api_client.get("/api/dashboard/financial-summary").json()
assert accueil["revenus"] == 800.0
def test_les_raccourcis_immeubles_ne_cumulent_pas_la_dette(
api_client, deux_comptes_rendus
):
"""Le raccourci montre la dette en cours, pas sa somme mois apres mois."""
(raccourci,) = api_client.get("/api/dashboard/immeubles-shortcuts").json()
assert raccourci["total_impayes"] == 300.0
assert raccourci["total_revenus"] == 1600.0 # 800 + 800, report exclu
def test_la_tendance_mensuelle_reste_comparable(api_client, deux_comptes_rendus):
"""Chaque mois pese son loyer, sinon le second parait meilleur qu'il n'est."""
tendances = api_client.get("/api/dashboard/monthly-trends").json()
assert [point["revenus"] for point in tendances] == [800.0, 800.0]