diff --git a/src/plesna_gerance/api/routes/lot_analyse.py b/src/plesna_gerance/api/routes/lot_analyse.py index 7380fea..1d10528 100644 --- a/src/plesna_gerance/api/routes/lot_analyse.py +++ b/src/plesna_gerance/api/routes/lot_analyse.py @@ -14,6 +14,16 @@ Deux limites sont assumées plutôt que contournées : - **les lignes sont rendues telles qu'extraites**, sans regroupement ni dédoublonnage. Un acompte et son solde restent deux lignes, parce que le compte rendu les porte ainsi. + +Le bloc `loyer` fait exception à ce cumul : il remet le loyer sur un axe de +temps, seule façon de voir une révision, une vacance ou un décrochage que les +totaux écrasent. Ses règles vivent dans `services.loyers`, et la comparaison au +parc les rejoue à l'identique pour les autres lots — situer un chiffre par +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. """ from datetime import date @@ -33,6 +43,16 @@ from ...database.models import ( Revenu, Tag, ) +from ...services.loyers import ( + EFFECTIF_MEDIANE_FIABLE, + loyer_au_m2, + mediane, + mois_de, + paliers, + parc_du_mois, + serie_du_lot, + variation, +) from ...services.referentiel import type_effectif from ...services.revenus_query import ( TYPE_LIGNE_REPORT, @@ -135,11 +155,118 @@ class Intervenant(BaseModel): derniere_date: date | None = None +class PointLoyer(BaseModel): + """Un mois de la courbe du loyer.""" + + mois: str # "2026-06" + #: Loyer hors charges du mois plein, `None` si aucune ligne ne le couvre. + loyer: float | None = None + charges: float | None = None + #: Facturé au prorata ce mois-là (entrée, sortie, avoir), hors du loyer. + prorata: float | None = None + #: Loyer au m², `None` sans surface saisie. + loyer_m2: float | None = None + #: Le loyer vient d'une ligne pluri-mensuelle répartie (bail trimestriel). + reparti: bool = False + #: Mois facturé seulement au prorata : un changement de locataire, pas une + #: vacance. Sans cette distinction, les deux se ressemblent sur la courbe. + en_transition: bool = False + + +class LigneHorsCourbe(BaseModel): + """Une ligne de loyer qu'aucun mois ne peut revendiquer. + + Régularisation rétroactive chevauchant plusieurs mois. Rendue à part pour + que la somme de la courbe et de ces lignes retrouve le facturé total. + """ + + periode_debut: date | None = None + periode_fin: date | None = None + montant: float = 0.0 + + +class LoyerEnVigueur(BaseModel): + """Le dernier loyer connu, et depuis quand il tient.""" + + mois: str + loyer: float + loyer_m2: float | None = None + #: Premier mois du palier courant : la date d'effet de la dernière révision. + depuis: str + #: Loyer d'avant la dernière révision, et l'écart en %. `None` si le lot + #: n'a connu qu'un seul niveau depuis le premier compte rendu. + precedent: float | None = None + variation_pct: float | None = None + #: Le dernier compte rendu de l'immeuble porte encore ce loyer. Faux pour + #: un lot dont le bail s'est arrêté : son dernier loyer est une archive, et + #: l'afficher comme courant ferait croire à une recette qui n'existe plus. + toujours_loue: bool = True + + +class PointParc(BaseModel): + """Un lot du parc, placé par sa surface et son loyer au m².""" + + lot_id: int + numero: str + immeuble_code: str | None = None + type_lot: str | None = None + surface: float + loyer_m2: float + #: Le lot dont on regarde la fiche. Il figure dans le nuage — s'y voir situé + #: est tout l'objet — mais reste hors des médianes, qu'il tirerait vers lui. + est_ce_lot: bool = False + + +class ComparaisonParc(BaseModel): + """Le loyer au m² du lot situé face aux lots comparables. + + Comparé sur le dernier mois loué du lot, et sur ce seul mois : rapprocher + un loyer de 2026 de loyers de 2024 mesurerait l'inflation autant que + l'écart entre deux biens. + + Deux lectures cohabitent, et la seconde corrige la première : les médianes + résument le parc en un chiffre, mais mélangent toutes les surfaces ; le + nuage garde la surface en abscisse, seule façon de voir si un lot est cher + *pour sa taille*. + """ + + mois: str + loyer_m2: float | None = None + + mediane_immeuble: float | None = None + nb_immeuble: int = 0 + mediane_type: float | None = None + nb_type: int = 0 + type_compare: str | None = None + + #: Lots loués ce mois-là dont la fiche n'a pas de surface : ils ne peuvent + #: pas être comparés. Compté et affiché, sans quoi une médiane sur huit + #: lots passerait pour une médiane sur tout le parc. + sans_surface: int = 0 + #: Effectif en dessous duquel la médiane décrit surtout le hasard. + effectif_faible: int = EFFECTIF_MEDIANE_FIABLE + + #: Tout le parc comparable, surface comprise, lot courant inclus et + #: signalé. Trié par surface : le nuage se lit de gauche à droite. + nuage: list[PointParc] = [] + + +class LotLoyer(BaseModel): + """Le loyer du lot dans le temps, et ce qu'il vaut au mètre carré.""" + + surface: float | None = None + serie: list[PointLoyer] = [] + hors_courbe: list[LigneHorsCourbe] = [] + en_vigueur: LoyerEnVigueur | None = None + parc: ComparaisonParc | None = None + + class LotAnalyseResponse(BaseModel): """Fiche complète d'un lot.""" identite: LotIdentite chiffres: LotChiffres + loyer: LotLoyer chronologie: list[LigneChronologie] intervenants: list[Intervenant] @@ -233,6 +360,129 @@ def _chiffres(session: Session, lot: Lot) -> LotChiffres: ) +def _comparaison( + session: Session, lot: Lot, mois: str, loyer_m2: float | None +) -> ComparaisonParc: + """Situe le loyer au m² du lot parmi les lots comparables du même mois.""" + parc = parc_du_mois(session, mois) + type_lot = type_effectif(lot) + + meme_immeuble = [ + autre.loyer_m2 + for autre in parc.comparables + if autre.immeuble_id == lot.immeuble_id and autre.lot_id != lot.id + ] + # Le type se compare à travers tout le parc : deux immeubles ne donnent + # jamais assez de T2 pour qu'une médiane par immeuble et par type ait un + # sens. Le lot lui-même est exclu des deux, sans quoi il tirerait vers lui + # la médiane à laquelle on le compare. + meme_type = [ + autre.loyer_m2 + for autre in parc.comparables + if type_lot is not None + and autre.type_lot == type_lot + and autre.lot_id != lot.id + ] + + # Le nuage prend tout le parc, sans filtre d'immeuble ni de type : avec une + # dizaine de fiches renseignées, restreindre le viderait, et c'est la + # surface — portée par l'abscisse — qui rend deux lots comparables. + nuage = [ + PointParc( + lot_id=autre.lot_id, + numero=autre.numero, + immeuble_code=autre.immeuble_code, + type_lot=autre.type_lot, + surface=autre.surface, + loyer_m2=autre.loyer_m2, + est_ce_lot=autre.lot_id == lot.id, + ) + for autre in sorted(parc.comparables, key=lambda autre: autre.surface) + ] + + return ComparaisonParc( + mois=mois, + loyer_m2=loyer_m2, + mediane_immeuble=mediane(meme_immeuble), + nb_immeuble=len(meme_immeuble), + mediane_type=mediane(meme_type), + nb_type=len(meme_type), + type_compare=type_lot, + sans_surface=parc.sans_surface, + nuage=nuage, + ) + + +def _loyer(session: Session, lot: Lot) -> 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. + """ + fiche = lot.caracteristiques + surface = fiche.surface if fiche else None + + serie = serie_du_lot(session, lot.id) + + points = [ + PointLoyer( + mois=point.mois, + loyer=point.loyer, + charges=point.charges, + prorata=point.prorata, + loyer_m2=loyer_au_m2(point.loyer, surface), + reparti=point.reparti, + en_transition=point.en_transition, + ) + for point in serie.mois + ] + + hors_courbe = [ + LigneHorsCourbe( + periode_debut=ligne.periode_debut, + periode_fin=ligne.periode_fin, + montant=round(ligne.loyers, 2), + ) + for ligne in serie.ecartees + ] + + niveaux = paliers(serie.mois) + en_vigueur = None + parc = None + + if niveaux: + courant = niveaux[-1] + precedent = niveaux[-2].loyer if len(niveaux) > 1 else None + + # Le dernier compte rendu de l'immeuble donne l'actualité : un lot dont + # le loyer s'arrête avant lui n'est plus loué. + dernier_cr = session.execute( + select(func.max(Document.date)).where( + Document.immeuble_id == lot.immeuble_id + ) + ).scalar() + + en_vigueur = LoyerEnVigueur( + mois=courant.mois_fin, + loyer=courant.loyer, + loyer_m2=loyer_au_m2(courant.loyer, surface), + depuis=courant.mois_debut, + precedent=precedent, + variation_pct=variation(precedent, courant.loyer), + toujours_loue=dernier_cr is None or courant.mois_fin >= mois_de(dernier_cr), + ) + parc = _comparaison(session, lot, courant.mois_fin, en_vigueur.loyer_m2) + + return LotLoyer( + surface=surface, + serie=points, + hors_courbe=hors_courbe, + en_vigueur=en_vigueur, + parc=parc, + ) + + def _libelle_recette(revenu: Revenu) -> str: """Ce que la ligne de recette dit d'elle-même. @@ -358,6 +608,7 @@ async def analyser_lot( return LotAnalyseResponse( identite=_identite(session, lot), chiffres=_chiffres(session, lot), + loyer=_loyer(session, lot), chronologie=_chronologie(session, lot), intervenants=_intervenants(session, lot), ) diff --git a/src/plesna_gerance/services/loyers.py b/src/plesna_gerance/services/loyers.py new file mode 100644 index 0000000..e231d2e --- /dev/null +++ b/src/plesna_gerance/services/loyers.py @@ -0,0 +1,384 @@ +"""Le loyer d'un lot mois par mois, et ce qu'il vaut au mètre carré. + +Le reste de la fiche d'un lot cumule tout l'historique en un chiffre. Ce module +fait l'inverse : il remet le loyer sur un axe de temps, seule façon de voir une +révision, une vacance ou un décrochage. + +**L'axe est le mois loué, pas le mois du compte rendu.** Un loyer de mars +facturé en mars et un rappel de mars facturé en avril ne décrivent pas le même +mois ; classer par date de document mettrait le second sur avril et ferait un +faux creux suivi d'un faux pic. + +Trois formes de lignes cohabitent dans ``revenus`` sous le même +``type_ligne = "loyer"``, et les confondre fausse la courbe : + +- le **loyer d'un mois** (01/03 → 31/03), cas courant ; +- le **loyer d'un trimestre** (01/10 → 31/12), forme réelle des baux + commerciaux du parc. Le laisser sur son seul mois de début creuserait deux + mois sur trois ; il est donc réparti à parts égales sur les mois couverts. + Ce n'est pas une clé de répartition inventée : la période est portée par le + compte rendu, et diviser un montant par le nombre de mois qu'il couvre ne + suppose rien de plus que ce que la ligne dit déjà ; +- le **prorata** (22/06 → 30/06 à l'entrée d'un locataire, 18/10 → 18/10 pour + un avoir), qui ne vaut pas un mois plein. Il est rattaché à son mois mais + compté à part du loyer : additionner les deux ferait passer un mois de + changement de locataire pour un mois à loyer effondré, et un mois annulé par + avoir pour un mois où le loyer aurait baissé. + +La frontière tient à la période : commencer un premier de mois et finir un +dernier de mois, c'est couvrir des mois entiers, donc porter un loyer. Tout le +reste est un prorata, rattaché à son mois quand il y tient — et écarté quand il +chevauche plusieurs mois sans les couvrir (14/03 → 31/08, régularisation), car +aucun mois ne peut alors le revendiquer. + +Un mois sans loyer plein mais avec un prorata n'est donc pas une vacance : c'est +un mois de transition, et la fiche le distingue au lieu de le confondre avec un +trou. + +Le mètre carré vient de la fiche saisie (`lot_caracteristiques.surface`), pas +des comptes rendus qui l'ignorent. Sans surface, le ratio vaut ``None`` et non +zéro : la page montre le trou plutôt que d'afficher un loyer au m² de 0 €. +""" + +from calendar import monthrange +from dataclasses import dataclass, field +from datetime import date + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from ..database.models import Immeuble, Lot, LotCaracteristiques, Revenu +from ..services.referentiel import TYPE_LOT_EFFECTIF, joindre_fiche + +#: Type des lignes portant un loyer. Les rappels et les régularisations +#: (`rappel_loyer`, `divers`) décrivent des rattrapages, pas le loyer d'un mois : +#: la chronologie de la fiche les montre déjà, la courbe les laisse dehors. +TYPE_LIGNE_LOYER = "loyer" + +#: Sous ce nombre de lots comparables, une médiane décrit surtout le hasard. +#: Elle reste calculée et affichée avec son effectif — le lecteur tranche. +EFFECTIF_MEDIANE_FIABLE = 3 + + +def mois_de(jour: date) -> str: + """Le mois d'une date, au format ``2026-01``.""" + return f"{jour.year:04d}-{jour.month:02d}" + + +def mois_suivant(mois: str) -> str: + """Le mois d'après, même format.""" + annee, numero = (int(part) for part in mois.split("-")) + return f"{annee + 1:04d}-01" if numero == 12 else f"{annee:04d}-{numero + 1:02d}" + + +def mois_entiers(debut: date | None, fin: date | None) -> list[str]: + """Les mois entiers que couvre la période, vide si elle en couvre aucun. + + Une période part du premier jour d'un mois et s'arrête au dernier jour d'un + mois : elle couvre alors ces mois-là, un ou plusieurs. Sinon, c'est un + prorata, et aucun mois ne lui appartient entièrement. + """ + if debut is None or fin is None or fin < debut: + return [] + if debut.day != 1 or fin.day != monthrange(fin.year, fin.month)[1]: + return [] + + mois, dernier = mois_de(debut), mois_de(fin) + couverts = [mois] + while mois != dernier: + mois = mois_suivant(mois) + couverts.append(mois) + return couverts + + +@dataclass +class MoisLoue: + """Ce qu'un mois a été facturé, charges et proratas à part.""" + + mois: str + #: Loyer hors charges pour un mois plein. `None` quand aucune ligne ne + #: couvre le mois entier : le lot est vacant, sorti de la gestion, en + #: changement de locataire, ou son compte rendu manque. Zéro dirait « loué + #: gratuitement », ce qu'aucun document ne dit. + loyer: float | None = None + #: Provisions sur charges appelées avec le loyer plein. + charges: float | None = None + #: Facturé au prorata sur ce mois : entrée ou sortie en cours de mois, + #: avoir. `None` s'il n'y en a pas. Tenu hors du loyer, qui doit rester + #: comparable d'un mois à l'autre. + prorata: float | None = None + #: Vrai quand le loyer vient d'une ligne pluri-mensuelle répartie. + reparti: bool = False + + @property + def en_transition(self) -> bool: + """Mois sans loyer plein mais facturé au prorata : pas une vacance.""" + return self.loyer is None and self.prorata is not None + + +@dataclass +class LigneEcartee: + """Une ligne de loyer qu'aucun mois ne peut revendiquer. + + Elle chevauche plusieurs mois sans en couvrir aucun entièrement : une + régularisation rétroactive. La rendre telle quelle laisse le lecteur la + rapprocher de la chronologie, où elle figure aussi. + """ + + periode_debut: date | None + periode_fin: date | None + loyers: float + charges: float + + +@dataclass +class Serie: + """La suite des mois loués d'un lot, et ce qui n'a pas pu y entrer.""" + + mois: list[MoisLoue] = field(default_factory=list) + ecartees: list[LigneEcartee] = field(default_factory=list) + + +def repartir(lignes) -> Serie: + """Range des lignes de loyer sur l'axe des mois. + + Args: + lignes: itérable de ``(periode_debut, periode_fin, loyers, provisions)`` + + Returns: + La série des mois observés, du plus ancien au plus récent, trous + compris ; et les lignes écartées faute de couvrir des mois entiers. + """ + cumul: dict[str, MoisLoue] = {} + ecartees: list[LigneEcartee] = [] + + for debut, fin, loyers, provisions in lignes: + couverts = mois_entiers(debut, fin) + + if not couverts: + # Un prorata tient dans un mois ; au-delà, c'est une régularisation + # rétroactive qu'aucun mois ne peut recevoir. + if debut is not None and fin is not None and mois_de(debut) == mois_de(fin): + point = cumul.setdefault(mois_de(debut), MoisLoue(mois=mois_de(debut))) + point.prorata = (point.prorata or 0.0) + (loyers or 0.0) + else: + ecartees.append( + LigneEcartee( + periode_debut=debut, + periode_fin=fin, + loyers=loyers or 0.0, + charges=provisions or 0.0, + ) + ) + continue + + part_loyer = (loyers or 0.0) / len(couverts) + part_charges = (provisions or 0.0) / len(couverts) + + for mois in couverts: + point = cumul.setdefault(mois, MoisLoue(mois=mois)) + point.loyer = (point.loyer or 0.0) + part_loyer + point.charges = (point.charges or 0.0) + part_charges + point.reparti = point.reparti or len(couverts) > 1 + + if not cumul: + return Serie(ecartees=ecartees) + + # Les mois sans aucune ligne restent dans la suite, à `None` : un trou dans + # la courbe se voit, un mois absent de l'axe passerait inaperçu. + serie: list[MoisLoue] = [] + mois, dernier = min(cumul), max(cumul) + while True: + point = cumul.get(mois, MoisLoue(mois=mois)) + if point.loyer is not None: + point.loyer = round(point.loyer, 2) + point.charges = round(point.charges or 0.0, 2) + if point.prorata is not None: + point.prorata = round(point.prorata, 2) + serie.append(point) + if mois == dernier: + break + mois = mois_suivant(mois) + + return Serie(mois=serie, ecartees=ecartees) + + +@dataclass +class Palier: + """Une période pendant laquelle le loyer n'a pas bougé.""" + + mois_debut: str + mois_fin: str + loyer: float + + +def paliers(serie: list[MoisLoue]) -> list[Palier]: + """Découpe la série aux changements de loyer. + + Un mois sans loyer plein ferme le palier en cours : reprendre au même + montant après une vacance, c'est un nouveau bail qui se trouve tomber au + même prix, pas un loyer qui n'aurait jamais bougé. + + Le centime tranche l'égalité — les montants viennent de divisions par le + nombre de mois d'un trimestre, où un tiers d'euro ne retombe pas juste. + """ + trouves: list[Palier] = [] + + for point in serie: + if point.loyer is None: + continue + en_cours = trouves[-1] if trouves else None + continu = ( + en_cours is not None + and abs(en_cours.loyer - point.loyer) < 0.01 + and mois_suivant(en_cours.mois_fin) == point.mois + ) + if continu: + en_cours.mois_fin = point.mois + else: + trouves.append( + Palier(mois_debut=point.mois, mois_fin=point.mois, loyer=point.loyer) + ) + + return trouves + + +def variation(depuis: float | None, vers: float | None) -> float | None: + """Écart en pourcentage entre deux loyers, `None` si l'un manque.""" + if not depuis or vers is None: + return None + return round((vers - depuis) / abs(depuis) * 100, 2) + + +def loyer_au_m2(loyer: float | None, surface: float | None) -> float | None: + """Loyer mensuel hors charges rapporté au mètre carré. + + `None` dès qu'un des deux manque : la fiche du lot n'est pas saisie, ou le + mois n'a pas de loyer. Une surface nulle ou négative est traitée comme + absente — elle ne peut venir que d'une saisie fautive. + """ + if loyer is None or surface is None or surface <= 0: + return None + return round(loyer / surface, 2) + + +def mediane(valeurs: list[float]) -> float | None: + """Médiane d'un échantillon, `None` s'il est vide. + + Médiane et non moyenne : sur trente lots, un local commercial suffit à + tirer une moyenne loin de ce que paye un appartement. + """ + if not valeurs: + return None + ordonnees = sorted(valeurs) + milieu = len(ordonnees) // 2 + if len(ordonnees) % 2: + return round(ordonnees[milieu], 2) + return round((ordonnees[milieu - 1] + ordonnees[milieu]) / 2, 2) + + +def _lignes_de_loyer(session: Session, lot_id: int | None = None): + """Les lignes de loyer, par lot : période et montants. + + Sans `lot_id`, tout le parc en une requête — la comparaison a besoin des + trente lots à la fois, et les interroger un par un ferait trente allers. + """ + stmt = select( + Revenu.lot_id, + Revenu.periode_debut, + Revenu.periode_fin, + Revenu.loyers, + Revenu.provisions, + ).where(Revenu.type_ligne == TYPE_LIGNE_LOYER) + if lot_id is not None: + stmt = stmt.where(Revenu.lot_id == lot_id) + + par_lot: dict[int, list] = {} + for ligne_lot, debut, fin, loyers, provisions in session.execute(stmt): + par_lot.setdefault(ligne_lot, []).append((debut, fin, loyers, provisions)) + return par_lot + + +def serie_du_lot(session: Session, lot_id: int) -> Serie: + """Les mois loués d'un lot, du premier au dernier connu.""" + return repartir(_lignes_de_loyer(session, lot_id).get(lot_id, [])) + + +@dataclass +class LotComparable: + """Un lot du parc ramené à son loyer au m² sur un mois donné. + + Porte sa surface autant que son ratio : le loyer au m² décroît fortement + avec la taille du logement — sur le parc, un studio de 21 m² se loue près du + double au m² d'un T3 de 106 m². Une comparaison qui perd la surface compare + donc des choses qui n'ont pas à l'être. + """ + + lot_id: int + immeuble_id: int + immeuble_code: str | None + numero: str + type_lot: str | None + surface: float + loyer_m2: float + + +@dataclass +class Parc: + """Ce que le parc permet de comparer, et ce qu'il ne permet pas.""" + + comparables: list[LotComparable] = field(default_factory=list) + #: Lots loués ce mois-là mais dont la fiche n'a pas de surface. Ils ne + #: peuvent pas entrer dans une comparaison au m² ; les taire ferait passer + #: une médiane sur trois lots pour une médiane sur tout le parc. + sans_surface: int = 0 + + +def parc_du_mois(session: Session, mois: str) -> Parc: + """Loyer au m² de chaque lot loué le mois donné. + + Passe par la même répartition que la fiche d'un lot : une médiane calculée + autrement que les chiffres qu'elle situe ne voudrait rien dire. + """ + lots = session.execute( + joindre_fiche( + select( + Lot.id, + Lot.immeuble_id, + Immeuble.code, + Lot.numero, + TYPE_LOT_EFFECTIF, + LotCaracteristiques.surface, + ).join(Immeuble, Immeuble.id == Lot.immeuble_id) + ) + ).all() + + # Toutes les lignes de loyer du parc en une requête : les répartir lot par + # lot depuis la base ferait une trentaine d'allers pour un seul affichage. + lignes_par_lot = _lignes_de_loyer(session) + + parc = Parc() + for lot_id, immeuble_id, immeuble_code, numero, type_lot, surface in lots: + serie = repartir(lignes_par_lot.get(lot_id, [])) + point = next((p for p in serie.mois if p.mois == mois), None) + if point is None or point.loyer is None or point.loyer <= 0: + continue + + ratio = loyer_au_m2(point.loyer, surface) + if ratio is None: + parc.sans_surface += 1 + continue + + parc.comparables.append( + LotComparable( + lot_id=lot_id, + immeuble_id=immeuble_id, + immeuble_code=immeuble_code, + numero=numero, + type_lot=type_lot, + surface=surface, + loyer_m2=ratio, + ) + ) + + return parc diff --git a/tests/test_lot_analyse.py b/tests/test_lot_analyse.py index 9a476a9..dbeffde 100644 --- a/tests/test_lot_analyse.py +++ b/tests/test_lot_analyse.py @@ -8,7 +8,7 @@ regrouper des lignes que le compte rendu a émises séparément. import pytest -from plesna_gerance.database.models import Immeuble, Lot +from plesna_gerance.database.models import Immeuble, Lot, LotCaracteristiques from plesna_gerance.database.service import DatabaseService @@ -223,3 +223,164 @@ def test_le_total_d_un_intervenant_est_celui_de_ses_lignes(api_client, donnees): round(sum(ligne["montant"] for ligne in lignes), 2) == intervenant["montant"] ) + + +def test_le_loyer_se_lit_mois_par_mois(api_client, donnees): + """Deux comptes rendus, deux mois : la fiche les remet sur un axe de temps.""" + _, 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 [point["loyer"] for point in loyer["serie"]] == [500.0, 500.0] + assert loyer["en_vigueur"]["loyer"] == 500.0 + assert loyer["en_vigueur"]["depuis"] == "2024-01" + # Un seul niveau depuis le premier compte rendu : aucune révision à montrer. + assert loyer["en_vigueur"]["precedent"] is None + + +def test_sans_surface_saisie_le_loyer_au_m2_reste_vide(api_client, donnees): + """Le ratio manquant se voit ; un zéro laisserait croire à un loyer nul.""" + _, lot = donnees + + loyer = api_client.get(f"/api/lots/{lot.id}/analyse").json()["loyer"] + + assert loyer["surface"] is None + assert all(point["loyer_m2"] is None for point in loyer["serie"]) + assert loyer["en_vigueur"]["loyer_m2"] is None + + +def test_la_surface_saisie_allume_le_loyer_au_m2(api_client, db_session, donnees): + """La fiche saisie est la seule source de surface : aucun PDF n'en porte.""" + _, lot = donnees + db_session.add(LotCaracteristiques(lot_id=lot.id, surface=50.0)) + db_session.commit() + + loyer = api_client.get(f"/api/lots/{lot.id}/analyse").json()["loyer"] + + assert loyer["surface"] == 50.0 + assert loyer["en_vigueur"]["loyer_m2"] == 10.0 + + +@pytest.fixture +def parc(db_session, sample_data): + """Un compte rendu portant trois lots, dont un sans surface saisie. + + Se situer suppose des voisins : le nuage n'a de sens qu'à plusieurs. Les + surfaces sont volontairement contrastées (20 m² à 15 €/m², 50 m² à 10 €/m²) + pour reproduire la pente du parc réel, où le petit se loue plus cher au m². + """ + + def locataire(numero, nom, loyer): + return { + "lot": {"numero": numero, "type": "Appartement"}, + "locataire": {"nom": nom}, + "lignes": [ + { + "type": "loyer", + "periode": {"debut": "2024-01-01", "fin": "2024-01-31"}, + "loyers": loyer, + "total": loyer, + "regles": loyer, + "impayes": 0.0, + } + ], + } + + DatabaseService(db_session).save_document( + data={ + **sample_data, + "situation_locataires": [ + locataire("01", "DUPONT", 500.0), + locataire("02", "MARTIN", 300.0), + locataire("03", "DURAND", 700.0), + ], + } + ) + + immeuble = db_session.query(Immeuble).filter(Immeuble.code == "IMM1").one() + lots = { + lot.numero: lot + for lot in db_session.query(Lot).filter(Lot.immeuble_id == immeuble.id) + } + + db_session.add(LotCaracteristiques(lot_id=lots["01"].id, surface=50.0)) + db_session.add(LotCaracteristiques(lot_id=lots["02"].id, surface=20.0)) + # Le lot 03 reste sans fiche : c'est le cas majoritaire en base. + db_session.commit() + return lots + + +def test_le_nuage_situe_le_lot_parmi_ses_voisins(api_client, parc): + """Trié par surface, le lot courant présent et signalé. + + Il figure dans le nuage — s'y voir situé est tout l'objet — alors qu'il est + exclu des médianes, qu'il tirerait vers lui. + """ + nuage = api_client.get(f"/api/lots/{parc['01'].id}/analyse").json()["loyer"][ + "parc" + ]["nuage"] + + assert [(point["surface"], point["loyer_m2"]) for point in nuage] == [ + (20.0, 15.0), + (50.0, 10.0), + ] + assert [point["est_ce_lot"] for point in nuage] == [False, True] + assert nuage[0]["numero"] == "02" + + +def test_un_lot_sans_surface_n_entre_pas_dans_le_nuage(api_client, parc): + """Sans surface, aucune abscisse : le lot ne peut pas être placé. + + Il n'est pas pour autant oublié — `sans_surface` le compte, et la page le + dit sous les médianes. + """ + comparaison = api_client.get(f"/api/lots/{parc['01'].id}/analyse").json()["loyer"][ + "parc" + ] + + assert len(comparaison["nuage"]) == 2 + assert parc["03"].id not in [point["lot_id"] for point in comparaison["nuage"]] + assert comparaison["sans_surface"] == 1 + + +def test_le_nuage_garde_le_lot_courant_meme_seul(api_client, db_session, donnees): + """Seul lot mesuré du parc : le nuage le porte quand même. + + Le vider dans ce cas ferait disparaître le point qu'on cherche justement à + situer, et la page ne dirait plus rien du lot ouvert. + """ + _, lot = donnees + db_session.add(LotCaracteristiques(lot_id=lot.id, surface=50.0)) + db_session.commit() + + nuage = api_client.get(f"/api/lots/{lot.id}/analyse").json()["loyer"]["parc"][ + "nuage" + ] + + assert len(nuage) == 1 + assert nuage[0]["est_ce_lot"] is True + + +def test_la_comparaison_compte_les_lots_qu_elle_ne_peut_pas_voir( + api_client, db_session, donnees +): + """Un lot sans surface ne peut pas entrer dans une médiane au m². + + Taire ces lots ferait passer une médiane sur une poignée de lots pour une + médiane sur tout le parc — c'est le chiffre, et non son effectif, qui + tromperait. + """ + _, lot = donnees + db_session.add(LotCaracteristiques(lot_id=lot.id, surface=50.0)) + db_session.commit() + + parc = api_client.get(f"/api/lots/{lot.id}/analyse").json()["loyer"]["parc"] + + assert parc["mois"] == "2024-02" + assert parc["loyer_m2"] == 10.0 + # Seul lot de la base : rien à quoi le comparer, et la médiane ne se + # rabat pas sur lui-même. + assert parc["mediane_immeuble"] is None + assert parc["nb_immeuble"] == 0 + assert parc["sans_surface"] == 0 diff --git a/tests/test_loyers.py b/tests/test_loyers.py new file mode 100644 index 0000000..7d80304 --- /dev/null +++ b/tests/test_loyers.py @@ -0,0 +1,253 @@ +"""Tests de la mise en temps du loyer. + +Ce que ces tests protègent, c'est la lecture d'une courbe : un mois vacant, un +mois de changement de locataire et un mois à loyer plein doivent rester trois +choses différentes. Les cas ne sont pas inventés — ils viennent tous du parc +réel (bail commercial trimestriel, relocation en cours de mois, avoir annulant +un loyer, régularisation rétroactive à cheval sur six mois). +""" + +from datetime import date + +import pytest + +from plesna_gerance.services.loyers import ( + loyer_au_m2, + mediane, + mois_entiers, + mois_suivant, + paliers, + repartir, + variation, +) + + +def ligne(debut: str, fin: str, loyers: float, provisions: float = 0.0): + """Une ligne de loyer telle que la base la porte.""" + return (date.fromisoformat(debut), date.fromisoformat(fin), loyers, provisions) + + +class TestMoisEntiers: + """Ce qui fait qu'une période porte un loyer plutôt qu'un prorata.""" + + def test_un_mois_plein_couvre_son_mois(self): + assert mois_entiers(date(2026, 3, 1), date(2026, 3, 31)) == ["2026-03"] + + def test_fevrier_se_termine_le_28_ou_le_29(self): + assert mois_entiers(date(2025, 2, 1), date(2025, 2, 28)) == ["2025-02"] + assert mois_entiers(date(2024, 2, 1), date(2024, 2, 29)) == ["2024-02"] + + def test_un_trimestre_couvre_ses_trois_mois(self): + assert mois_entiers(date(2025, 10, 1), date(2025, 12, 31)) == [ + "2025-10", + "2025-11", + "2025-12", + ] + + def test_une_periode_partielle_ne_couvre_aucun_mois(self): + assert mois_entiers(date(2024, 6, 22), date(2024, 6, 30)) == [] + assert mois_entiers(date(2025, 3, 14), date(2025, 8, 31)) == [] + + def test_une_periode_absente_ou_inversee_ne_couvre_rien(self): + assert mois_entiers(None, date(2026, 3, 31)) == [] + assert mois_entiers(date(2026, 3, 31), date(2026, 3, 1)) == [] + + def test_le_passage_a_l_annee_suivante(self): + assert mois_suivant("2025-12") == "2026-01" + + +class TestRepartition: + """Comment les lignes se rangent sur l'axe des mois.""" + + def test_un_loyer_mensuel_va_dans_son_mois(self): + serie = repartir([ligne("2026-03-01", "2026-03-31", 640.0, 31.0)]) + + assert [point.mois for point in serie.mois] == ["2026-03"] + assert serie.mois[0].loyer == 640.0 + assert serie.mois[0].charges == 31.0 + assert serie.mois[0].reparti is False + + def test_un_bail_trimestriel_se_repartit_sur_ses_mois(self): + """Sans répartition, deux mois sur trois d'un bail commercial seraient + montrés vides alors que le local est loué.""" + serie = repartir([ligne("2025-10-01", "2025-12-31", 3963.27)]) + + assert [point.mois for point in serie.mois] == [ + "2025-10", + "2025-11", + "2025-12", + ] + assert [point.loyer for point in serie.mois] == [1321.09, 1321.09, 1321.09] + assert all(point.reparti for point in serie.mois) + + def test_un_mois_sans_ligne_reste_vide_et_non_a_zero(self): + """Un loyer à zéro se lirait comme un logement prêté ; le trou dit la + vacance, qui est ce que les comptes rendus montrent.""" + serie = repartir( + [ + ligne("2024-01-01", "2024-01-31", 850.0), + ligne("2024-04-01", "2024-04-30", 900.0), + ] + ) + + assert [point.mois for point in serie.mois] == [ + "2024-01", + "2024-02", + "2024-03", + "2024-04", + ] + assert [point.loyer for point in serie.mois] == [850.0, None, None, 900.0] + + def test_un_prorata_se_range_a_part_du_loyer(self): + """Le locataire entre le 22 : le mois est facturé, mais pas au prix + d'un mois plein. Additionner les deux inventerait une baisse de loyer.""" + serie = repartir([ligne("2024-06-22", "2024-06-30", 417.0)]) + + point = serie.mois[0] + assert point.mois == "2024-06" + assert point.loyer is None + assert point.prorata == 417.0 + assert point.en_transition is True + + def test_un_mois_de_transition_n_est_pas_une_vacance(self): + """Sortie le 3, entrée le 14 : le mois est loué deux fois en morceaux. + Le montrer vide le confondrait avec un mois sans locataire.""" + serie = repartir( + [ + ligne("2025-03-01", "2025-03-03", 109.68), + ligne("2025-03-14", "2025-03-31", 622.2), + ] + ) + + point = serie.mois[0] + assert point.loyer is None + assert point.prorata == pytest.approx(731.88) + assert point.en_transition is True + + def test_un_avoir_ne_deforme_pas_le_loyer_du_mois(self): + """Le loyer d'octobre est annulé par un avoir. Le loyer contractuel + reste 1390 ; l'avoir se lit à côté, sans creuser la courbe.""" + serie = repartir( + [ + ligne("2024-10-01", "2024-10-31", 1390.0), + ligne("2024-10-18", "2024-10-18", -1390.0), + ] + ) + + point = serie.mois[0] + assert point.loyer == 1390.0 + assert point.prorata == -1390.0 + assert point.en_transition is False + + def test_une_regularisation_a_cheval_reste_hors_de_la_courbe(self): + """De mars à août sans couvrir un mois entier : l'étaler inventerait + six demi-mois de loyer.""" + serie = repartir( + [ + ligne("2025-09-01", "2025-09-30", 1023.0), + ligne("2025-03-14", "2025-08-31", -418.55), + ] + ) + + assert [point.mois for point in serie.mois] == ["2025-09"] + assert len(serie.ecartees) == 1 + assert serie.ecartees[0].loyers == -418.55 + + def test_sans_aucune_ligne_la_serie_est_vide(self): + assert repartir([]).mois == [] + + +class TestPaliers: + """Les révisions de loyer, lues dans la suite des mois.""" + + def test_un_loyer_stable_ne_fait_qu_un_palier(self): + serie = repartir( + [ + ligne("2026-01-01", "2026-01-31", 900.0), + ligne("2026-02-01", "2026-02-28", 900.0), + ligne("2026-03-01", "2026-03-31", 900.0), + ] + ) + + niveaux = paliers(serie.mois) + assert len(niveaux) == 1 + assert (niveaux[0].mois_debut, niveaux[0].mois_fin) == ("2026-01", "2026-03") + + def test_une_revision_ouvre_un_palier(self): + serie = repartir( + [ + ligne("2025-12-01", "2025-12-31", 900.0), + ligne("2026-01-01", "2026-01-31", 907.85), + ligne("2026-02-01", "2026-02-28", 907.85), + ] + ) + + niveaux = paliers(serie.mois) + assert [n.loyer for n in niveaux] == [900.0, 907.85] + assert niveaux[-1].mois_debut == "2026-01" + + def test_une_vacance_coupe_le_palier_meme_a_loyer_egal(self): + """Relouer au même prix après trois mois vides, c'est un nouveau bail : + dire que le loyer « n'a pas bougé depuis 2024 » serait faux.""" + serie = repartir( + [ + ligne("2024-01-01", "2024-01-31", 850.0), + ligne("2024-05-01", "2024-05-31", 850.0), + ] + ) + + niveaux = paliers(serie.mois) + assert len(niveaux) == 2 + assert niveaux[-1].mois_debut == "2024-05" + + def test_les_centimes_d_un_trimestre_ne_creent_pas_de_faux_paliers(self): + """3963,27 divisé par trois ne retombe pas juste ; l'égalité se juge au + centime, sinon chaque mois ouvrirait son propre palier.""" + serie = repartir( + [ + ligne("2025-01-01", "2025-03-31", 3963.27), + ligne("2025-04-01", "2025-06-30", 3963.27), + ] + ) + + assert len(paliers(serie.mois)) == 1 + + +class TestLoyerAuM2: + """Le ratio, et ce qu'il refuse de calculer.""" + + def test_le_ratio_se_calcule_hors_charges(self): + assert loyer_au_m2(640.0, 47.84) == 13.38 + + def test_sans_surface_il_n_y_a_pas_de_ratio(self): + """Zéro serait un loyer au m² nul ; `None` est un trou à combler.""" + assert loyer_au_m2(640.0, None) is None + + def test_une_surface_absurde_vaut_une_surface_absente(self): + assert loyer_au_m2(640.0, 0) is None + assert loyer_au_m2(640.0, -10) is None + + def test_un_mois_sans_loyer_n_a_pas_de_ratio(self): + assert loyer_au_m2(None, 47.84) is None + + +class TestMedianeEtVariation: + """Les deux agrégats qui situent un lot.""" + + def test_la_mediane_resiste_a_un_local_commercial(self): + """Une moyenne serait tirée par la valeur extrême ; c'est tout + l'intérêt de la médiane sur un parc de trente lots.""" + assert mediane([11.0, 12.0, 13.0, 14.0, 90.0]) == 13.0 + + def test_la_mediane_d_un_effectif_pair_prend_le_milieu(self): + assert mediane([10.0, 12.0, 14.0, 16.0]) == 13.0 + + def test_sans_lot_comparable_il_n_y_a_pas_de_mediane(self): + assert mediane([]) is None + + def test_la_variation_se_lit_en_pourcentage(self): + assert variation(1023.0, 1031.06) == 0.79 + + def test_une_variation_sans_reference_n_existe_pas(self): + assert variation(None, 900.0) is None + assert variation(0.0, 900.0) is None