Новый endpoint для секции «По вашей улице» в trade-in UI. Возвращает ДКП-сделки Росреестра матчащиеся по улице (open dataset агрегирует до street level — номера дома нет) + rooms + area BETWEEN target ± 15%. Использует rosreestr_deals после PR-A (#549) — только ДКП, без ДДУ-первички. Response: median/range за period_months (default 12) + top-10 последних сделок. Empty StreetDealsResponse если street не извлёкся или count=0. Tests: extract_street_name parsing, empty case, aggregation.
310 lines
11 KiB
Python
310 lines
11 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
|
||
source: str # 'avito_imv' | 'yandex_valuation'
|
||
median_price_per_m2: int
|
||
n_lots: int
|
||
median_price_rub: int
|
||
|
||
|
||
class CianPriceChangeStats(BaseModel):
|
||
"""Статистика изменений цены для одного Cian-аналога.
|
||
|
||
Источник: offer_price_history JOIN listings (source='cian').
|
||
"""
|
||
|
||
cian_id: str
|
||
listing_id: int
|
||
n_changes: int # COUNT(*) из offer_price_history
|
||
last_change_time: datetime | None
|
||
last_diff_percent: float | None # последняя дельта (-5% если цена снизилась)
|
||
total_change_pct: float | None # суммарно (current - first) / first * 100
|
||
first_seen_price: int | None
|
||
current_price: int # из listings.price_rub
|
||
|
||
|
||
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
|
||
|
||
|
||
# ── Sell-time sensitivity (срок продажи по бакетам цены) ─────────────────────
|
||
|
||
|
||
class SellTimeBucket(BaseModel):
|
||
"""Один бакет срока продажи для данного ценового диапазона."""
|
||
|
||
price_premium_label: str # 'cheap' | 'median' | 'plus5' | 'plus10'
|
||
price_premium_pct: float # -5.0, 0.0, 5.0, 10.0 для UI
|
||
median_exposure_days: int | None
|
||
p25_days: int | None
|
||
p75_days: int | None
|
||
n_lots: int
|
||
|
||
|
||
class SellTimeSensitivityResponse(BaseModel):
|
||
"""Ответ GET /estimate/{id}/sell-time-sensitivity."""
|
||
|
||
house_ids: list[int]
|
||
radius_m: int
|
||
target_median_price_per_m2: int | None # benchmark — медиана ₽/м² за последние 2 года
|
||
buckets: list[SellTimeBucket]
|
||
|
||
|
||
# ── Street-level deals (rosreestr open dataset) ──────────────────────────────
|
||
|
||
|
||
class StreetDealsResponse(BaseModel):
|
||
"""Ответ GET /api/v1/trade-in/street-deals.
|
||
|
||
Open dataset Росреестра агрегирует адреса до уровня улицы (без номера дома).
|
||
Поэтому это per-street view, а не per-house.
|
||
"""
|
||
|
||
# извлечённая улица, напр. «Космонавтов» / None если не определилась
|
||
street: str | None
|
||
period_from: date
|
||
period_to: date
|
||
count: int # число всех matching сделок, не только топ-10
|
||
median_price_rub: int # 0 если count == 0
|
||
median_price_per_m2: int
|
||
range_low_rub: int
|
||
range_high_rub: int
|
||
deals: list[AnalogLot] # последние 10 по deal_date DESC
|