"""Search endpoint — POST /api/v1/search (Phase 3.2, master plan sec 9.1).""" from __future__ import annotations import logging import time from typing import Annotated from fastapi import APIRouter, Depends from sqlalchemy import text from sqlalchemy.orm import Session from app.core.db import get_db from app.schemas.search import SearchParams from app.schemas.search_response import SearchResponse, SearchResultItem from app.services.cache import get_search_cache from app.services.search_query import build_count_query, build_search_query logger = logging.getLogger(__name__) router = APIRouter() def _map_row(row: object) -> dict[str, object]: """Маппит строку matview в поля SearchResultItem. SQL отдаёт колонку `cadastral_number`, а в схеме поле зовётся `kadastr_num` — без явного маппинга значение молча теряется (#1514). """ data = dict(row) if "cadastral_number" in data: data["kadastr_num"] = data.pop("cadastral_number") return data @router.post("/search", response_model=SearchResponse) async def search( params: SearchParams, db: Annotated[Session, Depends(get_db)], ) -> SearchResponse: """Поиск listings по фильтрам (cross-source matview + Redis 5min cache).""" started = time.monotonic() cache = get_search_cache() cache_key = cache.search_key(params.model_dump(mode="json")) cached = await cache.get(cache_key) if cached is not None: logger.info( "search cache HIT key=%s page=%d size=%d", cache_key[:24], params.page, params.page_size, ) resp = SearchResponse.model_validate(cached) resp.cache_hit = True return resp sql, args = build_search_query(params) rows = db.execute(text(sql), args).mappings().all() count_sql, count_args = build_count_query(params) total = int(db.execute(text(count_sql), count_args).scalar() or 0) items = [SearchResultItem.model_validate(_map_row(r)) for r in rows] elapsed_ms = (time.monotonic() - started) * 1000.0 response = SearchResponse( items=items, total=total, page=params.page, page_size=params.page_size, elapsed_ms=round(elapsed_ms, 1), cache_hit=False, ) await cache.set(cache_key, response.model_dump(mode="json"), ttl=cache.TTL_SEARCH) logger.info( "search MISS page=%d size=%d total=%d items=%d elapsed=%.1fms", params.page, params.page_size, total, len(items), elapsed_ms, ) return response