diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index 04dff251..f7760ce8 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -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 используется diff --git a/tradein-mvp/backend/tests/test_geocode_suggest_address_join.py b/tradein-mvp/backend/tests/test_geocode_suggest_address_join.py new file mode 100644 index 00000000..33484230 --- /dev/null +++ b/tradein-mvp/backend/tests/test_geocode_suggest_address_join.py @@ -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"