748 lines
26 KiB
Python
748 lines
26 KiB
Python
"""Trade-In Estimator — endpoints (TI-1 mock + TI-2 PDF).
|
||
|
||
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, File, HTTPException, Response, UploadFile
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.db import get_db
|
||
from app.schemas.trade_in import (
|
||
AggregatedEstimate,
|
||
AnalogLot,
|
||
HouseInfoForEstimate,
|
||
IMVBenchmarkResponse,
|
||
PhotoMeta,
|
||
TradeInEstimateInput,
|
||
)
|
||
from app.services.exporters.trade_in_pdf import generate_trade_in_pdf
|
||
from app.services.image_sanitizer import ImageSanitizationError, sanitize_image
|
||
|
||
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)
|
||
async def estimate(
|
||
payload: TradeInEstimateInput,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> AggregatedEstimate:
|
||
"""Реальная оценка через SQL aggregation поверх listings + deals.
|
||
|
||
1. Geocode address → lat/lon
|
||
2. PostGIS ST_DWithin радиус 800м (или 2км fallback)
|
||
3. Tukey IQR outlier filter
|
||
4. Median + Q1 + Q3 + confidence с explanation
|
||
"""
|
||
from app.services.estimator import estimate_quality
|
||
return await estimate_quality(payload, db)
|
||
|
||
|
||
# OLD MOCK PATH — оставлен как fallback для отладки; не вызывается из router.
|
||
def _estimate_legacy_mock(
|
||
payload: TradeInEstimateInput,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> AggregatedEstimate:
|
||
"""Старая mock-реализация. НЕ используется в роутере, удалить когда стабилизируем новый estimator."""
|
||
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 истёк.
|
||
"""
|
||
row = db.execute(
|
||
text(
|
||
"""
|
||
SELECT id, median_price, range_low, range_high, median_price_per_m2,
|
||
confidence, confidence_explanation, n_analogs,
|
||
analogs, actual_deals, sources_used, data_freshness_minutes,
|
||
expires_at, address, lat, lon,
|
||
area_m2, rooms, floor, total_floors,
|
||
year_built, house_type, repair_state, has_balcony
|
||
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 [])]
|
||
|
||
# ВАЖНО: возвращаем ПОЛНЫЙ набор полей. Раньше эндпоинт отдавал огрызок
|
||
# без sources_used / confidence_explanation / координат — и при открытии
|
||
# оценки по ссылке (?id=) карточка источников пустела до «0/7».
|
||
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,
|
||
confidence_explanation=row.confidence_explanation,
|
||
n_analogs=row.n_analogs,
|
||
period_months=12,
|
||
analogs=analogs,
|
||
actual_deals=actual_deals,
|
||
expires_at=row.expires_at,
|
||
target_address=row.address,
|
||
target_lat=row.lat,
|
||
target_lon=row.lon,
|
||
sources_used=row.sources_used or [],
|
||
data_freshness_minutes=row.data_freshness_minutes,
|
||
area_m2=row.area_m2,
|
||
rooms=row.rooms,
|
||
floor=row.floor,
|
||
total_floors=row.total_floors,
|
||
year_built=row.year_built,
|
||
house_type=row.house_type,
|
||
repair_state=row.repair_state,
|
||
has_balcony=row.has_balcony,
|
||
)
|
||
|
||
|
||
@router.get("/estimate/{estimate_id}/pdf")
|
||
def estimate_pdf(
|
||
estimate_id: UUID,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
brand: str | None = None,
|
||
) -> Response:
|
||
"""Скачать 4-страничный PDF-отчёт для оценки trade-in.
|
||
|
||
Возвращает application/pdf attachment.
|
||
404 — оценка не найдена.
|
||
410 — оценка просрочена (TTL 24ч).
|
||
"""
|
||
row = db.execute(
|
||
text(
|
||
"""
|
||
SELECT id, median_price, range_low, range_high, median_price_per_m2,
|
||
confidence, confidence_explanation, n_analogs,
|
||
analogs, actual_deals, sources_used, data_freshness_minutes,
|
||
expires_at,
|
||
address, lat, lon, area_m2, rooms, floor, total_floors,
|
||
year_built, house_type, repair_state, has_balcony
|
||
FROM trade_in_estimates
|
||
WHERE id = CAST(:id AS uuid)
|
||
"""
|
||
),
|
||
{"id": str(estimate_id)},
|
||
).fetchone()
|
||
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="estimate not found")
|
||
|
||
if row.expires_at.replace(tzinfo=UTC) < datetime.now(tz=UTC):
|
||
raise HTTPException(status_code=410, detail="estimate expired (24h TTL)")
|
||
|
||
analogs = [AnalogLot(**a) for a in (row.analogs or [])]
|
||
actual_deals = [AnalogLot(**a) for a in (row.actual_deals or [])]
|
||
|
||
estimate = 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,
|
||
confidence_explanation=row.confidence_explanation,
|
||
n_analogs=row.n_analogs,
|
||
period_months=24,
|
||
analogs=analogs,
|
||
actual_deals=actual_deals,
|
||
expires_at=row.expires_at,
|
||
target_address=row.address,
|
||
target_lat=row.lat,
|
||
target_lon=row.lon,
|
||
sources_used=row.sources_used or [],
|
||
data_freshness_minutes=row.data_freshness_minutes,
|
||
)
|
||
input_snapshot = {
|
||
"address": row.address,
|
||
"area_m2": row.area_m2,
|
||
"rooms": row.rooms,
|
||
"floor": row.floor,
|
||
"total_floors": row.total_floors,
|
||
"year_built": row.year_built,
|
||
"house_type": row.house_type,
|
||
"repair_state": row.repair_state,
|
||
"has_balcony": row.has_balcony,
|
||
}
|
||
|
||
from app.services.brand import get_brand as _resolve_brand
|
||
brand_obj = _resolve_brand(brand, db)
|
||
pdf_bytes = generate_trade_in_pdf(estimate, input_snapshot, brand=brand_obj)
|
||
filename = f"trade-in-{brand_obj.slug}-{estimate_id}.pdf"
|
||
logger.info(
|
||
"PDF generated estimate_id=%s brand=%s size=%d",
|
||
estimate_id, brand_obj.slug, len(pdf_bytes),
|
||
)
|
||
return Response(
|
||
content=pdf_bytes,
|
||
media_type="application/pdf",
|
||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||
)
|
||
|
||
|
||
# ── Фото квартиры (#394) ─────────────────────────────────────────────────────
|
||
_MAX_PHOTO_BYTES = 10 * 1024 * 1024 # 10 МБ на фото
|
||
_MAX_PHOTOS_PER_ESTIMATE = 12
|
||
_ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/heic"}
|
||
|
||
|
||
@router.post("/estimate/{estimate_id}/photos", response_model=PhotoMeta)
|
||
async def upload_photo(
|
||
estimate_id: UUID,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
file: Annotated[UploadFile, File()],
|
||
) -> PhotoMeta:
|
||
"""Загрузить фото квартиры к оценке (#394). Хранение в estimate_photos (bytea)."""
|
||
estimate = db.execute(
|
||
text("SELECT 1 FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
|
||
{"id": str(estimate_id)},
|
||
).fetchone()
|
||
if estimate is None:
|
||
raise HTTPException(status_code=404, detail="estimate not found")
|
||
|
||
ctype = (file.content_type or "").lower()
|
||
if ctype not in _ALLOWED_IMAGE_TYPES:
|
||
raise HTTPException(
|
||
status_code=415, detail=f"unsupported content-type: {ctype or 'unknown'}"
|
||
)
|
||
|
||
count = db.execute(
|
||
text("SELECT count(*) FROM estimate_photos WHERE estimate_id = CAST(:id AS uuid)"),
|
||
{"id": str(estimate_id)},
|
||
).scalar_one()
|
||
if count >= _MAX_PHOTOS_PER_ESTIMATE:
|
||
raise HTTPException(
|
||
status_code=409, detail=f"photo limit reached ({_MAX_PHOTOS_PER_ESTIMATE})"
|
||
)
|
||
|
||
content = await file.read()
|
||
if not content:
|
||
raise HTTPException(status_code=400, detail="empty file")
|
||
if len(content) > _MAX_PHOTO_BYTES:
|
||
raise HTTPException(status_code=413, detail="file too large (max 10 MB)")
|
||
|
||
# Sanitize: re-encode through Pillow to drop EXIF, kill polyglot payloads,
|
||
# cap dimensions. Closes finding #6 from 2026-05-24 audit.
|
||
try:
|
||
sanitized_bytes, sanitized_ctype = sanitize_image(content)
|
||
except ImageSanitizationError as e:
|
||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||
|
||
row = db.execute(
|
||
text(
|
||
"""
|
||
INSERT INTO estimate_photos
|
||
(estimate_id, filename, content_type, content, size_bytes)
|
||
VALUES (CAST(:eid AS uuid), :fn, :ct, :content, :sz)
|
||
RETURNING id, filename, content_type, size_bytes, uploaded_at
|
||
"""
|
||
),
|
||
{
|
||
"eid": str(estimate_id),
|
||
"fn": file.filename,
|
||
"ct": sanitized_ctype,
|
||
"content": sanitized_bytes,
|
||
"sz": len(sanitized_bytes),
|
||
},
|
||
).mappings().fetchone()
|
||
db.commit()
|
||
logger.info(
|
||
"photo uploaded: estimate=%s photo=%s orig_size=%d sanitized_size=%d ctype=%s",
|
||
estimate_id, row["id"], len(content), len(sanitized_bytes), sanitized_ctype,
|
||
)
|
||
return PhotoMeta(**row)
|
||
|
||
|
||
@router.get("/estimate/{estimate_id}/photos", response_model=list[PhotoMeta])
|
||
def list_photos(
|
||
estimate_id: UUID,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> list[PhotoMeta]:
|
||
"""Список фото оценки — метаданные, без содержимого (#394)."""
|
||
rows = db.execute(
|
||
text(
|
||
"""
|
||
SELECT id, filename, content_type, size_bytes, uploaded_at
|
||
FROM estimate_photos
|
||
WHERE estimate_id = CAST(:id AS uuid)
|
||
ORDER BY uploaded_at
|
||
"""
|
||
),
|
||
{"id": str(estimate_id)},
|
||
).mappings().all()
|
||
return [PhotoMeta(**r) for r in rows]
|
||
|
||
|
||
@router.get("/estimate/{estimate_id}/photos/{photo_id}")
|
||
def get_photo(
|
||
estimate_id: UUID,
|
||
photo_id: UUID,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> Response:
|
||
"""Отдать содержимое фото — image bytes (#394)."""
|
||
row = db.execute(
|
||
text(
|
||
"""
|
||
SELECT content, content_type
|
||
FROM estimate_photos
|
||
WHERE id = CAST(:pid AS uuid) AND estimate_id = CAST(:eid AS uuid)
|
||
"""
|
||
),
|
||
{"pid": str(photo_id), "eid": str(estimate_id)},
|
||
).fetchone()
|
||
if row is None:
|
||
raise HTTPException(status_code=404, detail="photo not found")
|
||
return Response(
|
||
content=bytes(row.content),
|
||
media_type=row.content_type,
|
||
headers={"Cache-Control": "private, max-age=3600"},
|
||
)
|
||
|
||
|
||
# ── История и кэш (#399) ─────────────────────────────────────────────────────
|
||
@router.get("/history")
|
||
def estimate_history(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
limit: int = 50,
|
||
) -> list[dict[str, object]]:
|
||
"""История оценок (#399) — последние N записей trade_in_estimates."""
|
||
rows = db.execute(
|
||
text(
|
||
"""
|
||
SELECT id, address, rooms, area_m2, median_price,
|
||
confidence, n_analogs, created_at
|
||
FROM trade_in_estimates
|
||
ORDER BY created_at DESC
|
||
LIMIT :limit
|
||
"""
|
||
),
|
||
{"limit": min(max(limit, 1), 200)},
|
||
).mappings().all()
|
||
return [dict(r) for r in rows]
|
||
|
||
|
||
@router.get("/cache-stats")
|
||
def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]:
|
||
"""Состояние данных и кэшей (#399) — для страницы «Кэш»."""
|
||
row = db.execute(
|
||
text(
|
||
"""
|
||
SELECT
|
||
(SELECT count(*) FROM geocode_cache) AS geocode_cache,
|
||
(SELECT count(*) FROM geocode_cache WHERE expires_at > NOW())
|
||
AS geocode_cache_fresh,
|
||
(SELECT count(*) FROM listings WHERE is_active) AS listings_active,
|
||
(SELECT max(scraped_at) FROM listings) AS listings_last_scraped,
|
||
(SELECT count(*) FROM deals) AS deals,
|
||
(SELECT count(*) FROM gendesign_cad_buildings) AS cad_buildings,
|
||
(SELECT count(*) FROM house_metadata) AS house_metadata,
|
||
(SELECT count(*) FROM trade_in_estimates) AS estimates_total
|
||
"""
|
||
)
|
||
).mappings().fetchone()
|
||
return dict(row) if row else {}
|
||
|
||
|
||
# ── Stage 4a: house info + IMV benchmark для UI ───────────────────────────────
|
||
|
||
|
||
@router.get("/estimate/{estimate_id}/houses", response_model=list[HouseInfoForEstimate])
|
||
def get_estimate_houses(
|
||
estimate_id: UUID,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> list[HouseInfoForEstimate]:
|
||
"""House(s) информация для estimate.
|
||
|
||
Логика: estimate сохранён с target_address + lat/lon. Ищем houses которые:
|
||
- либо лежат в радиусе 500м от target (PostGIS)
|
||
- либо linked через listings.house_id_fk у аналогов оценки
|
||
|
||
Возвращаем top-5 ближайших домов с их characteristics из houses table.
|
||
Пустой список если нет matches.
|
||
"""
|
||
# Сначала достанем target_lat/lon из estimate
|
||
target = db.execute(
|
||
text(
|
||
"""
|
||
SELECT lat, lon FROM trade_in_estimates
|
||
WHERE id = CAST(:id AS uuid)
|
||
"""
|
||
),
|
||
{"id": str(estimate_id)},
|
||
).fetchone()
|
||
if target is None:
|
||
raise HTTPException(status_code=404, detail="estimate not found")
|
||
if target.lat is None or target.lon is None:
|
||
return [] # нет координат → не можем искать дома
|
||
|
||
rows = db.execute(
|
||
text(
|
||
"""
|
||
SELECT
|
||
id AS house_id, ext_house_id, address, short_address,
|
||
lat, lon, year_built, total_floors, house_type,
|
||
passenger_elevators, cargo_elevators,
|
||
has_concierge, closed_yard, has_playground, parking_type,
|
||
developer_name, rating, reviews_count,
|
||
COALESCE(raw_characteristics, '[]'::jsonb) AS raw_characteristics,
|
||
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS distance_m
|
||
FROM houses
|
||
WHERE source = 'avito'
|
||
AND geom IS NOT NULL
|
||
AND ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, 500)
|
||
ORDER BY distance_m
|
||
LIMIT 5
|
||
"""
|
||
),
|
||
{"lat": target.lat, "lon": target.lon},
|
||
).mappings().all()
|
||
|
||
return [
|
||
HouseInfoForEstimate(**{k: v for k, v in row.items() if k != "distance_m"})
|
||
for row in rows
|
||
]
|
||
|
||
|
||
@router.get("/estimate/{estimate_id}/imv-benchmark", response_model=IMVBenchmarkResponse)
|
||
def get_estimate_imv_benchmark(
|
||
estimate_id: UUID,
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> IMVBenchmarkResponse:
|
||
"""Avito IMV benchmark для estimate (для UI badge «наша 6.4М · Avito 6.29М»).
|
||
|
||
Источники lookup:
|
||
1. avito_imv_evaluations WHERE estimate_id = :id (если linked в estimator)
|
||
2. Если не linked — fallback: most recent IMV для same address (TTL 24h)
|
||
"""
|
||
# Сначала пытаемся найти directly linked
|
||
row = db.execute(
|
||
text(
|
||
"""
|
||
SELECT cache_key, recommended_price, lower_price, higher_price,
|
||
market_count, fetched_at
|
||
FROM avito_imv_evaluations
|
||
WHERE estimate_id = CAST(:id AS uuid)
|
||
ORDER BY fetched_at DESC
|
||
LIMIT 1
|
||
"""
|
||
),
|
||
{"id": str(estimate_id)},
|
||
).fetchone()
|
||
|
||
if row is None:
|
||
# Fallback: same address за 24h (на случай если link не успел)
|
||
est = db.execute(
|
||
text(
|
||
"""
|
||
SELECT address FROM trade_in_estimates
|
||
WHERE id = CAST(:id AS uuid)
|
||
"""
|
||
),
|
||
{"id": str(estimate_id)},
|
||
).fetchone()
|
||
if est is None:
|
||
raise HTTPException(status_code=404, detail="estimate not found")
|
||
if est.address:
|
||
row = db.execute(
|
||
text(
|
||
"""
|
||
SELECT cache_key, recommended_price, lower_price, higher_price,
|
||
market_count, fetched_at
|
||
FROM avito_imv_evaluations
|
||
WHERE address = :address
|
||
AND fetched_at > NOW() - INTERVAL '24 hours'
|
||
ORDER BY fetched_at DESC
|
||
LIMIT 1
|
||
"""
|
||
),
|
||
{"address": est.address},
|
||
).fetchone()
|
||
|
||
if row is None:
|
||
return IMVBenchmarkResponse(available=False)
|
||
|
||
# Get our_median_price для compare
|
||
our = db.execute(
|
||
text(
|
||
"""
|
||
SELECT median_price FROM trade_in_estimates
|
||
WHERE id = CAST(:id AS uuid)
|
||
"""
|
||
),
|
||
{"id": str(estimate_id)},
|
||
).fetchone()
|
||
our_median = our.median_price if our else None
|
||
diff_pct = None
|
||
if our_median and row.recommended_price:
|
||
diff_pct = round((our_median - row.recommended_price) / row.recommended_price * 100, 1)
|
||
|
||
return IMVBenchmarkResponse(
|
||
available=True,
|
||
cache_key=row.cache_key,
|
||
recommended_price=row.recommended_price,
|
||
lower_price=row.lower_price,
|
||
higher_price=row.higher_price,
|
||
market_count=row.market_count,
|
||
fetched_at=row.fetched_at,
|
||
our_median_price=our_median,
|
||
diff_pct=diff_pct,
|
||
)
|