Files
pdf_oralia_vibe/tests/test_loyers.py
Bertrand Benjamin b1ff0812c1 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>
2026-08-21 22:38:41 +02:00

254 lines
9.0 KiB
Python

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