Files
pdf_oralia_vibe/src/plesna_gerance/api/schemas/models.py
Bertrand Benjamin 9f5f46a93a feat: donne aux immeubles un nom d'usage
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>
2026-07-28 15:40:27 +02:00

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