Merge pull request 'Адреса МЕРЫ: «Сулимова, 3/2» и «Сулимова, 32» больше не один дом — дробь в номере не теряется при сопоставлении с ГАР и схлопывании дублей' (#3578) from fix/canon-addr-slash into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 16s
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 4m12s
Deploy Trade-In / build-backend (push) Successful in 1m43s
Deploy Trade-In / deploy (push) Successful in 6m12s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m49s
Deploy Trade-In / deploy-status (push) Successful in 3s

This commit is contained in:
bot-backend 2026-09-17 12:57:38 +00:00
commit a1c7f8ea92
6 changed files with 363 additions and 15 deletions

View file

@ -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: (`_mapping_sql`) no 300-line copy-paste:
1. FIAS pass clusters by lower(NULLIF(house_fias_id, '')) (the ФИАС/ГАР building UUID, 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 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 cluster. Catches duplicates the canon MISSES: посёлок truncation (Кедровка/Шувакиш
«Сулимова, 3/2» same canon «сулимова32») and посёлок truncation (Кедровка/Шувакиш colliding with a same-named ЕКБ street) and, until migration 322 (#2189), slash-collapse
colliding with a same-named ЕКБ street). («Сулимова, 32» vs «Сулимова, 3/2» same canon «сулимова32»).
2. CANON pass the canonical-address clustering above, now with a CROSS-FIAS GUARD: within a 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 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 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. # 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 # 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 посёлок # duplicates the canon misses — посёлок truncation (Кедровка/Шувакиш) and, until migration 322
# truncation (Кедровка/Шувакиш). Only non-null fias clusters. # (#2189), slash-collapse («Сулимова, 32» vs «Сулимова, 3/2»). Only non-null fias clusters.
_FIAS_KEY_EXPR = """ _FIAS_KEY_EXPR = """
CASE WHEN NULLIF(house_fias_id, '') IS NOT NULL CASE WHEN NULLIF(house_fias_id, '') IS NOT NULL
THEN 'fias:' || lower(house_fias_id) THEN 'fias:' || lower(house_fias_id)

View file

@ -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'),
'(?<![0-9])/|/(?![0-9])', '', 'g');
$func$ LANGUAGE sql IMMUTABLE;
COMMENT ON FUNCTION tradein_canon_addr(text) IS
'Канонический ключ адреса для ГАР↔houses матча и схлопывания дублей домов (мигр. 144 + 147 + 311 + 322): '
'lower+ё→е, срез района, приклеенного к номеру дома («17Ар-н Академический» → «17а»), '
'нормализация проспект/бульвар (пр-т/пр-кт/б-р), срез гео-префиксов '
'(Россия/обл/Екатеринбург/г/мкр/м-н/пос/жилой район/р-н/снт/пгт) и маркера дома «д.», '
'затем срез типов улиц как токенов на границе не-кириллицы → оставляет только [а-я0-9] '
'и «/» между цифрами («3/2» ≠ «32»).';
REINDEX INDEX gar_house_flats_canon_idx;
DO $$
DECLARE
reset_rows bigint;
BEGIN
UPDATE houses h
SET gar_house_guid = NULL, gar_flat_count = NULL, gar_matched_at = NULL, gar_match_method = NULL
FROM gar_house_flats g
WHERE g.house_guid = h.gar_house_guid
AND h.gar_match_method = 'canon_addr'
AND tradein_canon_addr(COALESCE(h.short_address, h.full_address, h.address))
<> 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;

View file

@ -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_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/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_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_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_no_city_filter_ambiguous_canon_not_matched
tests/test_gar_flats_loader.py::test_house_gets_gar_guid_only_from_its_own_region tests/test_gar_flats_loader.py::test_house_gets_gar_guid_only_from_its_own_region

View file

@ -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") == "арта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"),
]

View file

@ -342,6 +342,8 @@ def test_canon_addr_street_type_and_punct_collapse() -> None:
def test_canon_addr_strips_rayon_and_korpus_slash() -> None: def test_canon_addr_strips_rayon_and_korpus_slash() -> None:
# «р-н Ленинский, улица Цвиллинга, 7/6» → «цвиллинга76» (район срезан, слэш выброшен). # «р-н Ленинский, улица Цвиллинга, 7/6» → «цвиллинга76» (район срезан, слэш выброшен).
# Так было в зеркалируемой 144; с мигр. 322 дробь в ключе остаётся (#2189,
# tests/test_2189_canon_keeps_slash.py проверяет саму функцию).
assert _canon_addr_py("р-н Ленинский, улица Цвиллинга, 7/6") == "цвиллинга76" assert _canon_addr_py("р-н Ленинский, улица Цвиллинга, 7/6") == "цвиллинга76"

View file

@ -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. """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» 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 and «Сулимова, 3/2» shared the canon «сулимова32» (until migration 322, #2189) but are different
fias UUIDs veto the merge. buildings their distinct fias UUIDs veto the merge.
""" """
flat = _flat(_MAPPING_SQL) flat = _flat(_MAPPING_SQL)
assert "h.house_fias_id AS loser_fias" in flat 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Бр-н Академический") == ( assert canon("Екатеринбург, ул. Евгения Савкова, 44Бр-н Академический") == (
"евгениясавкова44б" "евгениясавкова44б"
) )
assert canon("Екатеринбург, Рассветная ул., 8/1р-н Кировский") == "рассветная81" assert canon("Екатеринбург, Рассветная ул., 8/1р-н Кировский") == "рассветная8/1"
# the letter is a different building: «17А» never collapses into «17» # the letter is a different building: «17А» never collapses into «17»
assert canon("ул. Евгения Савкова, 17") == "евгениясавкова17" assert canon("ул. Евгения Савкова, 17") == "евгениясавкова17"
# the digit anchor keeps «мкр-н» intact (an unanchored «р-н …» strip gives «мк2») # the digit anchor keeps «мкр-н» intact (an unanchored «р-н …» strip gives «мк2»)
assert canon("Мкр-н Кутузовский, 2") == "нкутузовский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 # 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. # 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; A. same house_fias_id, DIFFERENT canon (different streets) MERGED by the FIAS pass;
the loser's listing re-points onto the single survivor. 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 B. same canon («ул.»/«улица», same number), BOTH fias filled and DIFFERENT NOT merged
(cross-fias guard vetoes provably different buildings), both survive. (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 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. house_fias_id + gar_house_guid CARRY OVER onto the fias-less keeper.
D. a second full run is idempotent (nothing left to merge). 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)," " 'F-SAME-1772',NULL,NULL),"
"(900021,'cian', 'EXT-F-L','u','СовсемДругая1772, 77',56.84009,60.60000," "(900021,'cian', 'EXT-F-L','u','СовсемДругая1772, 77',56.84009,60.60000,"
" 'F-SAME-1772',NULL,NULL)," " 'F-SAME-1772',NULL,NULL),"
# B — same canon (slash-collapse), DIFFERENT fias → cross-fias guard BLOCKS # B — same canon, DIFFERENT fias → cross-fias guard BLOCKS
"(900022,'avito','EXT-B-1','u','Клара1772, 32',56.84000,60.60000," "(900022,'avito','EXT-B-1','u','ул. Клара1772, 32',56.84000,60.60000,"
" 'F-B1-1772',NULL,NULL)," " '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)," " 'F-B2-1772',NULL,NULL),"
# C — same canon, fias only on the loser → canon-pass merge + carry-over # 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)," "(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( db.execute(
_t( _t(
"DELETE FROM house_address_aliases WHERE normalized_address IN " "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')" "'донбасс1772, 8')"
) )
) )