feat: met le loyer d'un lot sur un axe de temps et le rapporte au mètre carré
La fiche d'un lot cumulait tout son historique en un chiffre. Une révision de loyer, une vacance ou un décrochage y étaient donc invisibles. Le bloc `loyer` remet les lignes sur un axe de temps, dont l'unité est le mois loué et non le mois du compte rendu : un rappel de mars facturé en avril décrit mars. Trois formes de lignes cohabitent sous le même `type_ligne = "loyer"`, et les confondre fausse la courbe : - le loyer d'un mois, cas courant ; - le loyer d'un trimestre, forme réelle des baux commerciaux du parc, réparti sur les mois qu'il couvre — sans quoi deux mois sur trois paraîtraient vides alors que le local est loué ; - le prorata d'entrée, de sortie ou l'avoir, rattaché à son mois mais compté à part. Les additionner ferait passer un mois de changement de locataire pour un mois à loyer effondré. Un mois sans ligne reste vide plutôt qu'à zéro : zéro dirait « loué gratuitement », ce qu'aucun compte rendu ne dit. Un mois facturé seulement au prorata est signalé comme transition, pour ne pas se confondre avec une vacance. Le mètre carré vient de la fiche saisie, qu'aucun PDF ne porte : tant qu'elle manque, le ratio reste nul et la page renvoie vers la saisie. Les médianes qui situent le lot rejouent exactement la même répartition pour les autres lots — les calculer autrement ne voudrait rien dire — et s'accompagnent toujours de leur effectif et du nombre de lots exclus faute de surface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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),
|
||||
)
|
||||
|
||||
384
src/plesna_gerance/services/loyers.py
Normal file
384
src/plesna_gerance/services/loyers.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user