fix(tradein/gar): дом получает ГАР-guid только своего региона (#2583 H5)

Фильтры матча стояли только на стороне ГАР, UPDATE houses шёл по канону
улица+дом по всем домам всех регионов. После #3523 (матч для 77 и 50) это
стало порчей: прод 2026-09-17 — 578 домов региона 66 с guid региона 50,
340 — с guid 77, 1371 дом 50 с guid 77, 497 домов 77 с guid 50. По чужому
guid ЖКХ/ФРТ/капремонт-загрузчики тянут данные другого дома.

Матч сверяет регион дома с регионом ГАР-строки и ранжирует канон внутри
региона. Перед матчем снимаются уже проставленные canon_addr-guid чужого
региона (дом без региона не трогаем) — иначе дом без пары в своём регионе
так и остался бы с чужим. Эффект на проде — после ops-прогона --match-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-09-17 14:35:23 +05:00
parent a4e97c4b70
commit b9c89641d1
4 changed files with 142 additions and 12 deletions

View file

@ -603,11 +603,17 @@ def default_city_filter_for_region(region_code: str | None) -> str | None:
# НЕ разрешается угадыванием: такой canon вообще не матчится (безопасная деградация — # НЕ разрешается угадыванием: такой canon вообще не матчится (безопасная деградация —
# пропущенный дом лучше неверно приписанного). Предикат `gar_flat_count IS DISTINCT FROM` → # пропущенный дом лучше неверно приписанного). Предикат `gar_flat_count IS DISTINCT FROM` →
# повторный прогон не трогает уже совпавшие строки. # повторный прогон не трогает уже совпавшие строки.
#
# #2583 H5: фильтры выше стоят только на стороне ГАР, а UPDATE houses шёл по одному канону
# по ВСЕМ домам всех регионов. После #3523 (матч для 77/50) прогон региона 50 проставил
# подмосковные guid екатеринбургским домам с тем же каноном (прод 2026-09-17: 578 домов
# региона 66 с guid региона 50, 340 — региона 77, 1371 дом 50 с guid 77, 497 домов 77 с
# guid 50). Дом получает guid только своего региона; канон ранжируется внутри региона.
_MATCH_SQL = text( _MATCH_SQL = text(
""" """
WITH gar_scope AS ( WITH gar_scope AS (
SELECT SELECT
tradein_canon_addr(norm_address) AS canon, house_guid, flat_count tradein_canon_addr(norm_address) AS canon, house_guid, flat_count, region_code
FROM gar_house_flats FROM gar_house_flats
WHERE flat_count > 0 WHERE flat_count > 0
AND tradein_canon_addr(norm_address) <> '' AND tradein_canon_addr(norm_address) <> ''
@ -622,15 +628,15 @@ _MATCH_SQL = text(
), ),
gar_ranked AS ( gar_ranked AS (
SELECT SELECT
canon, house_guid, flat_count, canon, house_guid, flat_count, region_code,
ROW_NUMBER() OVER ( ROW_NUMBER() OVER (
PARTITION BY canon ORDER BY flat_count DESC, house_guid PARTITION BY region_code, canon ORDER BY flat_count DESC, house_guid
) AS rn, ) AS rn,
COUNT(*) OVER (PARTITION BY canon) AS canon_hits COUNT(*) OVER (PARTITION BY region_code, canon) AS canon_hits
FROM gar_scope FROM gar_scope
), ),
gar_pick AS ( gar_pick AS (
SELECT canon, house_guid, flat_count SELECT canon, house_guid, flat_count, region_code
FROM gar_ranked FROM gar_ranked
WHERE rn = 1 WHERE rn = 1
AND (CAST(:city AS text) IS NOT NULL OR canon_hits = 1) AND (CAST(:city AS text) IS NOT NULL OR canon_hits = 1)
@ -644,10 +650,33 @@ _MATCH_SQL = text(
WHERE tradein_canon_addr( WHERE tradein_canon_addr(
COALESCE(h.short_address, h.full_address, h.address) COALESCE(h.short_address, h.full_address, h.address)
) = gp.canon ) = gp.canon
AND CAST(h.region_code AS text) = gp.region_code
AND h.gar_flat_count IS DISTINCT FROM gp.flat_count AND h.gar_flat_count IS DISTINCT FROM gp.flat_count
""" """
) )
# #2583 H5: снять уже проставленные guid чужого региона (см. коммент у _MATCH_SQL). Сам
# _MATCH_SQL их не перезапишет, если у дома нет пары в своём регионе, — а по чужому guid
# ЖКХ/ФРТ/капремонт-загрузчики продолжат тянуть данные другого дома. Только canon_addr и
# только доказуемо чужие (регион дома известен и не совпадает) — дом без региона не трогаем.
_CLEAR_FOREIGN_REGION_SQL = text(
"""
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 CAST(h.region_code AS text) <> g.region_code
AND (
CAST(:region AS text) IS NULL
OR CAST(h.region_code AS text) = CAST(:region AS text)
)
"""
)
def match_houses_to_gar( def match_houses_to_gar(
db: Session, db: Session,
@ -666,11 +695,14 @@ def match_houses_to_gar(
""" """
if isinstance(city_filter, CityFilterAutoType): if isinstance(city_filter, CityFilterAutoType):
city_filter = default_city_filter_for_region(region_code) city_filter = default_city_filter_for_region(region_code)
cleared = db.execute(_CLEAR_FOREIGN_REGION_SQL, {"region": region_code}).rowcount
result = db.execute(_MATCH_SQL, {"region": region_code, "city": city_filter}) result = db.execute(_MATCH_SQL, {"region": region_code, "city": city_filter})
matched = result.rowcount matched = result.rowcount
logger.info( logger.info(
"gar match: проставлено gar_flat_count домам=%d (region=%s, city=%s)", "gar match: проставлено gar_flat_count домам=%d, снято guid чужого региона=%d"
" (region=%s, city=%s)",
matched, matched,
cleared,
region_code, region_code,
city_filter, city_filter,
) )

View file

@ -33,6 +33,7 @@ tests/tasks/test_cadastral_geo_match.py::test_real_knn_nearest_within_threshold_
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_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_house_dedup_merge.py::test_real_canon_clusterkey_and_geo_guard_merge_semantics tests/test_house_dedup_merge.py::test_real_canon_clusterkey_and_geo_guard_merge_semantics
tests/test_house_dedup_merge.py::test_real_fias_pass_cross_guard_and_identity_carryover tests/test_house_dedup_merge.py::test_real_fias_pass_cross_guard_and_identity_carryover
tests/test_house_dedup_merge.py::test_real_fias_pass_ignores_geo_guard tests/test_house_dedup_merge.py::test_real_fias_pass_ignores_geo_guard

View file

@ -236,7 +236,8 @@ def test_match_distinct_on_tiebreak_max_flat_count() -> None:
# Канонический ключ (мигр. 144), а не точное равенство norm_address. # Канонический ключ (мигр. 144), а не точное равенство norm_address.
assert "tradein_canon_addr(norm_address) AS canon" in flat assert "tradein_canon_addr(norm_address) AS canon" in flat
# Tie-break на canon (PARTITION BY): строка с макс flat_count, затем меньший house_guid. # Tie-break на canon (PARTITION BY): строка с макс flat_count, затем меньший house_guid.
assert "PARTITION BY canon ORDER BY flat_count DESC, house_guid" in flat # #2583 H5: ранжирование внутри региона — канон «ленина5» 66-го и 50-го не соревнуются.
assert "PARTITION BY region_code, canon ORDER BY flat_count DESC, house_guid" in flat
# houses-сторона: тот же канон поверх COALESCE(short/full/address). # houses-сторона: тот же канон поверх COALESCE(short/full/address).
houses_side = ( houses_side = (
"tradein_canon_addr( COALESCE(h.short_address, h.full_address, h.address) ) = gp.canon" "tradein_canon_addr( COALESCE(h.short_address, h.full_address, h.address) ) = gp.canon"
@ -512,6 +513,7 @@ def test_upsert_and_canon_match_populates_gar_flat_count(gar_dir: str) -> None:
text( text(
"CREATE TEMP TABLE houses (" "CREATE TEMP TABLE houses ("
" id serial PRIMARY KEY, short_address text, full_address text, address text," " id serial PRIMARY KEY, short_address text, full_address text, address text,"
" region_code smallint,"
" gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz," " gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz,"
" gar_match_method text" " gar_match_method text"
") ON COMMIT DROP" ") ON COMMIT DROP"
@ -522,8 +524,8 @@ def test_upsert_and_canon_match_populates_gar_flat_count(gar_dir: str) -> None:
# (flat=0) не матч. «ул. Мира,5» — для теста ЕКБ-фильтра (см. ниже). # (flat=0) не матч. «ул. Мира,5» — для теста ЕКБ-фильтра (см. ниже).
db.execute( db.execute(
text( text(
"INSERT INTO houses (short_address) VALUES " "INSERT INTO houses (short_address, region_code) VALUES "
"('ул. Шаумяна,10'), ('ул Ленина, 7'), ('ул. Мира,5')" "('ул. Шаумяна,10', 66), ('ул Ленина, 7', 66), ('ул. Мира,5', 66)"
) )
) )
@ -613,6 +615,7 @@ def test_no_city_filter_ambiguous_canon_not_matched() -> None:
text( text(
"CREATE TEMP TABLE houses (" "CREATE TEMP TABLE houses ("
" id serial PRIMARY KEY, short_address text, full_address text, address text," " id serial PRIMARY KEY, short_address text, full_address text, address text,"
" region_code smallint,"
" gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz," " gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz,"
" gar_match_method text" " gar_match_method text"
") ON COMMIT DROP" ") ON COMMIT DROP"
@ -633,7 +636,10 @@ def test_no_city_filter_ambiguous_canon_not_matched() -> None:
) )
) )
db.execute( db.execute(
text("INSERT INTO houses (short_address) VALUES ('ул. Ленина,5'), ('ул. Мира,1')") text(
"INSERT INTO houses (short_address, region_code) VALUES "
"('ул. Ленина,5', 50), ('ул. Мира,1', 50)"
)
) )
matched = gfl.match_houses_to_gar(db, region_code="50", city_filter=None) matched = gfl.match_houses_to_gar(db, region_code="50", city_filter=None)
@ -652,3 +658,90 @@ def test_no_city_filter_ambiguous_canon_not_matched() -> None:
finally: finally:
db.rollback() db.rollback()
db.close() db.close()
@pytest.mark.skipif(_live_session() is None, reason="нет доступной Postgres test-БД")
def test_house_gets_gar_guid_only_from_its_own_region() -> None:
"""#2583 H5: UPDATE houses ограничен регионом дома, чужие guid снимаются.
Прод 2026-09-17, после матча регионов 77/50 (#3523): 578 домов региона 66 несли guid
региона 50 и 340 региона 77. Канон не несёт ни город, ни регион, поэтому
екатеринбургская «Ленина, 5» и подмосковная «Ленина, 5» один ключ.
"""
from sqlalchemy import text
db = _live_session()
assert db is not None
try:
raw = db.connection()
raw.exec_driver_sql(_NORMALIZER_FN)
raw.exec_driver_sql(_CANON_FN)
db.execute(
text(
"CREATE TEMP TABLE gar_house_flats ("
" house_guid text PRIMARY KEY, object_id bigint, region_code text NOT NULL,"
" flat_count int, full_address text, norm_address text, street_name text,"
" house_num text, loaded_at timestamptz NOT NULL DEFAULT now(), gar_version text"
") ON COMMIT DROP"
)
)
db.execute(
text(
"CREATE TEMP TABLE houses ("
" id serial PRIMARY KEY, short_address text, full_address text, address text,"
" region_code smallint,"
" gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz,"
" gar_match_method text"
") ON COMMIT DROP"
)
)
db.execute(
text(
"INSERT INTO gar_house_flats "
"(house_guid, region_code, flat_count, full_address, norm_address) VALUES "
"('g66-lenina', '66', 60, 'обл Свердловская, г Екатеринбург, ул Ленина, 5', "
"'ул Ленина, 5'), "
"('g50-lenina', '50', 400, 'обл Московская, г Химки, ул Ленина, 5', "
"'ул Ленина, 5'), "
"('g50-mira', '50', 250, 'обл Московская, г Химки, ул Мира, 7', 'ул Мира, 7')"
)
)
# ЕКБ «Ленина, 5» и «Мира, 7» уже несут подмосковные guid (как на проде после
# прогона региона 50); подмосковная «Ленина, 5» и дом без региона — не матчены.
db.execute(
text(
"INSERT INTO houses (short_address, region_code, gar_house_guid, gar_flat_count, "
"gar_match_method) VALUES "
"('ул. Ленина,5', 66, 'g50-lenina', 400, 'canon_addr'), "
"('ул. Мира,7', 66, 'g50-mira', 250, 'canon_addr'), "
"('ул. Ленина,5', 50, NULL, NULL, NULL), "
"('ул. Мира,7', NULL, 'g50-mira', 250, 'canon_addr')"
)
)
def _state() -> set[tuple[Any, ...]]:
rows = db.execute(
text(
"SELECT short_address, region_code, gar_house_guid, gar_flat_count FROM houses"
)
).all()
return {tuple(r) for r in rows}
assert gfl.match_houses_to_gar(db, region_code="66") == 1
assert _state() == {
("ул. Ленина,5", 66, "g66-lenina", 60), # свой ЕКБ-дом вместо подмосковного
("ул. Мира,7", 66, None, None), # пары в 66 нет — чужой guid снят, не оставлен
("ул. Ленина,5", 50, None, None), # прогон 66 не трогает дома 50
("ул. Мира,7", None, "g50-mira", 250), # регион дома неизвестен — не доказуемо
}
assert gfl.match_houses_to_gar(db, region_code="50", city_filter=None) == 1
assert _state() == {
("ул. Ленина,5", 66, "g66-lenina", 60), # прогон 50 не перетирает ЕКБ
("ул. Мира,7", 66, None, None),
("ул. Ленина,5", 50, "g50-lenina", 400),
("ул. Мира,7", None, "g50-mira", 250),
}
finally:
db.rollback()
db.close()

View file

@ -178,10 +178,14 @@ LIMIT 20;
одного — canon НЕ матчится вовсе (пропущенный дом лучше неверно приписанного). С одного — canon НЕ матчится вовсе (пропущенный дом лучше неверно приписанного). С
city-фильтром (регион 66 по умолчанию) это ограничение не действует — коллизия там уже city-фильтром (регион 66 по умолчанию) это ограничение не действует — коллизия там уже
закрыта сужением по городу, поведение byte-for-byte прежнее. закрыта сужением по городу, поведение byte-for-byte прежнее.
- **Регион дома (#2583 H5).** Дом получает guid только ГАР-строки своего региона
(`houses.region_code`), канон ранжируется внутри региона. Перед матчем снимаются уже
проставленные `canon_addr`-guid чужого региона (дом без `region_code` не трогаем) — лог
пишет «снято guid чужого региона=N». После выкатки прогнать `--match-only` для 66, 77 и 50.
- **Tie-break.** Если несколько ГАР-строк дают один канон И (city-фильтр задан ИЛИ canon - **Tie-break.** Если несколько ГАР-строк дают один канон И (city-фильтр задан ИЛИ canon
однозначен без фильтра), матчер берёт строку с **максимальным `flat_count`** (при равенстве — однозначен без фильтра), матчер берёт строку с **максимальным `flat_count`** (при равенстве —
лексикографически меньший `house_guid`), через `ROW_NUMBER() OVER (PARTITION BY canon ORDER лексикографически меньший `house_guid`), через `ROW_NUMBER() OVER (PARTITION BY region_code, canon
BY flat_count DESC, house_guid)`. ORDER BY flat_count DESC, house_guid)`.
- **Дома с 0 квартир** грузятся (`flat_count=0`), но матчер их игнорирует (`flat_count > 0`). - **Дома с 0 квартир** грузятся (`flat_count=0`), но матчер их игнорирует (`flat_count > 0`).
Под `tradein_canon_addr(norm_address) WHERE flat_count > 0` создан функциональный индекс Под `tradein_canon_addr(norm_address) WHERE flat_count > 0` создан функциональный индекс
`gar_house_flats_canon_idx` (мигр. 144) под JOIN матчера. `gar_house_flats_canon_idx` (мигр. 144) под JOIN матчера.