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:
2026-08-21 22:38:41 +02:00
parent fa54d523a5
commit b1ff0812c1
4 changed files with 1050 additions and 1 deletions

View File

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