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:
parent
7ef4e91e50
commit
9c4c13780e
2 changed files with 146 additions and 31 deletions
|
|
@ -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(
|
||||
# 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
|
||||
"""
|
||||
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)
|
||||
),
|
||||
{"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,
|
||||
|
|
|
|||
|
|
@ -95,6 +95,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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue