fix(tradein): подсказки адреса — пустые части адреса выпадают, а не печатаются
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 16s
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 5m50s

Прод отдавал «г Екатеринбург, ул Фролова , д. 29, корп.»: реестровый
readable_address (ЕГРН) приходит УЖЕ склеенным вместе с пустыми частями —
маркер без значения печатается, лишний пробел перед запятой остаётся, и все
тиры /suggest (cadastral / geoportal / houses / DaData / Nominatim) пропускали
строку насквозь.

Чиним склейку, а не конкретный случай: `tidy_address()` — один проход по
частям (схлопнуть пробелы, выбросить пустые и маркер-без-номера:
корп./стр./лит./кв./оф./пом. и пр.), склейка обратно. Применяется в
`GeocodeSuggestion.__post_init__` — единственной точке, через которую проходят
все тиры, включая локальные, которые питают и `geocode()`.

Тест на функцию склейки, фальсифицирован снятием фильтра пустых: краснеет по
значению строки (10 из 14), а не исключением.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-08-30 14:58:03 +05:00
parent 56e4de0b35
commit 663d63aea4
2 changed files with 103 additions and 0 deletions

View file

@ -810,6 +810,33 @@ async def _nominatim_lookup(address: str, city_hint: str | None = None) -> Geoco
# ── Suggest (автокомплит) ───────────────────────────────────────────────────
# Части адреса приходят из реестров УЖЕ склеенными — и склеенными грязно:
# ЕГРН `readable_address` печатает маркер без значения и оставляет лишний
# пробел («г Екатеринбург, ул Фролова , д. 29, корп.»). Чиним склейку, а не
# конкретный случай: один проход по частям — схлопнуть пробелы, выбросить
# пустые и маркер-без-номера (корп./стр./лит./кв. и пр.), склеить обратно.
_RE_ADDR_WS = re.compile(r"\s+")
_RE_ADDR_BARE_MARKER = re.compile(
r"^(?:д|дом|корп|корпус|к|стр|строение|соор|сооружение|лит|литера|литер"
r"|кв|квартира|оф|офис|пом|помещение|вл|владение|уч|участок)\.?$",
re.IGNORECASE,
)
def tidy_address(s: str) -> str:
"""«г Екатеринбург, ул Фролова , д. 29, корп.» → «г Екатеринбург, ул Фролова, д. 29».
Часть без значения ВЫПАДАЕТ, а не печатается пустым маркером.
"""
parts = []
for raw in s.split(","):
part = _RE_ADDR_WS.sub(" ", raw).strip()
if not part or _RE_ADDR_BARE_MARKER.match(part):
continue
parts.append(part)
return ", ".join(parts)
@dataclass(frozen=True, slots=True)
class GeocodeSuggestion:
label: str # формат "Малышева 30, Октябрьский район"
@ -822,6 +849,12 @@ class GeocodeSuggestion:
# street/locality-кандидатов остаётся None (нет стабильного house-fias).
fias_id: str | None = None
def __post_init__(self) -> None:
# Единственная точка, через которую проходят ВСЕ тиры (cadastral/
# geoportal/houses/DaData/Nominatim) — чистим здесь, а не у каждого.
object.__setattr__(self, "label", tidy_address(self.label))
object.__setattr__(self, "full_address", tidy_address(self.full_address))
# ── DaData suggest wrapper ──────────────────────────────────────────────────
# Маппинг DaData kind → GeocodeSuggestion.kind (внутри geocoder используется

View file

@ -0,0 +1,70 @@
"""Склейка адреса в подсказках: пустые части ВЫПАДАЮТ, а не печатаются.
Прод-дефект: «г Екатеринбург, ул Фролова , д. 29, корп.» двойной пробел
перед запятой и висячее «корп.» без номера (ЕГРН `readable_address` отдаёт
маркер даже когда значения нет, и все тиры /suggest пропускали строку насквозь).
"""
import pytest
from app.services.geocoder import GeocodeSuggestion, tidy_address
@pytest.mark.parametrize(
("raw", "expected"),
[
# Прод-случай целиком.
(
"г Екатеринбург, ул Фролова , д. 29, корп.",
"г Екатеринбург, ул Фролова, д. 29",
),
# Непустой корпус ОБЯЗАН остаться.
(
"г Екатеринбург, ул Фролова, д. 29, корп. 2",
"г Екатеринбург, ул Фролова, д. 29, корп. 2",
),
# Соседние типы частей — та же болезнь, тот же механизм.
("г Екатеринбург, ул Мира, д. 1, стр.", "г Екатеринбург, ул Мира, д. 1"),
("г Екатеринбург, ул Мира, д. 1, лит.", "г Екатеринбург, ул Мира, д. 1"),
("г Екатеринбург, ул Мира, д. 1, кв.", "г Екатеринбург, ул Мира, д. 1"),
("г Екатеринбург, ул Мира, д. 1, стр. 5", "г Екатеринбург, ул Мира, д. 1, стр. 5"),
("г Екатеринбург, ул Мира, д. 1, лит. А", "г Екатеринбург, ул Мира, д. 1, лит. А"),
# Пустая часть посередине и подряд идущие запятые.
("г Екатеринбург, , ул Мира, корп. , д. 1", "г Екатеринбург, ул Мира, д. 1"),
# Маркер без точки и без номера.
("г Екатеринбург, ул Мира, д 1, корпус", "г Екатеринбург, ул Мира, д 1"),
# Чистая строка не меняется.
("г Екатеринбург, ул Малышева, д 30", "г Екатеринбург, ул Малышева, д 30"),
],
)
def test_tidy_address(raw: str, expected: str) -> None:
assert tidy_address(raw) == expected
@pytest.mark.parametrize(
"raw",
[
"г Екатеринбург, ул Фролова , д. 29, корп.",
"г Екатеринбург, , ул Мира, корп. , д. 1",
"г Екатеринбург, ул Мира, д. 1, стр. , лит.",
],
)
def test_no_double_spaces_or_dangling_parts(raw: str) -> None:
out = tidy_address(raw)
assert " " not in out
assert " ," not in out
assert not out.endswith(",")
assert out.split(", ")[-1] not in {"корп.", "стр.", "лит.", "кв.", "д."}
def test_suggestion_cleans_both_label_and_full_address() -> None:
"""Чистка стоит в единой точке — её проходят ВСЕ тиры /suggest."""
s = GeocodeSuggestion(
label="ул Фролова , д. 29, корп.",
full_address="г Екатеринбург, ул Фролова , д. 29, корп.",
lat=56.8,
lon=60.6,
kind="house",
)
assert s.label == "ул Фролова, д. 29"
assert s.full_address == "г Екатеринбург, ул Фролова, д. 29"