Merge pull request 'ДКП-коридор по Москве не строился: имя улицы не извлекалось из московского формата адреса' (#3473) from fix/msk-street-name-suffix into main
Some checks failed
Deploy Trade-In / changes (push) Successful in 22s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m27s
Deploy Trade-In / test (push) Successful in 5m8s
Deploy Trade-In / build-backend (push) Successful in 1m30s
Deploy Trade-In / deploy (push) Successful in 1m47s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Has been cancelled
Some checks failed
Deploy Trade-In / changes (push) Successful in 22s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m27s
Deploy Trade-In / test (push) Successful in 5m8s
Deploy Trade-In / build-backend (push) Successful in 1m30s
Deploy Trade-In / deploy (push) Successful in 1m47s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Has been cancelled
This commit is contained in:
commit
1eee4b955d
4 changed files with 328 additions and 14 deletions
|
|
@ -2201,6 +2201,7 @@ def get_street_deals(
|
|||
_percentile,
|
||||
_resolve_target_city,
|
||||
extract_street_name,
|
||||
region_code_for_address,
|
||||
)
|
||||
|
||||
now = datetime.now(tz=UTC)
|
||||
|
|
@ -2244,6 +2245,17 @@ def get_street_deals(
|
|||
target_city = _resolve_target_city(address)
|
||||
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 = (
|
||||
db.execute(
|
||||
text(
|
||||
|
|
@ -2255,6 +2267,7 @@ def get_street_deals(
|
|||
AND address ILIKE :street_pattern
|
||||
AND address ~* :street_regex
|
||||
{city_filter}
|
||||
AND region_code = CAST(:region_code AS integer)
|
||||
-- #3256: фильтра по rooms нет — deals.rooms синтезирована из площади
|
||||
-- (тот же CASE 30/44/62/85, что area_bucket), т.е. это был второй
|
||||
-- ступенчатый фильтр по площади поверх полосы ±15% ниже. Развёрнуто —
|
||||
|
|
@ -2269,6 +2282,7 @@ def get_street_deals(
|
|||
"street_pattern": "%" + street_name + "%",
|
||||
"street_regex": r"\m" + street_name + r"\M",
|
||||
"target_city": target_city.lower() if target_city else None,
|
||||
"region_code": region_code,
|
||||
"area_min": area_min,
|
||||
"area_max": area_max,
|
||||
"period_months": period_months,
|
||||
|
|
@ -2503,7 +2517,12 @@ def get_sales_vs_listings(
|
|||
|
||||
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:
|
||||
return SalesVsListingsResponse(
|
||||
|
|
@ -2533,16 +2552,36 @@ def get_sales_vs_listings(
|
|||
# известная H1) → фильтр не применяется на TVF-стороне (см. миграцию 205).
|
||||
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 = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
deal_id, deal_date, deal_price_rub, deal_price_per_m2,
|
||||
deal_area_m2, deal_rooms, deal_floor, deal_address,
|
||||
listing_id, listing_source, listing_source_url,
|
||||
listing_date, listing_price_rub, listing_price_per_m2,
|
||||
listing_area_m2, days_listing_to_deal, discount_pct
|
||||
sv.deal_id, sv.deal_date, sv.deal_price_rub, sv.deal_price_per_m2,
|
||||
sv.deal_area_m2, sv.deal_rooms, sv.deal_floor, sv.deal_address,
|
||||
sv.listing_id, sv.listing_source, sv.listing_source_url,
|
||||
sv.listing_date, sv.listing_price_rub, sv.listing_price_per_m2,
|
||||
sv.listing_area_m2, sv.days_listing_to_deal, sv.discount_pct
|
||||
FROM street_sales_vs_listings(
|
||||
CAST(:street_pattern AS text),
|
||||
CAST(:area_m2 AS numeric),
|
||||
|
|
@ -2551,7 +2590,9 @@ def get_sales_vs_listings(
|
|||
CAST(:area_tolerance AS numeric),
|
||||
CAST(:period_months AS integer),
|
||||
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,
|
||||
"period_months": period_months,
|
||||
"target_city": target_city,
|
||||
"region_code": region_code,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
|
|
|
|||
|
|
@ -1991,6 +1991,38 @@ def _resolve_target_city(address_text: str | None) -> str | 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(
|
||||
db: Session,
|
||||
*,
|
||||
|
|
@ -5987,12 +6019,34 @@ def _extract_short_addr(full_address: str | None) -> str | None:
|
|||
# идентичный результат (см. test_street_deals_endpoint.py). Бывшая отдельная
|
||||
# bare-альтернатива "мкр" убрана как ставшая избыточной — "мкр\.?" уже
|
||||
# покрывает оба варианта (с точкой и без).
|
||||
_STREET_KW_RE = re.compile(
|
||||
r"(?<![А-Яа-яёЁa-zA-Z])"
|
||||
# Общий список street-keyword альтернатив — единственное место, где он
|
||||
# перечислен. И _STREET_KW_RE (forward, требует \s+ после), и
|
||||
# _STREET_KW_SUFFIX_RE (moscow-reverse, без \s+) собираются из этой же
|
||||
# константы, чтобы при добавлении нового типа улицы (аллея/линия/...) их
|
||||
# списки не разъехались независимой правкой одного из двух regex.
|
||||
_STREET_KW_ALTERNATION = (
|
||||
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,
|
||||
)
|
||||
|
||||
|
|
@ -6017,10 +6071,29 @@ def extract_street_name(full_address: str | None) -> str | None:
|
|||
"ул. Большая Конюшенная, 25" → "Большая Конюшенная"
|
||||
"" → None
|
||||
|
||||
Московский формат — keyword стоит ПОСЛЕ имени улицы ("Название улица, N"),
|
||||
а не перед ним:
|
||||
"Москва, Тверская улица, 6" → "Тверская"
|
||||
"Москва, 1-я Тверская-Ямская улица, 12" → "1-я Тверская-Ямская"
|
||||
"Москва, Брюсов переулок, 8" → "Брюсов"
|
||||
"Москва, Ленинский проспект, 30" → "Ленинский"
|
||||
"Театр имени М. Н. Ермоловой, 5/6, Тверская улица, 58,
|
||||
Тверской район, Москва, Центральный федеральный округ,
|
||||
125009, Россия" (reverse-формат Nominatim) → "Тверская"
|
||||
"Москва, Проектируемый проезд № 4062, 5" → None (безымянный
|
||||
нумерованный проезд)
|
||||
|
||||
Алгоритм:
|
||||
1. Ищем street-keyword (ул/улица/пр/проспект/...) — case-insensitive.
|
||||
2. После keyword берём 1-3 слова до запятой или номера дома.
|
||||
3. Если keyword не нашёлся — пытаемся первый capitalized токен с
|
||||
1. Ищем street-keyword (ул/улица/пр/проспект/...) — case-insensitive,
|
||||
берём 1-3 слова ПОСЛЕ него до запятой или номера дома (forward-формат).
|
||||
2. Московский формат: сразу после keyword — запятая/конец строки (имени
|
||||
после него нет, т.к. keyword в этом формате идёт ПОСЛЕ названия).
|
||||
Ищем keyword ещё раз без требования пробела после него и берём 1-3
|
||||
слова ДО keyword внутри той же запятой-секции ("Тверская улица, 6" →
|
||||
"Тверская"). Не срабатывает, если сразу после keyword — "№"
|
||||
(нумерованный проезд без имени, напр. "Проектируемый проезд №
|
||||
4062" — извлекать нечего, "Проектируемый" НЕ street name).
|
||||
3. Если keyword не нашёлся вовсе — пытаемся первый capitalized токен с
|
||||
поиском до запятой или номера (fallback для адресов без keyword'а).
|
||||
|
||||
Returns None если ничего не извлеклось.
|
||||
|
|
@ -6038,7 +6111,27 @@ def extract_street_name(full_address: str | None) -> str | None:
|
|||
if nm:
|
||||
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" без "ул."
|
||||
nm = _STREET_NAME_RE.match(s)
|
||||
if nm:
|
||||
|
|
|
|||
|
|
@ -419,6 +419,57 @@ def test_sales_vs_listings_target_city_none_when_city_unresolved(
|
|||
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) ───────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -107,6 +107,50 @@ def test_extract_street_name_parametrized(address: str | None, expected: str | N
|
|||
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(
|
||||
"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"), (
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue