fix(tradein/canon): канон-ключ адреса больше не теряет дробь в номере дома — «Сулимова, 3/2» ≠ «32» (#2189)
Some checks failed
CI Trade-In / backend-tests (pull_request) Failing after 8m8s
CI Trade-In / changes (pull_request) Successful in 24s
CI / changes (pull_request) Successful in 31s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Some checks failed
CI Trade-In / backend-tests (pull_request) Failing after 8m8s
CI Trade-In / changes (pull_request) Successful in 24s
CI / changes (pull_request) Successful in 31s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
tradein_canon_addr последним шагом оставлял только [а-я0-9], и «3/2», «10/7», «4/1» схлопывались в «32», «107», «41». Матчер ГАР при фильтре по городу брал из общего ключа строку с max(flat_count): дом «Высоцкого, 4/2» получил 725 квартир ГАР-дома «42». Миграция 322: тело 311 без изменений, кроме последнего шага — «/» остаётся, если с обеих сторон цифры. Новый ключ — только уточнение старого (без «/» он равен старому), поэтому склеить новые пары правка не может. Прод 17.09 (read-only): ключ меняется у 2549 из 49 204 домов, распавшиеся канон-кластеры — 113 пар, ни одной ближе 250 м и ни одной с общим ФИАС. Затем REINDEX функционального индекса и сброс 47 canon_addr-сопоставлений, державшихся только на потерянной дроби (порог остановки 100). Живой тест B в test_real_fias_pass_cross_guard_and_identity_carryover держался на паре «32»/«3/2»: после 322 у неё разные ключи, и страж разных ФИАС в тесте не участвовал (со снятым стражем тест оставался зелёным). Пара заменена на «ул.»/«улица». Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
e3616e3611
commit
3d9eaa2704
6 changed files with 361 additions and 15 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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_house_dedup_merge.py::test_real_canon_clusterkey_and_geo_guard_merge_semantics
|
||||
|
|
|
|||
213
tradein-mvp/backend/tests/test_2189_canon_keeps_slash.py
Normal file
213
tradein-mvp/backend/tests/test_2189_canon_keeps_slash.py
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
"""#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')"
|
||||
)
|
||||
)
|
||||
db.execute(
|
||||
text(
|
||||
"INSERT INTO houses (id, source, ext_house_id, url, address) VALUES "
|
||||
"(900189, 'avito', 'EXT-2189-32', 'u', 'Екатеринбург, ул. Сулимова2189, 32'),"
|
||||
"(900190, 'cian', 'EXT-2189-3-2', 'u', 'ул. Сулимова2189, 3 / 2')"
|
||||
)
|
||||
)
|
||||
|
||||
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"),
|
||||
]
|
||||
|
|
@ -341,6 +341,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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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')"
|
||||
)
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue