feat(tradein-api): surface backfilled houses + placement history (#528)
All checks were successful
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 38s
Deploy Trade-In / changes (push) Successful in 6s
Deploy Trade-In / build-backend (push) Successful in 44s

This commit is contained in:
lekss361 2026-05-24 14:22:50 +00:00
parent f2f9e5b79b
commit 7dc11e4339
2 changed files with 146 additions and 31 deletions

View file

@ -7,7 +7,7 @@ from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import Annotated
from typing import Annotated, Any
from uuid import UUID
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
@ -360,6 +360,16 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]:
# ── Stage 4a: house info + IMV benchmark для UI ───────────────────────────────
_HOUSE_SELECT_COLS = """
h.id AS house_id, h.source, h.ext_house_id, h.address, h.short_address,
h.lat, h.lon, h.year_built, h.total_floors, h.house_type,
h.passenger_elevators, h.cargo_elevators,
h.has_concierge, h.closed_yard, h.has_playground, h.parking_type,
h.developer_name, h.rating, h.reviews_count,
COALESCE(h.raw_characteristics, '[]'::jsonb) AS raw_characteristics
"""
@router.get("/estimate/{estimate_id}/houses", response_model=list[HouseInfoForEstimate])
def get_estimate_houses(
estimate_id: UUID,
@ -367,18 +377,17 @@ def get_estimate_houses(
) -> list[HouseInfoForEstimate]:
"""House(s) информация для estimate.
Логика: estimate сохранён с target_address + lat/lon. Ищем houses которые:
- либо лежат в радиусе 500м от target (PostGIS)
- либо linked через listings.house_id_fk у аналогов оценки
Логика (двойной поиск, union-deduplicate):
1. Прямое совпадение по нормализованному адресу (tradein_normalize_short_addr).
2. Geo-nearby ST_DWithin 500м, любой source (avito/derived/etc.).
Возвращаем top-5 ближайших домов с их characteristics из houses table.
Возвращаем прямой матч + nearby (dedup by id), up to ~6 домов.
Пустой список если нет matches.
"""
# Сначала достанем target_lat/lon из estimate
target = db.execute(
text(
"""
SELECT lat, lon FROM trade_in_estimates
SELECT lat, lon, address FROM trade_in_estimates
WHERE id = CAST(:id AS uuid)
"""
),
@ -386,37 +395,142 @@ def get_estimate_houses(
).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()
# Path 1: прямой матч по нормализованному адресу
direct: list[Any] = []
if target.address:
direct = list(
db.execute(
text(
f"""
SELECT DISTINCT {_HOUSE_SELECT_COLS},
0 AS distance_m
FROM houses h
WHERE h.short_address = tradein_normalize_short_addr(:addr)
OR tradein_normalize_short_addr(h.address)
= tradein_normalize_short_addr(:addr)
LIMIT 1
"""
),
{"addr": target.address},
).mappings().all()
)
# Path 2: geo-nearby (any source, 500м radius)
nearby: list[Any] = []
if target.lat and target.lon:
nearby = list(
db.execute(
text(
f"""
SELECT DISTINCT {_HOUSE_SELECT_COLS},
ST_Distance(
h.geom::geography,
ST_MakePoint(:lon, :lat)::geography
)::int AS distance_m
FROM houses h
WHERE h.geom IS NOT NULL
AND ST_DWithin(
h.geom::geography,
ST_MakePoint(:lon, :lat)::geography,
500
)
ORDER BY distance_m
LIMIT 5
"""
),
{"lat": target.lat, "lon": target.lon},
).mappings().all()
)
# Merge: direct первым, затем nearby (dedup by house_id)
seen_ids = {r["house_id"] for r in direct}
merged = list(direct) + [r for r in nearby if r["house_id"] not in seen_ids]
return [
HouseInfoForEstimate(**{k: v for k, v in row.items() if k != "distance_m"})
for row in rows
for row in merged
]
@router.get("/estimate/{estimate_id}/placement-history")
def get_estimate_placement_history(
estimate_id: UUID,
db: Annotated[Session, Depends(get_db)],
) -> list[dict]:
"""Историческая продажная активность по дому(ам) target estimate.
Возвращает rows из house_placement_history для всех houses связанных с
target адресом. Сортировано по last_price_date DESC.
"""
target = db.execute(
text(
"""
SELECT lat, lon, address 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")
# Поиск house_ids по нормализованному адресу
house_ids: list[int] = []
if target.address:
rows = db.execute(
text(
"""
SELECT id FROM houses
WHERE short_address = tradein_normalize_short_addr(:addr)
OR tradein_normalize_short_addr(address) = tradein_normalize_short_addr(:addr)
"""
),
{"addr": target.address},
).all()
house_ids = [r.id for r in rows]
if not house_ids and target.lat and target.lon:
# Geo fallback: 100м (tight radius чтобы не смешать соседние дома)
rows = db.execute(
text(
"""
SELECT id FROM houses
WHERE geom IS NOT NULL
AND ST_DWithin(
geom::geography,
ST_MakePoint(:lon, :lat)::geography,
100
)
LIMIT 3
"""
),
{"lat": target.lat, "lon": target.lon},
).all()
house_ids = [r.id for r in rows]
if not house_ids:
return []
history = db.execute(
text(
"""
SELECT id, source, house_id, ext_item_id, title, rooms, area_m2,
floor, total_floors, start_price, start_price_date,
last_price, last_price_date, removed_date, exposure_days,
notes
FROM house_placement_history
WHERE house_id = ANY(:house_ids)
ORDER BY COALESCE(last_price_date, start_price_date) DESC NULLS LAST
LIMIT 50
"""
),
{"house_ids": house_ids},
).mappings().all()
return [dict(r) for r in history]
@router.get("/estimate/{estimate_id}/imv-benchmark", response_model=IMVBenchmarkResponse)
def get_estimate_imv_benchmark(
estimate_id: UUID,

View file

@ -110,6 +110,7 @@ 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