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:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
148
src/plesna_gerance/api/routes/referentiel.py
Normal file
148
src/plesna_gerance/api/routes/referentiel.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
34
src/plesna_gerance/services/referentiel.py
Normal file
34
src/plesna_gerance/services/referentiel.py
Normal 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
|
||||
88
src/plesna_gerance/utils/logements.py
Normal file
88
src/plesna_gerance/utils/logements.py
Normal 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
57
tests/test_logements.py
Normal 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("", "")
|
||||
205
tests/test_referentiel_api.py
Normal file
205
tests/test_referentiel_api.py
Normal 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
|
||||
Reference in New Issue
Block a user