fix(tradein): ДКП-коридор по Москве не строился — имя улицы не извлекалось
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 6m5s

`extract_street_name` возвращал None для любого московского адреса, потому
что парсер ждёт тип улицы ПЕРЕД названием («ул. Малышева»), а в Москве он
стоит после: «Тверская улица, 6». Keyword-регекс требует пробел сразу за
типом, там запятая — совпадения нет вовсе; дальше fallback брал первый
токен с большой буквы, получал «Москва» из стоп-списка и отдавал None.

Следствие на проде (замер 12.09): оценка по московскому адресу отвечает
200 с 25 аналогами, но `dkp_corridor` в ответе — null, при 212 937
московских ДКП в базе. Коридор сделок по Москве не строился ни разу.

Добавлен второй проход: ищем тип улицы без требования пробела и берём
1-3 слова ДО него в пределах той же запятой-секции. Прежний путь не
тронут — «ул. X» и реверс-формат Nominatim разбираются как раньше;
непустые результаты не меняются, новый проход даёт значение только там,
где раньше был None. Списки типов улиц вынесены в общую константу, чтобы
два регекса не разъехались при добавлении нового типа.

Нумерованные проезды («Проектируемый проезд № 4062») намеренно остаются
None: имя «Проектируемый» собрало бы коридор по сотням разных проездов.

## Регион-скоуп двух ручек

Непустое имя улицы включает `/street-deals` и `/sales-vs-listings`, где
раньше для Москвы был ранний выход. Обе скоупятся только по
`_resolve_target_city` — словарю городов Свердловской области, — поэтому
для Москвы фильтр города пуст, и остаётся один ILIKE по улице.

Замер на проде: улица «Ясная» — 168 сделок в регионе 66 и 80 в 77,
«Советская» — 1202 и 17. Без фильтра региона московский запрос смешал бы
екатеринбургские сделки в медиану, то есть фикс парсера сам по себе
открыл бы дыру. Поэтому в обе ручки добавлен обязательный фильтр по
`region_code`; регион выводится из адреса через реестр регионов точным
сравнением сегмента, а не подстрокой — иначе екатеринбургская
«Московская улица» уехала бы в регион 77.

В `deals` регион заполнен у всех строк (66 → 108 623, 77 → 212 937,
NULL нет), так что фильтр ничего не отрезает у существующих запросов.

У `/sales-vs-listings` табличная функция параметра региона не знает, её
миграция в этот фикс не входит. Фильтр применён снаружи, соединением с
`deals` по идентификатору сделки: сторона объявлений остаётся без
регион-скоупа. Это осознанный компромисс, он описан в коде; полный фикс
— отдельная миграция с параметром региона внутри функции.

Тесты: 535 passed во всех файлах, затрагивающих коридор и уличную
статистику (+13 новых), ruff чистый.
This commit is contained in:
bot-backend 2026-09-12 13:43:56 +03:00
parent cfcdb9393c
commit d78b1f8881
4 changed files with 328 additions and 14 deletions

View file

@ -2201,6 +2201,7 @@ def get_street_deals(
_percentile, _percentile,
_resolve_target_city, _resolve_target_city,
extract_street_name, extract_street_name,
region_code_for_address,
) )
now = datetime.now(tz=UTC) now = datetime.now(tz=UTC)
@ -2244,6 +2245,17 @@ def get_street_deals(
target_city = _resolve_target_city(address) target_city = _resolve_target_city(address)
city_filter = "AND LOWER(city) = CAST(:target_city AS text)" if target_city else "" city_filter = "AND LOWER(city) = CAST(:target_city AS text)" if target_city else ""
# #dkp-corridor-scope (2026-09-12): region_code — обязательный фильтр, в отличие
# от city_filter выше (который применяется, только если _resolve_target_city
# узнал город обл.66). extract_street_name после фикса московского порядка
# ("Название улица") стал возвращать имя улицы и для Москвы, а
# _resolve_target_city знает ТОЛЬКО города обл.66 → для Москвы target_city=None,
# city_filter пуст, и без region_code одноимённая улица другого региона
# подмешалась бы в выборку (прод-замер: "Ясная" — 168 сделок в 66 и 80 в 77,
# "Советская" — 1202 и 17). region_code заполнен у всех deals (66→108 623,
# 77→212 937, NULL нет) — фильтр не отрезает ни одной существующей строки.
region_code = region_code_for_address(address)
rows = ( rows = (
db.execute( db.execute(
text( text(
@ -2255,6 +2267,7 @@ def get_street_deals(
AND address ILIKE :street_pattern AND address ILIKE :street_pattern
AND address ~* :street_regex AND address ~* :street_regex
{city_filter} {city_filter}
AND region_code = CAST(:region_code AS integer)
-- #3256: фильтра по rooms нет — deals.rooms синтезирована из площади -- #3256: фильтра по rooms нет — deals.rooms синтезирована из площади
-- (тот же CASE 30/44/62/85, что area_bucket), т.е. это был второй -- (тот же CASE 30/44/62/85, что area_bucket), т.е. это был второй
-- ступенчатый фильтр по площади поверх полосы ±15% ниже. Развёрнуто -- ступенчатый фильтр по площади поверх полосы ±15% ниже. Развёрнуто
@ -2269,6 +2282,7 @@ def get_street_deals(
"street_pattern": "%" + street_name + "%", "street_pattern": "%" + street_name + "%",
"street_regex": r"\m" + street_name + r"\M", "street_regex": r"\m" + street_name + r"\M",
"target_city": target_city.lower() if target_city else None, "target_city": target_city.lower() if target_city else None,
"region_code": region_code,
"area_min": area_min, "area_min": area_min,
"area_max": area_max, "area_max": area_max,
"period_months": period_months, "period_months": period_months,
@ -2503,7 +2517,12 @@ def get_sales_vs_listings(
Per-street view: Росреестр open dataset агрегирует адреса до улицы. Per-street view: Росреестр open dataset агрегирует адреса до улицы.
""" """
from app.services.estimator import _percentile, _resolve_target_city, extract_street_name from app.services.estimator import (
_percentile,
_resolve_target_city,
extract_street_name,
region_code_for_address,
)
def _empty(reason_street: str | None = None) -> SalesVsListingsResponse: def _empty(reason_street: str | None = None) -> SalesVsListingsResponse:
return SalesVsListingsResponse( return SalesVsListingsResponse(
@ -2533,16 +2552,36 @@ def get_sales_vs_listings(
# известная H1) → фильтр не применяется на TVF-стороне (см. миграцию 205). # известная H1) → фильтр не применяется на TVF-стороне (см. миграцию 205).
target_city = _resolve_target_city(address) target_city = _resolve_target_city(address)
# #dkp-corridor-scope (2026-09-12): region_code — фильтр ДОПОЛНИТЕЛЬНО к
# target_city выше, нужен по той же причине, что и в /street-deals: для
# Москвы _resolve_target_city (словарь городов ТОЛЬКО обл.66) возвращает
# None → target_city-фильтр внутри TVF (миграция 205) не срабатывает ни
# для deals, ни для listings, и одноимённая улица другого региона
# подмешивается в пары (см. estimator.region_code_for_address).
#
# TVF street_sales_vs_listings() (миграция 205) параметра региона не
# знает — сама TVF не трогается (это отдельная миграция, вне текущего
# фикса), фильтр применён СНАРУЖИ: оборачиваем вызов в JOIN на deals по
# deal_id и фильтруем region_code ТОЛЬКО на стороне сделок. Сторона
# listings внутри TVF остаётся НЕ отфильтрованной по региону — это
# известный узкий компромисс, не побочный эффект: у listings нет
# общего для всех источников поля региона (city заполнен частично, см.
# комментарий 205 выше), а сам JOIN внутри TVF уже требует совпадения
# street_pattern + rooms + area + date proximity, что резко сужает шанс
# чужого региона на listing-стороне. Полный фикс (передать region_code
# внутрь TVF седьмым/восьмым параметром) — отдельная миграция.
region_code = region_code_for_address(address)
rows = ( rows = (
db.execute( db.execute(
text( text(
""" """
SELECT SELECT
deal_id, deal_date, deal_price_rub, deal_price_per_m2, sv.deal_id, sv.deal_date, sv.deal_price_rub, sv.deal_price_per_m2,
deal_area_m2, deal_rooms, deal_floor, deal_address, sv.deal_area_m2, sv.deal_rooms, sv.deal_floor, sv.deal_address,
listing_id, listing_source, listing_source_url, sv.listing_id, sv.listing_source, sv.listing_source_url,
listing_date, listing_price_rub, listing_price_per_m2, sv.listing_date, sv.listing_price_rub, sv.listing_price_per_m2,
listing_area_m2, days_listing_to_deal, discount_pct sv.listing_area_m2, sv.days_listing_to_deal, sv.discount_pct
FROM street_sales_vs_listings( FROM street_sales_vs_listings(
CAST(:street_pattern AS text), CAST(:street_pattern AS text),
CAST(:area_m2 AS numeric), CAST(:area_m2 AS numeric),
@ -2551,7 +2590,9 @@ def get_sales_vs_listings(
CAST(:area_tolerance AS numeric), CAST(:area_tolerance AS numeric),
CAST(:period_months AS integer), CAST(:period_months AS integer),
CAST(:target_city AS text) CAST(:target_city AS text)
) ) sv
JOIN deals d ON d.id = sv.deal_id
WHERE d.region_code = CAST(:region_code AS integer)
""" """
), ),
{ {
@ -2562,6 +2603,7 @@ def get_sales_vs_listings(
"area_tolerance": area_tolerance, "area_tolerance": area_tolerance,
"period_months": period_months, "period_months": period_months,
"target_city": target_city, "target_city": target_city,
"region_code": region_code,
}, },
) )
.mappings() .mappings()

View file

@ -1991,6 +1991,38 @@ def _resolve_target_city(address_text: str | None) -> str | None:
return None return None
def region_code_for_address(address: str | None) -> int:
"""Код региона покрытия (regions.REGIONS) по городу в адресе.
Режет адрес по запятым, каждый сегмент strip+lower, ищет ТОЧНОЕ
совпадение с одним из городов региона (regions.region_by_city). Точное
равенство сегмента, а НЕ подстрока: подстрочный поиск "москва" в адресе
поймал бы екатеринбургскую "Московская улица" и увёл её в регион 77
сегмент "московская улица" целиком "москва".
#dkp-corridor-scope (2026-09-12): нужен для WHERE region_code = ... в
get_street_deals/get_sales_vs_listings extract_street_name после фикса
московского порядка ("Название улица") стал возвращать имя улицы и для
Москвы, и без явного region-скоупа одноимённая улица из другого региона
(66 vs 77) подмешивалась бы в медиану (напр. "Ясная": 168 сделок в 66 и
80 в 77, "Советская": 1202 и 17).
Returns regions.DEFAULT_REGION_CODE (66), если ни один сегмент не
распознан как город ни одного региона байт-в-байт прежнее поведение
(все ЕКБ/безымянные адреса и раньше трактовались как регион 66).
"""
if not address:
return regions_mod.DEFAULT_REGION_CODE
for raw_segment in address.split(","):
segment = raw_segment.strip()
if not segment:
continue
region = regions_mod.region_by_city(segment)
if region:
return region.code
return regions_mod.DEFAULT_REGION_CODE
def _fetch_dkp_corridor( def _fetch_dkp_corridor(
db: Session, db: Session,
*, *,
@ -5987,12 +6019,34 @@ def _extract_short_addr(full_address: str | None) -> str | None:
# идентичный результат (см. test_street_deals_endpoint.py). Бывшая отдельная # идентичный результат (см. test_street_deals_endpoint.py). Бывшая отдельная
# bare-альтернатива "мкр" убрана как ставшая избыточной — "мкр\.?" уже # bare-альтернатива "мкр" убрана как ставшая избыточной — "мкр\.?" уже
# покрывает оба варианта (с точкой и без). # покрывает оба варианта (с точкой и без).
_STREET_KW_RE = re.compile( # Общий список street-keyword альтернатив — единственное место, где он
r"(?<![А-Яа-яёЁa-zA-Z])" # перечислен. И _STREET_KW_RE (forward, требует \s+ после), и
# _STREET_KW_SUFFIX_RE (moscow-reverse, без \s+) собираются из этой же
# константы, чтобы при добавлении нового типа улицы (аллея/линия/...) их
# списки не разъехались независимой правкой одного из двух regex.
_STREET_KW_ALTERNATION = (
r"(?:ул\.?|улица|пр\.?|пр-т|проспект|пер\.?|переулок|" r"(?:ул\.?|улица|пр\.?|пр-т|проспект|пер\.?|переулок|"
r"б-р|бульвар|ш\.?|шоссе|наб\.?|набережная|проезд|тракт|" r"б-р|бульвар|ш\.?|шоссе|наб\.?|набережная|проезд|тракт|"
r"пл\.?|площадь|мкр\.?|микрорайон)" r"пл\.?|площадь|мкр\.?|микрорайон)"
r"\s+", )
_STREET_KW_RE = re.compile(
r"(?<![А-Яа-яёЁa-zA-Z])" + _STREET_KW_ALTERNATION + r"\s+",
flags=re.IGNORECASE | re.UNICODE,
)
# Тот же список keyword'ов (_STREET_KW_ALTERNATION), но БЕЗ обязательного
# \s+ после них — вместо этого lookahead на запятую/пробел/конец строки.
# Нужен для московского формата "Название улица, 6" (Тверская улица,
# Ленинский проспект, Брюсов переулок): там сразу после keyword идёт запятая,
# и _STREET_KW_RE (требующий \s+) вообще не матчит эту позицию — ни keyword,
# ни имя после него найти нельзя. Используется ТОЛЬКО как fallback ниже,
# после того как forward-путь через _STREET_KW_RE не дал результата — может
# дать результат там, где раньше был None (напр. keyword найден, но имя
# после него не распарсилось: "Тверская ул. 6"); непустые результаты
# форматов, которые уже что-то возвращали, не меняются.
_STREET_KW_SUFFIX_RE = re.compile(
r"(?<![А-Яа-яёЁa-zA-Z])" + _STREET_KW_ALTERNATION + r"(?=[,\s]|$)",
flags=re.IGNORECASE | re.UNICODE, flags=re.IGNORECASE | re.UNICODE,
) )
@ -6017,10 +6071,29 @@ def extract_street_name(full_address: str | None) -> str | None:
"ул. Большая Конюшенная, 25" "Большая Конюшенная" "ул. Большая Конюшенная, 25" "Большая Конюшенная"
"" None "" None
Московский формат keyword стоит ПОСЛЕ имени улицы ("Название улица, N"),
а не перед ним:
"Москва, Тверская улица, 6" "Тверская"
"Москва, 1-я Тверская-Ямская улица, 12" "1-я Тверская-Ямская"
"Москва, Брюсов переулок, 8" "Брюсов"
"Москва, Ленинский проспект, 30" "Ленинский"
"Театр имени М. Н. Ермоловой, 5/6, Тверская улица, 58,
Тверской район, Москва, Центральный федеральный округ,
125009, Россия" (reverse-формат Nominatim) → "Тверская"
"Москва, Проектируемый проезд № 4062, 5" None (безымянный
нумерованный проезд)
Алгоритм: Алгоритм:
1. Ищем street-keyword (ул/улица/пр/проспект/...) case-insensitive. 1. Ищем street-keyword (ул/улица/пр/проспект/...) case-insensitive,
2. После keyword берём 1-3 слова до запятой или номера дома. берём 1-3 слова ПОСЛЕ него до запятой или номера дома (forward-формат).
3. Если keyword не нашёлся пытаемся первый capitalized токен с 2. Московский формат: сразу после keyword запятая/конец строки (имени
после него нет, т.к. keyword в этом формате идёт ПОСЛЕ названия).
Ищем keyword ещё раз без требования пробела после него и берём 1-3
слова ДО keyword внутри той же запятой-секции ("Тверская улица, 6"
"Тверская"). Не срабатывает, если сразу после keyword ""
(нумерованный проезд без имени, напр. "Проектируемый проезд №
4062" — извлекать нечего, "Проектируемый" НЕ street name).
3. Если keyword не нашёлся вовсе пытаемся первый capitalized токен с
поиском до запятой или номера (fallback для адресов без keyword'а). поиском до запятой или номера (fallback для адресов без keyword'а).
Returns None если ничего не извлеклось. Returns None если ничего не извлеклось.
@ -6038,7 +6111,27 @@ def extract_street_name(full_address: str | None) -> str | None:
if nm: if nm:
return nm.group(1).strip() return nm.group(1).strip()
# 2. Fallback: нет keyword — пробуем первый capitalized токен # 2. Московский формат "Название улица, N": keyword идёт ПОСЛЕ имени, и
# сразу за ним запятая/конец строки — _STREET_KW_RE (шаг 1) такую позицию
# вообще не матчит, т.к. требует \s+ сразу после keyword. Берём keyword
# отдельно (без требования пробела) и смотрим на слова ДО него в пределах
# той же запятой-секции.
km = _STREET_KW_SUFFIX_RE.search(s)
if km:
# Исключение: "Проектируемый проезд № 4062" — keyword с числовым
# индексом БЕЗ имени (forward-паттерн "keyword № N", не moscow-
# reverse). Слово перед keyword ("Проектируемый") здесь — прилагательное
# к самому "проезду", а не имя улицы; сотни таких проездов имеют один
# и тот же "Проектируемый" — коридор по нему смешал бы все их сделки.
after = s[km.end() :].lstrip()
if not after.startswith(""):
segment_start = s.rfind(",", 0, km.start()) + 1
before = s[segment_start : km.start()].strip()
words = before.split()
if words:
return " ".join(words[-3:])
# 3. Fallback: нет keyword — пробуем первый capitalized токен
# Используется для "Большая Конюшенная, 25" без "ул." # Используется для "Большая Конюшенная, 25" без "ул."
nm = _STREET_NAME_RE.match(s) nm = _STREET_NAME_RE.match(s)
if nm: if nm:

View file

@ -419,6 +419,57 @@ def test_sales_vs_listings_target_city_none_when_city_unresolved(
assert params["target_city"] is None assert params["target_city"] is None
# ── Test: region-scope (#dkp-corridor-scope 2026-09-12) ───────────────────────
def test_sales_vs_listings_region_code_scoped_for_moscow(trade_in_app: FastAPI) -> None:
"""Московский адрес: target_city остаётся None (словарь только обл.66), но
region_code=77 обязан уйти в SQL иначе TVF смешивает московские сделки с
одноимённой улицей ЕКБ (region_code=66) на стороне deals."""
db_mock = _make_db_mock([])
_override_db(trade_in_app, db_mock)
client = TestClient(trade_in_app)
resp = client.get(
"/api/v1/trade-in/sales-vs-listings",
params={
"address": "Москва, Тверская улица, 6",
"area_m2": 44.3,
"rooms": 2,
},
)
assert resp.status_code == 200
assert db_mock.execute.called
args, kwargs = db_mock.execute.call_args
sql_text = str(args[0])
params = args[1] if len(args) > 1 else kwargs.get("parameters", {})
assert params["target_city"] is None
assert params["region_code"] == 77
assert "region_code" in sql_text and "deals" in sql_text, (
"SQL must JOIN deals and filter by region_code"
)
def test_sales_vs_listings_region_code_unchanged_for_ekaterinburg(trade_in_app: FastAPI) -> None:
"""Регресс-гейт: екатеринбургский адрес по-прежнему скоупится в регион 66."""
db_mock = _make_db_mock([])
_override_db(trade_in_app, db_mock)
client = TestClient(trade_in_app)
resp = client.get(
"/api/v1/trade-in/sales-vs-listings",
params={
"address": "г. Екатеринбург, ул. Малышева, 125",
"area_m2": 65.5,
"rooms": 3,
},
)
assert resp.status_code == 200
args, kwargs = db_mock.execute.call_args
params = args[1] if len(args) > 1 else kwargs.get("parameters", {})
assert params["region_code"] == 66
# ── Test: response shape (Pydantic validation) ─────────────────────────────── # ── Test: response shape (Pydantic validation) ───────────────────────────────

View file

@ -107,6 +107,50 @@ def test_extract_street_name_parametrized(address: str | None, expected: str | N
assert extract_street_name(address) == expected assert extract_street_name(address) == expected
@pytest.mark.parametrize(
"address,expected",
[
# Live-prod repro (2026-09-12): московский формат — keyword стоит
# ПОСЛЕ имени улицы ("Название улица, N"), а не перед ним. Сразу
# после keyword идёт запятая — _STREET_KW_RE (форматы "ул. X")
# такую позицию вообще не матчит (требует \s+), из-за чего
# extract_street_name возвращал None для ВСЕХ московских адресов,
# и ДКП-коридор (_fetch_dkp_corridor) для Москвы никогда не строился
# (212 937 московских сделок в БД, dkp_corridor=null на проде).
("Москва, Тверская улица, 6", "Тверская"),
("Москва, 1-я Тверская-Ямская улица, 12", "1-я Тверская-Ямская"),
("Москва, Брюсов переулок, 8", "Брюсов"),
("Москва, Ленинский проспект, 30", "Ленинский"),
# Reverse-формат Nominatim с московским порядком keyword'а.
(
"Театр имени М. Н. Ермоловой, 5/6, Тверская улица, 58, Тверской"
" район, Москва, Центральный федеральный округ, 125009, Россия",
"Тверская",
),
# Площадь — тот же реверс-порядок, keyword не только "улица"/"переулок".
("Москва, Красная площадь, 1", "Красная"),
# Новая Москва — Coммунарка/поселения — тот же keyword-порядок работает
# без явного упоминания слова "Москва" в адресе.
("поселение Сосенское, Коммунарка, Ясная улица, 5", "Ясная"),
# Голый keyword без имени перед ним (пустая запятая-секция) — извлекать
# нечего, а не "" или мусор из соседнего сегмента.
("Москва, улица, 5", None),
# Нумерованный проезд БЕЗ имени: "Проектируемый" — прилагательное к
# самому "проезду" (сотни таких проездов в Москве имеют один и тот же
# префикс), keyword+"№" — НЕ moscow-reverse формат с именем.
("Москва, Проектируемый проезд № 4062, 5", None),
],
)
def test_extract_street_name_moscow_reverse_order(
address: str | None, expected: str | None
) -> None:
"""Московский формат "Название улица, N" — keyword после имени, не перед
ним (#dkp-corridor live-prod fix 2026-09-12)."""
from app.services.estimator import extract_street_name
assert extract_street_name(address) == expected
@pytest.mark.parametrize( @pytest.mark.parametrize(
"address,expected", "address,expected",
[ [
@ -358,3 +402,87 @@ def test_street_regex_param_passed_to_db(trade_in_app: FastAPI) -> None:
assert r"\m" in regex_val or r"\b" in regex_val or regex_val.startswith(r"\m"), ( assert r"\m" in regex_val or r"\b" in regex_val or regex_val.startswith(r"\m"), (
f"Expected word-boundary in regex, got: {regex_val!r}" f"Expected word-boundary in regex, got: {regex_val!r}"
) )
# ── Test: region-scope (#dkp-corridor-scope 2026-09-12) ───────────────────────
@pytest.mark.parametrize(
"address,expected_region_code",
[
("Москва, Тверская улица, 6", 77),
("Екатеринбург, ул. Космонавтов, 50", 66),
# Точное совпадение сегмента, НЕ подстрока: "Московская улица" в ЕКБ
# содержит "москва" как подстроку, но сегмент целиком ≠ "москва" — не
# должна уехать в регион 77 (иначе одноимённая улица ЕКБ подмешалась
# бы в московскую выборку, и наоборот).
("Екатеринбург, Московская улица, 12", 66),
(None, 66),
("", 66),
],
)
def test_region_code_for_address(address: str | None, expected_region_code: int) -> None:
from app.services.estimator import region_code_for_address
assert region_code_for_address(address) == expected_region_code
def test_street_deals_region_code_scoped_for_moscow(trade_in_app: FastAPI) -> None:
"""Московский адрес: extract_street_name теперь распознаёт улицу, но
_resolve_target_city знает только города обл.66 без region_code фильтра
одноимённая улица ЕКБ (region_code=66) подмешалась бы в московскую выборку.
Ручка обязана передать region_code=77 в SQL-фильтр."""
db_mock = _make_db_mock([])
from app.core.db import get_db
def _override():
yield db_mock
trade_in_app.dependency_overrides[get_db] = _override
client = TestClient(trade_in_app)
client.get(
"/api/v1/trade-in/street-deals",
params={
"address": "Москва, Тверская улица, 6",
"area_m2": 50.0,
"rooms": 2,
},
)
assert db_mock.execute.called
call_args = db_mock.execute.call_args
sql_text = str(call_args[0][0])
params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("parameters", {})
assert "region_code" in params, f"region_code not passed to SQL: {params}"
assert params["region_code"] == 77
assert "region_code" in sql_text, "SQL must filter by region_code"
def test_street_deals_region_code_unchanged_for_ekaterinburg(trade_in_app: FastAPI) -> None:
"""Регресс-гейт: екатеринбургский адрес по-прежнему скоупится в регион 66
(DEFAULT_REGION_CODE) фильтр не отрезает существующие ЕКБ-результаты."""
db_mock = _make_db_mock([])
from app.core.db import get_db
def _override():
yield db_mock
trade_in_app.dependency_overrides[get_db] = _override
client = TestClient(trade_in_app)
client.get(
"/api/v1/trade-in/street-deals",
params={
"address": "Екатеринбург, ул. Космонавтов, 50",
"area_m2": 50.0,
"rooms": 2,
},
)
assert db_mock.execute.called
call_args = db_mock.execute.call_args
params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("parameters", {})
assert params["region_code"] == 66