diff --git a/src/plesna_gerance/api/routes/lot_analyse.py b/src/plesna_gerance/api/routes/lot_analyse.py index 1d10528..57d5612 100644 --- a/src/plesna_gerance/api/routes/lot_analyse.py +++ b/src/plesna_gerance/api/routes/lot_analyse.py @@ -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( - 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) + _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), + ) + .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( - Depense.immeuble_id == lot.immeuble_id, Depense.lot_id.is_(None) + _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,14 +809,17 @@ def _intervenants(session: Session, lot: Lot) -> list[Intervenant]: ) rows = session.execute( - select( - Depense.fournisseur, - func.count(Depense.id), - montant_net, - func.max(Document.date), + _borner( + select( + Depense.fournisseur, + func.count(Depense.id), + montant_net, + func.max(Document.date), + ) + .join(Document, Depense.document_id == Document.id) + .where(Depense.lot_id == lot.id, Depense.fournisseur.is_not(None)), + periode, ) - .join(Document, Depense.document_id == Document.id) - .where(Depense.lot_id == lot.id, Depense.fournisseur.is_not(None)) .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, ) diff --git a/tests/test_lot_analyse.py b/tests/test_lot_analyse.py index dbeffde..7b92f8a 100644 --- a/tests/test_lot_analyse.py +++ b/tests/test_lot_analyse.py @@ -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.