From 663d63aea4142b5ab31115086f8a86abcb0b64f5 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 30 Aug 2026 14:58:03 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein):=20=D0=BF=D0=BE=D0=B4=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=D0=B7=D0=BA=D0=B8=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=D0=B0?= =?UTF-8?q?=20=E2=80=94=20=D0=BF=D1=83=D1=81=D1=82=D1=8B=D0=B5=20=D1=87?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D0=B8=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=D0=B0?= =?UTF-8?q?=20=D0=B2=D1=8B=D0=BF=D0=B0=D0=B4=D0=B0=D1=8E=D1=82,=20=D0=B0?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D0=BF=D0=B5=D1=87=D0=B0=D1=82=D0=B0=D1=8E?= =?UTF-8?q?=D1=82=D1=81=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прод отдавал «г Екатеринбург, ул Фролова , д. 29, корп.»: реестровый readable_address (ЕГРН) приходит УЖЕ склеенным вместе с пустыми частями — маркер без значения печатается, лишний пробел перед запятой остаётся, и все тиры /suggest (cadastral / geoportal / houses / DaData / Nominatim) пропускали строку насквозь. Чиним склейку, а не конкретный случай: `tidy_address()` — один проход по частям (схлопнуть пробелы, выбросить пустые и маркер-без-номера: корп./стр./лит./кв./оф./пом. и пр.), склейка обратно. Применяется в `GeocodeSuggestion.__post_init__` — единственной точке, через которую проходят все тиры, включая локальные, которые питают и `geocode()`. Тест на функцию склейки, фальсифицирован снятием фильтра пустых: краснеет по значению строки (10 из 14), а не исключением. Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/services/geocoder.py | 33 +++++++++ .../test_geocode_suggest_address_join.py | 70 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 tradein-mvp/backend/tests/test_geocode_suggest_address_join.py 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" -- 2.45.3