"""Trade-In Estimator — endpoints (TI-2 PDF, photos, history). Реальная оценка делается через app.services.estimator.estimate_quality(). """ from __future__ import annotations import logging from datetime import UTC, date, datetime, timedelta from typing import Annotated, Any from uuid import UUID from fastapi import APIRouter, Depends, File, Header, 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, CianPriceChangeStats, HouseAnalyticsKpi, HouseAnalyticsResponse, HouseInfoForEstimate, IMVBenchmarkResponse, PhotoMeta, PlacementHistoryEntry, PriceHistoryYearPoint, QuotaStatus, RecentSoldEntry, SalesListingPair, SalesVsListingsResponse, SellTimeBucket, SellTimeSensitivityResponse, StreetDealsResponse, TradeInEstimateInput, ) from app.services import account_quota 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() @router.post("/estimate", response_model=AggregatedEstimate) async def estimate( payload: TradeInEstimateInput, db: Annotated[Session, Depends(get_db)], x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None, ) -> 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 Применяется лимит 15 успешных оценок в месяц на аккаунт (кроме admin/kopylov). """ account_quota.check_and_raise(db, x_authenticated_user) from app.services.estimator import estimate_quality result = await estimate_quality(payload, db) account_quota.increment(db, x_authenticated_user) return result @router.get("/quota", response_model=QuotaStatus) def get_quota( db: Annotated[Session, Depends(get_db)], x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None, ) -> QuotaStatus: """Статус квоты оценок для текущего аккаунта. Возвращает limit / used / remaining / unlimited для X-Authenticated-User. Без заголовка (dev-режим без Caddy) — unlimited True, used 0. """ status = account_quota.get_status(db, x_authenticated_user) return QuotaStatus(**status) @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, canonical_address, house_cadnum, house_fias_id, dadata_qc_geo, dadata_metro, expected_sold_price, expected_sold_range_low, expected_sold_range_high, expected_sold_per_m2, asking_to_sold_ratio, ratio_basis 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") from app.services.estimator import _qc_geo_to_precision 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». # DaData-обогащёнка (canonical/cadnum/fias/precision/metro) тоже # rehydrate'ится из row — иначе бейдж точности + метро не показывались # на shared-link reopen / в PDF. 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, # #648 Stage 3 — sold-correction columns rehydrated for shared-link reopen. # (numeric ratio → float for the Pydantic field.) expected_sold_price_rub=row.expected_sold_price, expected_sold_range_low_rub=row.expected_sold_range_low, expected_sold_range_high_rub=row.expected_sold_range_high, expected_sold_per_m2=row.expected_sold_per_m2, asking_to_sold_ratio=( float(row.asking_to_sold_ratio) if row.asking_to_sold_ratio is not None else None ), ratio_basis=row.ratio_basis, 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, canonical_address=row.canonical_address, house_cadnum=row.house_cadnum, house_fias_id=row.house_fias_id, address_precision=_qc_geo_to_precision(row.dadata_qc_geo), metro_nearest=(row.dadata_metro or []), ) @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, canonical_address, house_cadnum, house_fias_id, dadata_qc_geo, dadata_metro, expected_sold_price, expected_sold_range_low, expected_sold_range_high, expected_sold_per_m2, asking_to_sold_ratio, ratio_basis 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)") from app.services.estimator import _qc_geo_to_precision 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, # #648 Stage 3 — sold-correction columns rehydrated so the PDF carries them. expected_sold_price_rub=row.expected_sold_price, expected_sold_range_low_rub=row.expected_sold_range_low, expected_sold_range_high_rub=row.expected_sold_range_high, expected_sold_per_m2=row.expected_sold_per_m2, asking_to_sold_ratio=( float(row.asking_to_sold_ratio) if row.asking_to_sold_ratio is not None else None ), ratio_basis=row.ratio_basis, canonical_address=row.canonical_address, house_cadnum=row.house_cadnum, house_fias_id=row.house_fias_id, address_precision=_qc_geo_to_precision(row.dadata_qc_geo), metro_nearest=(row.dadata_metro or []), ) 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 ─────────────────────────────── _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, db: Annotated[Session, Depends(get_db)], ) -> list[HouseInfoForEstimate]: """House(s) информация для estimate. Логика (двойной поиск, union-deduplicate): 1. Прямое совпадение по нормализованному адресу (tradein_normalize_short_addr). 2. Geo-nearby — ST_DWithin 500м, любой source (avito/derived/etc.). Возвращаем прямой матч + nearby (dedup by id), up to ~6 домов. Пустой список если нет matches. """ 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") # 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 is not None and target.lon is not None: 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 merged ] @router.get("/estimate/{estimate_id}/placement-history", response_model=list[PlacementHistoryEntry]) def get_estimate_placement_history( estimate_id: UUID, db: Annotated[Session, Depends(get_db)], ) -> list[PlacementHistoryEntry]: """Историческая продажная активность по дому(ам) 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 is not None and target.lon is not None: # 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 [PlacementHistoryEntry(**dict(r)) for r in history] @router.get("/estimate/{estimate_id}/house-analytics", response_model=HouseAnalyticsResponse) def get_estimate_house_analytics( estimate_id: UUID, db: Annotated[Session, Depends(get_db)], ) -> HouseAnalyticsResponse: """House-level analytics from house_placement_history backfill. Resolves target house(s) — если в самом доме <8 hist rows — расширяем поиск до 300м. Возвращает: price-history by year (median ₽/м²), recent sold (12mo), KPI. """ 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") # 1. Resolve target house_ids (same as placement-history endpoint) 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 is not None and target.lon is not None: 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] # 2. Expand to 300m if too few hist rows radius_used = 0 n_in_house = 0 if house_ids: n_in_house = ( db.execute( text("SELECT COUNT(*) FROM house_placement_history WHERE house_id = ANY(:ids)"), {"ids": house_ids}, ).scalar() or 0 ) if n_in_house < 8 and target.lat is not None and target.lon is not None: rows = db.execute( text( "SELECT id FROM houses WHERE geom IS NOT NULL AND ST_DWithin(" "geom::geography, ST_MakePoint(:lon, :lat)::geography, 300) LIMIT 30" ), {"lat": target.lat, "lon": target.lon}, ).all() house_ids = sorted(set(house_ids) | {r.id for r in rows}) radius_used = 300 if not house_ids: return HouseAnalyticsResponse( house_ids=[], radius_m=0, price_history=[], recent_sold=[], kpi=HouseAnalyticsKpi( total_lots=0, sold_count=0, sold_rate_pct=0.0, median_exposure_days=None, median_bargain_pct=None, ), ) # 3. Price history by year × source (median ₽/м²) price_history_rows = db.execute( text( """ SELECT EXTRACT(YEAR FROM COALESCE(last_price_date, start_price_date))::int AS year, source, COUNT(*) AS n_lots, percentile_cont(0.5) WITHIN GROUP (ORDER BY last_price / NULLIF(area_m2, 0))::int AS median_price_per_m2, percentile_cont(0.5) WITHIN GROUP (ORDER BY last_price)::int AS median_price_rub FROM house_placement_history WHERE house_id = ANY(:ids) AND last_price IS NOT NULL AND last_price > 100000 AND area_m2 IS NOT NULL AND area_m2 > 10 AND COALESCE(last_price_date, start_price_date) IS NOT NULL GROUP BY year, source ORDER BY year ASC, source ASC """ ), {"ids": house_ids}, ).mappings().all() # 4. Recent sold (12 months, with removed_date) recent_sold_rows = db.execute( text( """ SELECT id, source, rooms, area_m2, floor, start_price, last_price, removed_date, exposure_days, CASE WHEN start_price > 0 AND last_price IS NOT NULL THEN ROUND((start_price - last_price)::numeric / start_price * 100, 1) ELSE NULL END AS discount_pct FROM house_placement_history WHERE house_id = ANY(:ids) AND removed_date IS NOT NULL AND removed_date > (NOW() - INTERVAL '12 months')::date ORDER BY removed_date DESC LIMIT 20 """ ), {"ids": house_ids}, ).mappings().all() # 5. KPI aggregate kpi_row = db.execute( text( """ SELECT COUNT(*) AS total_lots, COUNT(*) FILTER (WHERE removed_date IS NOT NULL) AS sold_count, CASE WHEN COUNT(*) > 0 THEN ROUND( COUNT(*) FILTER (WHERE removed_date IS NOT NULL)::numeric / COUNT(*) * 100, 1 ) ELSE 0 END AS sold_rate_pct, percentile_cont(0.5) WITHIN GROUP (ORDER BY exposure_days) FILTER (WHERE exposure_days IS NOT NULL) AS median_exposure_days, ROUND( percentile_cont(0.5) WITHIN GROUP ( ORDER BY (start_price - last_price)::numeric / NULLIF(start_price, 0) * 100 ) FILTER ( WHERE start_price > 0 AND last_price IS NOT NULL AND last_price != start_price )::numeric, 1 ) AS median_bargain_pct FROM house_placement_history WHERE house_id = ANY(:ids) """ ), {"ids": house_ids}, ).mappings().first() return HouseAnalyticsResponse( house_ids=house_ids, radius_m=radius_used, price_history=[PriceHistoryYearPoint(**dict(r)) for r in price_history_rows], recent_sold=[RecentSoldEntry(**dict(r)) for r in recent_sold_rows], kpi=HouseAnalyticsKpi( total_lots=kpi_row["total_lots"] or 0 if kpi_row else 0, sold_count=kpi_row["sold_count"] or 0 if kpi_row else 0, sold_rate_pct=float(kpi_row["sold_rate_pct"] or 0) if kpi_row else 0.0, median_exposure_days=( int(kpi_row["median_exposure_days"]) if kpi_row and kpi_row["median_exposure_days"] is not None else None ), median_bargain_pct=( float(kpi_row["median_bargain_pct"]) if kpi_row and kpi_row["median_bargain_pct"] is not None else None ), ), ) @router.get( "/estimate/{estimate_id}/cian-price-changes", response_model=list[CianPriceChangeStats], ) def get_estimate_cian_price_changes( estimate_id: UUID, db: Annotated[Session, Depends(get_db)], ) -> list[CianPriceChangeStats]: """История изменений цены для Cian-аналогов из estimate. Для каждого cian-аналога в estimate.analogs: - extract cian_id из URL (/sale/flat//) - JOIN listings (source='cian', source_id IN cian_ids) - JOIN offer_price_history per listing_id - Aggregate: n_changes, last_change_time, last_diff_percent, total_change_pct Возвращает только аналоги с хотя бы одним изменением цены. Пустой список если нет cian-аналогов или нет истории. """ rows = db.execute( text( """ WITH cian_analogs AS ( SELECT DISTINCT substring(a->>'source_url' from '/sale/flat/(\\d+)/') AS cian_id FROM trade_in_estimates, jsonb_array_elements(analogs) a WHERE id = CAST(:eid AS uuid) AND a->>'source' = 'cian' AND a->>'source_url' IS NOT NULL AND substring(a->>'source_url' from '/sale/flat/(\\d+)/') IS NOT NULL ), listings_resolved AS ( SELECT l.id AS listing_id, l.source_id AS cian_id, l.price_rub::int AS current_price FROM listings l JOIN cian_analogs c ON l.source_id = c.cian_id WHERE l.source = 'cian' ), changes_agg AS ( SELECT oph.listing_id, COUNT(*) AS n_changes, MAX(oph.change_time) AS last_change_time, (array_agg(oph.diff_percent ORDER BY oph.change_time DESC))[1] AS last_diff_percent, ( SELECT oph2.price_rub::int FROM offer_price_history oph2 WHERE oph2.listing_id = oph.listing_id ORDER BY oph2.change_time ASC LIMIT 1 ) AS first_seen_price FROM offer_price_history oph WHERE oph.listing_id IN (SELECT listing_id FROM listings_resolved) GROUP BY oph.listing_id ) SELECT lr.cian_id, lr.listing_id, ca.n_changes::int, ca.last_change_time, ca.last_diff_percent::float AS last_diff_percent, ca.first_seen_price, lr.current_price, CASE WHEN ca.first_seen_price IS NOT NULL AND ca.first_seen_price > 0 THEN ROUND( (lr.current_price - ca.first_seen_price)::numeric / ca.first_seen_price * 100, 1 )::float ELSE NULL END AS total_change_pct FROM listings_resolved lr JOIN changes_agg ca ON ca.listing_id = lr.listing_id WHERE ca.n_changes > 0 ORDER BY ca.last_change_time DESC """ ), {"eid": str(estimate_id)}, ).mappings().all() return [CianPriceChangeStats(**dict(r)) for r in rows] @router.get( "/estimate/{estimate_id}/sell-time-sensitivity", response_model=SellTimeSensitivityResponse, ) def get_estimate_sell_time_sensitivity( estimate_id: UUID, db: Annotated[Session, Depends(get_db)], ) -> SellTimeSensitivityResponse: """Срок продажи в зависимости от цены к медиане дома/района. 4 бакета: -5% / медиана (±3%) / +5% / +10%. Median exposure_days + p25/p75. Filter last_price > start_price * 0.7 — отбрасываем подозрительно заниженные лоты (выбросы, ошибки парсинга). """ # 1. Resolve house_ids (same logic as house-analytics) 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: 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 is not None and target.lon is not None: 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] # Expand to 300m if too few rows (same threshold as house-analytics) radius_used = 0 n_in_house = 0 if house_ids: n_in_house = ( db.execute( text("SELECT COUNT(*) FROM house_placement_history WHERE house_id = ANY(:ids)"), {"ids": house_ids}, ).scalar() or 0 ) if n_in_house < 8 and target.lat is not None and target.lon is not None: rows = db.execute( text( "SELECT id FROM houses WHERE geom IS NOT NULL AND ST_DWithin(" "geom::geography, ST_MakePoint(:lon, :lat)::geography, 300) LIMIT 30" ), {"lat": target.lat, "lon": target.lon}, ).all() house_ids = sorted(set(house_ids) | {r.id for r in rows}) radius_used = 300 if not house_ids: return SellTimeSensitivityResponse( house_ids=[], radius_m=0, target_median_price_per_m2=None, buckets=[], ) # 2. Compute benchmark median ₽/м² for last 2 years target_median = db.execute( text( """ SELECT percentile_cont(0.5) WITHIN GROUP ( ORDER BY last_price / NULLIF(area_m2, 0) )::int AS median_ppm2 FROM house_placement_history WHERE house_id = ANY(:ids) AND last_price IS NOT NULL AND last_price > 100000 AND area_m2 > 10 AND COALESCE(last_price_date, start_price_date) > (NOW() - INTERVAL '2 years')::date AND (start_price = 0 OR last_price > start_price * 0.7) """ ), {"ids": house_ids}, ).scalar() # 3. Per-year median (для расчёта premium per lot); используем CTE для bucket-расчёта bucket_rows = db.execute( text( """ WITH year_medians AS ( SELECT EXTRACT(YEAR FROM COALESCE(last_price_date, start_price_date))::int AS year, percentile_cont(0.5) WITHIN GROUP ( ORDER BY last_price / NULLIF(area_m2, 0) ) AS median_ppm2 FROM house_placement_history WHERE house_id = ANY(:ids) AND last_price IS NOT NULL AND area_m2 > 10 AND (start_price = 0 OR last_price > start_price * 0.7) GROUP BY year ), lots_with_premium AS ( SELECT hph.exposure_days, CASE WHEN ym.median_ppm2 IS NULL OR ym.median_ppm2 = 0 THEN NULL ELSE ((hph.last_price / NULLIF(hph.area_m2, 0)) - ym.median_ppm2) / ym.median_ppm2 * 100 END AS premium_pct FROM house_placement_history hph JOIN year_medians ym ON ym.year = EXTRACT(YEAR FROM COALESCE(hph.last_price_date, hph.start_price_date))::int WHERE hph.house_id = ANY(:ids) AND hph.removed_date IS NOT NULL AND hph.exposure_days IS NOT NULL AND hph.area_m2 > 10 AND (hph.start_price = 0 OR hph.last_price > hph.start_price * 0.7) ), bucketed AS ( SELECT CASE WHEN premium_pct BETWEEN -10 AND -3 THEN 'cheap' WHEN premium_pct BETWEEN -3 AND 3 THEN 'median' WHEN premium_pct BETWEEN 3 AND 8 THEN 'plus5' WHEN premium_pct BETWEEN 8 AND 15 THEN 'plus10' ELSE NULL END AS bucket, exposure_days FROM lots_with_premium WHERE premium_pct IS NOT NULL ) SELECT bucket, COUNT(*) AS n_lots, percentile_cont(0.5) WITHIN GROUP (ORDER BY exposure_days)::int AS median_exposure_days, percentile_cont(0.25) WITHIN GROUP (ORDER BY exposure_days)::int AS p25_days, percentile_cont(0.75) WITHIN GROUP (ORDER BY exposure_days)::int AS p75_days FROM bucketed WHERE bucket IS NOT NULL GROUP BY bucket """ ), {"ids": house_ids}, ).mappings().all() # 4. Build buckets — гарантируем все 4 даже если данных нет в bucket bucket_map = {r["bucket"]: dict(r) for r in bucket_rows} bucket_definitions = [ ("cheap", -5.0), ("median", 0.0), ("plus5", 5.0), ("plus10", 10.0), ] buckets: list[SellTimeBucket] = [] for label, pct in bucket_definitions: r = bucket_map.get(label) buckets.append( SellTimeBucket( price_premium_label=label, price_premium_pct=pct, median_exposure_days=r["median_exposure_days"] if r else None, p25_days=r["p25_days"] if r else None, p75_days=r["p75_days"] if r else None, n_lots=r["n_lots"] if r else 0, ) ) return SellTimeSensitivityResponse( house_ids=house_ids, radius_m=radius_used, target_median_price_per_m2=int(target_median) if target_median else None, buckets=buckets, ) @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, ) # ── Street-level deals (rosreestr open dataset) ─────────────────────────────── @router.get("/street-deals", response_model=StreetDealsResponse) def get_street_deals( address: str, area_m2: float, rooms: int, db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment] period_months: int = 12, area_tolerance: float = 0.15, ) -> StreetDealsResponse: """ДКП-сделки Росреестра по улице целевого адреса. Open dataset Росреестра агрегирует адреса до улицы (без номера дома). Поэтому это per-street view, не per-house. Фильтр по rooms + area сужает выборку до квартир-аналогов. После PR-A (#549) таблица deals содержит только ДКП (ДДУ-первичка отфильтрована в import-rosreestr.sh). """ from app.services.estimator import _deal_to_analog, _percentile, extract_street_name now = datetime.now(tz=UTC) period_from: date = (now - timedelta(days=period_months * 30)).date() period_to: date = now.date() def _empty() -> StreetDealsResponse: return StreetDealsResponse( street=None, period_from=period_from, period_to=period_to, count=0, median_price_rub=0, median_price_per_m2=0, range_low_rub=0, range_high_rub=0, deals=[], ) street_name = extract_street_name(address) if not street_name: logger.warning("street-deals: could not extract street from address=%r", address) return _empty() area_min = area_m2 * (1.0 - area_tolerance) area_max = area_m2 * (1.0 + area_tolerance) rows = db.execute( text( """ SELECT address, area_m2, rooms, floor, total_floors, price_rub, price_per_m2, deal_date, source FROM deals WHERE source = 'rosreestr' AND address ILIKE :street_pattern AND rooms = CAST(:rooms AS integer) AND area_m2 BETWEEN :area_min AND :area_max AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval AND price_rub > 0 ORDER BY deal_date DESC """ ), { "street_pattern": "%" + street_name + "%", "rooms": rooms, "area_min": area_min, "area_max": area_max, "period_months": period_months, }, ).mappings().all() if not rows: logger.info( "street-deals: no rows found street=%r rooms=%d area=%.1f±%.0f%%", street_name, rooms, area_m2, area_tolerance * 100, ) return StreetDealsResponse( street=street_name, period_from=period_from, period_to=period_to, count=0, median_price_rub=0, median_price_per_m2=0, range_low_rub=0, range_high_rub=0, deals=[], ) count = len(rows) prices_rub = sorted(float(r["price_rub"]) for r in rows) prices_ppm2 = sorted(float(r["price_per_m2"]) for r in rows if r["price_per_m2"]) median_ppm2 = _percentile(prices_ppm2, 0.5) if prices_ppm2 else 0.0 median_price_rub = ( int(median_ppm2 * area_m2) if median_ppm2 else int(_percentile(prices_rub, 0.5)) ) range_low_rub = int(prices_rub[0]) range_high_rub = int(prices_rub[-1]) top10 = [_deal_to_analog(dict(r)) for r in rows[:10]] logger.info( "street-deals: street=%r rooms=%d area=%.1f count=%d median_ppm2=%.0f", street_name, rooms, area_m2, count, median_ppm2, ) return StreetDealsResponse( street=street_name, period_from=period_from, period_to=period_to, count=count, median_price_rub=median_price_rub, median_price_per_m2=int(median_ppm2), range_low_rub=range_low_rub, range_high_rub=range_high_rub, deals=top10, ) # ── Sales vs Listings (PR K — Foundation Phase 1 of issue #564) ────────────── @router.get("/sales-vs-listings", response_model=SalesVsListingsResponse) def get_sales_vs_listings( address: str, area_m2: float, rooms: int, db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment] window_days: int = 180, area_tolerance: float = 0.15, period_months: int = 24, ) -> SalesVsListingsResponse: """Pairs (ДКП-сделка, listing) для улицы целевого адреса (PR K / #564). Для каждой ДКП-сделки Росреестра в окне `period_months` пытаемся найти matching listing на той же улице с такими же rooms / близкой area_m2 / listing_date в окне [deal_date - window_days, deal_date + 30d grace]. Возвращаем LEFT JOIN: сделки без listing match сохраняются (listing_* = None), чтобы вычислить linkage_rate. discount_pct = (deal_price - listing_price) / listing_price * 100. Отрицательный = продали дешевле asking → reasoned discount от торга. Per-street view: Росреестр open dataset агрегирует адреса до улицы. """ from app.services.estimator import _percentile, extract_street_name def _empty(reason_street: str | None = None) -> SalesVsListingsResponse: return SalesVsListingsResponse( street=reason_street, period_months=period_months, window_days=window_days, area_tolerance=area_tolerance, total_deals=0, deals_with_listings=0, linkage_rate_pct=0.0, median_discount_pct=None, pairs=[], ) street_name = extract_street_name(address) if not street_name: logger.warning("sales-vs-listings: cannot extract street from %r", address) return _empty() rows = db.execute( text( """ SELECT deal_id, deal_date, deal_price_rub, deal_price_per_m2, deal_area_m2, deal_rooms, deal_floor, deal_address, listing_id, listing_source, listing_source_url, listing_date, listing_price_rub, listing_price_per_m2, listing_area_m2, days_listing_to_deal, discount_pct FROM street_sales_vs_listings( CAST(:street_pattern AS text), CAST(:area_m2 AS numeric), CAST(:rooms AS integer), CAST(:window_days AS integer), CAST(:area_tolerance AS numeric), CAST(:period_months AS integer) ) """ ), { "street_pattern": "%" + street_name + "%", "area_m2": area_m2, "rooms": rooms, "window_days": window_days, "area_tolerance": area_tolerance, "period_months": period_months, }, ).mappings().all() if not rows: logger.info( "sales-vs-listings: no deals street=%r rooms=%d area=%.1f period_months=%d", street_name, rooms, area_m2, period_months, ) return _empty(reason_street=street_name) pairs = [ SalesListingPair( deal_id=r["deal_id"], deal_date=r["deal_date"], deal_price_rub=int(r["deal_price_rub"]), deal_price_per_m2=int(r["deal_price_per_m2"] or 0), deal_area_m2=float(r["deal_area_m2"]), deal_rooms=int(r["deal_rooms"]), deal_floor=r["deal_floor"], deal_address=r["deal_address"], listing_id=r["listing_id"], listing_source=r["listing_source"], listing_source_url=r["listing_source_url"], listing_date=r["listing_date"], listing_price_rub=( int(r["listing_price_rub"]) if r["listing_price_rub"] is not None else None ), listing_price_per_m2=( int(r["listing_price_per_m2"]) if r["listing_price_per_m2"] is not None else None ), listing_area_m2=( float(r["listing_area_m2"]) if r["listing_area_m2"] is not None else None ), days_listing_to_deal=r["days_listing_to_deal"], discount_pct=( float(r["discount_pct"]) if r["discount_pct"] is not None else None ), ) for r in rows ] total_deals = len(pairs) deals_with_listings = sum(1 for p in pairs if p.listing_id is not None) linkage_rate_pct = ( round(deals_with_listings / total_deals * 100, 1) if total_deals else 0.0 ) discounts = sorted(p.discount_pct for p in pairs if p.discount_pct is not None) median_discount = ( round(_percentile(discounts, 0.5), 2) if discounts else None ) logger.info( "sales-vs-listings: street=%r deals=%d with_listings=%d linkage=%.1f%% median_disc=%s", street_name, total_deals, deals_with_listings, linkage_rate_pct, f"{median_discount:+.2f}%" if median_discount is not None else "n/a", ) return SalesVsListingsResponse( street=street_name, period_months=period_months, window_days=window_days, area_tolerance=area_tolerance, total_deals=total_deals, deals_with_listings=deals_with_listings, linkage_rate_pct=linkage_rate_pct, median_discount_pct=median_discount, pairs=pairs, )