feat(tradein-api): surface backfilled houses + placement history

After PR #527 bootstrap'нул 6,507 houses and linked 18,197 listings.
- /estimate/{id}/houses no longer filters source='avito' — returns direct
  match по нормализованному address + nearby houses (any source).
- New /estimate/{id}/placement-history returns historical lots from
  house_placement_history for the target house(s).
This commit is contained in:
lekss361 2026-05-24 17:15:30 +03:00
parent 7ef4e91e50
commit 9c4c13780e
2 changed files with 146 additions and 31 deletions

View file

@ -7,7 +7,7 @@ from __future__ import annotations
import logging import logging
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Annotated from typing import Annotated, Any
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile 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 ─────────────────────────────── # ── 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]) @router.get("/estimate/{estimate_id}/houses", response_model=list[HouseInfoForEstimate])
def get_estimate_houses( def get_estimate_houses(
estimate_id: UUID, estimate_id: UUID,
@ -367,18 +377,17 @@ def get_estimate_houses(
) -> list[HouseInfoForEstimate]: ) -> list[HouseInfoForEstimate]:
"""House(s) информация для estimate. """House(s) информация для estimate.
Логика: estimate сохранён с target_address + lat/lon. Ищем houses которые: Логика (двойной поиск, union-deduplicate):
- либо лежат в радиусе 500м от target (PostGIS) 1. Прямое совпадение по нормализованному адресу (tradein_normalize_short_addr).
- либо linked через listings.house_id_fk у аналогов оценки 2. Geo-nearby ST_DWithin 500м, любой source (avito/derived/etc.).
Возвращаем top-5 ближайших домов с их characteristics из houses table. Возвращаем прямой матч + nearby (dedup by id), up to ~6 домов.
Пустой список если нет matches. Пустой список если нет matches.
""" """
# Сначала достанем target_lat/lon из estimate
target = db.execute( target = db.execute(
text( text(
""" """
SELECT lat, lon FROM trade_in_estimates SELECT lat, lon, address FROM trade_in_estimates
WHERE id = CAST(:id AS uuid) WHERE id = CAST(:id AS uuid)
""" """
), ),
@ -386,37 +395,142 @@ def get_estimate_houses(
).fetchone() ).fetchone()
if target is None: if target is None:
raise HTTPException(status_code=404, detail="estimate not found") raise HTTPException(status_code=404, detail="estimate not found")
if target.lat is None or target.lon is None:
return [] # нет координат → не можем искать дома
rows = db.execute( # Path 1: прямой матч по нормализованному адресу
text( direct: list[Any] = []
""" if target.address:
SELECT direct = list(
id AS house_id, ext_house_id, address, short_address, db.execute(
lat, lon, year_built, total_floors, house_type, text(
passenger_elevators, cargo_elevators, f"""
has_concierge, closed_yard, has_playground, parking_type, SELECT DISTINCT {_HOUSE_SELECT_COLS},
developer_name, rating, reviews_count, 0 AS distance_m
COALESCE(raw_characteristics, '[]'::jsonb) AS raw_characteristics, FROM houses h
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS distance_m WHERE h.short_address = tradein_normalize_short_addr(:addr)
FROM houses OR tradein_normalize_short_addr(h.address)
WHERE source = 'avito' = tradein_normalize_short_addr(:addr)
AND geom IS NOT NULL LIMIT 1
AND ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, 500) """
ORDER BY distance_m ),
LIMIT 5 {"addr": target.address},
""" ).mappings().all()
), )
{"lat": target.lat, "lon": target.lon},
).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 [ return [
HouseInfoForEstimate(**{k: v for k, v in row.items() if k != "distance_m"}) 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) @router.get("/estimate/{estimate_id}/imv-benchmark", response_model=IMVBenchmarkResponse)
def get_estimate_imv_benchmark( def get_estimate_imv_benchmark(
estimate_id: UUID, estimate_id: UUID,

View file

@ -95,6 +95,7 @@ class HouseInfoForEstimate(BaseModel):
"""Summary информации о доме целевой квартиры (для GET /estimate/{id}/houses).""" """Summary информации о доме целевой квартиры (для GET /estimate/{id}/houses)."""
house_id: int | None = None house_id: int | None = None
source: str | None = None # 'avito' / 'derived' / 'cian_newbuilding' / etc.
ext_house_id: str | None = None ext_house_id: str | None = None
address: str | None = None address: str | None = None
short_address: str | None = None short_address: str | None = None