From f4fd0184ebaa0403bcca59abadaa27fb85ba226d Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 19:36:10 +0300 Subject: [PATCH] feat(trade-in): TI-1 mock /trade-in/estimate endpoint + schema + migration --- backend/app/api/v1/trade_in.py | 317 ++++++++++++++++++++++++++++ backend/app/main.py | 2 + backend/app/schemas/trade_in.py | 51 +++++ data/sql/115_trade_in_estimates.sql | 43 ++++ 4 files changed, 413 insertions(+) create mode 100644 backend/app/api/v1/trade_in.py create mode 100644 backend/app/schemas/trade_in.py create mode 100644 data/sql/115_trade_in_estimates.sql diff --git a/backend/app/api/v1/trade_in.py b/backend/app/api/v1/trade_in.py new file mode 100644 index 00000000..6296c4ef --- /dev/null +++ b/backend/app/api/v1/trade_in.py @@ -0,0 +1,317 @@ +"""Trade-In Estimator — mock endpoint (TI-1). + +MOCK implementation: returns realistic ЕКБ price bands by rooms/floor/repair. +TODO TI-1b: заменить _mock_estimate() на реальный SQL aggregation из +objective_lots + rosreestr_deals после OBJ-1/2 merge. +""" + +from __future__ import annotations + +import json +import logging +import random +from datetime import UTC, datetime, timedelta +from typing import Annotated +from uuid import UUID, uuid4 + +from fastapi import APIRouter, Depends +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.db import get_db +from app.schemas.trade_in import AggregatedEstimate, AnalogLot, TradeInEstimateInput + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# ЕКБ-адреса для фейковых аналогов (реальные улицы центра) +_EKB_STREETS = [ + "ул. Малышева", + "ул. Куйбышева", + "ул. 8 Марта", + "ул. Белинского", + "пр. Ленина", + "ул. Толмачёва", + "ул. Радищева", + "ул. Мамина-Сибиряка", + "ул. Луначарского", + "ул. Первомайская", +] + +# Базовые ценовые диапазоны по комнатности (ЕКБ, 2026) +_PRICE_BANDS: dict[int, dict[str, int | float | str]] = { + 0: { # студия ~25 м² + "median": 6_500_000, + "low": 5_800_000, + "high": 7_500_000, + "ppm2": 260_000, + "ref_area": 25.0, + }, + 1: { # 1к ~40 м² + "median": 9_000_000, + "low": 8_000_000, + "high": 10_500_000, + "ppm2": 225_000, + "ref_area": 40.0, + }, + 2: { # 2к ~60 м² + "median": 12_500_000, + "low": 11_000_000, + "high": 14_000_000, + "ppm2": 208_000, + "ref_area": 60.0, + }, + 3: { # 3к ~80 м² + "median": 17_000_000, + "low": 15_000_000, + "high": 19_000_000, + "ppm2": 213_000, + "ref_area": 80.0, + }, +} + + +def _floor_factor(floor: int, total_floors: int) -> float: + """±5% поправка за этаж: 1й и последний этаж снижают цену.""" + if floor == 1: + return 0.95 + if floor == total_floors: + return 0.97 + return 1.0 + + +def _repair_factor(repair_state: str | None) -> float: + """±10% поправка за состояние отделки.""" + factors = { + "needs_repair": 0.90, + "standard": 1.00, + "good": 1.05, + "excellent": 1.10, + } + return factors.get(repair_state or "standard", 1.0) + + +def _confidence(rooms: int) -> str: + if 1 <= rooms <= 3: + return "high" + return "medium" + + +def _gen_analogs( + rooms: int, + area_m2: float, + base_ppm2: int, + n: int, + *, + is_listing: bool, +) -> list[AnalogLot]: + """Генерирует список фейковых аналогов (объявления или сделки).""" + rng = random.Random(42 + rooms + n) + result: list[AnalogLot] = [] + today = datetime.now(tz=UTC).date() + for i in range(n): + street = _EKB_STREETS[i % len(_EKB_STREETS)] + building_no = rng.randint(1, 120) + apt_no = rng.randint(1, 300) + addr = f"г. Екатеринбург, {street}, {building_no}, кв. {apt_no}" + + area_jitter = area_m2 * rng.uniform(0.85, 1.15) + ppm2_jitter = int(base_ppm2 * rng.uniform(0.90, 1.10)) + price = int(area_jitter * ppm2_jitter) + + floor_val = rng.randint(2, 16) + total_fl = rng.randint(floor_val, 20) + + if is_listing: + dom = rng.randint(5, 120) + listing_dt = today - timedelta(days=dom) + else: + dom = rng.randint(10, 60) + listing_dt = today - timedelta(days=rng.randint(30, 365)) + + result.append( + AnalogLot( + address=addr, + area_m2=round(area_jitter, 1), + rooms=rooms if rooms > 0 else 0, + floor=floor_val, + total_floors=total_fl, + price_rub=price, + price_per_m2=ppm2_jitter, + listing_date=listing_dt, + days_on_market=dom, + photo_url=None, + ) + ) + return result + + +def _mock_estimate(payload: TradeInEstimateInput) -> AggregatedEstimate: + """Возвращает mock-оценку на основе диапазонов ЕКБ 2026. + + Логика: + - Берём базовый band по rooms (0-3+). + - Масштабируем на фактическую площадь относительно референсной. + - Применяем поправку за этаж (±5%) и отделку (±10%). + - Генерируем 7-10 аналогов (листинги) и 3-5 actual_deals. + """ + rooms_key = min(payload.rooms, 3) # 4к+ → диапазон 3к + band = _PRICE_BANDS[rooms_key] + + # Масштаб по площади + ref_area: float = band["ref_area"] # type: ignore[assignment] + area_scale = payload.area_m2 / ref_area + + ff = _floor_factor(payload.floor, payload.total_floors) + rf = _repair_factor(payload.repair_state) + combined = area_scale * ff * rf + + median = int(band["median"] * combined) # type: ignore[operator] + low = int(band["low"] * combined) # type: ignore[operator] + high = int(band["high"] * combined) # type: ignore[operator] + ppm2 = int(band["ppm2"] * ff * rf) # type: ignore[operator] + + n_analogs = random.randint(7, 10) + n_deals = random.randint(3, 5) + + analogs = _gen_analogs(rooms_key, payload.area_m2, ppm2, n_analogs, is_listing=True) + actual_deals = _gen_analogs( + rooms_key, payload.area_m2, int(ppm2 * 0.93), n_deals, is_listing=False + ) + + now = datetime.now(tz=UTC) + return AggregatedEstimate( + estimate_id=uuid4(), + median_price_rub=median, + range_low_rub=low, + range_high_rub=high, + median_price_per_m2=ppm2, + confidence=_confidence(payload.rooms), + n_analogs=n_analogs + n_deals, + period_months=24, + analogs=analogs, + actual_deals=actual_deals, + expires_at=now + timedelta(hours=24), + ) + + +@router.post("/estimate", response_model=AggregatedEstimate) +def estimate( + payload: TradeInEstimateInput, + db: Annotated[Session, Depends(get_db)], +) -> AggregatedEstimate: + """MOCK реализация оценки квартиры для Trade-In. + + TODO TI-1b: заменить на реальный SQL aggregation из objective_lots + после OBJ-1/2 merge (issue #314). + """ + result = _mock_estimate(payload) + + analogs_json = json.dumps( + [a.model_dump(mode="json") for a in result.analogs], + ensure_ascii=False, + ) + deals_json = json.dumps( + [a.model_dump(mode="json") for a in result.actual_deals], + ensure_ascii=False, + ) + + db.execute( + text( + """ + INSERT INTO trade_in_estimates ( + id, address, area_m2, rooms, floor, total_floors, + year_built, house_type, repair_state, has_balcony, + median_price, range_low, range_high, median_price_per_m2, + confidence, n_analogs, analogs, actual_deals, expires_at + ) VALUES ( + CAST(:id AS uuid), + :address, :area_m2, :rooms, :floor, :total_floors, + :year_built, :house_type, :repair_state, :has_balcony, + :median_price, :range_low, :range_high, :median_price_per_m2, + :confidence, :n_analogs, + CAST(:analogs AS jsonb), + CAST(:actual_deals AS jsonb), + :expires_at + ) + """ + ), + { + "id": str(result.estimate_id), + "address": payload.address, + "area_m2": payload.area_m2, + "rooms": payload.rooms, + "floor": payload.floor, + "total_floors": payload.total_floors, + "year_built": payload.year_built, + "house_type": payload.house_type, + "repair_state": payload.repair_state, + "has_balcony": payload.has_balcony, + "median_price": result.median_price_rub, + "range_low": result.range_low_rub, + "range_high": result.range_high_rub, + "median_price_per_m2": result.median_price_per_m2, + "confidence": result.confidence, + "n_analogs": result.n_analogs, + "analogs": analogs_json, + "actual_deals": deals_json, + "expires_at": result.expires_at, + }, + ) + db.commit() + + logger.info( + "trade_in estimate saved id=%s rooms=%d area=%.1f confidence=%s", + result.estimate_id, + payload.rooms, + payload.area_m2, + result.confidence, + ) + return result + + +@router.get("/estimate/{estimate_id}", response_model=AggregatedEstimate) +def get_estimate( + estimate_id: UUID, + db: Annotated[Session, Depends(get_db)], +) -> AggregatedEstimate: + """Получить сохранённую оценку по UUID (для генерации PDF). + + Возвращает 404 если оценка не найдена или TTL истёк. + """ + from fastapi import HTTPException + + row = db.execute( + text( + """ + SELECT id, median_price, range_low, range_high, median_price_per_m2, + confidence, n_analogs, analogs, actual_deals, expires_at, + address, area_m2, rooms + FROM trade_in_estimates + WHERE id = CAST(:id AS uuid) + AND expires_at > NOW() + """ + ), + {"id": str(estimate_id)}, + ).fetchone() + + if row is None: + raise HTTPException(status_code=404, detail="estimate not found or expired") + + analogs = [AnalogLot(**a) for a in (row.analogs or [])] + actual_deals = [AnalogLot(**a) for a in (row.actual_deals or [])] + + return AggregatedEstimate( + estimate_id=row.id, + median_price_rub=row.median_price, + range_low_rub=row.range_low, + range_high_rub=row.range_high, + median_price_per_m2=row.median_price_per_m2, + confidence=row.confidence, + n_analogs=row.n_analogs, + period_months=24, + analogs=analogs, + actual_deals=actual_deals, + expires_at=row.expires_at, + ) diff --git a/backend/app/main.py b/backend/app/main.py index ea91b877..7874ee48 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -25,6 +25,7 @@ from app.api.v1 import ( custom_pois, parcels, photos, + trade_in, ) from app.core.config import settings from app.observability.sentry_scrub import scrub_sensitive_query @@ -97,6 +98,7 @@ app.include_router( prefix="/api/v1/admin/etl", tags=["admin", "etl"], ) +app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"]) @app.get("/health") diff --git a/backend/app/schemas/trade_in.py b/backend/app/schemas/trade_in.py new file mode 100644 index 00000000..de21322a --- /dev/null +++ b/backend/app/schemas/trade_in.py @@ -0,0 +1,51 @@ +"""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 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 + + +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 + + +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"] + n_analogs: int + period_months: int # 24 + analogs: list[AnalogLot] # top 5-10 listings + actual_deals: list[AnalogLot] # реальные продажи last 12 mo + expires_at: datetime diff --git a/data/sql/115_trade_in_estimates.sql b/data/sql/115_trade_in_estimates.sql new file mode 100644 index 00000000..df9e787a --- /dev/null +++ b/data/sql/115_trade_in_estimates.sql @@ -0,0 +1,43 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS trade_in_estimates ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Input snapshot + address text NOT NULL, + lat double precision, + lon double precision, + area_m2 numeric(8, 2) NOT NULL, + rooms int NOT NULL, + floor int NOT NULL, + total_floors int NOT NULL, + year_built int, + house_type text, + repair_state text, + has_balcony boolean, + + -- Output + median_price bigint NOT NULL, + range_low bigint NOT NULL, + range_high bigint NOT NULL, + median_price_per_m2 int NOT NULL, + confidence text NOT NULL CHECK (confidence IN ('low', 'medium', 'high')), + n_analogs int NOT NULL DEFAULT 0, + analogs jsonb NOT NULL DEFAULT '[]'::jsonb, + actual_deals jsonb NOT NULL DEFAULT '[]'::jsonb, + + -- Metadata + created_at timestamptz NOT NULL DEFAULT NOW(), + expires_at timestamptz NOT NULL DEFAULT NOW() + interval '24 hours' +); + +CREATE INDEX IF NOT EXISTS trade_in_estimates_created_idx + ON trade_in_estimates (created_at DESC); + +CREATE INDEX IF NOT EXISTS trade_in_estimates_expires_idx + ON trade_in_estimates (expires_at); + +COMMENT ON TABLE trade_in_estimates IS + '#314 TradeIn MVP — стор estimates с input snapshot + aggregated output. TTL 24h.'; + +COMMIT;