Backend: GET /api/v1/trade-in/estimate/{id}/house-analytics — resolves target
house_ids через нормализованный адрес или geo-fallback 100m, expand до 300m
если в самом доме <8 hist rows. Возвращает:
- price_history: median ₽/м² по году (из house_placement_history)
- recent_sold: последние 12 мес снятые с фактической ценой + торгом
- kpi: median exposure_days, median bargain %, sold_rate %
Frontend: HouseAnalyticsSection (под PlacementHistoryCard) c 3 блоками:
- PriceHistoryChart (Recharts line, median ₽/м²)
- HouseAnalyticsKpiRow (3 KPI cards)
- RecentSoldList (таблица снятых лотов)
Использует уже собранные данные house_placement_history (15k+ rows от
backfill scripts: avito_imv + yandex_valuation). Новых таблиц нет.
248 lines
8.9 KiB
Python
248 lines
8.9 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
|
||
|
||
|
||
class PlacementHistoryEntry(BaseModel):
|
||
"""Запись истории размещения лота в доме (`house_placement_history`)."""
|
||
|
||
id: int
|
||
source: str
|
||
house_id: int | None = None
|
||
ext_item_id: str
|
||
title: str | None = None
|
||
rooms: int | None = None
|
||
area_m2: float | None = None
|
||
floor: int | None = None
|
||
total_floors: int | None = None
|
||
start_price: int | None = None
|
||
start_price_date: date | None = None
|
||
last_price: int | None = None
|
||
last_price_date: date | None = None
|
||
removed_date: date | None = None
|
||
exposure_days: int | None = None
|
||
notes: str | None = None
|
||
|
||
|
||
# ── 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)
|
||
|
||
|
||
# ── House analytics (house_placement_history backfill) ───────────────────────
|
||
|
||
|
||
class PriceHistoryYearPoint(BaseModel):
|
||
"""Медианная цена ₽/м² и число лотов за год."""
|
||
|
||
year: int
|
||
median_price_per_m2: int
|
||
n_lots: int
|
||
median_price_rub: int
|
||
|
||
|
||
class RecentSoldEntry(BaseModel):
|
||
"""Лот из house_placement_history снятый с продажи за последние 12 мес."""
|
||
|
||
id: int
|
||
source: str # 'avito_imv' | 'yandex_valuation'
|
||
rooms: int | None
|
||
area_m2: float | None
|
||
floor: int | None
|
||
start_price: int | None
|
||
last_price: int | None
|
||
removed_date: date | None
|
||
exposure_days: int | None
|
||
discount_pct: float | None
|
||
|
||
|
||
class HouseAnalyticsKpi(BaseModel):
|
||
"""Агрегированные KPI по дому(ам) из house_placement_history."""
|
||
|
||
total_lots: int
|
||
sold_count: int
|
||
sold_rate_pct: float
|
||
median_exposure_days: int | None
|
||
median_bargain_pct: float | None
|
||
|
||
|
||
class HouseAnalyticsResponse(BaseModel):
|
||
"""Ответ GET /estimate/{id}/house-analytics."""
|
||
|
||
house_ids: list[int]
|
||
radius_m: int
|
||
price_history: list[PriceHistoryYearPoint]
|
||
recent_sold: list[RecentSoldEntry]
|
||
kpi: HouseAnalyticsKpi
|