gendesign/tradein-mvp/backend/app/services/search_query.py
bot-backend b64334d7bd
Some checks failed
CI / backend-tests (push) Has been cancelled
CI / frontend-tests (push) Has been cancelled
CI / openapi-codegen-check (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been cancelled
CI / openapi-codegen-check (pull_request) Has been cancelled
feat(tradein): segment=vtorichka|novostroyki|all query-param on listings (#1188)
2026-06-17 21:43:01 +03:00

154 lines
6.1 KiB
Python

"""SQL builder for /api/v1/search — parameterized, psycopg v3 (CAST(:x AS type))."""
from __future__ import annotations
from app.schemas.search import SearchParams
_SORT_SQL: dict[str, str] = {
"price_asc": "price_rub ASC NULLS LAST",
"price_desc": "price_rub DESC NULLS LAST",
"area_desc": "total_area DESC NULLS LAST",
"area_asc": "total_area ASC NULLS LAST",
"date_desc": "scraped_at DESC NULLS LAST",
"dist_asc": (
"ST_Distance(geom::geography, "
"ST_MakePoint(CAST(:lon AS double precision), "
"CAST(:lat AS double precision))::geography) ASC"
),
}
# Сегмент рынка (#1188). listings_search_mv не несёт колонку listing_segment,
# поэтому фильтруем через подзапрос к базовой таблице listings, переиспользуя
# canon-предикат #1186: NULL = legacy вторичка до миграции 011.
_VTORICHKA_GUARD = "(listing_segment IS NULL OR listing_segment = 'vtorichka')"
_SEGMENT_SQL: dict[str, str | None] = {
"vtorichka": (f"listing_id IN (SELECT id FROM listings WHERE {_VTORICHKA_GUARD})"),
"novostroyki": (
"listing_id IN (SELECT id FROM listings WHERE listing_segment = 'novostroyki')"
),
"all": None,
}
def build_search_query(params: SearchParams) -> tuple[str, dict[str, object]]:
"""Возвращает (sql, args) для SELECT из listings_search_mv."""
where: list[str] = ["1=1"]
args: dict[str, object] = {}
if params.lat is not None and params.lon is not None:
where.append(
"ST_DWithin(geom::geography, "
"ST_MakePoint(CAST(:lon AS double precision), "
"CAST(:lat AS double precision))::geography, "
"CAST(:radius_m AS integer))"
)
args["lat"] = params.lat
args["lon"] = params.lon
args["radius_m"] = params.radius_m
if params.rooms is not None:
where.append("rooms = CAST(:rooms AS integer)")
args["rooms"] = params.rooms
if params.rooms_in:
where.append("rooms = ANY(CAST(:rooms_in AS integer[]))")
args["rooms_in"] = params.rooms_in
if params.area_m2_min is not None:
where.append("total_area >= CAST(:area_min AS double precision)")
args["area_min"] = params.area_m2_min
if params.area_m2_max is not None:
where.append("total_area <= CAST(:area_max AS double precision)")
args["area_max"] = params.area_m2_max
if params.price_rub_min is not None:
where.append("price_rub >= CAST(:price_min AS bigint)")
args["price_min"] = params.price_rub_min
if params.price_rub_max is not None:
where.append("price_rub <= CAST(:price_max AS bigint)")
args["price_max"] = params.price_rub_max
if params.price_per_m2_max is not None:
where.append("price_per_m2 <= CAST(:ppm2_max AS bigint)")
args["ppm2_max"] = params.price_per_m2_max
if params.floor_min is not None:
where.append("floor >= CAST(:floor_min AS integer)")
args["floor_min"] = params.floor_min
if params.floor_max is not None:
where.append("floor <= CAST(:floor_max AS integer)")
args["floor_max"] = params.floor_max
if params.year_built_min is not None:
where.append("year_built >= CAST(:yb_min AS integer)")
args["yb_min"] = params.year_built_min
if params.year_built_max is not None:
where.append("year_built <= CAST(:yb_max AS integer)")
args["yb_max"] = params.year_built_max
if params.house_class:
where.append("house_class = ANY(CAST(:hclass AS text[]))")
args["hclass"] = params.house_class
if params.floors_total_min is not None:
where.append("total_floors >= CAST(:fl_total_min AS integer)")
args["fl_total_min"] = params.floors_total_min
if params.floors_total_max is not None:
where.append("total_floors <= CAST(:fl_total_max AS integer)")
args["fl_total_max"] = params.floors_total_max
if params.has_kadastr:
where.append("cadastral_number IS NOT NULL")
segment_clause = _SEGMENT_SQL[params.segment]
if segment_clause is not None:
where.append(segment_clause)
if params.sources:
where.append("sources && CAST(:sources AS text[])")
args["sources"] = params.sources
if params.multi_source_only:
where.append("source_count >= 2")
if params.require_avito:
where.append("has_avito = true")
if params.require_cian:
where.append("has_cian = true")
if params.require_yandex:
where.append("has_yandex = true")
if params.address_query:
where.append("address ILIKE CAST(:addr_like AS text)")
args["addr_like"] = f"%{params.address_query}%"
if params.description_query:
where.append("tsv @@ plainto_tsquery('russian', CAST(:descq AS text))")
args["descq"] = params.description_query
where_sql = " AND ".join(where)
order_sql = _SORT_SQL[params.sort]
sql = (
"SELECT listing_id, source, source_url, address, lat, lng, "
"rooms, total_area, floor, total_floors, price_rub, price_per_m2, "
"cadastral_number, scraped_at, "
"house_id, year_built, house_class, developer_name, "
"house_rating, house_ratings_count, "
"source_count, sources, has_avito, has_cian, has_yandex, "
"house_median_ppm2, district "
"FROM listings_search_mv "
f"WHERE {where_sql} "
f"ORDER BY {order_sql} "
"LIMIT CAST(:limit AS integer) OFFSET CAST(:offset AS integer)"
)
args["limit"] = params.page_size
args["offset"] = params.offset
return sql, args
def build_count_query(params: SearchParams) -> tuple[str, dict[str, object]]:
"""COUNT(*) — отдельный запрос для total."""
sql, args = build_search_query(params)
where_start = sql.find("WHERE ")
order_start = sql.find(" ORDER BY ")
where_clause = sql[where_start:order_start]
count_args = {k: v for k, v in args.items() if k not in ("limit", "offset")}
count_sql = f"SELECT count(*) AS total FROM listings_search_mv {where_clause}"
return count_sql, count_args