diff --git a/src/plesna_gerance/api/routes/dashboard.py b/src/plesna_gerance/api/routes/dashboard.py index 8053e3b..39947b9 100644 --- a/src/plesna_gerance/api/routes/dashboard.py +++ b/src/plesna_gerance/api/routes/dashboard.py @@ -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) + ] diff --git a/tests/test_dashboard_coherence.py b/tests/test_dashboard_coherence.py new file mode 100644 index 0000000..227f108 --- /dev/null +++ b/tests/test_dashboard_coherence.py @@ -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]