feat: décrit les logements dans un référentiel saisi à la main

Les lots n'étaient connus que par l'extraction PDF : un numéro, un type
souvent vide, et rien sur le bien lui-même. Cette table de caractéristiques
(surface, étage, bâtiment, chauffage, DPE, rapprochement impôts) donne au
référentiel une source de vérité indépendante des comptes rendus.

Table séparée de `lots` à dessein : une ré-extraction ne peut alors pas
écraser la saisie, et le désaccord sur le type de lot reste visible au lieu
d'être arbitré en silence. La fiche gagne, le PDF comble les trous.

Échéance du DPE et écart de surface ne sont pas stockés mais calculés : une
colonne dérivée finirait par mentir après une correction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 18:00:42 +02:00
parent ac63922896
commit 5a08b0c7e5
13 changed files with 720 additions and 31 deletions

57
tests/test_logements.py Normal file
View File

@@ -0,0 +1,57 @@
"""Tests des valeurs derivees des caracteristiques d'un logement.
Echeance de DPE et ecart de surface ne sont pas stockes : ils se recalculent a
chaque lecture. Ces tests figent les regles de calcul, seul endroit ou une
erreur passerait inapercue puisqu'aucune donnee saisie ne la contredirait.
"""
from datetime import date
from plesna_gerance.utils.logements import (
delta_surface,
dpe_echeance,
type_en_ecart,
)
def test_un_dpe_vaut_dix_ans():
assert dpe_echeance(date(2021, 2, 8)) == date(2031, 2, 8)
def test_un_dpe_du_29_fevrier_expire_le_28():
"""2020 est bissextile, 2030 non : la date doit reculer, pas exploser."""
assert dpe_echeance(date(2020, 2, 29)) == date(2030, 2, 28)
def test_sans_date_de_dpe_pas_d_echeance():
assert dpe_echeance(None) is None
def test_ecart_de_surface_positif_quand_les_impots_en_retiennent_plus():
assert delta_surface(100.0, 119.0) == 19.0
def test_ecart_de_surface_negatif_quand_la_mesure_est_plus_grande():
assert delta_surface(148.0, 146.0) == -2.0
def test_une_seule_surface_ne_donne_aucun_ecart():
"""Un ecart de 0 affirmerait que les deux sources concordent."""
assert delta_surface(148.0, None) is None
assert delta_surface(None, 146.0) is None
def test_meme_type_ecrit_autrement_n_est_pas_un_ecart():
assert not type_en_ecart("Loc. Commercial", "Loc, Commercial")
assert not type_en_ecart("Appartement T3", "appartement t3")
def test_deux_types_differents_sont_un_ecart():
assert type_en_ecart("Appartement T3", "Appartement T2")
def test_un_type_absent_n_est_pas_un_ecart():
"""Le PDF laisse souvent le type vide : ce n'est pas une contradiction."""
assert not type_en_ecart(None, "Appartement T3")
assert not type_en_ecart("Appartement T3", None)
assert not type_en_ecart("", "")

View File

