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:
@@ -24,12 +24,33 @@ rapport à des chiffres obtenus autrement ne voudrait rien dire.
|
||||
Le loyer au mètre carré vient de la surface saisie sur la fiche du logement.
|
||||
Aucun compte rendu n'en porte : tant qu'elle manque, le ratio reste `null` et la
|
||||
page renvoie vers la saisie plutôt que d'afficher un zéro.
|
||||
|
||||
Une **fenêtre de temps** optionnelle (`mois`) restreint les chiffres, la
|
||||
chronologie, les intervenants, les locataires et la courbe du loyer. Trois
|
||||
règles la gouvernent :
|
||||
|
||||
- **elle est calée sur le dernier compte rendu du lot**, pas sur aujourd'hui.
|
||||
Un lot dont l'extraction s'arrête il y a huit mois afficherait sinon une page
|
||||
vide sur « 3 derniers mois », ce qui se lirait comme une absence d'activité ;
|
||||
- **elle coupe la courbe du loyer, sans la recalculer**. Les paliers, la
|
||||
dernière révision et le mois de comparaison au parc restent lus sur toute la
|
||||
série : « depuis mai 26 » doit désigner la révision qui a fixé ce loyer, pas
|
||||
la borne du filtre, et la médiane du parc ne peut pas changer de mois de
|
||||
référence à chaque changement de période ;
|
||||
- **elle ne s'applique pas au restant dû**, qui est un stock et non un flux :
|
||||
le borner ferait disparaître une dette bien réelle dès qu'aucun compte rendu
|
||||
ne tombe dans la fenêtre (cf. `services.revenus_query`).
|
||||
|
||||
Sans `mois`, tout l'historique est rendu — le défaut ne cache rien. Avec, la
|
||||
réponse porte `periode`, qui dit les bornes retenues et combien de lignes
|
||||
restent dehors : un filtre doit annoncer ce qu'il masque.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
import calendar
|
||||
from datetime import date, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -64,6 +85,26 @@ from ...services.revenus_query import (
|
||||
router = APIRouter(prefix="/api", tags=["lots"])
|
||||
|
||||
|
||||
class Periode(BaseModel):
|
||||
"""La fenêtre de temps réellement appliquée, et ce qu'elle laisse dehors.
|
||||
|
||||
Renvoyée même quand rien n'est filtré : la page affiche ainsi toujours
|
||||
l'étendue de ce qu'elle montre, plutôt que de le laisser deviner.
|
||||
"""
|
||||
|
||||
#: Nombre de mois demandé, `None` pour tout l'historique.
|
||||
mois: int | None = None
|
||||
#: Bornes retenues, incluses. `None` des deux côtés sans filtre.
|
||||
debut: date | None = None
|
||||
fin: date | None = None
|
||||
#: Date du dernier compte rendu portant une ligne de ce lot — l'origine sur
|
||||
#: laquelle la fenêtre est calée. `None` pour un lot sans aucune ligne.
|
||||
ancre: date | None = None
|
||||
#: Lignes de chronologie que la fenêtre écarte. Une fenêtre qui masque
|
||||
#: quarante opérations doit le dire, sans quoi le lot paraîtrait calme.
|
||||
lignes_masquees: int = 0
|
||||
|
||||
|
||||
class LotIdentite(BaseModel):
|
||||
"""Qui est ce lot : son rattachement, et ce que sa fiche en dit."""
|
||||
|
||||
@@ -82,9 +123,14 @@ class LotIdentite(BaseModel):
|
||||
chauffage: str | None = None
|
||||
dpe_classe: str | None = None
|
||||
|
||||
#: Noms portés par les comptes rendus. Les dates d'entrée et de sortie ne
|
||||
#: sont pas extraites : l'ordre n'a pas de sens ici.
|
||||
#: Noms portés par les comptes rendus de la période. Les dates d'entrée et
|
||||
#: de sortie ne sont pas extraites : le seul rattachement au temps dont on
|
||||
#: dispose est le compte rendu où le nom figure. L'ordre n'a pas de sens.
|
||||
locataires: list[str] = []
|
||||
#: Locataires du lot qu'aucun compte rendu de la période ne porte. Comptés
|
||||
#: pour qu'une fiche filtrée n'ait pas l'air de n'avoir jamais eu qu'un
|
||||
#: occupant.
|
||||
locataires_masques: int = 0
|
||||
|
||||
|
||||
class LotChiffres(BaseModel):
|
||||
@@ -252,7 +298,13 @@ class ComparaisonParc(BaseModel):
|
||||
|
||||
|
||||
class LotLoyer(BaseModel):
|
||||
"""Le loyer du lot dans le temps, et ce qu'il vaut au mètre carré."""
|
||||
"""Le loyer du lot dans le temps, et ce qu'il vaut au mètre carré.
|
||||
|
||||
La courbe suit la fenêtre choisie ; `en_vigueur` et `parc` non. Ces deux-là
|
||||
décrivent l'état courant : réduits à la fenêtre, « depuis mai 26 » daterait
|
||||
de la borne du filtre au lieu de la révision qui l'a fixé, et la médiane du
|
||||
parc changerait de mois de référence à chaque changement de période.
|
||||
"""
|
||||
|
||||
surface: float | None = None
|
||||
serie: list[PointLoyer] = []
|
||||
@@ -260,6 +312,13 @@ class LotLoyer(BaseModel):
|
||||
en_vigueur: LoyerEnVigueur | None = None
|
||||
parc: ComparaisonParc | None = None
|
||||
|
||||
#: Premier mois tracé quand une fenêtre est active, `None` sans filtre.
|
||||
depuis_mois: str | None = None
|
||||
#: Mois de loyer antérieurs à la fenêtre : connus, mais hors de la courbe.
|
||||
mois_masques: int = 0
|
||||
#: Lignes hors-courbe antérieures à la fenêtre, également non listées.
|
||||
hors_courbe_masquees: int = 0
|
||||
|
||||
|
||||
class LotAnalyseResponse(BaseModel):
|
||||
"""Fiche complète d'un lot."""
|
||||
@@ -269,16 +328,118 @@ class LotAnalyseResponse(BaseModel):
|
||||
loyer: LotLoyer
|
||||
chronologie: list[LigneChronologie]
|
||||
intervenants: list[Intervenant]
|
||||
#: Fenêtre appliquée aux chiffres, à la chronologie et aux intervenants —
|
||||
#: jamais au loyer, ni au restant dû.
|
||||
periode: Periode = Field(default_factory=Periode)
|
||||
|
||||
|
||||
def _identite(session: Session, lot: Lot) -> LotIdentite:
|
||||
def _recule(reference: date, mois: int) -> date:
|
||||
"""La même date, `mois` mois plus tôt.
|
||||
|
||||
Le 31 mars reculé d'un mois donne le 28 février : un mois calendaire n'a pas
|
||||
de durée fixe, et l'arithmétique en jours ferait dériver la borne.
|
||||
"""
|
||||
total = reference.year * 12 + reference.month - 1 - mois
|
||||
annee, index = divmod(total, 12)
|
||||
jour = min(reference.day, calendar.monthrange(annee, index + 1)[1])
|
||||
return date(annee, index + 1, jour)
|
||||
|
||||
|
||||
def _ancre(session: Session, lot: Lot) -> date | None:
|
||||
"""Date du dernier compte rendu portant une ligne de ce lot.
|
||||
|
||||
Recettes et dépenses comptent toutes deux : un lot sorti de la location
|
||||
peut n'avoir plus que des travaux, et caler la fenêtre sur ses seuls loyers
|
||||
la ferait finir avant ses dernières opérations.
|
||||
"""
|
||||
dernieres = [
|
||||
session.execute(
|
||||
select(func.max(Document.date))
|
||||
.join(table, table.document_id == Document.id)
|
||||
.where(table.lot_id == lot.id)
|
||||
).scalar()
|
||||
for table in (Revenu, Depense)
|
||||
]
|
||||
connues = [date_ for date_ in dernieres if date_ is not None]
|
||||
return max(connues) if connues else None
|
||||
|
||||
|
||||
def _fenetre(session: Session, lot: Lot, mois: int | None) -> Periode:
|
||||
"""Fenêtre demandée, ramenée aux dates que la base peut honorer.
|
||||
|
||||
Sans `mois`, ou sur un lot dont aucun compte rendu ne parle, il n'y a rien
|
||||
à borner : la période reste ouverte plutôt que de se rabattre sur
|
||||
aujourd'hui, qui viderait la fiche sans l'expliquer.
|
||||
"""
|
||||
ancre = _ancre(session, lot)
|
||||
if mois is None or ancre is None:
|
||||
return Periode(mois=mois, ancre=ancre)
|
||||
|
||||
# Le lendemain du même jour `mois` mois plus tôt : trois mois avant le
|
||||
# 15 février commencent le 16 novembre, sinon le compte rendu du 15 novembre
|
||||
# entrerait dans une fenêtre de trois mois et en ferait quatre.
|
||||
debut = _recule(ancre, mois) + timedelta(days=1)
|
||||
return Periode(mois=mois, debut=debut, fin=ancre, ancre=ancre)
|
||||
|
||||
|
||||
def _borner(stmt, periode: Periode):
|
||||
"""Restreint une requête à la fenêtre, sur la date du compte rendu.
|
||||
|
||||
La requête doit déjà joindre `Document` : les dépenses comme les recettes
|
||||
ne portent pas de date propre, elles empruntent celle du document qui les
|
||||
a émises — la seule date qu'un compte rendu garantisse.
|
||||
"""
|
||||
if periode.debut is not None:
|
||||
stmt = stmt.where(Document.date >= periode.debut)
|
||||
if periode.fin is not None:
|
||||
stmt = stmt.where(Document.date <= periode.fin)
|
||||
return stmt
|
||||
|
||||
|
||||
def _locataires(session: Session, lot: Lot, periode: Periode) -> tuple[list[str], int]:
|
||||
"""Occupants du lot sur la période, et nombre de ceux qu'elle écarte.
|
||||
|
||||
Aucune date d'entrée ni de sortie n'est extraite des comptes rendus : le
|
||||
seul rattachement au temps disponible est le document où le nom figure. Un
|
||||
locataire appartient donc à la période si un compte rendu de la fenêtre
|
||||
porte une de ses lignes.
|
||||
|
||||
Sans fenêtre, la liste reste celle de la table : un locataire enregistré
|
||||
dont aucune ligne n'a été rattachée — il en existe en base — disparaîtrait
|
||||
sinon de la vue par défaut, qui ne filtre justement rien.
|
||||
"""
|
||||
tous = list(
|
||||
session.execute(
|
||||
select(Locataire.nom)
|
||||
.where(Locataire.lot_id == lot.id)
|
||||
.order_by(Locataire.nom)
|
||||
).scalars()
|
||||
)
|
||||
if periode.debut is None and periode.fin is None:
|
||||
return tous, 0
|
||||
|
||||
presents = list(
|
||||
session.execute(
|
||||
_borner(
|
||||
select(Locataire.nom)
|
||||
.join(Revenu, Revenu.locataire_id == Locataire.id)
|
||||
.join(Document, Revenu.document_id == Document.id)
|
||||
.where(Locataire.lot_id == lot.id),
|
||||
periode,
|
||||
)
|
||||
.distinct()
|
||||
.order_by(Locataire.nom)
|
||||
).scalars()
|
||||
)
|
||||
return presents, len(tous) - len(presents)
|
||||
|
||||
|
||||
def _identite(session: Session, lot: Lot, periode: Periode) -> LotIdentite:
|
||||
"""Identité du lot, fiche saisie comprise quand elle existe."""
|
||||
immeuble = session.get(Immeuble, lot.immeuble_id)
|
||||
fiche = lot.caracteristiques
|
||||
|
||||
noms = session.execute(
|
||||
select(Locataire.nom).where(Locataire.lot_id == lot.id).order_by(Locataire.nom)
|
||||
).scalars()
|
||||
noms, masques = _locataires(session, lot, periode)
|
||||
|
||||
return LotIdentite(
|
||||
id=lot.id,
|
||||
@@ -292,18 +453,24 @@ def _identite(session: Session, lot: Lot) -> LotIdentite:
|
||||
bat=fiche.bat if fiche else None,
|
||||
chauffage=fiche.chauffage if fiche else None,
|
||||
dpe_classe=fiche.dpe_classe if fiche else None,
|
||||
locataires=list(noms),
|
||||
locataires=noms,
|
||||
locataires_masques=masques,
|
||||
)
|
||||
|
||||
|
||||
def _chiffres(session: Session, lot: Lot) -> LotChiffres:
|
||||
def _chiffres(session: Session, lot: Lot, periode: Periode) -> LotChiffres:
|
||||
"""Totaux du lot, en réutilisant les règles flux/stock des revenus.
|
||||
|
||||
Passer par `flux_par` et `restant_du_par` plutôt que de resommer ici : ces
|
||||
fonctions portent la distinction entre ce qui se cumule et ce qui est une
|
||||
photo, et la rejouer à la main la ferait diverger de la page Recettes.
|
||||
|
||||
La fenêtre borne les flux et rien d'autre. `restant_du` reste lu sur toute
|
||||
l'histoire : c'est la dette au dernier compte rendu, et la borner
|
||||
l'annulerait dès qu'aucun document ne tombe dans la fenêtre — un lot
|
||||
devrait alors 0 € tout en devant 49 000 €.
|
||||
"""
|
||||
flux = flux_par(Revenu.lot_id)
|
||||
flux = flux_par(Revenu.lot_id, periode.debut, periode.fin)
|
||||
dette = restant_du_par(Revenu.lot_id)
|
||||
|
||||
recettes = session.execute(
|
||||
@@ -321,20 +488,31 @@ def _chiffres(session: Session, lot: Lot) -> LotChiffres:
|
||||
).scalar()
|
||||
|
||||
depenses = session.execute(
|
||||
_borner(
|
||||
select(
|
||||
func.coalesce(func.sum(Depense.debit), 0.0),
|
||||
func.coalesce(func.sum(Depense.credit), 0.0),
|
||||
func.coalesce(func.sum(Depense.deductible), 0.0),
|
||||
func.coalesce(func.sum(Depense.locatif), 0.0),
|
||||
func.count(Depense.id),
|
||||
).where(Depense.lot_id == lot.id)
|
||||
)
|
||||
.join(Document, Depense.document_id == Document.id)
|
||||
.where(Depense.lot_id == lot.id),
|
||||
periode,
|
||||
)
|
||||
).one()
|
||||
debit, credit, deductible, locatif, nb_operations = depenses
|
||||
|
||||
# Charges de l'immeuble laissées hors des lots, pour situer le solde.
|
||||
# Charges de l'immeuble laissées hors des lots, pour situer le solde. Elles
|
||||
# suivent la fenêtre : la page les annonce « sur la période ».
|
||||
commun = session.execute(
|
||||
select(func.coalesce(func.sum(Depense.debit), 0.0)).where(
|
||||
_borner(
|
||||
select(func.coalesce(func.sum(Depense.debit), 0.0))
|
||||
.join(Document, Depense.document_id == Document.id)
|
||||
.where(
|
||||
Depense.immeuble_id == lot.immeuble_id, Depense.lot_id.is_(None)
|
||||
),
|
||||
periode,
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
@@ -413,17 +591,49 @@ def _comparaison(
|
||||
)
|
||||
|
||||
|
||||
def _loyer(session: Session, lot: Lot) -> LotLoyer:
|
||||
def _premier_mois(periode: Periode) -> str | None:
|
||||
"""Premier mois que la courbe trace, `None` sans fenêtre.
|
||||
|
||||
Une fenêtre de trois mois finissant en juillet trace mai, juin, juillet :
|
||||
le mois de la borne haute compte pour un. Reculer de `mois` pleins en
|
||||
ajouterait un quatrième, et le graphe démentirait son propre libellé.
|
||||
"""
|
||||
if periode.mois is None or periode.fin is None:
|
||||
return None
|
||||
return mois_de(_recule(periode.fin, periode.mois - 1))
|
||||
|
||||
|
||||
def _mois_de_fin(ligne) -> str | None:
|
||||
"""Mois où s'achève une ligne écartée de la courbe, `None` s'il manque."""
|
||||
borne = ligne.periode_fin or ligne.periode_debut
|
||||
return mois_de(borne) if borne is not None else None
|
||||
|
||||
|
||||
def _loyer(session: Session, lot: Lot, periode: Periode) -> LotLoyer:
|
||||
"""Le loyer du lot mois par mois, son niveau actuel et sa place au m².
|
||||
|
||||
Toute la logique de répartition vit dans `services.loyers` : la comparaison
|
||||
au parc rejoue exactement le même calcul pour les autres lots, sans quoi
|
||||
elle situerait un chiffre par rapport à des chiffres obtenus autrement.
|
||||
|
||||
La courbe est tracée sur la fenêtre, mais **calculée sur tout l'historique**
|
||||
puis coupée : les paliers, la dernière révision et le mois de comparaison au
|
||||
parc sortent de la série entière. Les recalculer sur les seuls mois affichés
|
||||
ferait dater la révision de la borne du filtre.
|
||||
|
||||
Rien n'est coupé après 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é.
|
||||
"""
|
||||
fiche = lot.caracteristiques
|
||||
surface = fiche.surface if fiche else None
|
||||
|
||||
serie = serie_du_lot(session, lot.id)
|
||||
depuis = _premier_mois(periode)
|
||||
|
||||
mois_traces = [
|
||||
point for point in serie.mois if depuis is None or point.mois >= depuis
|
||||
]
|
||||
|
||||
points = [
|
||||
PointLoyer(
|
||||
@@ -435,7 +645,16 @@ def _loyer(session: Session, lot: Lot) -> LotLoyer:
|
||||
reparti=point.reparti,
|
||||
en_transition=point.en_transition,
|
||||
)
|
||||
for point in serie.mois
|
||||
for point in mois_traces
|
||||
]
|
||||
|
||||
# Une régularisation suit la courbe : elle est retenue quand la période
|
||||
# qu'elle couvre atteint la fenêtre. Une ligne de 2024 listée sous une
|
||||
# courbe qui commence en 2026 n'aurait rien à quoi se rapporter.
|
||||
retenues = [
|
||||
ligne
|
||||
for ligne in serie.ecartees
|
||||
if depuis is None or _mois_de_fin(ligne) is None or _mois_de_fin(ligne) >= depuis
|
||||
]
|
||||
|
||||
hors_courbe = [
|
||||
@@ -444,7 +663,7 @@ def _loyer(session: Session, lot: Lot) -> LotLoyer:
|
||||
periode_fin=ligne.periode_fin,
|
||||
montant=round(ligne.loyers, 2),
|
||||
)
|
||||
for ligne in serie.ecartees
|
||||
for ligne in retenues
|
||||
]
|
||||
|
||||
niveaux = paliers(serie.mois)
|
||||
@@ -480,6 +699,9 @@ def _loyer(session: Session, lot: Lot) -> LotLoyer:
|
||||
hors_courbe=hors_courbe,
|
||||
en_vigueur=en_vigueur,
|
||||
parc=parc,
|
||||
depuis_mois=depuis,
|
||||
mois_masques=len(serie.mois) - len(mois_traces),
|
||||
hors_courbe_masquees=len(serie.ecartees) - len(retenues),
|
||||
)
|
||||
|
||||
|
||||
@@ -547,7 +769,28 @@ def _chronologie(session: Session, lot: Lot) -> list[LigneChronologie]:
|
||||
return lignes
|
||||
|
||||
|
||||
def _intervenants(session: Session, lot: Lot) -> list[Intervenant]:
|
||||
def _restreindre(
|
||||
lignes: list[LigneChronologie], periode: Periode
|
||||
) -> tuple[list[LigneChronologie], int]:
|
||||
"""Lignes de la fenêtre, et nombre de celles qu'elle laisse dehors.
|
||||
|
||||
Filtré en Python plutôt qu'en SQL : les lignes sont déjà chargées, un lot
|
||||
en porte quelques dizaines, et c'est ce qui donne le compte des masquées
|
||||
sans requête supplémentaire — ce compte est ce qui rend le filtre honnête.
|
||||
"""
|
||||
if periode.debut is None and periode.fin is None:
|
||||
return lignes, 0
|
||||
|
||||
gardees = [
|
||||
ligne
|
||||
for ligne in lignes
|
||||
if (periode.debut is None or ligne.date >= periode.debut)
|
||||
and (periode.fin is None or ligne.date <= periode.fin)
|
||||
]
|
||||
return gardees, len(lignes) - len(gardees)
|
||||
|
||||
|
||||
def _intervenants(session: Session, lot: Lot, periode: Periode) -> list[Intervenant]:
|
||||
"""Entreprises intervenues sur le lot, la plus engagée en tête.
|
||||
|
||||
Simple regroupement sur le fournisseur porté par chaque opération : rien
|
||||
@@ -566,6 +809,7 @@ def _intervenants(session: Session, lot: Lot) -> list[Intervenant]:
|
||||
)
|
||||
|
||||
rows = session.execute(
|
||||
_borner(
|
||||
select(
|
||||
Depense.fournisseur,
|
||||
func.count(Depense.id),
|
||||
@@ -573,7 +817,9 @@ def _intervenants(session: Session, lot: Lot) -> list[Intervenant]:
|
||||
func.max(Document.date),
|
||||
)
|
||||
.join(Document, Depense.document_id == Document.id)
|
||||
.where(Depense.lot_id == lot.id, Depense.fournisseur.is_not(None))
|
||||
.where(Depense.lot_id == lot.id, Depense.fournisseur.is_not(None)),
|
||||
periode,
|
||||
)
|
||||
.group_by(Depense.fournisseur)
|
||||
.order_by(montant_net.desc())
|
||||
).all()
|
||||
@@ -592,23 +838,41 @@ def _intervenants(session: Session, lot: Lot) -> list[Intervenant]:
|
||||
@router.get("/lots/{lot_id}/analyse", response_model=LotAnalyseResponse)
|
||||
async def analyser_lot(
|
||||
lot_id: int,
|
||||
mois: int | None = Query(
|
||||
None,
|
||||
ge=1,
|
||||
description="Nombre de mois a retenir avant le dernier compte rendu du lot",
|
||||
),
|
||||
session: Session = Depends(get_session),
|
||||
) -> LotAnalyseResponse:
|
||||
"""Tout ce que les comptes rendus portent sur un lot.
|
||||
|
||||
- **lot_id**: ID du lot
|
||||
- **mois**: fenêtre optionnelle, comptée à rebours du dernier compte rendu
|
||||
du lot. Absente, tout l'historique est rendu — c'est le défaut, pour
|
||||
qu'aucun filtre implicite ne cache d'opérations.
|
||||
|
||||
Sans borne de période : l'historique est court et le montrer entier évite
|
||||
qu'un filtre par défaut cache des opérations sans le dire.
|
||||
La fenêtre borne les chiffres, la chronologie, les intervenants, les
|
||||
locataires et la courbe du loyer. Le restant dû, le loyer en vigueur et la
|
||||
comparaison au parc y échappent, pour les raisons données en tête de
|
||||
module. Ce qu'elle écarte est compté : `periode.lignes_masquees` pour la
|
||||
chronologie, `loyer.mois_masques` pour la courbe,
|
||||
`identite.locataires_masques` pour les occupants.
|
||||
"""
|
||||
lot = session.get(Lot, lot_id)
|
||||
if lot is None:
|
||||
raise HTTPException(status_code=404, detail="Lot introuvable.")
|
||||
|
||||
return LotAnalyseResponse(
|
||||
identite=_identite(session, lot),
|
||||
chiffres=_chiffres(session, lot),
|
||||
loyer=_loyer(session, lot),
|
||||
chronologie=_chronologie(session, lot),
|
||||
intervenants=_intervenants(session, lot),
|
||||
periode = _fenetre(session, lot, mois)
|
||||
chronologie, periode.lignes_masquees = _restreindre(
|
||||
_chronologie(session, lot), periode
|
||||
)
|
||||
|
||||
return LotAnalyseResponse(
|
||||
identite=_identite(session, lot, periode),
|
||||
chiffres=_chiffres(session, lot, periode),
|
||||
loyer=_loyer(session, lot, periode),
|
||||
chronologie=chronologie,
|
||||
intervenants=_intervenants(session, lot, periode),
|
||||
periode=periode,
|
||||
)
|
||||
|
||||
@@ -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