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

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