From 9c4c13780e59aa12eb2b2fc1d2103f621df2c1ad Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 24 May 2026 17:15:30 +0300 Subject: [PATCH] feat(tradein-api): surface backfilled houses + placement history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- tradein-mvp/backend/app/api/v1/trade_in.py | 176 ++++++++++++++++---- tradein-mvp/backend/app/schemas/trade_in.py | 1 + 2 files changed, 146 insertions(+), 31 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index b50d8921..c8badb8c 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -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, diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 7ae83e1b..07eece14 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -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