All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m54s
Три находки эпика #2674 про верхние тиры матчинга домов. Замеры — прод tradein-postgres, 2026-08-05/06. Кадастр от площадок не приходит вообще. listings.cadastral_number (кадастр КВАРТИРЫ) — 0 из 93 408; единственный писатель, парсер Циана, читает offer["cadastralNumber"], которого в ответе нет. Все 28 504 заполненных building_cadastral_number на 100% пришли из локального гео-зеркала ЕГРН (tasks/cadastral_geo_match.py, KNN <=50 м) — проверено джойном к cad_buildings_local. Поэтому снят фильтр поиска has_kadastr: предикат `cadastral_number IS NOT NULL` мог вернуть только пустую выдачу. Колонка и писатель оставлены — заработают сами, если площадка начнёт отдавать кадастр. Tier 0 cadastr_exact оставлен, но не подключён к гео-кадастру. Он достижим по построению (ScrapedLot -> адаптер -> матчер), просто данных нет; подать туда KNN-заполнение НЕЛЬЗЯ: как ключ здания оно не инъективно — 656 из 3 260 значений накрывают >1 здание ГАР (20.1%), 751 из 2 864 зданий получают >1 значение (26.2%). Это был бы over-merge с confidence 1.0. Заодно исправлено ложное утверждение в шапке cadastral_geo_match.py, будто Tier 0 трактует эту колонку как подсказку. Tier 0.5 fias_exact удалён из match_or_create_house. Параметра house_fias_id не было ни в Protocol scraper_kit.contracts.HouseMatcher, ни в RealMatcherAdapter, ни у двух прямых вызывающих — передать его было некому. В match_house_readonly тир оставлен: у estimate-пути источник ФИАС есть (payload.target_fias_id / DaData). Что чинит сопоставление на самом деле: ключ идентичности в house_dedup_merge расширен с house_fias_id до COALESCE(house_fias_id, gar_house_guid). Это один и тот же UUID здания в ГАР (3 666 совпадений из 3 667 домов, где заполнены оба), но заполняют его разные источники, и половина в проход не входила. Read-only прогон отрендеренного mapping-SQL на проде: старый ключ — 0 пар, новый — 781 (8.3% таблицы houses, 6 389 объявлений на них). Канон-проход эти дома узнаёт (900 пар из 919 имеют один канон-адрес), но блокирует гео-стражем: 356 пар с NULL geom, 457 дальше 250 м (максимум 5 065 км — битый геокод). Ровно аргумент #2187: общий UUID здания старше близости. Качество сопоставления сейчас: 0 из 49 502 строк house_sources сматчены верхними тирами; fingerprint 58.97%, new 22.65%, geo_proximity 18.36%. Тесты: новый tests/test_matching_tier_reachability_2674.py сверяет параметры матчера с границей вызова (Protocol + адаптер) — ловит класс «ветка есть, передать некому», который обычный тест не видит, потому что зовёт функцию напрямую. Удалены два теста мёртвого fias-тира: они были зелёными ровно потому, что обходили границу вызова. Refs #2674
158 lines
6.8 KiB
Python
158 lines
6.8 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
|
||
|
||
# Фильтр has_kadastr удалён (#2674): `listings.cadastral_number` (кадастр КВАРТИРЫ)
|
||
# пуст у всех 93 408 объявлений — площадки его не отдают (единственный писатель,
|
||
# парсер Циана, читает offer["cadastralNumber"], которого в ответе нет). Предикат
|
||
# `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
|