From ef82a707fcc055915881fb9b6e7c286e1a0de7dc Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 13:35:41 +0300 Subject: [PATCH] =?UTF-8?q?fix(mera):=20=D0=BC=D0=BE=D1=81=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=D1=81=D0=BA=D0=B8=D0=B9=20city=5Fhint=20=D0=B1=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20=D1=83=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=D0=B8=D1=82=20=D0=BC=D0=BE=D0=BB=D1=87=D0=B0=20=D0=B2=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B3=D0=B8=D0=BE=D0=BD=2066?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Публичный `/suggest` и кабинетный `/api/v1/geocode/suggest` всегда звали геокодер с `region_code=66`: публичная ручка регион не передавала вовсе, а у кабинетной он был обязательным параметром со значением по умолчанию. `city_hint="Москва"` на это не влиял — DaData и Nominatim получали свердловский hard-констрейнт и молча возвращали ПУСТО. С сайта и из кабинета московский адрес просто нельзя было ввести, хотя оценка, проба покрытия и реестр регионов Москву уже поддерживают. Добавлен `effective_region_code()`: явный `region_code` важнее вывода из `city_hint`, вывод идёт через существующий реестр `app.services.regions` (`REGIONS[77].cities` содержит «москва»), последний рубеж — прежний `DEFAULT_REGION_CODE`. Отдельного списка городов не заводим: разъехаться двум спискам — вопрос времени. Поведение сегодняшних клиентов не меняется байт-в-байт: без `city_hint` и с любым свердловским городом регион по-прежнему 66. Публичная схема принимает `region_code` на будущее — если фронт когда-нибудь начнёт его слать, он будет приоритетнее хинта; неизвестный регион как и раньше отдаёт 422 из геокодера, а не 500. Тесты: четыре инварианта на сам хелпер (нет хинта → 66; свердловский город → 66; Москва → 77; явный 66 поверх Москвы → 66) и по одному на каждую ручку — что вниз по потоку уезжает ожидаемый регион. Прежние тесты region-скоупа геокодера не тронуты. 189 passed в связанных файлах, ruff чистый. --- tradein-mvp/backend/app/api/public/mera.py | 6 ++- tradein-mvp/backend/app/api/v1/geocode.py | 36 ++++++++++--- .../tests/test_3051_suggest_region_scope.py | 51 +++++++++++++++++++ .../backend/tests/test_public_mera_api.py | 19 +++++++ 4 files changed, 104 insertions(+), 8 deletions(-) diff --git a/tradein-mvp/backend/app/api/public/mera.py b/tradein-mvp/backend/app/api/public/mera.py index db853872..73baadaf 100644 --- a/tradein-mvp/backend/app/api/public/mera.py +++ b/tradein-mvp/backend/app/api/public/mera.py @@ -79,7 +79,7 @@ from pydantic import BaseModel, Field from sqlalchemy import text from sqlalchemy.orm import Session -from app.api.v1.geocode import SuggestResponse, suggest_addresses +from app.api.v1.geocode import SuggestResponse, effective_region_code, suggest_addresses from app.api.v1.trade_in import coverage_probe, estimate from app.core.config import settings from app.core.db import get_db @@ -222,6 +222,9 @@ class PublicSuggestInput(BaseModel): q: str = Field(min_length=2, max_length=200) limit: int = Field(default=8, ge=1, le=10) city_hint: str | None = Field(default=None, max_length=100) + # #3051: явный регион с фронта (если он его когда-нибудь пришлёт) — + # приоритетнее вывода из city_hint, см. effective_region_code. + region_code: int | None = Field(default=None) def _fold(text: str) -> str: @@ -326,6 +329,7 @@ async def public_suggest( limit=payload.limit, db=db, city_hint=payload.city_hint, + region_code=effective_region_code(payload.region_code, payload.city_hint), ) finally: _suggest_slots.release() diff --git a/tradein-mvp/backend/app/api/v1/geocode.py b/tradein-mvp/backend/app/api/v1/geocode.py index ae439589..3d3c4e98 100644 --- a/tradein-mvp/backend/app/api/v1/geocode.py +++ b/tradein-mvp/backend/app/api/v1/geocode.py @@ -13,12 +13,30 @@ from sqlalchemy.orm import Session from app.core.db import get_db, run_db_thread from app.services.estimator import _lookup_house_facts from app.services.geocoder import GeocodeResult, geocode, reverse_geocode, suggest +from app.services.regions import DEFAULT_REGION_CODE, region_by_city logger = logging.getLogger(__name__) router = APIRouter() +def effective_region_code(region_code: int | None, city_hint: str | None) -> int: + """Регион для геокодера (#3051): явный `region_code` > вывод из `city_hint` > 66. + + До этого хелпера `/suggest` всегда уходил в геокодер с регионом 66 + по умолчанию — московский `city_hint` («Москва») молча получал + свердловский bbox-констрейнт и терял подсказки. Реестр `app.services.regions` + уже знает, каким городам какой регион соответствует (REGIONS[77].cities + содержит «москва») — используем его вместо повторного захардкоженного списка. + """ + if region_code is not None: + return region_code + region = region_by_city(city_hint) + if region is not None: + return region.code + return DEFAULT_REGION_CODE + + @router.get("/lookup", response_model=GeocodeResult) async def lookup( address: Annotated[str, Query(min_length=3, max_length=500)], @@ -81,16 +99,17 @@ async def suggest_addresses( ), ] = None, region_code: Annotated[ - int, + int | None, Query( description=( - "Регион покрытия (#3051). Дефолт 66 — Свердловская область, прежнее " - "поведение для существующих клиентов. 77 — Москва: без него DaData " - "и Nominatim получают свердловский hard-констрейнт и молча " - "возвращают ПУСТО на московском адресе." + "Регион покрытия (#3051). None (дефолт) — выводится из `city_hint` " + "через реестр регионов, иначе 66 (Свердловская область, прежнее " + "поведение). 77 — Москва: без него DaData и Nominatim получают " + "свердловский hard-констрейнт и молча возвращают ПУСТО на " + "московском адресе." ), ), - ] = 66, + ] = None, ) -> SuggestResponse: """Автокомплит адресов в регионе `region_code` (дефолт 66 — Свердловская область; ЕКБ — основной трафик, остаётся быстрым fast-path). @@ -105,8 +124,11 @@ async def suggest_addresses( /api/v1/geocode/suggest?q=Ленина+1&city_hint=Нижний+Тагил /api/v1/geocode/suggest?q=Тверская+6®ion_code=77 # Москва """ + resolved_region_code = effective_region_code(region_code, city_hint) try: - items = await suggest(q, db=db, limit=limit, city_hint=city_hint, region_code=region_code) + items = await suggest( + q, db=db, limit=limit, city_hint=city_hint, region_code=resolved_region_code + ) except ValueError as exc: # Регион вне реестра покрытия — 422, а не 500: это ошибка ввода клиента. raise HTTPException(status_code=422, detail=str(exc)) from exc diff --git a/tradein-mvp/backend/tests/test_3051_suggest_region_scope.py b/tradein-mvp/backend/tests/test_3051_suggest_region_scope.py index 5b251ce6..a12499d6 100644 --- a/tradein-mvp/backend/tests/test_3051_suggest_region_scope.py +++ b/tradein-mvp/backend/tests/test_3051_suggest_region_scope.py @@ -23,6 +23,8 @@ os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db" import pytest +from app.api.v1 import geocode as geocode_module +from app.api.v1.geocode import SuggestResponse, effective_region_code from app.services.geocoder import ( OBLAST66_VIEWBOX, SVERDLOVSK_OBLAST_REGION, @@ -179,3 +181,52 @@ async def test_suggest_unknown_region_raises() -> None: """Неизвестный регион — ValueError до похода к провайдерам.""" with pytest.raises(ValueError, match="unknown region_code"): await suggest("Ленина 1", db=None, limit=5, region_code=99) + + +# ── API-слой: effective_region_code и хендлер /suggest ────────────────────── +# +# Всё выше проверяет, что `suggest()` в geocoder.py УМЕЕТ регион. Ниже — +# СЛЕДУЮЩИЙ слой той же истории #3051: сам HTTP-хендлер `/suggest` (и его +# публичный прокси в mera.py) до сих пор звал `suggest()` с `region_code=66` +# по умолчанию БЕЗУСЛОВНО, даже когда `city_hint='Москва'` уже прямо говорил, +# какой регион нужен. Приоритет: явный `region_code` > вывод из `city_hint` +# (реестр `app.services.regions`) > 66. Инварианты ниже — на хелпере +# `effective_region_code`, который эту логику и вносит. + + +def test_effective_region_no_hint_no_explicit_defaults_to_66() -> None: + assert effective_region_code(None, None) == 66 + + +def test_effective_region_sverdlovsk_city_hint_stays_66() -> None: + assert effective_region_code(None, "Серов") == 66 + + +def test_effective_region_moscow_city_hint_infers_77() -> None: + assert effective_region_code(None, "Москва") == 77 + + +def test_effective_region_explicit_wins_over_moscow_hint() -> None: + assert effective_region_code(66, "Москва") == 66 + + +async def test_suggest_handler_resolves_region_from_moscow_hint() -> None: + """`suggest_addresses` без явного `region_code` реально передаёт вниз 77, + а не только хелпер в изоляции.""" + captured: dict[str, object] = {} + + async def _fake_suggest(q, db=None, limit=8, city_hint=None, region_code=66): + captured["region_code"] = region_code + return [] + + with patch.object(geocode_module, "suggest", _fake_suggest): + result = await geocode_module.suggest_addresses( + q="Тверская 6", + limit=8, + db=MagicMock(), + city_hint="Москва", + region_code=None, + ) + + assert isinstance(result, SuggestResponse) + assert captured["region_code"] == 77 diff --git a/tradein-mvp/backend/tests/test_public_mera_api.py b/tradein-mvp/backend/tests/test_public_mera_api.py index 474450ac..47aebc1e 100644 --- a/tradein-mvp/backend/tests/test_public_mera_api.py +++ b/tradein-mvp/backend/tests/test_public_mera_api.py @@ -640,6 +640,25 @@ def test_suggest_passes_city_prefixed_query_downstream(client: TestClient) -> No assert captured["q"] == "Серов, Ленина 1" # Сам хинт продолжаем передавать: от него зависит гейт кадастрового тира. assert captured["city_hint"] == "Серов" + # Серов — регион 66, вывод из city_hint не меняет прежнее поведение. + assert captured["region_code"] == 66 + + +def test_suggest_infers_region_from_moscow_city_hint(client: TestClient) -> None: + """#3051: `city_hint='Москва'` без явного region_code должен уйти в 77, + а не молча получить свердловский region_code=66 по умолчанию. + """ + captured: dict[str, object] = {} + + async def _fake(**kwargs): + captured.update(kwargs) + return SuggestResponse(items=[]) + + with patch.object(public_mera, "suggest_addresses", _fake): + resp = client.post(f"{PREFIX}/suggest", json={"q": "Тверская 6", "city_hint": "Москва"}) + + assert resp.status_code == 200, resp.text + assert captured["region_code"] == 77 # ── 9. Невалидный вход: 422 и всё тот же бюджет ────────────────────────────── -- 2.45.3