@@ -0,0 +1,205 @@
"""Tests du referentiel des logements.
Ces caracteristiques sont saisies a la main : elles n'ont aucune autre source
que l'utilisateur, donc rien ne les reconstituerait si un enregistrement les
perdait. Les tests portent sur ce qui menace cette saisie : l'ecraser depuis un
PDF, la vider a moitie, ou la laisser contredire l'extraction en silence.
"""
import pytest
from plesna_gerance.database.models import Depense, Immeuble, Lot
from plesna_gerance.database.service import DatabaseService
@pytest.fixture
def immeuble_et_lot(db_session, sample_data):
"""Un immeuble et son lot 01, tels que l'extraction les cree."""
DatabaseService(db_session).save_document(data=sample_data)
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
FICHE = {
"bat": "Rue",
"etage": "RC",
"type": "Loc. Commercial",
"surface": 148.0,
"surface_date_diag": "2019-06-01",
"chauffage": "Electrique",
"dpe_classe": "C",
"dpe_date_realisation": "2021-02-08",
"numero_fiscal": "690123456789",
"surface_impots": 146.0,
"note_impots": "Surface relevee sur l'avis 2024",
}
def test_liste_les_lots_sans_fiche(api_client, immeuble_et_lot):
"""Un lot jamais decrit doit apparaitre, sinon il n'est pas saisissable."""
immeuble, lot = immeuble_et_lot
response = api_client.get(f"/api/immeubles/{immeuble.id}/lots/referentiel")
assert response.status_code == 200
lignes = response.json()
assert len(lignes) == 1
assert lignes[0]["id"] == lot.id
assert lignes[0]["numero"] == "01"
assert lignes[0]["caracteristiques"] is None
assert lignes[0]["type_extrait"] == "Appartement"
assert lignes[0]["type_effectif"] == "Appartement"
assert lignes[0]["type_ecart"] is False
def test_liste_un_immeuble_inconnu_donne_404(api_client):
assert api_client.get("/api/immeubles/99999/lots/referentiel").status_code == 404
def test_enregistre_puis_relit_une_fiche(api_client, immeuble_et_lot):
immeuble, lot = immeuble_et_lot
response = api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE)
assert response.status_code == 200
fiche = response.json()["caracteristiques"]
assert fiche["surface"] == 148.0
assert fiche["etage"] == "RC"
assert fiche["numero_fiscal"] == "690123456789"
relu = api_client.get(f"/api/immeubles/{immeuble.id}/lots/referentiel").json()[0]
assert relu["caracteristiques"]["surface"] == 148.0
assert relu["caracteristiques"]["dpe_classe"] == "C"
def test_expose_les_valeurs_derivees(api_client, immeuble_et_lot):
"""L'echeance du DPE et l'ecart de surface arrivent calcules."""
_, lot = immeuble_et_lot
fiche = api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE).json()[
"caracteristiques"
]
assert fiche["dpe_echeance"] == "2031-02-08"
assert fiche["delta_surface"] == -2.0
def test_une_seconde_ecriture_met_a_jour_sans_dupliquer(api_client, immeuble_et_lot):
immeuble, lot = immeuble_et_lot
api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE)
api_client.put(
f"/api/lots/{lot.id}/caracteristiques", json={**FICHE, "surface": 150.0}
)
lignes = api_client.get(f"/api/immeubles/{immeuble.id}/lots/referentiel").json()
assert len(lignes) == 1
assert lignes[0]["caracteristiques"]["surface"] == 150.0
def test_un_champ_vide_efface_la_valeur(api_client, immeuble_et_lot):
"""Corriger une erreur de saisie doit pouvoir revenir a « inconnu »."""
_, lot = immeuble_et_lot
api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE)
fiche = api_client.put(
f"/api/lots/{lot.id}/caracteristiques",
json={**FICHE, "etage": " ", "surface": None},
).json()["caracteristiques"]
assert fiche["etage"] is None
assert fiche["surface"] is None
assert fiche["delta_surface"] is None
def test_refuse_une_classe_dpe_inconnue(api_client, immeuble_et_lot):
_, lot = immeuble_et_lot
response = api_client.put(
f"/api/lots/{lot.id}/caracteristiques", json={**FICHE, "dpe_classe": "Z"}
)
assert response.status_code == 422
def test_refuse_une_surface_negative(api_client, immeuble_et_lot):
_, lot = immeuble_et_lot
response = api_client.put(
f"/api/lots/{lot.id}/caracteristiques", json={**FICHE, "surface": -10}
)
assert response.status_code == 422
def test_ecrire_sur_un_lot_inconnu_donne_404(api_client):
assert (
api_client.put("/api/lots/99999/caracteristiques", json=FICHE).status_code
== 404
)
def test_signale_un_desaccord_de_type_sans_effacer_le_pdf(api_client, immeuble_et_lot):
"""Les deux types restent lisibles : la fiche tranche, le PDF reste visible."""
_, lot = immeuble_et_lot
ligne = api_client.put(
f"/api/lots/{lot.id}/caracteristiques", json={**FICHE, "type": "Appartement T3"}
).json()
assert ligne["type_extrait"] == "Appartement"
assert ligne["type_effectif"] == "Appartement T3"
assert ligne["type_ecart"] is True
def test_le_type_saisi_prime_dans_la_liste_des_lots(api_client, immeuble_et_lot):
"""La priorite du referentiel vaut partout, pas seulement sur sa page."""
_, lot = immeuble_et_lot
api_client.put(
f"/api/lots/{lot.id}/caracteristiques", json={**FICHE, "type": "Appartement T3"}
)
lots = api_client.get("/api/lots").json()
assert [ligne["type"] for ligne in lots] == ["Appartement T3"]
def test_une_re_extraction_ne_touche_pas_la_fiche(
api_client, immeuble_et_lot, db_session, sample_data
):
"""Le point critique : reimporter le PDF ne doit rien perdre de la saisie."""
immeuble, lot = immeuble_et_lot
api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE)
DatabaseService(db_session).save_document(data=sample_data, overwrite=True)
lignes = api_client.get(f"/api/immeubles/{immeuble.id}/lots/referentiel").json()
fiche = next(ligne for ligne in lignes if ligne["id"] == lot.id)["caracteristiques"]
assert fiche is not None, "la re-extraction a perdu la fiche du lot"
assert fiche["surface"] == 148.0
def test_compte_les_rattachements_du_lot(api_client, immeuble_et_lot):
"""Un lot avec des revenus n'est pas un orphelin : la liste doit le dire."""
immeuble, lot = immeuble_et_lot
ligne = api_client.get(f"/api/immeubles/{immeuble.id}/lots/referentiel").json()[0]
assert ligne["nb_revenus"] == 1
# La depense de la fixture porte sur l'immeuble (lot_id NULL). La compter
# ici rendrait tout lot de l'immeuble faussement non supprimable.
assert ligne["nb_depenses"] == 0
def test_compte_les_depenses_propres_au_lot(api_client, immeuble_et_lot, db_session):
"""Une depense rattachee au lot, elle, doit bien remonter sur sa ligne."""
immeuble, lot = immeuble_et_lot
depense = db_session.query(Depense).one()
depense.lot_id = lot.id
db_session.commit()
ligne = api_client.get(f"/api/immeubles/{immeuble.id}/lots/referentiel").json()[0]
assert ligne["nb_depenses"] == 1