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>
387 lines
14 KiB
Python
387 lines
14 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 Immeuble, 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
|
|
|
|
|
|
@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
|