180 lines
7 KiB
Python
180 lines
7 KiB
Python
"""Pydantic schemas for Trade-In Estimator.
|
||
|
||
POST /api/v1/trade-in/estimate → AggregatedEstimate
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import date, datetime
|
||
from typing import Any, Literal
|
||
from uuid import UUID
|
||
|
||
from pydantic import BaseModel, Field
|
||
|
||
|
||
class TradeInEstimateInput(BaseModel):
|
||
address: str = Field(min_length=3, max_length=500)
|
||
area_m2: float = Field(gt=10, lt=500)
|
||
rooms: int = Field(ge=0, le=10) # 0 = студия
|
||
floor: int = Field(ge=1, le=100)
|
||
total_floors: int = Field(ge=1, le=100)
|
||
year_built: int | None = Field(default=None, ge=1800, le=2100)
|
||
house_type: Literal["panel", "brick", "monolith", "monolith_brick", "other"] | None = None
|
||
repair_state: Literal["needs_repair", "standard", "good", "excellent"] | None = None
|
||
has_balcony: bool | None = None
|
||
# CRM-поля (#395) — операционные, на расчёт оценки не влияют
|
||
ownership_type: str | None = Field(default=None, max_length=100)
|
||
has_mortgage: bool | None = None
|
||
client_name: str | None = Field(default=None, max_length=200)
|
||
client_phone: str | None = Field(default=None, max_length=50)
|
||
|
||
|
||
class AnalogLot(BaseModel):
|
||
address: str
|
||
area_m2: float
|
||
rooms: int
|
||
floor: int | None
|
||
total_floors: int | None
|
||
price_rub: int
|
||
price_per_m2: int
|
||
listing_date: date | None
|
||
days_on_market: int | None
|
||
photo_url: str | None = None
|
||
# ── Новые поля (Слой 5.2 — clickable links) ──
|
||
source: str | None = None # 'avito' / 'cian' / 'domklik' / 'rosreestr'
|
||
source_url: str | None = None # ссылка на оригинальное объявление / сделку
|
||
distance_m: int | None = None # расстояние до целевой квартиры в метрах
|
||
|
||
|
||
class CianChartPoint(BaseModel):
|
||
"""Одна точка 7-месячного chart Cian Valuation Calculator."""
|
||
|
||
date: str
|
||
price: float
|
||
|
||
|
||
class CianValuationSummary(BaseModel):
|
||
"""Cian Valuation Calculator данные для UI.
|
||
|
||
Источник: external_valuations table (source='cian_valuation').
|
||
Заполняется только при successful Cian Calculator call в estimator.py.
|
||
"""
|
||
|
||
sale_price_rub: int | None = None
|
||
rent_price_rub: int | None = None
|
||
chart: list[CianChartPoint] = Field(default_factory=list)
|
||
chart_change_pct: float | None = None
|
||
chart_change_direction: Literal["increase", "decrease", "neutral"] | None = None
|
||
|
||
|
||
class AggregatedEstimate(BaseModel):
|
||
estimate_id: UUID
|
||
median_price_rub: int
|
||
range_low_rub: int
|
||
range_high_rub: int
|
||
median_price_per_m2: int
|
||
confidence: Literal["low", "medium", "high"]
|
||
confidence_explanation: str | None = None # «Найдено 15 аналогов, разброс ±7%»
|
||
n_analogs: int
|
||
period_months: int # 24
|
||
analogs: list[AnalogLot] # top 5-10 listings
|
||
actual_deals: list[AnalogLot] # реальные продажи last 12 mo
|
||
expires_at: datetime
|
||
# ── Дополнительные метаданные ──
|
||
target_address: str | None = None # geocoded full address
|
||
target_lat: float | None = None
|
||
target_lon: float | None = None
|
||
sources_used: list[str] = Field(default_factory=list) # ['avito', 'cian', 'rosreestr']
|
||
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
|
||
est_days_on_market: int | None = None # прогноз срока продажи (медиана по аналогам)
|
||
cian_valuation: CianValuationSummary | None = None
|
||
# ── Параметры оценённой квартиры — нужны, чтобы восстановить карточку
|
||
# при открытии оценки по ссылке (?id=), когда формы-инпута уже нет ──
|
||
area_m2: float | None = None
|
||
rooms: int | None = None
|
||
floor: int | None = None
|
||
total_floors: int | None = None
|
||
year_built: int | None = None
|
||
house_type: str | None = None
|
||
repair_state: str | None = None
|
||
has_balcony: bool | None = None
|
||
|
||
|
||
class PhotoMeta(BaseModel):
|
||
"""Метаданные фото квартиры (#394). Содержимое отдаётся отдельным эндпоинтом."""
|
||
|
||
id: UUID
|
||
filename: str | None = None
|
||
content_type: str
|
||
size_bytes: int | None = None
|
||
uploaded_at: datetime
|
||
|
||
|
||
# ── Stage 4a response schemas ────────────────────────────────────────────────
|
||
|
||
|
||
class HouseInfoForEstimate(BaseModel):
|
||
"""Summary информации о доме целевой квартиры (для GET /estimate/{id}/houses)."""
|
||
|
||
house_id: int | None = None
|
||
source: str | None = None # 'avito' / 'derived' / 'cian_newbuilding' / etc.
|
||
ext_house_id: str | None = None
|
||
address: str | None = None
|
||
short_address: str | None = None
|
||
lat: float | None = None
|
||
lon: float | None = None
|
||
year_built: int | None = None
|
||
total_floors: int | None = None
|
||
house_type: str | None = None
|
||
passenger_elevators: int | None = None
|
||
cargo_elevators: int | None = None
|
||
has_concierge: bool | None = None
|
||
closed_yard: bool | None = None
|
||
has_playground: bool | None = None
|
||
parking_type: str | None = None
|
||
developer_name: str | None = None
|
||
rating: float | None = None
|
||
reviews_count: int | None = None
|
||
raw_characteristics: list[dict] = Field(default_factory=list)
|
||
|
||
|
||
class IMVBenchmarkResponse(BaseModel):
|
||
"""Avito IMV benchmark для UI (GET /estimate/{id}/imv-benchmark)."""
|
||
|
||
available: bool # есть ли IMV для этого estimate
|
||
cache_key: str | None = None
|
||
recommended_price: int | None = None
|
||
lower_price: int | None = None
|
||
higher_price: int | None = None
|
||
market_count: int | None = None
|
||
fetched_at: datetime | None = None
|
||
# comparison vs our estimate
|
||
our_median_price: int | None = None
|
||
diff_pct: float | None = None # (our - imv) / imv * 100
|
||
|
||
|
||
# ── In-app scheduler (Stage 4e) ─────────────────────────────────────────────
|
||
|
||
|
||
class ScheduleConfig(BaseModel):
|
||
"""Текущая конфигурация schedule для source."""
|
||
|
||
id: int
|
||
source: str
|
||
enabled: bool
|
||
window_start_hour: int = Field(ge=0, le=23)
|
||
window_end_hour: int = Field(ge=0, le=23)
|
||
default_params: dict[str, Any] = Field(default_factory=dict)
|
||
last_run_id: int | None = None
|
||
last_run_at: str | None = None # ISO
|
||
next_run_at: str | None = None # ISO
|
||
updated_at: str | None = None # ISO
|
||
|
||
|
||
class ScheduleConfigUpdate(BaseModel):
|
||
"""PUT body для update_schedule endpoint."""
|
||
|
||
enabled: bool = True
|
||
window_start_hour: int = Field(default=2, ge=0, le=23)
|
||
window_end_hour: int = Field(default=5, ge=0, le=23)
|
||
default_params: dict[str, Any] = Field(default_factory=dict)
|