diff --git a/tradein-mvp/backend/app/services/house_dedup_merge.py b/tradein-mvp/backend/app/services/house_dedup_merge.py index 3ca48152..422ba493 100644 --- a/tradein-mvp/backend/app/services/house_dedup_merge.py +++ b/tradein-mvp/backend/app/services/house_dedup_merge.py @@ -42,9 +42,9 @@ TWO PASSES (2026-07-02 follow-up): the SAME cluster→keeper→re-point→carry- (`_mapping_sql`) — no 300-line copy-paste: 1. FIAS pass — clusters by lower(NULLIF(house_fias_id, '')) (the ФИАС/ГАР building UUID, DaData-backfilled 2026-07-02 for 5 122 houses). Only non-null fias shared by >1 house_id - cluster. Catches duplicates the canon MISSES: slash-collapse («Сулимова, 32» vs - «Сулимова, 3/2» → same canon «сулимова32») and посёлок truncation (Кедровка/Шувакиш - colliding with a same-named ЕКБ street). + cluster. Catches duplicates the canon MISSES: посёлок truncation (Кедровка/Шувакиш + colliding with a same-named ЕКБ street) and, until migration 322 (#2189), slash-collapse + («Сулимова, 32» vs «Сулимова, 3/2» → same canon «сулимова32»). 2. CANON pass — the canonical-address clustering above, now with a CROSS-FIAS GUARD: within a canon cluster a loser is NOT merged when it AND the keeper both carry a non-null but DIFFERENT house_fias_id — provably different buildings the canon collapsed (the @@ -225,8 +225,8 @@ _KEEPER_ORDER = f""" # cluster key differs, parametrised into `_mapping_sql`, so there is NO copy-paste of the pipeline. # # FIAS key (#1772 follow-up, 2026-07-02): the ФИАС/ГАР building UUID (DaData backfill). Catches -# duplicates the canon misses — slash-collapse («Сулимова, 32» vs «Сулимова, 3/2») and посёлок -# truncation (Кедровка/Шувакиш). Only non-null fias clusters. +# duplicates the canon misses — посёлок truncation (Кедровка/Шувакиш) and, until migration 322 +# (#2189), slash-collapse («Сулимова, 32» vs «Сулимова, 3/2»). Only non-null fias clusters. _FIAS_KEY_EXPR = """ CASE WHEN NULLIF(house_fias_id, '') IS NOT NULL THEN 'fias:' || lower(house_fias_id) diff --git a/tradein-mvp/backend/data/sql/322_canon_keep_slash_in_house_number.sql b/tradein-mvp/backend/data/sql/322_canon_keep_slash_in_house_number.sql new file mode 100644 index 00000000..1d2c9f54 --- /dev/null +++ b/tradein-mvp/backend/data/sql/322_canon_keep_slash_in_house_number.sql @@ -0,0 +1,124 @@ +-- 322_canon_keep_slash_in_house_number.sql +-- +-- CONTEXT (#2189): канон-ключ tradein_canon_addr последним шагом оставлял только [а-я0-9], +-- и дробь в номере дома исчезала: «Сулимова, 3/2» ≡ «Сулимова, 32», «Краснолесья, 10/7» ≡ +-- «107», «Машиностроителей, 4/1» ≡ «41». В ГАР это разные дома с разными house_guid +-- («ул. Сулимова, 3/2» — 414 квартир, «ул. Сулимова, 32» — 120), а матчер ГАР↔houses +-- (gar_flats_loader._MATCH_SQL) при фильтре по городу берёт из такого ключа строку с +-- max(flat_count): так «Сулимова 32» получил 414 квартир дома 3/2. Ревизия DaData 02.07 +-- подтвердила класс (23 из 25 расхождений — в пользу DaData, метка dadata_fias_revision). +-- +-- WHAT: CREATE OR REPLACE FUNCTION tradein_canon_addr(text) — тело мигр. 311 без изменений, +-- кроме последнего шага: S6 оставляет [а-я0-9/], новый S7 убирает «/», если хотя бы с одной +-- стороны от неё не цифра. «3/2» → «3/2», «3 / 2» → «3/2» (пробелы S6 уже снял), а угловые +-- адреса «Гагарина, 27 / Первомайская, 81» и «204Г/1» дают прежний ключ. +-- +-- Новый ключ — ТОЛЬКО уточнение старого: убери из нового все «/» — получишь старый. Значит, +-- два адреса с одинаковым новым ключом имели одинаковый и старый; склеить дома, которые +-- раньше не склеивались, правка не может, только развести. +-- +-- Замер на проде 2026-09-17 (read-only, выражение нового тела inline в SELECT): +-- houses: ключ меняется у 2549 из 49 204; групп «один новый ключ — несколько старых» 0. +-- Распалось 47 прежних канон-кластеров — 113 пар домов, и ни одна не ближе 250 м +-- (минимум 256 м), общего ФИАС нет ни в одной, разный ФИАС у 5. Страж 250 м +-- house_dedup_merge эти пары и так не сливал, число слияний не меняется. +-- gar_house_flats: «/» между цифрами в 7237 строках с квартирами (66: 938, 77: 2711, +-- 50: 3588); групп «один новый ключ — несколько старых» 0. +-- Симуляция _MATCH_SQL по затронутым ключам (регион: сопоставлений было → стало, +-- неоднозначных ключей было → стало): 66 с фильтром ЕКБ 399 → 364, 17 → 9; +-- 77: 259 → 242, 38 → 12; 50: 400 → 448, 348 → 249. Каждая потерянная пара — +-- дробь против числа («Высоцкого, 4/2» ↔ ГАР «42», 725 квартир). +-- +-- DEPENDENCIES (existing prod objects): +-- - tradein_canon_addr(text) — мигр. 144/147/311 (заменяем тело, IMMUTABLE, сигнатура та же). +-- ДЕПЛОИТЬ ПОСЛЕ 311 (PR #3558): тело включает её шаг S0b, иначе порядок файлов неважен. +-- - gar_house_flats_canon_idx — функциональный индекс на tradein_canon_addr(norm_address) +-- (мигр. 144). Значения у 7237 строк меняются → REINDEX обязателен: Postgres не +-- перестраивает функциональный индекс при смене тела функции (урок мигр. 147). +-- +-- ЧИСТКА СОПОСТАВЛЕНИЙ, СДЕЛАННЫХ СКЛЕЕННЫМ КЛЮЧОМ. Матчер (_MATCH_SQL) только проставляет +-- gar_house_guid и никогда не снимает: дом «Высоцкого, 4/2», пришитый к ГАР «42» (725 квартир), +-- при перематче новым ключом не найдёт пары и так и останется с чужими квартирами. Поэтому +-- здесь сбрасываются ровно те canon_addr-сопоставления, у которых ключи дома и его ГАР-строки +-- совпадают без «/», но различаются с ней, — то есть держались только на потерянной дроби. +-- Сопоставления, разошедшиеся с ключом по другим причинам (прод: 4 дома, адрес сменился после +-- матча), и методы dadata_fias/dadata_fias_revision не трогаются. +-- Замер 17.09: 47 домов (регион ГАР 66 — 7, 77 — 21, 50 — 19), 3836 чужих квартир, 180 активных +-- объявлений; все 47 — дробь против числа («Удельная, 1/1» → «11», «Ландау, 5/1» → «51»). +-- Сомнительны 2 из 47: «Первомайская, 04А/04Б» ↔ ГАР «0/4а»/«0/4б» (регион 50) — возможно, +-- тот же дом, у которого источник потерял дробь; без ГАР-числа они честнее, чем с угаданным. +-- Порог остановки 100: сбросили больше — критерий ловит не то, миграция падает целиком. +-- Перематч (новые и исправленные пары) — отдельный шаг, см. PR. +-- +-- POST-DEPLOY: новые ключи houses сразу действуют в house_dedup_merge. Перематч ГАР — по желанию +-- владельца: python -m app.tasks.gar_flats_load --match-only --region 66 (и 77, 50). +-- +-- SAFETY / IDEMPOTENCY: CREATE OR REPLACE + REINDEX INDEX — повторный прогон no-op; сброшенные дома +-- второй раз не находятся (gar_match_method уже NULL). Раннер деплоя гонит файл через psql +-- ON_ERROR_STOP=on без --single-transaction → BEGIN/COMMIT в файле. + +BEGIN; +-- Конвенция проекта (#2752): REINDEX и UPDATE houses берут блокировки — не вставать в очередь. +SET LOCAL lock_timeout = '5s'; + +CREATE OR REPLACE FUNCTION tradein_canon_addr(s text) RETURNS text AS $func$ + SELECT regexp_replace( -- S7: «/» остаётся только между цифрами (#2189) + regexp_replace( -- S6: оставить только [а-я0-9/] + regexp_replace( -- S5: срез типов улиц (токены на границе не-кириллицы) + regexp_replace( -- S5b: срез маркера дома «д[.]» перед номером + regexp_replace( -- S4b: срез «мкр/пос/поселок ...,» только если следом улица + regexp_replace( -- S4: срез префикс-сегментов (каскад через lookahead) + regexp_replace( -- S3: срез суффикс-сегмента «<Имя> м-н,» + regexp_replace( -- S2: срез суффикс-сегмента «<...> обл/область,» + regexp_replace( -- S1b: «б-р» → «бульвар» + regexp_replace( -- S1a: «пр-т/пр-кт/пр кт/пркт» → «проспект» + regexp_replace( -- S0b: «17Ар-н Академический» → «17а» (#1772) + translate(lower(coalesce(s, '')), 'ё', 'е'), -- S0: lower + ё→е + '([0-9][а-я]?)р-н [^,]*', '\1', 'g'), + '(^|[^а-я])пр[-. ]?к?т([^а-я]|$)', '\1проспект\2', 'g'), + '(^|[^а-я])б-р([^а-я]|$)', '\1бульвар\2', 'g'), + '(^|,)[^,]* обл[а-я]*\.?(?=,)', '\1', 'g'), + '(^|,)[^,]* м-н(?=,)', '\1', 'g'), + '(^|,)\s*(россия|екатеринбург|город|жилой район|жилрайон|пгт|снт|р-н|м-н|г)([. ][^,]*)?(?=,)', '\1', 'g'), + '(^|,)\s*(мкр|поселок|пос)[. ][^,]*(?=,[^,]*[а-я])', '\1', 'g'), + '(^|[^а-я])д\.?(?= *[0-9])', '\1', 'g'), + '(^|[^а-я])(улица|ул|переулок|пер|проспект|пркт|пр|бульвар|бр|шоссе|ш|проезд|набережная|наб|площадь|пл|тупик|туп|аллея|микрорайон|мкр)([^а-я]|$)', '\1\3', 'g'), + '[^а-я0-9/]', '', 'g'), + '(? tradein_canon_addr(g.norm_address) + AND replace(tradein_canon_addr(COALESCE(h.short_address, h.full_address, h.address)), '/', '') + = replace(tradein_canon_addr(g.norm_address), '/', ''); + GET DIAGNOSTICS reset_rows = ROW_COUNT; + RAISE NOTICE 'houses: сброшено ГАР-сопоставлений, державшихся на потерянной дроби = %', reset_rows; + + -- Исключение откатывает всю транзакцию, включая замену функции. + IF reset_rows > 100 THEN + RAISE EXCEPTION + 'houses: к сбросу % canon_addr-сопоставлений при замере 17.09.2026 = 47 — ' + 'критерий ловит не то, миграция остановлена', reset_rows; + END IF; +END $$; + +COMMIT; diff --git a/tradein-mvp/backend/tests/skip_allowlist.txt b/tradein-mvp/backend/tests/skip_allowlist.txt index 576045cc..494ee7e0 100644 --- a/tradein-mvp/backend/tests/skip_allowlist.txt +++ b/tradein-mvp/backend/tests/skip_allowlist.txt @@ -31,6 +31,10 @@ tests/test_pdf_real_render.py tests/tasks/test_backfill_house_coords_from_listings.py::test_real_transfers_agreeing_coords_skips_scattered_and_is_idempotent tests/tasks/test_cadastral_geo_match.py::test_real_knn_nearest_within_threshold_picked tests/test_audit_api.py::test_real_accounts_and_analytics_aggregate_inserted_rows +tests/test_2189_canon_keeps_slash.py::test_real_canon_keeps_slash_between_digits +tests/test_2189_canon_keeps_slash.py::test_real_dedup_does_not_merge_slash_and_plain_number +tests/test_2189_canon_keeps_slash.py::test_real_gar_match_picks_the_house_with_the_same_number +tests/test_2189_canon_keeps_slash.py::test_migration_322_resets_only_matches_held_by_the_lost_slash tests/test_gar_flats_loader.py::test_upsert_and_canon_match_populates_gar_flat_count tests/test_gar_flats_loader.py::test_no_city_filter_ambiguous_canon_not_matched tests/test_gar_flats_loader.py::test_house_gets_gar_guid_only_from_its_own_region diff --git a/tradein-mvp/backend/tests/test_2189_canon_keeps_slash.py b/tradein-mvp/backend/tests/test_2189_canon_keeps_slash.py new file mode 100644 index 00000000..43e51b5f --- /dev/null +++ b/tradein-mvp/backend/tests/test_2189_canon_keeps_slash.py @@ -0,0 +1,215 @@ +"""#2189 — канон-ключ адреса не теряет дробь в номере дома («Сулимова, 3/2» ≠ «Сулимова, 32»). + +Проверяется функция, которую поставили миграции (322), а не её Python-копия, и оба её +потребителя: матч ГАР↔houses (gar_flats_loader) и схлопывание дублей (house_dedup_merge). +Без БД тесты пропускаются (записи в tests/skip_allowlist.txt), в CI идут на схеме из data/sql. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from sqlalchemy import text + +from app.services import gar_flats_loader as gfl +from app.services import house_dedup_merge as hdm + + +def _live_session() -> Any | None: + try: + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + + dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "") + if not dsn or "localhost:5432/test" in dsn: + return None + engine = create_engine(dsn, future=True) + with engine.connect() as conn: + conn.execute(text("SELECT 1")) + return sessionmaker(bind=engine, future=True)() + except Exception: + return None + + +needs_db = pytest.mark.skipif(_live_session() is None, reason="нет доступной Postgres test-БД") + + +@needs_db +def test_real_canon_keeps_slash_between_digits() -> None: + db = _live_session() + assert db is not None + + def canon(s: str) -> str: + return db.execute(text("SELECT tradein_canon_addr(:s)"), {"s": s}).scalar() + + try: + # живые пары с прода (ревизия DaData 02.07): это разные дома + assert canon("Екатеринбург, ул. Сулимова, 3/2") == "сулимова3/2" + assert canon("Екатеринбург, ул. Сулимова, 32") == "сулимова32" + assert canon("улица Краснолесья, 10/7") == "краснолесья10/7" + assert canon("ул. Краснолесья,107") == "краснолесья107" + assert canon("улица Машиностроителей, 4/1") != canon("ул. Машиностроителей, 41") + # пробелы вокруг дроби и формат ГАР дают тот же ключ, что и слитная запись + assert canon("ул. Сулимова, 3 / 2") == canon( + "р-н Кировский, мкр. Пионерский, ул. Сулимова,3/2" + ) + # «/» не между цифрами — как раньше: угловой адрес и литера перед дробью + assert canon( + "Екатеринбург, Втузгородок м-н, улица Гагарина, 27 / Первомайская улица, 81" + ) == ("гагарина27первомайская81") + assert canon("ул. 8 Марта,204Г/1") == "8марта204г1" + # остальная нормализация не задета + assert canon("ул. Шаумяна,20") == canon("ул. Шаумяна, 20") == "шаумяна20" + assert ( + canon("Екатеринбург, ул. Евгения Савкова, 17Ар-н Академический") == "евгениясавкова17а" + ) + finally: + db.rollback() + db.close() + + +@needs_db +def test_real_gar_match_picks_the_house_with_the_same_number() -> None: + """Прод: «Сулимова 32» получал ГАР-дом «3/2» (414 квартир) — max(flat_count) в общем ключе.""" + db = _live_session() + assert db is not None + try: + db.execute( + text( + "INSERT INTO gar_house_flats " + "(house_guid, region_code, flat_count, full_address, norm_address) VALUES " + "('g2189-3-2', '66', 414, 'г. Екатеринбург, ул. Сулимова2189, 3/2', " + " 'ул. Сулимова2189, 3/2')," + "('g2189-32', '66', 120, 'г. Екатеринбург, ул. Сулимова2189, 32', " + " 'ул. Сулимова2189, 32')" + ) + ) + # region_code обязателен: с #2583 H5 матчер даёт guid только ГАР-строке региона дома, + # дом без региона не матчится вовсе. + db.execute( + text( + "INSERT INTO houses (id, source, ext_house_id, url, address, region_code) VALUES " + "(900189, 'avito', 'EXT-2189-32', 'u', 'Екатеринбург, ул. Сулимова2189, 32', 66)," + "(900190, 'cian', 'EXT-2189-3-2', 'u', 'ул. Сулимова2189, 3 / 2', 66)" + ) + ) + + gfl.match_houses_to_gar(db, region_code="66") + + got = db.execute( + text( + "SELECT id, gar_house_guid, gar_flat_count, gar_match_method FROM houses " + "WHERE id IN (900189, 900190) ORDER BY id" + ) + ).all() + assert [tuple(r) for r in got] == [ + (900189, "g2189-32", 120, "canon_addr"), + (900190, "g2189-3-2", 414, "canon_addr"), + ] + finally: + db.rollback() + db.close() + + +@needs_db +def test_real_dedup_does_not_merge_slash_and_plain_number() -> None: + """Дома «3/2» и «32» без ФИАС в одной точке — разные здания, в один кластер не попадают.""" + db = _live_session() + assert db is not None + try: + db.execute( + text( + "INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon) VALUES " + "(900189, 'avito', 'EXT-2189-32', 'u', 'ул. Сулимова2189, 32', 56.84, 60.6)," + "(900190, 'cian', 'EXT-2189-3-2', 'u', 'ул. Сулимова2189, 3/2', 56.84009, 60.6)" + ) + ) + db.commit() + + hdm.merge_duplicate_houses(db, dry_run=False) + + ids = {r.id for r in db.execute(text("SELECT id FROM houses WHERE id IN (900189, 900190)"))} + assert ids == {900189, 900190} + finally: + db.rollback() + for sql in ( + "DELETE FROM house_sources WHERE house_id IN (900189, 900190)", + "DELETE FROM house_address_aliases WHERE house_id IN (900189, 900190)", + "DELETE FROM house_merge_log WHERE loser_id IN (900189, 900190)", + "DELETE FROM houses WHERE id IN (900189, 900190)", + ): + db.execute(text(sql)) + db.commit() + db.close() + + +_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "data" + / "sql" + / "322_canon_keep_slash_in_house_number.sql" +) + + +def test_migration_322_resets_only_matches_held_by_the_lost_slash() -> None: + """Матчер пары не снимает: «4/1», пришитый к ГАР «41», без миграции так и остался бы с ним. + + Прогон настоящего файла миграции; houses и gar_house_flats затенены временными таблицами + (pg_temp первым в search_path), реальные таблицы не трогаются. + """ + dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "") + if not dsn or "localhost:5432/test" in dsn: + pytest.skip("нет доступной Postgres test-БД") + import psycopg + + with psycopg.connect( + dsn.replace("postgresql+psycopg://", "postgresql://"), autocommit=True + ) as c: + c.execute( + "CREATE TEMP TABLE gar_house_flats (house_guid text PRIMARY KEY, region_code text," + " flat_count int, norm_address text)" + ) + c.execute( + "CREATE TEMP TABLE houses (id int PRIMARY KEY, short_address text, full_address text," + " address text, gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz," + " gar_match_method text)" + ) + c.execute( + "INSERT INTO gar_house_flats VALUES " + "('g-3-2', '66', 414, 'ул. Сулимова, 3/2')," + "('g-41', '66', 92, 'ул. Машиностроителей, 41')," + "('g-58', '66', 249, 'ул. Абрикосовая, 58')" + ) + c.execute( + "INSERT INTO houses (id, short_address, gar_house_guid, gar_flat_count, gar_matched_at," + " gar_match_method) VALUES " + # держится только на потерянной дроби → сброс + "(1, 'ул. Сулимова, 32', 'g-3-2', 414, now(), 'canon_addr')," + "(2, 'улица Машиностроителей, 4/1', 'g-41', 92, now(), 'canon_addr')," + # верная пара с дробью + "(3, 'Сулимова ул.,3/2', 'g-3-2', 414, now(), 'canon_addr')," + # не канон-метод + "(4, 'улица Машиностроителей, 4/1', 'g-41', 92, now(), 'dadata_fias_revision')," + # ключ разошёлся не из-за дроби (адрес сменился после матча) + "(5, 'жилой комплекс Совушки, Абрикосовая ул.,58', 'g-58', 249, now(), 'canon_addr')" + ) + + c.execute(_MIGRATION.read_text(encoding="utf-8")) + + rows = c.execute( + "SELECT id, gar_house_guid, gar_flat_count, gar_matched_at IS NOT NULL," + " gar_match_method FROM houses ORDER BY id" + ).fetchall() + assert rows == [ + (1, None, None, False, None), + (2, None, None, False, None), + (3, "g-3-2", 414, True, "canon_addr"), + (4, "g-41", 92, True, "dadata_fias_revision"), + (5, "g-58", 249, True, "canon_addr"), + ] diff --git a/tradein-mvp/backend/tests/test_gar_flats_loader.py b/tradein-mvp/backend/tests/test_gar_flats_loader.py index 381996b9..a9d47744 100644 --- a/tradein-mvp/backend/tests/test_gar_flats_loader.py +++ b/tradein-mvp/backend/tests/test_gar_flats_loader.py @@ -342,6 +342,8 @@ def test_canon_addr_street_type_and_punct_collapse() -> None: def test_canon_addr_strips_rayon_and_korpus_slash() -> None: # «р-н Ленинский, улица Цвиллинга, 7/6» → «цвиллинга76» (район срезан, слэш выброшен). + # Так было в зеркалируемой 144; с мигр. 322 дробь в ключе остаётся (#2189, + # tests/test_2189_canon_keeps_slash.py проверяет саму функцию). assert _canon_addr_py("р-н Ленинский, улица Цвиллинга, 7/6") == "цвиллинга76" diff --git a/tradein-mvp/backend/tests/test_house_dedup_merge.py b/tradein-mvp/backend/tests/test_house_dedup_merge.py index f8266955..0815e9fb 100644 --- a/tradein-mvp/backend/tests/test_house_dedup_merge.py +++ b/tradein-mvp/backend/tests/test_house_dedup_merge.py @@ -321,8 +321,8 @@ def test_cross_fias_guard_blocks_slash_collapse_over_merge() -> None: """Canon pass must NOT merge two rows that BOTH carry a non-null but DIFFERENT house_fias_id. This is the critical anti-over-merge fix for the canon slash-collapse class: «Сулимова, 32» - and «Сулимова, 3/2» share the canon «сулимова32» but are different buildings — their distinct - fias UUIDs veto the merge. + and «Сулимова, 3/2» shared the canon «сулимова32» (until migration 322, #2189) but are different + buildings — their distinct fias UUIDs veto the merge. """ flat = _flat(_MAPPING_SQL) assert "h.house_fias_id AS loser_fias" in flat @@ -1019,12 +1019,13 @@ def test_real_canon_strips_district_glued_to_house_number() -> None: assert canon("Екатеринбург, ул. Евгения Савкова, 44Бр-н Академический") == ( "евгениясавкова44б" ) - assert canon("Екатеринбург, Рассветная ул., 8/1р-н Кировский") == "рассветная81" + assert canon("Екатеринбург, Рассветная ул., 8/1р-н Кировский") == "рассветная8/1" # the letter is a different building: «17А» never collapses into «17» assert canon("ул. Евгения Савкова, 17") == "евгениясавкова17" # the digit anchor keeps «мкр-н» intact (an unanchored «р-н …» strip gives «мк2») assert canon("Мкр-н Кутузовский, 2") == "нкутузовский2" - assert canon("р-н Ленинский, улица Цвиллинга, 7/6") == "цвиллинга76" + # #2189: дробь в номере дома не теряется + assert canon("р-н Ленинский, улица Цвиллинга, 7/6") == "цвиллинга7/6" # End to end: the glued row and its clean twin (same point) merge; «17» without the # letter, at the same point, stays a separate building. @@ -1117,8 +1118,9 @@ def test_real_fias_pass_cross_guard_and_identity_carryover() -> None: A. same house_fias_id, DIFFERENT canon (different streets) → MERGED by the FIAS pass; the loser's listing re-points onto the single survivor. - B. same canon (slash-collapse «32»/«3/2»), BOTH fias filled and DIFFERENT → NOT merged - (cross-fias guard vetoes — provably different buildings), both survive. + B. same canon («ул.»/«улица», same number), BOTH fias filled and DIFFERENT → NOT merged + (cross-fias guard vetoes — provably different buildings), both survive. Until #2189 this + pair was «32»/«3/2»; migration 322 gave them different canons, so the guard went unused. C. same canon, fias only on the LOSER → MERGED by the canon pass; the loser's house_fias_id + gar_house_guid CARRY OVER onto the fias-less keeper. D. a second full run is idempotent (nothing left to merge). @@ -1140,10 +1142,10 @@ def test_real_fias_pass_cross_guard_and_identity_carryover() -> None: " 'F-SAME-1772',NULL,NULL)," "(900021,'cian', 'EXT-F-L','u','СовсемДругая1772, 77',56.84009,60.60000," " 'F-SAME-1772',NULL,NULL)," - # B — same canon (slash-collapse), DIFFERENT fias → cross-fias guard BLOCKS - "(900022,'avito','EXT-B-1','u','Клара1772, 32',56.84000,60.60000," + # B — same canon, DIFFERENT fias → cross-fias guard BLOCKS + "(900022,'avito','EXT-B-1','u','ул. Клара1772, 32',56.84000,60.60000," " 'F-B1-1772',NULL,NULL)," - "(900023,'cian', 'EXT-B-2','u','Клара1772, 3/2',56.84009,60.60000," + "(900023,'cian', 'EXT-B-2','u','улица Клара1772, 32',56.84009,60.60000," " 'F-B2-1772',NULL,NULL)," # C — same canon, fias only on the loser → canon-pass merge + carry-over "(900024,'avito','EXT-C-K','u','Донбасс1772, 8',56.84000,60.60000,NULL,NULL,NULL)," @@ -1206,7 +1208,8 @@ def test_real_fias_pass_cross_guard_and_identity_carryover() -> None: db.execute( _t( "DELETE FROM house_address_aliases WHERE normalized_address IN " - "('фиасодин1772, 10','совсемдругая1772, 77','клара1772, 32','клара1772, 3/2'," + "('фиасодин1772, 10','совсемдругая1772, 77','ул. клара1772, 32'," + "'улица клара1772, 32'," "'донбасс1772, 8')" ) )