gendesign/tradein-mvp/backend/app/schemas/trade_in.py
lekss361 23aebea812 feat(tradein): GET /street-deals — ДКП-сделки по улице target адреса
Новый 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.
2026-05-24 22:56:10 +03:00

310 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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