fix: aligne le dashboard sur la règle flux / stock
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:
@@ -17,6 +17,7 @@ from ...database.models import (
|
|||||||
Lot,
|
Lot,
|
||||||
Revenu,
|
Revenu,
|
||||||
)
|
)
|
||||||
|
from ...services.revenus_query import est_flux, flux_par, restant_du_par
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
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_date = str(last_doc.date)
|
||||||
last_document_reference = last_doc.reference
|
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 = (
|
revenus = (
|
||||||
session.execute(
|
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()
|
).scalar()
|
||||||
or 0.0
|
or 0.0
|
||||||
)
|
)
|
||||||
@@ -153,11 +157,13 @@ async def get_financial_summary(
|
|||||||
impayes_by_month: dict[str, float] = defaultdict(float)
|
impayes_by_month: dict[str, float] = defaultdict(float)
|
||||||
depenses_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 = (
|
revenus_stmt = (
|
||||||
select(
|
select(
|
||||||
Document.date,
|
Document.date,
|
||||||
func.sum(Revenu.total).label("total"),
|
func.sum(Revenu.total).filter(est_flux()).label("total"),
|
||||||
func.sum(Revenu.impayes).label("impayes"),
|
func.sum(Revenu.impayes).label("impayes"),
|
||||||
)
|
)
|
||||||
.join(Revenu, Revenu.document_id == Document.id)
|
.join(Revenu, Revenu.document_id == Document.id)
|
||||||
@@ -277,9 +283,10 @@ async def get_monthly_trends(
|
|||||||
today = date.today()
|
today = date.today()
|
||||||
start_date = (today.replace(day=1) - timedelta(days=months * 31)).replace(day=1)
|
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 = (
|
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)
|
.join(Revenu, Revenu.document_id == Document.id)
|
||||||
.where(Document.date >= start_date)
|
.where(Document.date >= start_date)
|
||||||
.group_by(Document.date)
|
.group_by(Document.date)
|
||||||
@@ -333,51 +340,55 @@ async def get_immeubles_shortcuts(
|
|||||||
|
|
||||||
- **limit**: Nombre maximum d'immeubles (defaut: 5)
|
- **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 = (
|
stmt = (
|
||||||
select(
|
select(
|
||||||
Immeuble,
|
Immeuble,
|
||||||
func.count(func.distinct(Lot.id)).label("nb_lots"),
|
effectifs.c.nb_lots,
|
||||||
func.count(func.distinct(Locataire.id)).label("nb_locataires"),
|
effectifs.c.nb_locataires,
|
||||||
func.count(func.distinct(Document.id)).label("nb_documents"),
|
flux.c.facture,
|
||||||
|
stock.c.restant_du,
|
||||||
)
|
)
|
||||||
.outerjoin(Lot, Lot.immeuble_id == Immeuble.id)
|
.outerjoin(effectifs, effectifs.c.immeuble_id == Immeuble.id)
|
||||||
.outerjoin(Locataire, Locataire.lot_id == Lot.id)
|
.outerjoin(activite, activite.c.immeuble_id == Immeuble.id)
|
||||||
.outerjoin(Document, Document.immeuble_id == Immeuble.id)
|
.outerjoin(flux, flux.c.cle == Immeuble.id)
|
||||||
.group_by(Immeuble.id)
|
.outerjoin(stock, stock.c.cle == Immeuble.id)
|
||||||
.order_by(desc("nb_documents"))
|
.order_by(desc(activite.c.nb_documents))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
)
|
)
|
||||||
|
|
||||||
result = session.execute(stmt)
|
return [
|
||||||
immeubles = result.all()
|
ImmeubleShortcutResponse(
|
||||||
|
id=row.Immeuble.id,
|
||||||
# Pour chaque immeuble, recuperer les totaux revenus/impayes
|
code=row.Immeuble.code,
|
||||||
shortcuts = []
|
adresse=row.Immeuble.adresse,
|
||||||
for row in immeubles:
|
ville=row.Immeuble.ville,
|
||||||
immeuble = row.Immeuble
|
nb_lots=row.nb_lots or 0,
|
||||||
|
nb_locataires=row.nb_locataires or 0,
|
||||||
# Revenus de cet immeuble
|
total_revenus=row.facture or 0.0,
|
||||||
revenus_stmt = (
|
total_impayes=row.restant_du or 0.0,
|
||||||
select(func.sum(Revenu.total), func.sum(Revenu.impayes))
|
|
||||||
.join(Lot, Revenu.lot_id == Lot.id)
|
|
||||||
.where(Lot.immeuble_id == immeuble.id)
|
|
||||||
)
|
)
|
||||||
rev_result = session.execute(revenus_stmt).first()
|
for row in session.execute(stmt)
|
||||||
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
|
|
||||||
|
|||||||
101
tests/test_dashboard_coherence.py
Normal file
101
tests/test_dashboard_coherence.py
Normal 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]
|
||||||
Reference in New Issue
Block a user