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

@@ -18,6 +18,7 @@ from .routes import (
documents_router,
extraction_router,
ia_router,
referentiel_router,
revenus_router,
tags_router,
)
@@ -52,6 +53,7 @@ app.include_router(tags_router)
app.include_router(analytics_router)
app.include_router(dashboard_router)
app.include_router(revenus_router)
app.include_router(referentiel_router)
if FEATURE_IA:
app.include_router(ia_router)
app.include_router(config_router)

View File

@@ -6,6 +6,7 @@ from .dashboard import router as dashboard_router
from .documents import router as documents_router
from .extraction import router as extraction_router
from .ia import router as ia_router
from .referentiel import router as referentiel_router
from .revenus import router as revenus_router
from .tags import router as tags_router
@@ -16,6 +17,7 @@ __all__ = [
"analytics_router",
"dashboard_router",
"revenus_router",
"referentiel_router",
"ia_router",
"config_router",
]

View File

@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
from ...database import get_session
from ...database.models import Depense, Document, Immeuble, Lot, Tag
from ...services.referentiel import TYPE_LOT_EFFECTIF, joindre_fiche
from ..schemas.models import (
CategorySummary,
DepenseDetail,
@@ -77,11 +78,15 @@ async def list_lots(
- **immeuble_id**: ID de l'immeuble pour filtrer (optionnel)
"""
stmt = (
select(Lot, Immeuble.code.label("immeuble_code"))
.join(Immeuble, Lot.immeuble_id == Immeuble.id)
.order_by(Immeuble.code, Lot.numero)
)
stmt = joindre_fiche(
select(
Lot.id,
Lot.numero,
Lot.immeuble_id,
TYPE_LOT_EFFECTIF.label("type"),
Immeuble.code.label("immeuble_code"),
).join(Immeuble, Lot.immeuble_id == Immeuble.id)
).order_by(Immeuble.code, Lot.numero)
if immeuble_id is not None:
stmt = stmt.where(Lot.immeuble_id == immeuble_id)
@@ -91,10 +96,10 @@ async def list_lots(
return [
LotResponse(
id=row.Lot.id,
numero=row.Lot.numero,
type=row.Lot.type,
immeuble_id=row.Lot.immeuble_id,
id=row.id,
numero=row.numero,
type=row.type,
immeuble_id=row.immeuble_id,
immeuble_code=row.immeuble_code,
)
for row in rows

View File

@@ -0,0 +1,148 @@
"""Référentiel des logements — caractéristiques saisies à la main.
Ces données ne viennent pas des PDF : elles décrivent le bien (surface, étage,
DPE, chauffage) et donnent au référentiel une source de vérité indépendante de
l'extraction.
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from ...database import get_session
from ...database.models import Depense, Immeuble, Lot, LotCaracteristiques, Revenu
from ...services.referentiel import type_effectif
from ...utils.logements import delta_surface, dpe_echeance, type_en_ecart
from ..schemas.models import (
CaracteristiquesBody,
CaracteristiquesResponse,
LotReferentielResponse,
)
router = APIRouter(prefix="/api", tags=["referentiel"])
#: Champs de la fiche, dans l'ordre de saisie. Sert à recopier le corps de la
#: requête vers le modèle sans énumérer les champs à chaque fois.
CHAMPS_FICHE = tuple(CaracteristiquesBody.model_fields)
def _caracteristiques_response(
fiche: LotCaracteristiques | None,
) -> CaracteristiquesResponse | None:
"""Fiche augmentée de ses valeurs dérivées, ou None si elle n'existe pas."""
if fiche is None:
return None
return CaracteristiquesResponse(
**{champ: getattr(fiche, champ) for champ in CHAMPS_FICHE},
dpe_echeance=dpe_echeance(fiche.dpe_date_realisation),
delta_surface=delta_surface(fiche.surface, fiche.surface_impots),
updated_at=fiche.updated_at,
)
def _lot_response(
lot: Lot,
immeuble_code: str | None,
nb_revenus: int = 0,
nb_depenses: int = 0,
) -> LotReferentielResponse:
"""Assemble la ligne de référentiel d'un lot."""
fiche = lot.caracteristiques
return LotReferentielResponse(
id=lot.id,
numero=lot.numero,
immeuble_id=lot.immeuble_id,
immeuble_code=immeuble_code,
type_extrait=lot.type,
type_effectif=type_effectif(lot),
type_ecart=type_en_ecart(lot.type, fiche.type if fiche else None),
caracteristiques=_caracteristiques_response(fiche),
nb_revenus=nb_revenus,
nb_depenses=nb_depenses,
)
@router.get(
"/immeubles/{immeuble_id}/lots/referentiel",
response_model=list[LotReferentielResponse],
)
async def list_lots_referentiel(
immeuble_id: int,
session: Session = Depends(get_session),
) -> list[LotReferentielResponse]:
"""Liste les lots d'un immeuble avec leur fiche de caractéristiques.
- **immeuble_id**: ID de l'immeuble
Les lots sans fiche sont renvoyés avec `caracteristiques` à `null` : le
tableau de saisie doit montrer les lignes vides autant que les remplies.
"""
immeuble = session.get(Immeuble, immeuble_id)
if immeuble is None:
raise HTTPException(status_code=404, detail="Immeuble introuvable.")
# Sous-requêtes corrélées plutôt que des jointures : compter revenus et
# dépenses dans la même jointure multiplierait les lignes entre elles.
nb_revenus = (
select(func.count(Revenu.id))
.where(Revenu.lot_id == Lot.id)
.correlate(Lot)
.scalar_subquery()
)
nb_depenses = (
select(func.count(Depense.id))
.where(Depense.lot_id == Lot.id)
.correlate(Lot)
.scalar_subquery()
)
stmt = (
select(Lot, nb_revenus.label("nb_revenus"), nb_depenses.label("nb_depenses"))
.where(Lot.immeuble_id == immeuble_id)
.order_by(Lot.numero)
)
return [
_lot_response(
row.Lot,
immeuble.code,
nb_revenus=row.nb_revenus or 0,
nb_depenses=row.nb_depenses or 0,
)
for row in session.execute(stmt).all()
]
@router.put("/lots/{lot_id}/caracteristiques", response_model=LotReferentielResponse)
async def upsert_caracteristiques(
lot_id: int,
body: CaracteristiquesBody,
session: Session = Depends(get_session),
) -> LotReferentielResponse:
"""Enregistre la fiche d'un lot, en la créant si elle n'existe pas encore.
- **lot_id**: ID du lot
Le corps décrit la fiche complète : un champ omis ou vidé efface la valeur
précédente, pour qu'une correction dans le tableau ne laisse pas de reste.
"""
lot = session.get(Lot, lot_id)
if lot is None:
raise HTTPException(status_code=404, detail="Lot introuvable.")
fiche = lot.caracteristiques
if fiche is None:
fiche = LotCaracteristiques(lot_id=lot.id)
session.add(fiche)
lot.caracteristiques = fiche
for champ in CHAMPS_FICHE:
setattr(fiche, champ, getattr(body, champ))
session.commit()
session.refresh(lot)
immeuble = session.get(Immeuble, lot.immeuble_id)
return _lot_response(lot, immeuble.code if immeuble else None)

View File

@@ -15,6 +15,7 @@ from ...database.models import (
Lot,
Revenu,
)
from ...services.referentiel import TYPE_LOT_EFFECTIF, joindre_fiche
from ...services.revenus_query import (
est_flux,
flux_par,
@@ -390,10 +391,11 @@ async def get_revenus_by_lot(
flux = flux_par(Revenu.lot_id)
dette = restant_du_par(Revenu.lot_id)
stmt = (
joindre_fiche(
select(
Lot.id,
Lot.numero,
Lot.type,
TYPE_LOT_EFFECTIF.label("type"),
Immeuble.code,
func.max(Locataire.nom).label("locataire_nom"),
flux.c.facture,
@@ -410,6 +412,7 @@ async def get_revenus_by_lot(
)
.outerjoin(flux, flux.c.cle == Lot.id)
.outerjoin(dette, dette.c.cle == Lot.id)
)
.group_by(Lot.id)
.order_by(desc("restant_du"), desc("facture"))
.limit(limit)

View File

@@ -1,10 +1,13 @@
"""Pydantic schemas for API request/response models."""
from .models import (
CaracteristiquesBody,
CaracteristiquesResponse,
DepenseDetail,
DepensesSummary,
DocumentSummary,
ImmeubleResponse,
LotReferentielResponse,
LotResponse,
PredictTagsRequest,
SaveRequest,
@@ -20,4 +23,7 @@ __all__ = [
"DepensesSummary",
"ImmeubleResponse",
"LotResponse",
"CaracteristiquesBody",
"CaracteristiquesResponse",
"LotReferentielResponse",
]

View File

@@ -1,9 +1,11 @@
"""Pydantic models for API requests and responses."""
from datetime import date
from datetime import date, datetime
from typing import Any
from pydantic import BaseModel
from pydantic import BaseModel, Field, field_validator
from ...utils.logements import DPE_CLASSES
# ============================================================
# Requests
@@ -180,3 +182,82 @@ class DepensesSummary(BaseModel):
by_tag: list[TagSummary]
by_month: list[MonthlySummary]
by_fournisseur: list[FournisseurSummary]
# ============================================================
# Référentiel des logements
# ============================================================
class CaracteristiquesBody(BaseModel):
"""Caractéristiques d'un logement telles que saisies.
Tous les champs sont optionnels : la fiche se remplit progressivement, et
une fiche partielle vaut mieux qu'une fiche refusée.
"""
bat: str | None = None
etage: str | None = None
type: str | None = None
surface: float | None = Field(None, ge=0)
surface_date_diag: date | None = None
chauffage: str | None = None
dpe_classe: str | None = None
dpe_date_realisation: date | None = None
numero_fiscal: str | None = None
surface_impots: float | None = Field(None, ge=0)
note_impots: str | None = None
@field_validator("dpe_classe")
@classmethod
def _classe_connue(cls, value: str | None) -> str | None:
"""Refuse une classe hors A-G, faute de quoi les KPI DPE mentiraient."""
if value is None or value == "":
return None
classe = value.strip().upper()
if classe not in DPE_CLASSES:
raise ValueError(f"Classe DPE inconnue : {value} (attendu A-G)")
return classe
@field_validator(
"bat", "etage", "type", "chauffage", "numero_fiscal", "note_impots"
)
@classmethod
def _texte_vide_vaut_absent(cls, value: str | None) -> str | None:
"""Un champ vide dans le tableau doit effacer la valeur, pas la figer."""
if value is None:
return None
value = value.strip()
return value or None
class CaracteristiquesResponse(CaracteristiquesBody):
"""Caractéristiques saisies, augmentées de leurs valeurs dérivées."""
#: Péremption du DPE, déduite de la date de réalisation (+10 ans).
dpe_echeance: date | None = None
#: Surface impôts moins surface mesurée ; None si une des deux manque.
delta_surface: float | None = None
updated_at: datetime | None = None
class LotReferentielResponse(BaseModel):
"""Un lot et sa fiche, tels qu'affichés dans le tableau du référentiel."""
id: int
numero: str
immeuble_id: int
immeuble_code: str | None
#: Type de lot vu par l'extraction PDF, conservé tel quel.
type_extrait: str | None
#: Type retenu : celui de la fiche s'il existe, sinon celui du PDF.
type_effectif: str | None
#: Vrai quand les deux sources se contredisent (comparaison normalisée).
type_ecart: bool = False
caracteristiques: CaracteristiquesResponse | None = None
#: Rattachements existants : un lot qui en a n'est pas supprimable.
nb_revenus: int = 0
nb_depenses: int = 0

View File

@@ -2,7 +2,17 @@
from . import storage
from .connection import get_engine, get_session, get_session_factory, init_db
from .models import Base, Depense, Document, Immeuble, Locataire, Lot, Revenu, Setting
from .models import (
Base,
Depense,
Document,
Immeuble,
Locataire,
Lot,
LotCaracteristiques,
Revenu,
Setting,
)
from .service import DatabaseService, DuplicateDocumentError
__all__ = [
@@ -14,6 +24,7 @@ __all__ = [
"Document",
"Immeuble",
"Lot",
"LotCaracteristiques",
"Locataire",
"Revenu",
"Depense",

View File

@@ -103,11 +103,58 @@ class Lot(Base):
)
revenus = relationship("Revenu", back_populates="lot")
depenses = relationship("Depense", back_populates="lot")
caracteristiques = relationship(
"LotCaracteristiques",
back_populates="lot",
uselist=False,
cascade="all, delete-orphan",
)
def __repr__(self) -> str:
return f"<Lot(numero={self.numero}, type={self.type})>"
class LotCaracteristiques(Base):
"""Caractéristiques d'un logement, saisies à la main.
Table séparée de `lots` à dessein : `lots` porte ce que l'extraction PDF
sait d'un lot, celle-ci ce que le propriétaire en sait. Une ré-extraction ne
peut donc structurellement pas écraser la saisie, et l'écart entre les deux
sources (le type de lot) reste calculable au lieu d'être perdu.
"""
__tablename__ = "lot_caracteristiques"
id = Column(Integer, primary_key=True, autoincrement=True)
lot_id = Column(Integer, ForeignKey("lots.id"), nullable=False, unique=True)
# Description physique
bat = Column(String(50), nullable=True) # "Rue", "Cour"
etage = Column(String(20), nullable=True) # "RC", "1", "SS", "Combles"
type = Column(String(100), nullable=True) # prioritaire sur Lot.type
surface = Column(Float, nullable=True) # m², mesure Oralia
surface_date_diag = Column(Date, nullable=True)
chauffage = Column(String(100), nullable=True)
# Réglementaire
dpe_classe = Column(String(1), nullable=True) # A..G
dpe_date_realisation = Column(Date, nullable=True) # échéance = +10 ans
# Rapprochement avec les impôts
numero_fiscal = Column(String(50), nullable=True) # clé de recherche impots.gouv
surface_impots = Column(Float, nullable=True) # m² déclarés
note_impots = Column(Text, nullable=True)
created_at = Column(DateTime, default=_utcnow)
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
# Relations
lot = relationship("Lot", back_populates="caracteristiques")
def __repr__(self) -> str:
return f"<LotCaracteristiques(lot_id={self.lot_id}, surface={self.surface})>"
class Locataire(Base):
"""Table des locataires avec historique."""

View File

@@ -0,0 +1,34 @@
"""Arbitrage entre ce que dit le PDF d'un lot et ce que sa fiche en dit.
Le type d'un lot est connu de deux sources : l'extraction PDF, qui le remplit
parfois mal et souvent pas du tout, et la fiche saisie à la main, qui fait foi.
La règle est donc « la fiche gagne, le PDF comble les trous » — et elle vit ici,
en un seul endroit, sous ses deux formes : une expression SQL pour les
agrégats, une fonction Python pour l'ORM. Les faire diverger reviendrait à
afficher deux types différents pour un même lot selon la page consultée.
Le désaccord entre les deux sources n'est jamais résolu en silence : il reste
visible via `type_en_ecart` (voir `utils.logements`).
"""
from sqlalchemy import Select, func
from ..database.models import Lot, LotCaracteristiques
#: Type de lot retenu, en SQL. Requiert la jointure de `joindre_fiche`.
TYPE_LOT_EFFECTIF = func.coalesce(LotCaracteristiques.type, Lot.type)
def joindre_fiche(stmt: Select) -> Select:
"""Ajoute à une requête sur `Lot` la jointure vers sa fiche.
La relation est 1↔1 : la jointure ne multiplie aucune ligne, elle peut donc
s'ajouter à une requête agrégée sans fausser les totaux.
"""
return stmt.outerjoin(LotCaracteristiques, LotCaracteristiques.lot_id == Lot.id)
def type_effectif(lot: Lot) -> str | None:
"""Type de lot retenu, depuis un objet chargé par l'ORM."""
fiche = lot.caracteristiques
return (fiche.type if fiche else None) or lot.type

View File

@@ -0,0 +1,88 @@
"""Valeurs dérivées des caractéristiques d'un logement.
Ni l'échéance du DPE ni l'écart de surface ne sont stockés : ce sont des
conséquences de valeurs saisies, et une colonne dérivée finit toujours par
mentir après une correction. Elles se calculent ici, en un seul endroit, pour
que l'API et les futurs KPI donnent le même résultat.
"""
import re
from datetime import date
#: Durée de validité d'un DPE réalisé après la réforme de 2021 (10 ans).
DPE_VALIDITE_ANNEES = 10
#: Classes possibles d'un DPE, de la plus performante à la moins performante.
DPE_CLASSES = ("A", "B", "C", "D", "E", "F", "G")
def dpe_echeance(date_realisation: date | None) -> date | None:
"""Date de péremption d'un DPE réalisé à `date_realisation`.
Args:
date_realisation: Date de réalisation du diagnostic, ou None
Returns:
Date de fin de validité, ou None si la date de réalisation manque
"""
if date_realisation is None:
return None
annee = date_realisation.year + DPE_VALIDITE_ANNEES
try:
return date_realisation.replace(year=annee)
except ValueError:
# 29 février d'une année bissextile vers une année qui ne l'est pas.
return date(annee, 2, 28)
def delta_surface(surface: float | None, surface_impots: float | None) -> float | None:
"""Écart entre la surface déclarée aux impôts et la surface mesurée.
Signe positif : les impôts retiennent plus de surface que la mesure Oralia.
Args:
surface: Surface mesurée (m²)
surface_impots: Surface déclarée aux impôts (m²)
Returns:
L'écart en m², ou None si une des deux surfaces manque
"""
if surface is None or surface_impots is None:
return None
return round(surface_impots - surface, 2)
def _type_comparable(valeur: str | None) -> str:
"""Écriture normalisée d'un type de lot, pour comparaison seulement."""
if not valeur:
return ""
return re.sub(r"[^a-z0-9]+", " ", valeur.lower()).strip()
def type_en_ecart(type_extrait: str | None, type_saisi: str | None) -> bool:
"""Le PDF et la fiche annoncent-ils deux types de lot différents ?
La comparaison ignore casse et ponctuation : le PDF écrit
"Loc. Commercial" là où une saisie donne "Loc, Commercial" ou
"loc commercial". Signaler ces trois-là comme un désaccord noierait le seul
écart qui compte, celui où les deux sources ne parlent pas du même logement.
Un type absent d'un côté n'est pas un écart : c'est une information qui
manque, pas une contradiction.
Args:
type_extrait: Type de lot vu par l'extraction PDF
type_saisi: Type de lot saisi dans la fiche
Returns:
True si les deux valeurs sont renseignées et se contredisent
"""
extrait = _type_comparable(type_extrait)
saisi = _type_comparable(type_saisi)
if not extrait or not saisi:
return False
return extrait != saisi

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