feat(tradein): segment=vtorichka|novostroyki|all query-param on listings (#1188)
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

This commit is contained in:
bot-backend 2026-06-17 21:43:01 +03:00
parent 584aa46f6f
commit b64334d7bd
3 changed files with 70 additions and 3 deletions

View file

@ -6,9 +6,11 @@ from typing import Literal
from pydantic import BaseModel, Field, model_validator
SortKey = Literal[
"price_asc", "price_desc", "area_desc", "area_asc", "date_desc", "dist_asc"
]
SortKey = Literal["price_asc", "price_desc", "area_desc", "area_asc", "date_desc", "dist_asc"]
# Сегмент рынка (#1188, поверх canon-предиката #1186).
# NULL listing_segment (legacy вторичка до миграции 011) трактуется как 'vtorichka'.
SegmentKey = Literal["vtorichka", "novostroyki", "all"]
class SearchParams(BaseModel):
@ -49,6 +51,19 @@ class SearchParams(BaseModel):
address_query: str | None = Field(default=None, max_length=200)
description_query: str | None = Field(default=None, max_length=200)
# --- Market segment (#1188) ---
segment: SegmentKey = Field(
default="vtorichka",
description=(
"Сегмент рынка для фильтрации listings. "
"`vtorichka` (по умолчанию, back-compat) — вторичка; "
"строки с listing_segment=NULL (legacy до миграции 011) "
"считаются вторичкой согласно canon-предикату #1186. "
"`novostroyki` — только первичка. "
"`all` — без фильтра по сегменту."
),
)
# --- Sort + pagination ---
sort: SortKey = "date_desc"
page: int = Field(default=1, ge=1, le=1000)

View file

@ -17,6 +17,18 @@ _SORT_SQL: dict[str, str] = {
),
}
# Сегмент рынка (#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."""
@ -86,6 +98,10 @@ def build_search_query(params: SearchParams) -> tuple[str, dict[str, object]]:
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

View file

@ -62,6 +62,42 @@ def test_build_query_sources_array():
assert args["sources"] == ["avito", "cian"]
def test_build_query_segment_default_vtorichka():
"""Без параметра segment — back-compat: canon-предикат вторички (#1186/#1188)."""
sql, _ = build_search_query(SearchParams())
assert SearchParams().segment == "vtorichka"
assert "listing_id IN (SELECT id FROM listings WHERE" in sql
assert "(listing_segment IS NULL OR listing_segment = 'vtorichka')" in sql
def test_build_query_segment_novostroyki():
"""segment=novostroyki — только первичка, без NULL-вторички."""
sql, _ = build_search_query(SearchParams(segment="novostroyki"))
assert "listing_segment = 'novostroyki'" in sql
# Не должен попасть canon-guard вторички.
assert "(listing_segment IS NULL OR listing_segment = 'vtorichka')" not in sql
def test_build_query_segment_all_no_filter():
"""segment=all — без фильтрации по сегменту."""
sql, _ = build_search_query(SearchParams(segment="all"))
assert "listing_segment" not in sql
def test_build_count_query_inherits_segment():
"""COUNT-запрос наследует segment-фильтр из build_search_query."""
sql, _ = build_count_query(SearchParams(segment="novostroyki"))
assert "listing_segment = 'novostroyki'" in sql
sql_v, _ = build_count_query(SearchParams())
assert "(listing_segment IS NULL OR listing_segment = 'vtorichka')" in sql_v
def test_segment_invalid_rejected():
"""Невалидное значение segment отклоняется валидацией."""
with pytest.raises(ValueError):
SearchParams(segment="kommercia")
def test_build_count_query_strips_limit():
sql, args = build_count_query(SearchParams(rooms=2, page=3))
assert "count(*)" in sql