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

@@ -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