Files
pdf_oralia_vibe/tests/test_dashboard_coherence.py
Bertrand Benjamin 9c2d4f7f82 feat: resume l'annee civile sur l'accueil
L'accueil ne decrivait qu'un document : les quatre tuiles lisaient le
dernier compte rendu importe, sans rien dire de ce que contient la base.

Il resume desormais une annee civile, avec un selecteur des annees
presentes. L'annee par defaut est celle du dernier compte rendu et non
l'annee en cours : la fenetre etait calee sur `date.today()`, si bien que
sans import depuis quelques mois l'ecran se serait vide alors que la base
est pleine.

Ce que les tuiles annoncent :

- recettes facturees, report exclu, avec l'encaisse et le recouvrement ;
- depenses, debit et credits recus ;
- net reverse, rapproche des soldes annonces par les comptes rendus ;
- restant du, date, car un stock ne se cumule pas d'un mois sur l'autre.

Le net reverse vaut « encaisse - debit + credit », et cette egalite tombe
au centime sur le solde extrait du PDF pour avril, mai et juin 2026. Elle
s'ecarte de 288,52 EUR en fevrier et de 1 188,03 EUR en mars, les deux mois
dont l'extraction a par ailleurs des defauts. L'ecart est donc affiche et
jamais lisse : c'est le seul controle de bout en bout dont on dispose sur
la qualite d'une extraction. Les regles vivent dans services/tresorerie.py,
a cote de celles des revenus.

Le lot compte est celui qui a ete facture dans l'annee : la table `lots`
retient deux ecritures par lot (« 0001 » et « 01 »), sequelle de la
normalisation des numeros, et en annoncerait 40 la ou il y en a 20.

Le graphique passe du vert et rouge au bleu et ambre : sous deuteranopie,
green-400 et red-400 ne se separent qu'a un delta E de 7,9, sous le seuil
de 8. La paire retenue tient a 30,2.

`/recent-revenus` disparait — c'etait le dernier agregat a compter les
lignes de report — et `/immeubles-shortcuts` est borne a la meme annee que
le reste de l'ecran, deux perimetres sur un ecran donnant deux montants
sans que rien ne les distingue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:52:10 +02:00

262 lines
9.1 KiB
Python

"""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.
L'accueil resume desormais une annee civile entiere. S'y ajoute donc ce que
cette bascule engage : la periode couverte suit les donnees et non l'horloge, et
le net reverse se rapproche du solde annonce par les comptes rendus.
"""
import copy
from datetime import date
import pytest
from plesna_gerance.database.service import DatabaseService
@pytest.fixture
def deux_comptes_rendus(db_session, sample_data):
"""Un impaye de 300 ne en janvier, reporte et non regle en fevrier."""
service = DatabaseService(db_session)
premier = copy.deepcopy(sample_data)
premier["metadata"]["document"]["reference"] = "M1"
premier["metadata"]["document"]["date"] = "2024-01-15"
premier["metadata"]["solde"] = {
"montant": 450.0,
"type": "crediteur",
"date_arrete": "2024-01-15",
}
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"] = "2024-02-15"
second["metadata"]["solde"] = {
"montant": 750.0,
"type": "crediteur",
"date_arrete": "2024-02-15",
}
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 _resume(api_client, **params):
return api_client.get("/api/dashboard/resume-annuel", params=params).json()
def test_les_deux_ecrans_annoncent_le_meme_impaye(api_client, db_session, sample_data):
"""300 dus, vus depuis l'accueil comme depuis la page Revenus.
Le compte rendu est date d'aujourd'hui a dessein : les deux ecrans ne
cadrent plus la meme periode — annee civile ici, douze mois glissants
la-bas — et seule une donnee recente tombe dans les deux fenetres. Leur
accord porte sur la regle, pas sur le cadrage.
"""
document = copy.deepcopy(sample_data)
document["metadata"]["document"]["date"] = date.today().isoformat()
document["situation_locataires"][0]["lignes"] = [
{
"type": "loyer",
"periode": {"debut": None, "fin": None},
"loyers": 800.0,
"total": 800.0,
"regles": 500.0,
"impayes": 300.0,
}
]
DatabaseService(db_session).save_document(data=document)
accueil = _resume(api_client)
revenus = api_client.get("/api/revenus/summary").json()
assert accueil["restant_du"] == 300.0
assert revenus["kpis"]["total_impayes"] == 300.0
def test_le_facture_de_l_annee_exclut_le_report(api_client, deux_comptes_rendus):
"""L'annee vaut ses deux loyers de 800, pas 1900 report compris."""
assert _resume(api_client)["facture"] == 1600.0
def test_l_encaisse_retient_les_reglements_de_dettes_anciennes(
api_client, db_session, sample_data
):
"""Un vieil impaye rattrape est un encaissement du mois, pas un report."""
service = DatabaseService(db_session)
document = copy.deepcopy(sample_data)
document["metadata"]["document"]["date"] = "2024-03-15"
document["situation_locataires"][0]["lignes"] = [
{
"type": "solde_anterieur",
"periode": {"debut": None, "fin": None},
"loyers": 300.0,
"total": 300.0,
"regles": 300.0,
"impayes": 0.0,
}
]
service.save_document(data=document)
resume = _resume(api_client)
assert resume["facture"] == 0.0
assert resume["encaisse"] == 300.0
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."""
par_mois = _resume(api_client)["par_mois"]
assert [point["mois"] for point in par_mois] == ["2024-01", "2024-02"]
assert [point["facture"] for point in par_mois] == [800.0, 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", params={"annee": 2024}
).json()
assert raccourci["total_impayes"] == 300.0
assert raccourci["total_revenus"] == 1600.0 # 800 + 800, report exclu
def test_le_net_reverse_se_rapproche_du_solde_annonce(api_client, deux_comptes_rendus):
"""Encaisse moins depenses doit retomber sur les soldes des comptes rendus.
C'est le seul controle de bout en bout sur la qualite d'une extraction :
1300 encaisses, 100 de depenses sur les deux documents, contre 450 + 750
annonces.
"""
resume = _resume(api_client)
assert resume["encaisse"] == 1300.0
assert resume["depenses_debit"] == 100.0
assert resume["net_reverse"] == 1200.0
assert resume["soldes_annonces"] == 1200.0
assert resume["ecart_soldes"] == 0.0
def test_un_ecart_de_solde_est_annonce_et_non_lisse(
api_client, db_session, sample_data
):
"""Quand le recalcul et le solde du PDF divergent, l'ecart doit se voir."""
document = copy.deepcopy(sample_data)
document["metadata"]["document"]["date"] = "2024-01-15"
document["metadata"]["solde"] = {
"montant": 1000.0,
"type": "crediteur",
"date_arrete": "2024-01-15",
}
DatabaseService(db_session).save_document(data=document)
resume = _resume(api_client)
# 500 encaisses - 50 de depenses = 450, la ou le document annonce 1000.
assert resume["net_reverse"] == 450.0
assert resume["soldes_annonces"] == 1000.0
assert resume["ecart_soldes"] == -550.0
def test_un_solde_debiteur_se_soustrait(api_client, db_session, sample_data):
"""Un mois debiteur est reclame au proprietaire, il ne s'ajoute pas."""
document = copy.deepcopy(sample_data)
document["metadata"]["document"]["date"] = "2024-01-15"
document["metadata"]["solde"] = {
"montant": 200.0,
"type": "débiteur",
"date_arrete": "2024-01-15",
}
DatabaseService(db_session).save_document(data=document)
assert _resume(api_client)["soldes_annonces"] == -200.0
def test_l_annee_par_defaut_suit_les_donnees_pas_l_horloge(
api_client, deux_comptes_rendus
):
"""Sans import recent, l'accueil montre la derniere annee pleine, pas du vide."""
resume = _resume(api_client)
assert resume["annee"] == 2024
assert resume["annee"] != date.today().year
assert resume["annees_disponibles"] == [2024]
assert resume["perimetre"]["nb_comptes_rendus"] == 2
def test_une_annee_sans_compte_rendu_reste_lisible(api_client, deux_comptes_rendus):
"""Choisir une annee vide donne des zeros, pas une erreur."""
resume = _resume(api_client, annee=2023)
assert resume["annee"] == 2023
assert resume["perimetre"]["nb_comptes_rendus"] == 0
assert resume["facture"] == 0.0
assert resume["restant_du"] == 0.0
assert resume["par_mois"] == []
def test_les_annees_ne_se_melangent_pas(api_client, db_session, sample_data):
"""Le total d'une annee ne doit rien emprunter a la suivante."""
service = DatabaseService(db_session)
for reference, jour in (("A", "2024-06-15"), ("B", "2025-06-15")):
document = copy.deepcopy(sample_data)
document["metadata"]["document"]["reference"] = reference
document["metadata"]["document"]["date"] = jour
service.save_document(data=document)
assert _resume(api_client, annee=2024)["facture"] == 500.0
assert _resume(api_client, annee=2025)["facture"] == 500.0
assert _resume(api_client)["annees_disponibles"] == [2025, 2024]
def test_le_perimetre_ne_compte_que_les_lots_factures(api_client, deux_comptes_rendus):
"""La table `lots` retient d'anciennes ecritures de numeros, sans revenu."""
assert _resume(api_client)["perimetre"]["nb_lots_factures"] == 1
def test_une_base_vide_ne_casse_pas_l_accueil(api_client):
"""Premiere ouverture de l'application : aucun document, aucune erreur."""
resume = _resume(api_client)
assert resume["annees_disponibles"] == []
assert resume["annee"] == date.today().year
assert resume["facture"] == 0.0
assert resume["taux_recouvrement"] == 100.0
assert resume["par_mois"] == []
assert api_client.get("/api/dashboard/immeubles-shortcuts").json() == []