La fiche du lot 15 annonçait « dernier loyer connu, arrêté après févr. 26 » alors qu'un locataire y est entré le 8 juillet — et la courbe juste dessous, où la barre de juillet est bien là, la contredisait. Le loyer en vigueur se lit sur les paliers, et un mois facturé au seul prorata n'en ouvre pas : celui d'avant s'arrête donc à la dernière mensualité pleine, quatre mois avant le nouveau bail. Ne rien lire après lui confond deux états que le compte rendu sépare : un lot qui n'est plus loué, et un lot entre deux locataires. La distinction tient à la frontière qui sert déjà à séparer loyer plein et prorata, la période portée par le compte rendu : - un prorata qui finit le dernier jour du mois sans partir du premier facture la fin du mois : quelqu'un est entré, le bail court encore après ; - un prorata qui part du premier sans l'atteindre facture le début : le bail s'arrête là ; - un montant négatif ne fait ni l'un ni l'autre. Un avoir porte parfois la période exacte de la ligne qu'il annule, et le lire comme une entrée inventerait une relocation là où rien n'a été loué. L'entrée l'emporte sur la sortie parce qu'elle vient après : ce lot voit son locataire partir le 10 mars et le suivant arriver le 8 juillet, et c'est le second qui décrit son état. Le montant affiché ne bouge pas — le nouveau bail n'a été facturé qu'au prorata, et en tirer un loyer mensuel l'inventerait. Seul ce qu'on en dit change : « dernier loyer plein · nouveau bail depuis juil. 26 ». Sans relocation, la sortie date enfin la fin de la location au mois où elle a lieu : « arrêté en cours de mars 26 » plutôt qu'« arrêté après févr. 26 », qui l'avançait d'un mois. Sur le parc : 31 lots loués, 1 relouté, 1 réellement arrêté. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
768 lines
27 KiB
Python
768 lines
27 KiB
Python
"""Tests de la fiche d'un lot.
|
|
|
|
Cette page restitue un lot tel que les comptes rendus le portent. Les tests
|
|
protègent donc ce qui la rendrait fausse ou trompeuse : cumuler un report de
|
|
solde, ventiler des charges d'immeuble qu'aucun document n'attribue, ou
|
|
regrouper des lignes que le compte rendu a émises séparément.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from plesna_gerance.database.models import (
|
|
Document,
|
|
Immeuble,
|
|
Locataire,
|
|
Lot,
|
|
LotCaracteristiques,
|
|
)
|
|
from plesna_gerance.database.service import DatabaseService
|
|
|
|
|
|
@pytest.fixture
|
|
def donnees(db_session, sample_data):
|
|
"""Deux comptes rendus sur un lot : un loyer réglé, puis un report impayé.
|
|
|
|
Le second document facture un loyer resté impayé et reporte le solde du
|
|
premier — la configuration exacte où un cumul naïf compterait deux fois la
|
|
même dette.
|
|
"""
|
|
service = DatabaseService(db_session)
|
|
service.save_document(data=sample_data)
|
|
|
|
suivant = {
|
|
**sample_data,
|
|
"metadata": {
|
|
**sample_data["metadata"],
|
|
"document": {
|
|
"reference": "REF002",
|
|
"date": "2024-02-15",
|
|
"type": "COMPTE RENDU DE GESTION",
|
|
},
|
|
},
|
|
"situation_locataires": [
|
|
{
|
|
"lot": {"numero": "01", "type": "Appartement"},
|
|
"locataire": {"nom": "DUPONT"},
|
|
"lignes": [
|
|
{
|
|
"type": "solde_anterieur",
|
|
"total": 300.0,
|
|
"regles": 0.0,
|
|
"impayes": 300.0,
|
|
},
|
|
{
|
|
"type": "loyer",
|
|
"periode": {"debut": "2024-02-01", "fin": "2024-02-29"},
|
|
"loyers": 500.0,
|
|
"total": 500.0,
|
|
"regles": 200.0,
|
|
"impayes": 300.0,
|
|
},
|
|
],
|
|
}
|
|
],
|
|
"recapitulatif_operations": [
|
|
{
|
|
"categorie": "DEPENSES_NON_RECUPERABLES",
|
|
"sous_categorie": "Travaux divers",
|
|
"fournisseur": "PLOMBERIE",
|
|
"description": "S01 ACOMPTE 40% remplacement chaudière",
|
|
# Le parser déduit ce numéro du préfixe de la description ; la
|
|
# fixture le fournit tel qu'il arrive en base.
|
|
"lot_numero": "01",
|
|
"montants": {"debit": 400.0, "deductible": 400.0},
|
|
},
|
|
{
|
|
"categorie": "DEPENSES_NON_RECUPERABLES",
|
|
"sous_categorie": "Travaux divers",
|
|
"fournisseur": "PLOMBERIE",
|
|
"description": "S01 SOLDE remplacement chaudière",
|
|
"lot_numero": "01",
|
|
"montants": {"debit": 600.0, "deductible": 600.0},
|
|
},
|
|
{
|
|
"categorie": "HONORAIRES_DE_GESTION",
|
|
"sous_categorie": "Frais d'expert",
|
|
"fournisseur": "EXPERTISE",
|
|
"description": "S01 - Remise état des lieux sortie",
|
|
"lot_numero": "01",
|
|
# Un avoir : la ligne rend au propriétaire au lieu de lui coûter.
|
|
"montants": {"debit": 0.0, "credit": 35.4},
|
|
},
|
|
],
|
|
}
|
|
service.save_document(data=suivant)
|
|
|
|
immeuble = db_session.query(Immeuble).filter(Immeuble.code == "IMM1").one()
|
|
lot = db_session.query(Lot).filter(Lot.immeuble_id == immeuble.id).one()
|
|
return immeuble, lot
|
|
|
|
|
|
def test_lot_inconnu_donne_404(api_client, donnees):
|
|
assert api_client.get("/api/lots/999999/analyse").status_code == 404
|
|
|
|
|
|
def test_identite_montre_les_trous_de_la_fiche(api_client, donnees):
|
|
"""Une caractéristique non saisie reste nulle : la page doit le montrer."""
|
|
_, lot = donnees
|
|
|
|
identite = api_client.get(f"/api/lots/{lot.id}/analyse").json()["identite"]
|
|
|
|
assert identite["numero"] == "01"
|
|
assert identite["immeuble_code"] == "IMM1"
|
|
assert identite["type_effectif"] == "Appartement"
|
|
assert identite["surface"] is None
|
|
assert identite["dpe_classe"] is None
|
|
assert identite["locataires"] == ["DUPONT"]
|
|
|
|
|
|
def test_le_report_de_solde_ne_gonfle_pas_le_facture(api_client, donnees):
|
|
"""Le facturé ne retient que les loyers, jamais la dette reportée.
|
|
|
|
Deux loyers de 500 € : cumuler en plus le report de 300 € afficherait 1300 €
|
|
facturés pour un lot qui n'a jamais rien facturé de tel.
|
|
"""
|
|
_, lot = donnees
|
|
|
|
chiffres = api_client.get(f"/api/lots/{lot.id}/analyse").json()["chiffres"]
|
|
|
|
assert chiffres["facture"] == 1000.0
|
|
assert chiffres["encaisse"] == 700.0
|
|
#: Photo du dernier compte rendu (300 de report + 300 de loyer), pas un cumul.
|
|
assert chiffres["restant_du"] == 600.0
|
|
assert chiffres["taux_recouvrement"] == 70.0
|
|
|
|
|
|
def test_les_charges_d_immeuble_restent_hors_du_lot(api_client, donnees):
|
|
"""Le nettoyage de l'immeuble ne doit pas atterrir dans un lot.
|
|
|
|
Aucune donnée ne dit quelle part revient à quel lot : la ventiler
|
|
inventerait des montants. Elle est exposée à part, comme contexte.
|
|
"""
|
|
_, lot = donnees
|
|
|
|
chiffres = api_client.get(f"/api/lots/{lot.id}/analyse").json()["chiffres"]
|
|
|
|
# 400 + 600 de travaux imputés au lot, sans le nettoyage de l'immeuble.
|
|
assert chiffres["depenses_debit"] == 1000.0
|
|
assert chiffres["depenses_credit"] == 35.4
|
|
assert chiffres["nb_operations"] == 3
|
|
# Le nettoyage du premier compte rendu, resté sans lot.
|
|
assert chiffres["depenses_immeuble_non_reparties"] == 50.0
|
|
# Encaissé (700) moins décaissé (1000), avoir rendu (35,40) : charges
|
|
# communes exclues.
|
|
assert chiffres["solde"] == -264.6
|
|
|
|
|
|
def test_la_chronologie_garde_les_lignes_telles_qu_extraites(api_client, donnees):
|
|
"""Acompte et solde restent deux lignes : le compte rendu les porte ainsi."""
|
|
_, lot = donnees
|
|
|
|
chronologie = api_client.get(f"/api/lots/{lot.id}/analyse").json()["chronologie"]
|
|
|
|
travaux = [ligne for ligne in chronologie if ligne["fournisseur"] == "PLOMBERIE"]
|
|
assert len(travaux) == 2
|
|
assert {ligne["montant"] for ligne in travaux} == {400.0, 600.0}
|
|
|
|
|
|
def test_la_chronologie_est_ordonnee_et_signale_les_reports(api_client, donnees):
|
|
"""L'ordre des comptes rendus est l'ordre de lecture de la page."""
|
|
_, lot = donnees
|
|
|
|
chronologie = api_client.get(f"/api/lots/{lot.id}/analyse").json()["chronologie"]
|
|
|
|
dates = [ligne["date"] for ligne in chronologie]
|
|
assert dates == sorted(dates)
|
|
assert dates[0] == "2024-01-15"
|
|
|
|
reports = [ligne for ligne in chronologie if ligne["est_report"]]
|
|
assert len(reports) == 1
|
|
assert reports[0]["montant"] == 300.0
|
|
|
|
|
|
def test_les_intervenants_agregent_le_fournisseur_du_compte_rendu(api_client, donnees):
|
|
"""Une entreprise, ses interventions et son montant — rien de déduit."""
|
|
_, lot = donnees
|
|
|
|
intervenants = api_client.get(f"/api/lots/{lot.id}/analyse").json()["intervenants"]
|
|
|
|
par_nom = {ligne["fournisseur"]: ligne for ligne in intervenants}
|
|
assert set(par_nom) == {"PLOMBERIE", "EXPERTISE"}
|
|
assert par_nom["PLOMBERIE"]["nb_interventions"] == 2
|
|
assert par_nom["PLOMBERIE"]["montant"] == 1000.0
|
|
assert par_nom["PLOMBERIE"]["derniere_date"] == "2024-02-15"
|
|
|
|
|
|
def test_un_avoir_rend_le_montant_de_l_intervenant_negatif(api_client, donnees):
|
|
"""Le crédit est déduit : une remise ne doit pas s'afficher comme un coût."""
|
|
_, lot = donnees
|
|
|
|
intervenants = api_client.get(f"/api/lots/{lot.id}/analyse").json()["intervenants"]
|
|
|
|
expertise = next(
|
|
ligne for ligne in intervenants if ligne["fournisseur"] == "EXPERTISE"
|
|
)
|
|
assert expertise["montant"] == -35.4
|
|
# La plus engagée en tête : un avoir se classe donc en dernier.
|
|
assert intervenants[-1]["fournisseur"] == "EXPERTISE"
|
|
|
|
|
|
def test_le_total_d_un_intervenant_est_celui_de_ses_lignes(api_client, donnees):
|
|
"""Invariant du dépliage : le détail doit retrouver le total affiché.
|
|
|
|
Les deux chiffres viennent de calculs séparés (agrégat SQL d'un côté, lignes
|
|
de la chronologie de l'autre) ; les laisser diverger ferait mentir la ligne
|
|
qu'on vient d'ouvrir.
|
|
"""
|
|
_, lot = donnees
|
|
|
|
analyse = api_client.get(f"/api/lots/{lot.id}/analyse").json()
|
|
|
|
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_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
|
|
|
|
|
|
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.
|
|
|
|
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
|
|
|
|
|
|
@pytest.fixture
|
|
def bail_qui_change(db_session, sample_data):
|
|
"""Un lot dont le bail s'arrête en cours de mois, puis reprend au suivant.
|
|
|
|
C'est la chronologie du lot 15 du parc : deux mois pleins, une sortie le
|
|
10 mars, quatre mois vides, puis une entrée le 8 juillet facturée au seul
|
|
prorata. Le dernier compte rendu ne porte donc aucun loyer plein, alors que
|
|
le lot est bel et bien reloué.
|
|
"""
|
|
|
|
def compte_rendu(reference, date_cr, lignes):
|
|
return {
|
|
**sample_data,
|
|
"metadata": {
|
|
**sample_data["metadata"],
|
|
"document": {
|
|
"reference": reference,
|
|
"date": date_cr,
|
|
"type": "COMPTE RENDU DE GESTION",
|
|
},
|
|
},
|
|
"situation_locataires": [
|
|
{
|
|
"lot": {"numero": "01", "type": "Appartement"},
|
|
"locataire": {"nom": lignes["nom"]},
|
|
"lignes": [
|
|
{
|
|
"type": "loyer",
|
|
"periode": {
|
|
"debut": lignes["debut"],
|
|
"fin": lignes["fin"],
|
|
},
|
|
"loyers": lignes["loyers"],
|
|
"total": lignes["loyers"],
|
|
"regles": lignes["loyers"],
|
|
"impayes": 0.0,
|
|
}
|
|
],
|
|
}
|
|
],
|
|
"recapitulatif_operations": [],
|
|
}
|
|
|
|
service = DatabaseService(db_session)
|
|
for reference, date_cr, lignes in [
|
|
(
|
|
"CR01",
|
|
"2026-01-26",
|
|
{
|
|
"nom": "SORTANT",
|
|
"debut": "2026-01-01",
|
|
"fin": "2026-01-31",
|
|
"loyers": 1447.05,
|
|
},
|
|
),
|
|
(
|
|
"CR02",
|
|
"2026-02-26",
|
|
{
|
|
"nom": "SORTANT",
|
|
"debut": "2026-02-01",
|
|
"fin": "2026-02-28",
|
|
"loyers": 1447.05,
|
|
},
|
|
),
|
|
(
|
|
"CR03",
|
|
"2026-03-23",
|
|
{
|
|
"nom": "SORTANT",
|
|
"debut": "2026-03-01",
|
|
"fin": "2026-03-10",
|
|
"loyers": 482.35,
|
|
},
|
|
),
|
|
(
|
|
"CR04",
|
|
"2026-07-28",
|
|
{
|
|
"nom": "ENTRANT",
|
|
"debut": "2026-07-08",
|
|
"fin": "2026-07-31",
|
|
"loyers": 1111.67,
|
|
},
|
|
),
|
|
]:
|
|
service.save_document(data=compte_rendu(reference, date_cr, lignes))
|
|
|
|
immeuble = db_session.query(Immeuble).filter(Immeuble.code == "IMM1").one()
|
|
lot = db_session.query(Lot).filter(Lot.immeuble_id == immeuble.id).one()
|
|
return lot
|
|
|
|
|
|
def test_un_lot_reloue_au_prorata_n_est_pas_un_lot_arrete(api_client, bail_qui_change):
|
|
"""Le palier s'arrête en février, le lot est reloué en juillet.
|
|
|
|
Sans cette lecture, la fiche annonce « arrêté après févr. » sur un lot qui
|
|
vient de retrouver un locataire — et la courbe juste dessous, où la barre
|
|
de juillet est bien là, la contredit.
|
|
"""
|
|
vigueur = api_client.get(f"/api/lots/{bail_qui_change.id}/analyse").json()["loyer"][
|
|
"en_vigueur"
|
|
]
|
|
|
|
assert vigueur["reloue_depuis"] == "2026-07"
|
|
assert vigueur["sortie_en"] is None
|
|
# Le montant reste celui du bail précédent : le nouveau n'a été facturé
|
|
# qu'au prorata, et en tirer un loyer mensuel l'inventerait.
|
|
assert vigueur["mois"] == "2026-02"
|
|
assert vigueur["loyer"] == 1447.05
|
|
assert vigueur["toujours_loue"] is False
|
|
|
|
|
|
def test_une_sortie_en_cours_de_mois_deborde_le_dernier_palier(
|
|
api_client, db_session, bail_qui_change
|
|
):
|
|
"""Sans relocation, c'est la sortie qui date la fin de la location.
|
|
|
|
Le dernier loyer plein est celui de février, mais le lot est resté loué
|
|
jusqu'au 10 mars : dire « arrêté après févr. » avancerait la sortie d'un
|
|
mois.
|
|
"""
|
|
dernier = (
|
|
db_session.query(Document).order_by(Document.date.desc()).first()
|
|
)
|
|
db_session.delete(dernier)
|
|
db_session.commit()
|
|
|
|
vigueur = api_client.get(f"/api/lots/{bail_qui_change.id}/analyse").json()["loyer"][
|
|
"en_vigueur"
|
|
]
|
|
|
|
assert vigueur["sortie_en"] == "2026-03"
|
|
assert vigueur["reloue_depuis"] is None
|
|
|
|
|
|
def test_un_loyer_qui_s_arrete_net_reste_un_loyer_arrete(api_client, donnees):
|
|
"""Aucun prorata après le dernier palier : rien ne nuance l'arrêt."""
|
|
_, lot = donnees
|
|
|
|
vigueur = api_client.get(f"/api/lots/{lot.id}/analyse").json()["loyer"][
|
|
"en_vigueur"
|
|
]
|
|
|
|
assert vigueur["reloue_depuis"] is None
|
|
assert vigueur["sortie_en"] is None
|