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>
This commit is contained in:
@@ -4,31 +4,33 @@ 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, timedelta
|
||||
from datetime import date
|
||||
|
||||
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."""
|
||||
"""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"] = _mois_glissant(1)
|
||||
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",
|
||||
@@ -43,7 +45,12 @@ def deux_comptes_rendus(db_session, sample_data):
|
||||
|
||||
second = copy.deepcopy(sample_data)
|
||||
second["metadata"]["document"]["reference"] = "M2"
|
||||
second["metadata"]["document"]["date"] = _mois_glissant(0)
|
||||
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",
|
||||
@@ -66,36 +73,189 @@ def deux_comptes_rendus(db_session, sample_data):
|
||||
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()
|
||||
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["impayes"] == 300.0
|
||||
assert accueil["restant_du"] == 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()
|
||||
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
|
||||
|
||||
assert accueil["revenus"] == 800.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").json()
|
||||
(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_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()
|
||||
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.
|
||||
|
||||
assert [point["revenus"] for point in tendances] == [800.0, 800.0]
|
||||
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() == []
|
||||
|
||||
Reference in New Issue
Block a user