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
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:
commit
a1c7f8ea92
6 changed files with 363 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:
|
(`_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)
|
||||||
|
|
|
||||||
|
|
@ -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_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
|
||||||
|
|
|
||||||
215
tradein-mvp/backend/tests/test_2189_canon_keeps_slash.py
Normal file
215
tradein-mvp/backend/tests/test_2189_canon_keeps_slash.py
Normal 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") == "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"),
|
||||||
|
]
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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')"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue