fix(tradein/geocoder): строгий матч литеры дома + починка DaData region-фильтра (#2622)
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m36s
Deploy Trade-In / build-backend (push) Successful in 59s
Deploy Trade-In / deploy (push) Successful in 1m26s
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m36s
Deploy Trade-In / build-backend (push) Successful in 59s
Deploy Trade-In / deploy (push) Successful in 1m26s
This commit is contained in:
parent
c1b407527b
commit
0abfe020df
4 changed files with 416 additions and 25 deletions
|
|
@ -343,6 +343,10 @@ async def suggest_addresses(
|
|||
жёсткий фильтр (не boost) на уровне указанного admin-поля — доп.
|
||||
параметров не требуется. По умолчанию не задан — поведение (и body
|
||||
запроса) для существующих вызовов не меняется.
|
||||
ВАЖНО: значение сравнивается с полем DaData `region`, где имя лежит
|
||||
БЕЗ типа («Свердловская», а тип — отдельно в `region_type`="обл").
|
||||
Передашь «Свердловская область» — совпадений не будет, и запрос
|
||||
вернёт ПУСТО без всякой ошибки (hard-filter, не boost).
|
||||
|
||||
Returns:
|
||||
list[DadataSuggestion] — пустой список если:
|
||||
|
|
|
|||
|
|
@ -156,7 +156,15 @@ SVERDLOVSK_OBLAST_CITIES = frozenset(
|
|||
# без district-префикса ложно ушёл бы в non-EKB gate.
|
||||
}
|
||||
)
|
||||
SVERDLOVSK_OBLAST_REGION = "Свердловская область"
|
||||
# Значение для DaData-констрейнта `locations: [{"region": ...}]`.
|
||||
# ВАЖНО: DaData хранит имя региона БЕЗ типа — `region="Свердловская"`,
|
||||
# `region_type="обл"` (тип лежит в отдельных полях `region_type` /
|
||||
# `region_with_type`). `locations` сравнивает именно с `region`, поэтому
|
||||
# «Свердловская область» не совпадает НИ С ЧЕМ и hard-фильтр молча схлопывал
|
||||
# выдачу в 0 подсказок (замер на проде: «Свердловская область» → 0 хитов,
|
||||
# «Свердловская» → 5 хитов, первый — искомый «д 13б» с fias_id).
|
||||
# Тип региона сюда дописывать нельзя — см. `test_dadata_region_constant_*`.
|
||||
SVERDLOVSK_OBLAST_REGION = "Свердловская"
|
||||
|
||||
# Word/phrase-boundary regex — НЕ substring — чтобы «Серова 27» не матчил город
|
||||
# «Серов», «Ирбитская 5» — «Ирбит», «Асбестовский пер.» — «Асбест», «Невьянский
|
||||
|
|
@ -637,13 +645,24 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]
|
|||
типа город/район, для autocomplete с привязкой к карте они бесполезны).
|
||||
|
||||
Label собирается из DaData `value` (короткая форма «ул Малышева, д 30»).
|
||||
Constraint — вся область (region='Свердловская область', hard-filter внутри
|
||||
`suggest_addresses`), а не один город ЕКБ — иначе Нижний Тагил/Серов/etc
|
||||
никогда не появились бы в подсказках.
|
||||
Constraint — вся область (region=`SVERDLOVSK_OBLAST_REGION`, hard-filter
|
||||
внутри `suggest_addresses`), а не один город ЕКБ — иначе Нижний Тагил/
|
||||
Серов/etc никогда не появились бы в подсказках.
|
||||
"""
|
||||
raw = await dadata.suggest_addresses(
|
||||
query, limit=limit, city=None, region=SVERDLOVSK_OBLAST_REGION
|
||||
)
|
||||
if not raw:
|
||||
# Region-констрейнт — hard-filter: неверное значение схлопывает выдачу в
|
||||
# 0 БЕЗ ошибки (так и жил баг «Свердловская область» → 0 подсказок).
|
||||
# Отдельный warning, чтобы следующая такая регрессия была видна в логах,
|
||||
# а не выглядела как «DaData ничего не знает про этот адрес».
|
||||
logger.warning(
|
||||
"dadata suggest: 0 кандидатов для %r при region=%r — "
|
||||
"проверь, что констрейнт совпадает с полем DaData `region` (без типа)",
|
||||
query[:60],
|
||||
SVERDLOVSK_OBLAST_REGION,
|
||||
)
|
||||
out: list[GeocodeSuggestion] = []
|
||||
for s in raw:
|
||||
if s.lat is None or s.lon is None:
|
||||
|
|
@ -946,23 +965,85 @@ def _parse_street_house(address: str) -> tuple[str, str] | None:
|
|||
return (street, house)
|
||||
|
||||
|
||||
# Извлечение номера дома из `readable_address` реестра. Реальные формы в
|
||||
# gendesign_cad_buildings (47k строк, замер 2026-08-02):
|
||||
# «д. 13» / «дом 13» / «сооружение 30» — 21k
|
||||
# «д. 13б» — 2.6k
|
||||
# «д. 13-б» — 2.1k
|
||||
# «д. 13 б» — 125
|
||||
# «д. 58/3», «д. 64-2» — 0.9k (угловые/корпусные номера)
|
||||
# «д. 11 (кв. 1-150)», «д. 102 корпус 1» — хвост, литерой НЕ является
|
||||
# Разбор:
|
||||
# \m… — маркер только с НАЧАЛА слова, иначе «проезд 8
|
||||
# Марта, д 5» дало бы дом «8» (старый `д\.?` без
|
||||
# границы слова ловил «д» внутри «проезд»)
|
||||
# [0-9]+ — номер
|
||||
# (\s*[-/]\s*[0-9]+)? — «58/3» / «64-2»: часть номера, а не мусор —
|
||||
# иначе «58» ложно совпало бы с «58/3»
|
||||
# (\s*-?\s*[а-яё](?![а-яё]))? — литера; lookahead отсекает начало слова
|
||||
# («102 корпус 1» → «102», не «102к»)
|
||||
_SQL_HOUSE_TOKEN_RE = (
|
||||
r"\m(?:дом|д\.?|строение|стр\.?|сооружение|соор\.?)\s*"
|
||||
r"([0-9]+(?:\s*[-/]\s*[0-9]+)?(?:\s*-?\s*[а-яё](?![а-яё]))?)"
|
||||
)
|
||||
# Нормализация извлечённого токена к канону `_norm_house`: убираем пробелы,
|
||||
# затем дефис ТОЛЬКО перед литерой («23-б» → «23б», но «64-2» остаётся «64-2»,
|
||||
# иначе он схлопнулся бы в реальный дом «642»).
|
||||
_SQL_HOUSE_TOKEN_NORM = (
|
||||
r"regexp_replace("
|
||||
r" regexp_replace("
|
||||
r" lower(COALESCE((regexp_match(readable_address, :house_token_re, 'i'))[1], '')),"
|
||||
r" '\s', '', 'g'),"
|
||||
r" '-([а-яё])', '\1', 'g')"
|
||||
)
|
||||
|
||||
|
||||
def _cadastral_house_match(db: Session, street: str, house: str) -> GeocodeSuggestion | None:
|
||||
"""Anchored cadastral match: ILIKE по улице + regex-anchor на дом-маркер.
|
||||
"""Anchored cadastral match: ILIKE по улице + СТРОГОЕ равенство номера дома.
|
||||
|
||||
SQL validated на проде (11/16 hits, 0 false positives). Anchor на
|
||||
«д./дом/строение» убивает ложный матч номера внутри «(1-83)»-диапазона.
|
||||
Литера — часть идентичности дома, а не украшение: «Новгородцевой 13б» и
|
||||
«Новгородцевой 13» — РАЗНЫЕ здания. Поэтому номер сравнивается равенством
|
||||
нормализованных форм (обе стороны → канон «13б»), а не «совпали цифры,
|
||||
литера опциональна».
|
||||
|
||||
`street` идёт ТОЛЬКО в bound-param ILIKE (безопасно). Для regex берём
|
||||
только ЦИФРЫ дома (regex-safe) — конкатенируем bound-param внутри SQL.
|
||||
Литеру (если есть) используем лишь для tie-break сортировки.
|
||||
Раньше в regex шли только ЦИФРЫ дома, литера была опциональна в WHERE и
|
||||
участвовала лишь как tie-break в ORDER BY — из-за чего запрос с литерой
|
||||
молча получал соседний дом БЕЗ неё (и наоборот: «Малышева 30» → «д. 30-б»),
|
||||
причём с `confidence="exact"` и записью в `geocode_cache` на 90 дней.
|
||||
|
||||
Regex-anchor на «д./дом/строение» (prefilter) сохранён: он дёшев, пушится
|
||||
в FDW и убивает ложный матч номера внутри «(1-83)»-диапазона. Точность
|
||||
даёт равенство токенов ниже.
|
||||
|
||||
`street` идёт ТОЛЬКО в bound-param ILIKE, номер дома в regex больше НЕ
|
||||
конкатенируется (сравнивается как текст) — regex-injection поверхность
|
||||
сузилась до цифр prefilter'а.
|
||||
|
||||
Нет дома с нужной литерой → возвращаем None, а НЕ «похожий» дом: пусть
|
||||
отработают следующие тиры. Тихо подставленный соседний дом здесь
|
||||
необратимо помечался бы `exact`.
|
||||
|
||||
ВНИМАНИЕ, цепочки различаются — не путать:
|
||||
* `geocode()` : geoportal → cadastral → `_cadastral_forward_sync`
|
||||
→ Nominatim → None. Тира DaData тут НЕТ.
|
||||
* `suggest()` : cadastral → DaData → Nominatim (единственный вызов
|
||||
`_dadata_suggest`).
|
||||
То есть на прямом вызове `geocode()` (API/PDF/восстановление по `?id=`)
|
||||
адрес с литерой, неизвестный ни геопорталу, ни Nominatim, даёт None —
|
||||
оценка не строится. Это сознательный выбор: честный отказ вместо
|
||||
уверенно-неверной оценки чужого дома. Основной UI-путь этим не задет —
|
||||
координаты приходят из выбранной подсказки (`ParamsPanel.tsx:776` →
|
||||
`api/v1/trade_in.py:128` использует lat/lon напрямую, минуя `geocode()`).
|
||||
"""
|
||||
house_digits_m = re.match(r"\d+", house)
|
||||
house_norm = _norm_house(house)
|
||||
house_digits_m = re.match(r"\d+", house_norm)
|
||||
if not house_digits_m:
|
||||
return None
|
||||
house_digits = house_digits_m.group(0)
|
||||
try:
|
||||
row = db.execute(
|
||||
text(r"""
|
||||
text(
|
||||
r"""
|
||||
SELECT readable_address, lat, lon
|
||||
FROM gendesign_cad_buildings
|
||||
WHERE readable_address ILIKE CAST('%' || :street || '%' AS text)
|
||||
|
|
@ -976,14 +1057,19 @@ def _cadastral_house_match(db: Session, street: str, house: str) -> GeocodeSugge
|
|||
'(п\.\s|пос[. ]|посёлок|поселок|северка|шабровский'
|
||||
|| '| км|снт|гараж|коллективный сад)'
|
||||
)
|
||||
ORDER BY
|
||||
(CASE WHEN CAST(:house_full AS text) ~ '[а-яё]'
|
||||
AND readable_address ~* (CAST(:house_full AS text) || '(\D|$)')
|
||||
THEN 0 ELSE 1 END),
|
||||
length(readable_address) ASC
|
||||
AND """
|
||||
+ _SQL_HOUSE_TOKEN_NORM
|
||||
+ r""" = CAST(:house_norm AS text)
|
||||
ORDER BY length(readable_address) ASC
|
||||
LIMIT 1
|
||||
"""),
|
||||
{"street": street, "house_digits": house_digits, "house_full": house},
|
||||
"""
|
||||
),
|
||||
{
|
||||
"street": street,
|
||||
"house_digits": house_digits,
|
||||
"house_norm": house_norm,
|
||||
"house_token_re": _SQL_HOUSE_TOKEN_RE,
|
||||
},
|
||||
).first()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
-- Инвалидация записей geocode_cache, отравленных багом матчинга литеры дома.
|
||||
--
|
||||
-- Контекст: `_cadastral_house_match` (app/services/geocoder.py) сравнивал дом
|
||||
-- только по ЦИФРАМ — литера была опциональна в WHERE и участвовала лишь как
|
||||
-- tie-break в ORDER BY. Итог, двусторонний:
|
||||
-- • «Новгородцевой 13б» → «дом 13» (запрос с литерой → дом без неё)
|
||||
-- • «Малышева 30» → «д. 30-б» (запрос без литеры → дом с литерой)
|
||||
-- Оба результата писались с provider-тиром локального реестра и
|
||||
-- `confidence='exact'`, TTL 90 дней → пользователь получал оценку ЧУЖОГО
|
||||
-- здания, помеченную как точная, и она залипала в кэше.
|
||||
--
|
||||
-- Здесь удаляем только ПОДОЗРИТЕЛЬНЫЕ строки, а не весь кэш: полная очистка
|
||||
-- сожгла бы квоту внешних геокодеров (DaData 10k/день) на ре-резолв заведомо
|
||||
-- корректных адресов. Удалённое будет пересчитано лениво, при следующем
|
||||
-- запросе, уже исправленным матчером.
|
||||
--
|
||||
-- Идемпотентность: чистый DELETE по предикату. Повторный прогон удалит 0 строк
|
||||
-- (первый уже вычистил всё подходящее), новых строк с такой же патологией
|
||||
-- исправленный код не создаёт. Безопасно для strict exit-1 авто-применения.
|
||||
|
||||
BEGIN;
|
||||
|
||||
DELETE FROM geocode_cache
|
||||
WHERE
|
||||
-- (a) В самом запросе была литера дома: под старым матчером такой адрес мог
|
||||
-- уехать в дом без литеры / с чужой литерой. Смотрим на ХВОСТ адреса
|
||||
-- (дом пишется последним) — иначе порядковые части улиц («1-я
|
||||
-- Пятилетки», «4-й Кианитовый») ложно читались бы как литера.
|
||||
-- `|city=` — суффикс ключа кэша (см. geocoder._cache_key), отрезаем.
|
||||
split_part(address_normalized, '|city=', 1) ~* '[0-9]+\s*-?\s*[а-яё]\s*$'
|
||||
|
||||
-- (b) Обратное направление: в запросе литеры НЕ было, а закэширован адрес
|
||||
-- реестра, у которого номер дома С литерой («Малышева 30» → «д. 30-б»).
|
||||
-- Извлечение номера — то же выражение, что и в исправленном матчере
|
||||
-- (geocoder._SQL_HOUSE_TOKEN_RE): маркер только с начала слова, литера
|
||||
-- — одиночная кириллическая буква, «58/3»/«64-2» литерой не считаются.
|
||||
OR (
|
||||
split_part(address_normalized, '|city=', 1) !~* '[0-9]+\s*-?\s*[а-яё]\s*$'
|
||||
AND full_address IS NOT NULL
|
||||
AND regexp_replace(
|
||||
regexp_replace(
|
||||
lower(COALESCE((regexp_match(
|
||||
full_address,
|
||||
'\m(?:дом|д\.?|строение|стр\.?|сооружение|соор\.?)\s*'
|
||||
|| '([0-9]+(?:\s*[-/]\s*[0-9]+)?(?:\s*-?\s*[а-яё](?![а-яё]))?)',
|
||||
'i'))[1], '')),
|
||||
'\s', '', 'g'),
|
||||
'-([а-яё])', '\1', 'g'
|
||||
) ~ '[а-яё]$'
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -12,6 +12,7 @@ Covers:
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
|
@ -26,9 +27,13 @@ sys.modules.setdefault("weasyprint", _wp_mock)
|
|||
|
||||
|
||||
from app.services.geocoder import ( # noqa: E402
|
||||
_SQL_HOUSE_TOKEN_RE,
|
||||
SVERDLOVSK_OBLAST_REGION,
|
||||
GeocodeResult,
|
||||
GeocodeSuggestion,
|
||||
_cadastral_house_match,
|
||||
_dadata_suggest,
|
||||
_norm_house,
|
||||
_parse_street_house,
|
||||
geocode,
|
||||
suggest,
|
||||
|
|
@ -143,8 +148,8 @@ def test_house_match_returns_none_for_non_numeric_house() -> None:
|
|||
db.execute.assert_not_called()
|
||||
|
||||
|
||||
def test_house_match_passes_only_digits_as_regex_param() -> None:
|
||||
"""house='26а' → :house_digits bound param is '26' (letter stripped for regex)."""
|
||||
def _house_match_params(house: str, street: str = "космонавтов") -> dict:
|
||||
"""Вызывает матчер с mock-сессией и возвращает bound-params запроса."""
|
||||
row = MagicMock()
|
||||
row.readable_address = "г. Екатеринбург, пр-кт Космонавтов, д. 26а"
|
||||
row.lat = 56.9
|
||||
|
|
@ -154,13 +159,58 @@ def test_house_match_passes_only_digits_as_regex_param() -> None:
|
|||
result.first.return_value = row
|
||||
db.execute.return_value = result
|
||||
|
||||
_cadastral_house_match(db, "космонавтов", "26а")
|
||||
_cadastral_house_match(db, street, house)
|
||||
|
||||
# second positional arg to execute() is the bound-params dict
|
||||
params = db.execute.call_args.args[1]
|
||||
assert params["house_digits"] == "26"
|
||||
assert params["house_full"] == "26а"
|
||||
return db.execute.call_args.args[1]
|
||||
|
||||
|
||||
def test_house_match_passes_full_normalized_house_not_just_digits() -> None:
|
||||
"""house='26а' → в запрос уходит ПОЛНЫЙ номер '26а', а не только цифры '26'.
|
||||
|
||||
Регрессия-гард на исходный баг: раньше литера отрезалась (`house_digits`
|
||||
= '26') и в WHERE была опциональна, поэтому «26а» матчился на дом «26».
|
||||
Цифры остаются отдельным параметром — но только как дешёвый prefilter.
|
||||
"""
|
||||
params = _house_match_params("26а")
|
||||
|
||||
assert params["house_norm"] == "26а"
|
||||
assert params["house_digits"] == "26" # prefilter only
|
||||
assert params["street"] == "космонавтов"
|
||||
# Литера больше не «подсказка для сортировки» — старый параметр ушёл.
|
||||
assert "house_full" not in params
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw_house", "expected_norm"),
|
||||
[
|
||||
("13б", "13б"),
|
||||
("13 б", "13б"),
|
||||
("13-б", "13б"),
|
||||
("13Б", "13б"),
|
||||
("13 Б", "13б"),
|
||||
("13", "13"),
|
||||
],
|
||||
)
|
||||
def test_house_match_normalizes_letter_spellings(raw_house: str, expected_norm: str) -> None:
|
||||
"""«13б» / «13 б» / «13-б» / «13Б» — одна и та же литера, один канон."""
|
||||
assert _house_match_params(raw_house, street="новгородцевой")["house_norm"] == expected_norm
|
||||
|
||||
|
||||
def test_house_match_sql_compares_house_by_equality() -> None:
|
||||
"""SQL сравнивает нормализованный номер РАВЕНСТВОМ, а не «литера опциональна».
|
||||
|
||||
Структурный гард: если кто-то вернёт матч по цифрам с опциональной литерой
|
||||
(`[а-яё]?` в WHERE как единственная проверка дома), тест упадёт.
|
||||
"""
|
||||
db = MagicMock()
|
||||
db.execute.return_value.first.return_value = None
|
||||
_cadastral_house_match(db, "новгородцевой", "13б")
|
||||
|
||||
sql = str(db.execute.call_args.args[0])
|
||||
assert "= CAST(:house_norm AS text)" in sql
|
||||
# tie-break по литере в ORDER BY больше не решает корректность
|
||||
assert "house_full" not in sql
|
||||
|
||||
|
||||
# ── geocode() wiring ─────────────────────────────────────────────────────────
|
||||
|
|
@ -604,3 +654,202 @@ async def test_suggest_skips_ekb_local_tier_for_unrecognized_locality(
|
|||
mock_house.assert_not_called()
|
||||
mock_forward.assert_not_called()
|
||||
mock_nominatim.assert_called_once()
|
||||
|
||||
|
||||
# ── House-letter matching semantics ─────────────────────────────────────────
|
||||
# Само сравнение дома выполняет Postgres, поэтому здесь — зеркало SQL-выражения
|
||||
# на Python. Паттерн НЕ дублируется: он выводится из той же константы
|
||||
# `_SQL_HOUSE_TOKEN_RE`, что уходит в запрос (Postgres `\m` = «начало слова»
|
||||
# ≡ Python `\b` перед словесным символом). Правка SQL-регекспа автоматически
|
||||
# меняет и эти проверки — рассинхрон невозможен.
|
||||
# Строки-адреса — реальные формы `readable_address` из gendesign_cad_buildings.
|
||||
|
||||
|
||||
def _sql_house_token(readable_address: str) -> str:
|
||||
"""Зеркало `_SQL_HOUSE_TOKEN_NORM`: извлечь номер дома и привести к канону."""
|
||||
py_pattern = _SQL_HOUSE_TOKEN_RE.replace("\\m", "\\b")
|
||||
m = re.search(py_pattern, readable_address, re.IGNORECASE)
|
||||
token = (m.group(1) if m else "").lower()
|
||||
token = re.sub(r"\s", "", token)
|
||||
return re.sub(r"-([а-яё])", r"\1", token)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("readable_address", "expected"),
|
||||
[
|
||||
# Литера в трёх написаниях + регистр → один канон
|
||||
("Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 7б", "7б"),
|
||||
("Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 23-б", "23б"),
|
||||
("Свердловская область, г. Екатеринбург, ул. X, д. 18 б", "18б"),
|
||||
("Свердловская область, г. Екатеринбург, ул. X, д. 13Б", "13б"),
|
||||
# Без литеры
|
||||
("Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 13", "13"),
|
||||
("Российская Федерация, город Екатеринбург, улица Новгородцевой, дом 13", "13"),
|
||||
("Российская Федерация, город Екатеринбург, улица Малышева, сооружение 30", "30"),
|
||||
("Российская Федерация, город Екатеринбург, улица Малышева, строение 30 в", "30в"),
|
||||
# Хвосты, которые литерой НЕ являются
|
||||
("Свердловская область, г. Екатеринбург, ул. X, д. 11 (кв. 1-150)", "11"),
|
||||
("Свердловская область, г. Екатеринбург, ул. X, д. 25, корп. 1", "25"),
|
||||
("Российская Федерация, город Екатеринбург, улица X, дом 102 корпус 1", "102"),
|
||||
("Свердловская область, г. Екатеринбург, ул. X, д. 16 угол улица Титова", "16"),
|
||||
# Угловые/корпусные номера — ЧАСТЬ номера, не отбрасываются
|
||||
("Свердловская область, г. Екатеринбург, ул. X, д. 58/3", "58/3"),
|
||||
("Свердловская область, г. Екатеринбург, ул. X, д. 36/24а", "36/24а"),
|
||||
# Дефис перед ЦИФРОЙ не схлопывается (иначе «64-2» стало бы домом «642»)
|
||||
("Свердловская область, г. Екатеринбург, ул. X, д. 64-2", "64-2"),
|
||||
("Свердловская область, г. Екатеринбург, ул. X, д. 642", "642"),
|
||||
# Маркер только с начала слова: «проезд» не даёт дом «8»
|
||||
("Свердловская область, г Екатеринбург, проезд 8 Марта, д 5", "5"),
|
||||
("Свердловская область, г Екатеринбург, ул Привокзальная, д 22", "22"),
|
||||
# Нет дом-маркера → номер не извлекаем (адрес недостижим этим тиром)
|
||||
("Свердловская область, город Екатеринбург, проезд 4-й ЕКАД Южный", ""),
|
||||
],
|
||||
)
|
||||
def test_sql_house_token_extraction(readable_address: str, expected: str) -> None:
|
||||
assert _sql_house_token(readable_address) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("query_house", "readable_address", "should_match", "label"),
|
||||
[
|
||||
# ── Прод-баг #1: «Новгородцевой 13б» отдавал дом 13 как exact ──────
|
||||
(
|
||||
"13б",
|
||||
"Российская Федерация, город Екатеринбург, улица Новгородцевой, дом 13",
|
||||
False,
|
||||
"запрос С литерой не берёт дом БЕЗ литеры",
|
||||
),
|
||||
# ── Прод-баг #2 (обратный): «Малышева 30» отдавал «д. 30-б» ────────
|
||||
(
|
||||
"30",
|
||||
"Свердловская область, г. Екатеринбург, ул. Малышева, д. 30-б",
|
||||
False,
|
||||
"запрос БЕЗ литеры не берёт дом С литерой",
|
||||
),
|
||||
(
|
||||
"7б",
|
||||
"Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 7в",
|
||||
False,
|
||||
"другая литера не матчится",
|
||||
),
|
||||
# ── Позитив: литера совпала во всех написаниях реестра ─────────────
|
||||
("7б", "Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 7б", True, "«13б»"),
|
||||
(
|
||||
"23б",
|
||||
"Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 23-б",
|
||||
True,
|
||||
"«13-б»",
|
||||
),
|
||||
("18б", "Свердловская область, г. Екатеринбург, ул. X, д. 18 б", True, "«13 б»"),
|
||||
("13б", "Свердловская область, г. Екатеринбург, ул. X, д. 13Б", True, "регистр"),
|
||||
# ── Позитив: без литеры ────────────────────────────────────────────
|
||||
(
|
||||
"13",
|
||||
"Российская Федерация, город Екатеринбург, улица Новгородцевой, дом 13",
|
||||
True,
|
||||
"«дом N»",
|
||||
),
|
||||
(
|
||||
"30",
|
||||
"Российская Федерация, город Екатеринбург, улица Малышева, сооружение 30",
|
||||
True,
|
||||
"«сооружение N»",
|
||||
),
|
||||
# Префикс числа не считается совпадением
|
||||
("13", "Свердловская область, г. Екатеринбург, ул. X, д. 130", False, "13 ≠ 130"),
|
||||
# Угловой номер не подменяет простой
|
||||
("58", "Свердловская область, г. Екатеринбург, ул. X, д. 58/3", False, "58 ≠ 58/3"),
|
||||
],
|
||||
)
|
||||
def test_house_letter_match_semantics(
|
||||
query_house: str, readable_address: str, should_match: bool, label: str
|
||||
) -> None:
|
||||
"""Равенство нормализованных номеров — обе стороны приводятся к одному канону."""
|
||||
matched = _sql_house_token(readable_address) == _norm_house(query_house)
|
||||
assert matched is should_match, label
|
||||
|
||||
|
||||
def test_query_letter_house_falls_through_instead_of_returning_neighbour() -> None:
|
||||
"""Нет дома с литерой → None (не «похожий» дом) → работают следующие тиры.
|
||||
|
||||
Ключевое свойство фикса: молчаливая подмена соседнего здания здесь
|
||||
помечалась бы `confidence="exact"` и кэшировалась на 90 дней.
|
||||
"""
|
||||
db = MagicMock()
|
||||
db.execute.return_value.first.return_value = None # дома «13б» в реестре нет
|
||||
|
||||
assert _cadastral_house_match(db, "новгородцевой", "13б") is None
|
||||
assert db.execute.call_args.args[1]["house_norm"] == "13б"
|
||||
|
||||
|
||||
async def test_geocode_letter_house_miss_reaches_nominatim() -> None:
|
||||
"""«Новгородцевой 13б» без хита в реестре доходит до Nominatim, а не
|
||||
возвращает дом 13 с `confidence="exact"`."""
|
||||
db = MagicMock()
|
||||
nominatim_result = GeocodeResult(
|
||||
lat=56.82,
|
||||
lon=60.68,
|
||||
full_address="ул. Новгородцевой, 13б, Екатеринбург",
|
||||
provider="nominatim",
|
||||
confidence="approximate",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.services.geocoder._cache_get", return_value=None),
|
||||
patch("app.services.geocoder._geoportal_house_match", return_value=None),
|
||||
patch("app.services.geocoder._cadastral_house_match", return_value=None) as mock_house,
|
||||
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
|
||||
patch("app.services.geocoder._cache_put"),
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_lookup",
|
||||
new_callable=AsyncMock,
|
||||
return_value=nominatim_result,
|
||||
) as mock_nominatim,
|
||||
):
|
||||
result = await geocode("Екатеринбург, Новгородцевой 13б", db)
|
||||
|
||||
assert result is not None
|
||||
assert result.confidence == "approximate"
|
||||
assert result.lat == pytest.approx(56.82)
|
||||
mock_house.assert_called_once()
|
||||
# в матчер ушёл ПОЛНЫЙ номер с литерой
|
||||
assert mock_house.call_args.args[2] == "13б"
|
||||
mock_nominatim.assert_called_once()
|
||||
|
||||
|
||||
def test_parse_street_house_keeps_letter_in_all_spellings() -> None:
|
||||
"""Парсер отдаёт литеру матчеру в каноне — иначе строгий матч бесполезен."""
|
||||
assert _parse_street_house("Новгородцевой 13б") == ("новгородцевой", "13б")
|
||||
assert _parse_street_house("Новгородцевой 13-б") == ("новгородцевой", "13б")
|
||||
assert _parse_street_house("Новгородцевой 13 Б") == ("новгородцевой", "13б")
|
||||
assert _parse_street_house("ул. Новгородцевой, д. 13б") == ("новгородцевой", "13б")
|
||||
assert _parse_street_house("Новгородцевой 13") == ("новгородцевой", "13")
|
||||
|
||||
|
||||
# ── DaData region constraint ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_dadata_region_constant_has_no_region_type() -> None:
|
||||
"""DaData `locations.region` сравнивается с именем БЕЗ типа.
|
||||
|
||||
«Свердловская область» — hard-filter, который не совпадает ни с чем и молча
|
||||
даёт 0 подсказок (прод-баг). Тип живёт в `region_type`/`region_with_type`.
|
||||
"""
|
||||
assert SVERDLOVSK_OBLAST_REGION == "Свердловская"
|
||||
lowered = SVERDLOVSK_OBLAST_REGION.lower()
|
||||
for type_word in ("область", "обл", "край", "респ"):
|
||||
assert type_word not in lowered, f"тип региона {type_word!r} ломает locations-фильтр"
|
||||
|
||||
|
||||
async def test_dadata_suggest_passes_region_without_type() -> None:
|
||||
"""`_dadata_suggest` отдаёт в DaData именно region-константу (не город)."""
|
||||
with patch(
|
||||
"app.services.geocoder.dadata.suggest_addresses",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[],
|
||||
) as mock_suggest:
|
||||
assert await _dadata_suggest("Новгородцевой 13б", limit=5) == []
|
||||
|
||||
kwargs = mock_suggest.call_args.kwargs
|
||||
assert kwargs["region"] == "Свердловская"
|
||||
assert kwargs["city"] is None
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue