From b64334d7bd871693a6c7e87b79d2d458f7a8dcc2 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 17 Jun 2026 21:43:01 +0300 Subject: [PATCH] feat(tradein): segment=vtorichka|novostroyki|all query-param on listings (#1188) --- tradein-mvp/backend/app/schemas/search.py | 21 +++++++++-- .../backend/app/services/search_query.py | 16 +++++++++ tradein-mvp/backend/tests/test_search_api.py | 36 +++++++++++++++++++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/tradein-mvp/backend/app/schemas/search.py b/tradein-mvp/backend/app/schemas/search.py index bf797ab0..b1ecad9a 100644 --- a/tradein-mvp/backend/app/schemas/search.py +++ b/tradein-mvp/backend/app/schemas/search.py @@ -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) diff --git a/tradein-mvp/backend/app/services/search_query.py b/tradein-mvp/backend/app/services/search_query.py index e0687876..b7d06ed7 100644 --- a/tradein-mvp/backend/app/services/search_query.py +++ b/tradein-mvp/backend/app/services/search_query.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_search_api.py b/tradein-mvp/backend/tests/test_search_api.py index 0e0512aa..0a7fc383 100644 --- a/tradein-mvp/backend/tests/test_search_api.py +++ b/tradein-mvp/backend/tests/test_search_api.py @@ -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 -- 2.45.3