Les comptes rendus n'identifient un immeuble que par son code de gestion (« 33689020 »), illisible partout où il s'affiche. La dénomination (« Servient ») le remplace à l'écran sans toucher au code, qui reste la clé venue des PDF. La colonne s'ajoute à une table déjà installée : elle passe donc par le rattrapage de schéma, qui accepte désormais l'absence de valeur de rattrapage. Déduire un nom d'usage du code en inventerait un. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
281 lines
7.2 KiB
Python
281 lines
7.2 KiB
Python
"""Pydantic models for API requests and responses."""
|
|
|
|
from datetime import date, datetime
|
|
from typing import Any
|
|
|
|
from pydantic import BaseModel, Field, field_validator
|
|
|
|
from ...utils.logements import DPE_CLASSES
|
|
|
|
# ============================================================
|
|
# Requests
|
|
# ============================================================
|
|
|
|
|
|
class SaveRequest(BaseModel):
|
|
"""Request body for saving extracted data."""
|
|
|
|
source_file: str | None = None
|
|
data: dict[str, Any]
|
|
depenses_tags: list[dict] | None = None
|
|
overwrite: bool = False
|
|
|
|
|
|
class PredictTagsRequest(BaseModel):
|
|
"""Request body for predicting tags."""
|
|
|
|
depenses: list[dict]
|
|
|
|
|
|
# ============================================================
|
|
# Responses - Documents
|
|
# ============================================================
|
|
|
|
|
|
class SaveResponse(BaseModel):
|
|
"""Response for save operation."""
|
|
|
|
success: bool
|
|
message: str
|
|
document_id: int | None = None
|
|
reference: str | None = None
|
|
date: str | None = None
|
|
|
|
|
|
class DocumentSummary(BaseModel):
|
|
"""Summary of a saved document."""
|
|
|
|
id: int
|
|
reference: str
|
|
date: str
|
|
type: str | None
|
|
source_file: str | None
|
|
immeuble_code: str | None
|
|
immeuble_adresse: str | None
|
|
solde_montant: float | None
|
|
solde_type: str | None
|
|
created_at: str
|
|
#: Derniere extraction ayant produit les donnees (absente sur une base
|
|
#: anterieure a ce champ : l'appelant retombe alors sur `created_at`).
|
|
extracted_at: str | None = None
|
|
has_pdf: bool = False
|
|
has_json: bool = False
|
|
|
|
|
|
# ============================================================
|
|
# Responses - Analytics
|
|
# ============================================================
|
|
|
|
|
|
class ImmeubleResponse(BaseModel):
|
|
"""Response for immeuble listing."""
|
|
|
|
id: int
|
|
code: str
|
|
denomination: str | None = None
|
|
adresse: str | None
|
|
ville: str | None
|
|
code_postal: str | None
|
|
nb_lots: int = 0
|
|
nb_depenses: int = 0
|
|
|
|
|
|
class LotResponse(BaseModel):
|
|
"""Response for lot listing."""
|
|
|
|
id: int
|
|
numero: str
|
|
type: str | None
|
|
immeuble_id: int
|
|
immeuble_code: str | None
|
|
|
|
|
|
class TagResponse(BaseModel):
|
|
"""Response for tag listing."""
|
|
|
|
id: int
|
|
nom: str
|
|
nb_depenses: int = 0
|
|
|
|
|
|
class FournisseurResponse(BaseModel):
|
|
"""Response for fournisseur listing."""
|
|
|
|
nom: str
|
|
nb_depenses: int
|
|
total_debit: float
|
|
|
|
|
|
class DepenseDetail(BaseModel):
|
|
"""Detailed depense for analytics."""
|
|
|
|
id: int
|
|
document_id: int
|
|
document_date: date | None
|
|
document_reference: str | None
|
|
immeuble_id: int
|
|
immeuble_code: str | None
|
|
immeuble_adresse: str | None
|
|
lot_id: int | None
|
|
lot_numero: str | None
|
|
tag_id: int | None
|
|
tag_nom: str | None
|
|
categorie: str | None
|
|
sous_categorie: str | None
|
|
fournisseur: str | None
|
|
description: str | None
|
|
debit: float
|
|
credit: float
|
|
tva: float
|
|
locatif: float
|
|
deductible: float
|
|
|
|
|
|
class CategorySummary(BaseModel):
|
|
"""Summary for a category."""
|
|
|
|
categorie: str
|
|
count: int
|
|
total_debit: float
|
|
total_credit: float
|
|
total_tva: float
|
|
total_locatif: float
|
|
total_deductible: float
|
|
|
|
|
|
class TagSummary(BaseModel):
|
|
"""Summary for a tag."""
|
|
|
|
tag_id: int | None
|
|
tag_nom: str | None
|
|
count: int
|
|
total_debit: float
|
|
|
|
|
|
class MonthlySummary(BaseModel):
|
|
"""Summary for a month."""
|
|
|
|
year: int
|
|
month: int
|
|
count: int
|
|
total_debit: float
|
|
total_credit: float
|
|
|
|
|
|
class FournisseurSummary(BaseModel):
|
|
"""Summary for a fournisseur."""
|
|
|
|
fournisseur: str | None
|
|
count: int
|
|
total_debit: float
|
|
|
|
|
|
class DepensesSummary(BaseModel):
|
|
"""Aggregated summary of depenses for analytics."""
|
|
|
|
total_count: int
|
|
total_debit: float
|
|
total_credit: float
|
|
total_tva: float
|
|
total_locatif: float
|
|
total_deductible: float
|
|
by_category: list[CategorySummary]
|
|
by_tag: list[TagSummary]
|
|
by_month: list[MonthlySummary]
|
|
by_fournisseur: list[FournisseurSummary]
|
|
|
|
|
|
# ============================================================
|
|
# Référentiel des logements
|
|
# ============================================================
|
|
|
|
|
|
class ImmeubleBody(BaseModel):
|
|
"""Ce qui se saisit sur un immeuble : son nom d'usage."""
|
|
|
|
denomination: str | None = None
|
|
|
|
@field_validator("denomination")
|
|
@classmethod
|
|
def _texte_vide_vaut_absent(cls, value: str | None) -> str | None:
|
|
"""Effacer le nom doit rendre l'immeuble à son code, pas le nommer « »."""
|
|
if value is None:
|
|
return None
|
|
return value.strip() or None
|
|
|
|
|
|
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
|
|
#: Nom d'usage de l'immeuble ; le tableau retombe sur le code s'il manque.
|
|
immeuble_denomination: str | None = 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
|