gendesign/tradein-mvp/backend/app/schemas/trade_in.py
bot-backend f1c5056397
Some checks failed
CI / backend-tests (push) Has been cancelled
CI / frontend-tests (push) Has been cancelled
CI / openapi-codegen-check (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / backend-tests (pull_request) Has been cancelled
CI / frontend-tests (pull_request) Has been cancelled
CI / openapi-codegen-check (pull_request) Has been cancelled
CI / changes (pull_request) Has been cancelled
docs(estimator): DKP corridor described as P10/P90 not min/max in schema+TS (#1520 follow-up)
2026-06-17 21:22:49 +03:00

489 lines
22 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, computed_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 | None = Field(default=None, ge=1, le=100)
total_floors: int | None = Field(default=None, 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
# ── Per-comp coords для MAP / price↔exposure views (web features) ──
# ADDITIVE + OPTIONAL. lat/lon из listings.lat-lon / deals.lat-lon (ST_Y/ST_X(geom)).
# Nullable: radius-фильтрованные аналоги имеют 100% coords, но Tier S
# (same-building через house_id_fk / address-prefix) может включать
# address-only Avito-лоты без geom → None (graceful, frontend пропускает на карте).
lat: float | None = None
lon: float | None = None
# ── Новые поля (Слой 5.2 — clickable links) ──
source: str | None = None # 'avito' / 'cian' / 'domklik' / 'rosreestr'
source_url: str | None = None # ссылка на оригинальное объявление / сделку
distance_m: int | None = None # расстояние до целевой квартиры в метрах
# ── Confidence tier (PR M / #564 Phase 3) ──
# Только для rosreestr-сделок: T0_per_house (kadastr_num exact match),
# T1_per_street (street-level only). Open dataset Росреестра не имеет
# kadastr_num — все ДКП-сделки сейчас T1. Поле зарезервировано на случай
# будущего enrichment data feed (ЕГРН direct).
tier: str | 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 AvitoImvSummary(BaseModel):
"""Avito IMV (Индекс Market Value) якорь для target-дома (#651).
Источник: `house_imv_evaluations` (per house_id, обновляется регулярно).
Это РЕАЛЬНАЯ рыночная оценка Avito по дому — служит anchor'ом для blend'а
(см. estimate_imv_blend_*). Сурфейсится в UI как референсный маркер на
ценовой шкале. None если для дома нет свежей IMV-записи.
"""
recommended_price: int | None = None # рекомендованная цена Avito, ₽
lower_price: int | None = None # нижняя граница IMV-коридора, ₽
higher_price: int | None = None # верхняя граница IMV-коридора, ₽
market_count: int | None = None # объём рынка, на котором построена оценка
class DkpCorridor(BaseModel):
"""Коридор реальных ДКП-сделок Росреестра для target (#652).
Источник: `deals` (source='rosreestr', ДКП-only), агрегированные по улице +
rooms + площади ±15% за период. ADVISORY: показывается как тонкая референсная
линия «коридор реальных сделок: XY млн»; если итоговая медиана ₽/м² выходит
за [low,high]×slack — добавляется текстовая пометка. НЕ хард-клампит оценку.
None / count=0 если по улице нет сопоставимых сделок.
"""
count: int # число ДКП-сделок в выборке
low_ppm2: int # P10 ₽/м² по сделкам (робастный коридор)
median_ppm2: int # медиана ₽/м²
high_ppm2: int # P90 ₽/м² по сделкам (робастный коридор)
period_months: int # окно поиска сделок
class PriceTrendPoint(BaseModel):
"""Одна точка месячного ₽/м² тренда для целевого дома / района (web TREND chart).
Источник: houses_price_dynamics (если заполнена) ИЛИ агрегация
house_placement_history по месяцам. month — 'YYYY-MM', ppm2 — медиана ₽/м².
"""
month: str # 'YYYY-MM'
ppm2: int # медиана ₽/м² за месяц
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%»
# #698: ПОЛНОЕ число найденных аналогов — НЕ равно len(analogs) (тот обрезан до
# top-10, см. поле `analogs` ниже). Консьюмер должен брать счёт отсюда, а не из len().
n_analogs: int
@computed_field # type: ignore[prop-decorator]
@property
def insufficient_data(self) -> bool:
"""#697: нет пригодной оценки — комплов/якоря не нашлось → headline median=0.
Фронт по этому флагу рендерит явное «недостаточно данных», а не буквальный
«0 ₽». Производное от median_price_rub (==0 ⇔ нет оценки), поэтому корректно
во всех конструкторах AggregatedEstimate автоматически (вкл. rehydrate на GET).
"""
return self.median_price_rub <= 0
period_months: int # 24
# #698: показываемый top-10 (обрезано в estimator.py:2026/2028) — НЕ полный список.
# Полное число аналогов — в n_analogs (len(analogs) ≤ 10 < n_analogs при большой выборке).
analogs: list[AnalogLot]
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 # сколько минут назад был самый свежий парсинг
# абсолютный timestamp самого свежего парсинга аналогов
last_scraped_at: datetime | None = None
est_days_on_market: int | None = None # прогноз срока продажи (медиана по аналогам)
cian_valuation: CianValuationSummary | None = None
# ── Месячный ₽/м² тренд для целевого дома (web TREND chart) — ADDITIVE + OPTIONAL ──
# ~12-24 точки. Источник: houses_price_dynamics (preferred, пока пуста в prod) →
# fallback агрегация house_placement_history по месяцам для target_house_id.
# None если house_id не разрешён / нет истории (graceful, frontend скрывает chart).
price_trend: list[PriceTrendPoint] | None = None
# ── #651/#652: внешние якоря (Avito IMV) + коридор реальных сделок (ДКП) ──
# avito_imv — реальная IMV-оценка дома (house_imv_evaluations). Используется
# как anchor для blend'а медианы (см. confidence_explanation).
# dkp_corridor — коридор ₽/м² по ДКП-сделкам Росреестра (advisory, не клампит).
# Оба nullable — None при отсутствии данных (graceful, common case без регрессий).
avito_imv: AvitoImvSummary | None = None
dkp_corridor: DkpCorridor | None = None
# ── Asking→sold correction (#648 Stage 3) — PURELY ADDITIVE ──
# Headline median_price_rub/range_*/median_price_per_m2 остаются ASKING (активные
# объявления). Эти параллельные expected_sold_* = asking × per-rooms ratio
# (asking_to_sold_ratios, migration 080) — релевантная для выкупа цена сделки.
# Backtest (#648 Stage 1) показал, что коррекция убирает bias asking-медианы
# +20% → 4% на held-out ДКП. None если ratio-таблицы нет / бакет пуст (graceful).
expected_sold_price_rub: int | None = None
expected_sold_range_low_rub: int | None = None
expected_sold_range_high_rub: int | None = None
expected_sold_per_m2: int | None = None
asking_to_sold_ratio: float | None = None # =sold/asking, ~0.720.93
ratio_basis: str | None = None # 'per_rooms' | 'global_fallback'
# ── DaData enrichment (PR Q1) — on-demand для target адреса ──
# canonical_address — DaData-нормализованная форма (с улицей в short form).
# house_cadnum — кадастровый номер ДОМА (для будущего matching Росреестра).
# house_fias_id — UUID ФИАС дома.
# metro_nearest — ближайшие станции метро [{name,line,distance}, ...].
# NULL если DaData credentials не заданы / quota exceeded / address не распознан.
canonical_address: str | None = None
house_cadnum: str | None = None
house_fias_id: str | None = None
metro_nearest: list[dict] = Field(default_factory=list)
# address_precision — точность гео-привязки адреса (из DaData qc_geo):
# «house» (qc_geo=0, дом точно), «street» (qc_geo=1, до улицы),
# «approximate» (qc_geo≥2: населённый пункт/город/регион/не распознан).
# None если DaData не отрабатывала (адрес не геокодирован) / credentials не заданы.
address_precision: Literal["house", "street", "approximate"] | 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
# ── Sales vs Listings (PR K, issue #564 Foundation Phase 1) ─────────────────
class SalesListingPair(BaseModel):
"""Пара (ДКП-сделка, listing того же ассортимента).
Возвращается из street_sales_vs_listings() SQL-function. Если для сделки
listing не нашёлся — все listing_* поля = None (LEFT JOIN).
"""
deal_id: int
deal_date: date
deal_price_rub: int
deal_price_per_m2: int
deal_area_m2: float
deal_rooms: int
deal_floor: int | None = None
deal_address: str | None = None
listing_id: int | None = None
listing_source: str | None = None # 'avito' / 'cian' / 'yandex' / 'n1' / 'domklik'
listing_source_url: str | None = None
listing_date: date | None = None
listing_price_rub: int | None = None
listing_price_per_m2: int | None = None
listing_area_m2: float | None = None
# Положительный = listing date раньше сделки (типичный кейс).
# Отрицательный = listing появился позже (отложенный парсинг).
days_listing_to_deal: int | None = None
# discount_pct = (deal_price - listing_price) / listing_price * 100.
# Отрицательный = продали дешевле выставленного (торг).
discount_pct: float | None = None
class SalesVsListingsResponse(BaseModel):
"""Ответ GET /api/v1/trade-in/sales-vs-listings.
Per-street pairs ДКП-сделок и matching listings. Aggregate KPIs показывают
linkage rate и медианный discount.
"""
street: str | None # извлечённое имя улицы, None если не извлеклось
period_months: int # окно поиска сделок
window_days: int # окно matching listing → deal
area_tolerance: float # 0.15 = ±15% по area_m2
total_deals: int # количество всех matching ДКП в улице/период
deals_with_listings: int # сколько имеют связанный listing
linkage_rate_pct: float # deals_with_listings / total_deals * 100
median_discount_pct: float | None # медиана по парам с listing
data_quality: str # "house_linked" | "street_only" | "no_data" (#721, ADR v3)
pairs: list[SalesListingPair] # все пары, sorted by deal_date DESC
# ── Account quota (#quota) ──────────────────────────────────────────────────
class QuotaStatus(BaseModel):
"""Статус квоты оценок для текущего аккаунта (GET /api/v1/trade-in/quota)."""
limit: int # MONTHLY_LIMIT = 15
used: int # использовано в текущем месяце
remaining: int # max(0, limit - used)
unlimited: bool # True для admin / kopylov / без заголовка