feat: borne l'analyse d'un lot à une période
La fiche d'un lot rendait tout son historique, et rien d'autre. Sur un parc suivi depuis 2023, lire ce qu'un lot a fait ces trois derniers mois supposait de faire soi-même la soustraction. Le paramètre `mois` ouvre une fenêtre sur les chiffres, la chronologie, les intervenants, les locataires et la courbe du loyer. Trois décisions la gouvernent, et chacune évite un chiffre qui mentirait : - **la fenêtre est calée sur le dernier compte rendu du lot**, jamais sur aujourd'hui. Les derniers comptes rendus du parc datent de juillet alors qu'on est en août : une fenêtre glissante depuis la date du jour décalerait déjà tout d'un mois, et viderait entièrement la fiche d'un lot sorti de la gestion — une page vide se lisant comme une absence d'activité plutôt que comme un filtre trop court ; - **le restant dû y échappe**, parce qu'un stock ne se borne pas comme un flux. Le ramener à la fenêtre l'annulerait dès qu'aucun compte rendu n'y tombe : un lot afficherait 0 € dû tout en devant plusieurs milliers ; - **le loyer en vigueur et la comparaison au parc y échappent aussi.** La courbe, elle, est bien coupée, mais après coup : les paliers restent lus sur la série entière, sans quoi « depuis mai 26 » daterait de la borne du filtre au lieu de la révision qui a fixé ce loyer, et la médiane du parc changerait de mois de référence à chaque changement de période. Rien n'est coupé au-delà de la fenêtre : un bail trimestriel facturé d'avance porte des mois postérieurs au dernier compte rendu, et les retirer ferait croire que le lot cesse d'être loué. Les locataires n'ont ni date d'entrée ni date de sortie — aucune n'est extraite des comptes rendus. Leur seul rattachement au temps est donc le document qui les porte. Hors fenêtre, la liste reste malgré tout celle de la table : la base contient des locataires dont aucune ligne ne dépend, et la vue par défaut ne filtre rien. Un filtre qui masque doit dire ce qu'il masque : la réponse porte le compte des lignes, des mois et des occupants laissés dehors, et les bornes effectivement retenues. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,12 @@ regrouper des lignes que le compte rendu a émises séparément.
|
||||
|
||||
import pytest
|
||||
|
||||
from plesna_gerance.database.models import Immeuble, Lot, LotCaracteristiques
|
||||
from plesna_gerance.database.models import (
|
||||
Immeuble,
|
||||
Locataire,
|
||||
Lot,
|
||||
LotCaracteristiques,
|
||||
)
|
||||
from plesna_gerance.database.service import DatabaseService
|
||||
|
||||
|
||||
@@ -262,6 +267,233 @@ def test_la_surface_saisie_allume_le_loyer_au_m2(api_client, db_session, donnees
|
||||
assert loyer["en_vigueur"]["loyer_m2"] == 10.0
|
||||
|
||||
|
||||
def test_sans_periode_demandee_rien_n_est_borne(api_client, donnees):
|
||||
"""Le défaut ne filtre pas : la page s'ouvre sur tout l'historique.
|
||||
|
||||
Un filtre par défaut cacherait des opérations dès l'arrivée sur la fiche,
|
||||
sans que rien ne le signale.
|
||||
"""
|
||||
_, lot = donnees
|
||||
|
||||
periode = api_client.get(f"/api/lots/{lot.id}/analyse").json()["periode"]
|
||||
|
||||
assert periode["mois"] is None
|
||||
assert periode["debut"] is None
|
||||
assert periode["fin"] is None
|
||||
assert periode["lignes_masquees"] == 0
|
||||
# L'ancre est renvoyée quand même : la page sait sur quoi une fenêtre se
|
||||
# calerait avant même d'en demander une.
|
||||
assert periode["ancre"] == "2024-02-15"
|
||||
|
||||
|
||||
def test_la_fenetre_se_cale_sur_le_dernier_compte_rendu_du_lot(api_client, donnees):
|
||||
"""Comptée à rebours des données, jamais d'aujourd'hui.
|
||||
|
||||
Ces comptes rendus datent de 2024 : une fenêtre calée sur la date du jour
|
||||
viderait la fiche et ferait passer un lot documenté pour un lot sans
|
||||
activité.
|
||||
"""
|
||||
_, lot = donnees
|
||||
|
||||
periode = api_client.get(f"/api/lots/{lot.id}/analyse?mois=1").json()["periode"]
|
||||
|
||||
assert periode["fin"] == "2024-02-15"
|
||||
assert periode["debut"] == "2024-01-16"
|
||||
|
||||
|
||||
def test_une_fenetre_d_un_mois_ne_retient_qu_un_compte_rendu(api_client, donnees):
|
||||
"""Un mois de fenêtre, un compte rendu : le précédent tombe dehors.
|
||||
|
||||
Bornes incluses des deux côtés, le compte rendu du 15 janvier entrerait
|
||||
dans une fenêtre d'un mois finissant le 15 février — elle en couvrirait
|
||||
deux.
|
||||
"""
|
||||
_, lot = donnees
|
||||
|
||||
analyse = api_client.get(f"/api/lots/{lot.id}/analyse?mois=1").json()
|
||||
|
||||
dates = {ligne["date"] for ligne in analyse["chronologie"]}
|
||||
assert dates == {"2024-02-15"}
|
||||
# Le premier compte rendu portait un loyer et une opération de nettoyage
|
||||
# d'immeuble ; seules ses lignes de lot comptent ici.
|
||||
assert analyse["periode"]["lignes_masquees"] == 1
|
||||
|
||||
|
||||
def test_les_chiffres_suivent_la_fenetre(api_client, donnees):
|
||||
"""Facturé, dépenses et charges communes se recalculent sur la période."""
|
||||
_, lot = donnees
|
||||
|
||||
chiffres = api_client.get(f"/api/lots/{lot.id}/analyse?mois=1").json()["chiffres"]
|
||||
|
||||
# Le seul loyer du second compte rendu, sans celui de janvier.
|
||||
assert chiffres["facture"] == 500.0
|
||||
assert chiffres["encaisse"] == 200.0
|
||||
assert chiffres["nb_operations"] == 3
|
||||
# Le nettoyage de l'immeuble datait du premier compte rendu.
|
||||
assert chiffres["depenses_immeuble_non_reparties"] == 0.0
|
||||
|
||||
|
||||
def test_le_restant_du_echappe_a_la_fenetre(api_client, donnees):
|
||||
"""Un stock ne se borne pas : la dette reste celle du dernier compte rendu.
|
||||
|
||||
La ramener à la fenêtre l'annulerait dès qu'aucun compte rendu n'y tombe —
|
||||
un lot afficherait 0 € dû tout en devant plusieurs milliers.
|
||||
"""
|
||||
_, lot = donnees
|
||||
|
||||
chiffres = api_client.get(f"/api/lots/{lot.id}/analyse?mois=1").json()["chiffres"]
|
||||
|
||||
assert chiffres["restant_du"] == 600.0
|
||||
|
||||
|
||||
def test_les_intervenants_suivent_la_fenetre(api_client, donnees):
|
||||
"""Le tableau des entreprises décrit la même période que la chronologie."""
|
||||
_, lot = donnees
|
||||
|
||||
analyse = api_client.get(f"/api/lots/{lot.id}/analyse?mois=1").json()
|
||||
|
||||
# Invariant du dépliage, sous fenêtre comme sans : le détail doit retrouver
|
||||
# le total. Les deux viennent de calculs séparés — un agrégat SQL borné
|
||||
# d'un côté, les lignes filtrées de l'autre — et une fenêtre appliquée d'un
|
||||
# seul côté les ferait diverger sans que rien ne le signale.
|
||||
assert analyse["intervenants"]
|
||||
for intervenant in analyse["intervenants"]:
|
||||
lignes = [
|
||||
ligne
|
||||
for ligne in analyse["chronologie"]
|
||||
if ligne["fournisseur"] == intervenant["fournisseur"]
|
||||
]
|
||||
assert len(lignes) == intervenant["nb_interventions"]
|
||||
assert (
|
||||
round(sum(ligne["montant"] for ligne in lignes), 2)
|
||||
== intervenant["montant"]
|
||||
)
|
||||
|
||||
|
||||
def test_la_courbe_du_loyer_suit_la_fenetre(api_client, donnees):
|
||||
"""Le graphe se limite aux mois de la période, et dit ce qu'il ne trace pas.
|
||||
|
||||
Le mois de la borne haute compte pour un : une fenêtre d'un mois finissant
|
||||
en février trace février seul, sans quoi le graphe démentirait son libellé.
|
||||
"""
|
||||
_, lot = donnees
|
||||
|
||||
loyer = api_client.get(f"/api/lots/{lot.id}/analyse?mois=1").json()["loyer"]
|
||||
|
||||
assert [point["mois"] for point in loyer["serie"]] == ["2024-02"]
|
||||
assert loyer["depuis_mois"] == "2024-02"
|
||||
assert loyer["mois_masques"] == 1
|
||||
|
||||
|
||||
def test_le_loyer_en_vigueur_ignore_la_fenetre(api_client, donnees):
|
||||
"""Les paliers restent lus sur toute la série, même courbe tronquée.
|
||||
|
||||
Recalculés sur les seuls mois tracés, « depuis » daterait de la borne du
|
||||
filtre : ce loyer semblerait révisé en février alors qu'il n'a jamais
|
||||
bougé depuis janvier.
|
||||
"""
|
||||
_, lot = donnees
|
||||
|
||||
loyer = api_client.get(f"/api/lots/{lot.id}/analyse?mois=1").json()["loyer"]
|
||||
|
||||
assert loyer["en_vigueur"]["depuis"] == "2024-01"
|
||||
assert loyer["en_vigueur"]["loyer"] == 500.0
|
||||
# La comparaison au parc garde son mois de référence : le dernier mois loué.
|
||||
assert loyer["parc"]["mois"] == "2024-02"
|
||||
|
||||
|
||||
def test_sans_fenetre_la_courbe_reste_entiere(api_client, donnees):
|
||||
"""Le défaut ne coupe rien, et ne prétend pas avoir coupé."""
|
||||
_, lot = donnees
|
||||
|
||||
loyer = api_client.get(f"/api/lots/{lot.id}/analyse").json()["loyer"]
|
||||
|
||||
assert [point["mois"] for point in loyer["serie"]] == ["2024-01", "2024-02"]
|
||||
assert loyer["depuis_mois"] is None
|
||||
assert loyer["mois_masques"] == 0
|
||||
|
||||
|
||||
def test_la_fenetre_ne_garde_que_les_occupants_de_la_periode(
|
||||
api_client, db_session, donnees, sample_data
|
||||
):
|
||||
"""Un locataire appartient à la période si un compte rendu l'y porte.
|
||||
|
||||
Aucune date d'entrée ni de sortie n'est extraite : le document où le nom
|
||||
figure est le seul rattachement au temps dont on dispose.
|
||||
"""
|
||||
_, lot = donnees
|
||||
DatabaseService(db_session).save_document(
|
||||
data={
|
||||
**sample_data,
|
||||
"metadata": {
|
||||
**sample_data["metadata"],
|
||||
"document": {
|
||||
"reference": "REF003",
|
||||
"date": "2024-03-15",
|
||||
"type": "COMPTE RENDU DE GESTION",
|
||||
},
|
||||
},
|
||||
"situation_locataires": [
|
||||
{
|
||||
"lot": {"numero": "01", "type": "Appartement"},
|
||||
"locataire": {"nom": "MARTIN"},
|
||||
"lignes": [
|
||||
{
|
||||
"type": "loyer",
|
||||
"periode": {"debut": "2024-03-01", "fin": "2024-03-31"},
|
||||
"loyers": 500.0,
|
||||
"total": 500.0,
|
||||
"regles": 500.0,
|
||||
"impayes": 0.0,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
identite = api_client.get(f"/api/lots/{lot.id}/analyse?mois=1").json()["identite"]
|
||||
|
||||
assert identite["locataires"] == ["MARTIN"]
|
||||
# DUPONT n'est pas effacé pour autant : la fiche dit qu'il en manque un.
|
||||
assert identite["locataires_masques"] == 1
|
||||
|
||||
entier = api_client.get(f"/api/lots/{lot.id}/analyse").json()["identite"]
|
||||
assert entier["locataires"] == ["DUPONT", "MARTIN"]
|
||||
assert entier["locataires_masques"] == 0
|
||||
|
||||
|
||||
def test_sans_fenetre_un_locataire_sans_ligne_reste_liste(
|
||||
api_client, db_session, donnees
|
||||
):
|
||||
"""La vue par défaut ne filtre rien, pas même par les lignes rattachées.
|
||||
|
||||
La base porte des locataires dont aucune ligne ne dépend ; les faire
|
||||
disparaître de la fiche entière serait un filtre que personne n'a demandé.
|
||||
"""
|
||||
_, lot = donnees
|
||||
db_session.add(Locataire(lot_id=lot.id, nom="ORPHELIN"))
|
||||
db_session.commit()
|
||||
|
||||
identite = api_client.get(f"/api/lots/{lot.id}/analyse").json()["identite"]
|
||||
|
||||
assert identite["locataires"] == ["DUPONT", "ORPHELIN"]
|
||||
|
||||
|
||||
def test_un_lot_sans_ligne_garde_une_periode_ouverte(api_client, db_session, donnees):
|
||||
"""Rien à quoi caler la fenêtre : la fiche s'ouvre au lieu d'échouer."""
|
||||
immeuble, _ = donnees
|
||||
vide = Lot(immeuble_id=immeuble.id, numero="99")
|
||||
db_session.add(vide)
|
||||
db_session.commit()
|
||||
|
||||
analyse = api_client.get(f"/api/lots/{vide.id}/analyse?mois=3").json()
|
||||
|
||||
assert analyse["periode"]["ancre"] is None
|
||||
assert analyse["periode"]["debut"] is None
|
||||
assert analyse["chronologie"] == []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parc(db_session, sample_data):
|
||||
"""Un compte rendu portant trois lots, dont un sans surface saisie.
|
||||
|
||||
Reference in New Issue
Block a user