gendesign/tradein-mvp/backend/app/schemas/trade_in.py
lekss361 496f18e16d feat(tradein): аккаунт praktika + лимит 15 оценок/мес, чистка брендинга PRINZIP в UI
Account quota (server-side, по X-Authenticated-User):
- новая таблица account_estimate_usage (миграция 076, идемпотентная)
- app/services/account_quota.py: 15 успешных оценок в календарный месяц на аккаунт
- enforcement в POST /estimate (429 при исчерпании) + GET /quota
- admin и kopylov — без лимита; нет заголовка (dev) → fail-open
- frontend: useQuota hook, счётчик «осталось N из 15», блок формы + баннер
  «Свяжитесь с Артёмом Копыловым» при исчерпании

Account praktika:
- caddy/users.caddy.snippet + auth/roles.yaml (роль pilot)

Брендинг PRINZIP убран только из UI (футер + css-коммент); таблица brands,
PDF white-label и скрейпер Яндекса (реальный застройщик PRINZIP) не тронуты.
2026-05-29 01:00:24 +03:00

396 lines
16 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 | 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
# ── Новые поля (Слой 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 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
# ── 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
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 / без заголовка