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:
2026-08-22 17:26:22 +02:00
parent 2ee8e101b8
commit 005675728d
2 changed files with 539 additions and 43 deletions

View File

@@ -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.