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:
parent
a4e97c4b70
commit
b9c89641d1
4 changed files with 142 additions and 12 deletions
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 матчера.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue