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