diff --git a/tradein-mvp/backend/app/services/gar_flats_loader.py b/tradein-mvp/backend/app/services/gar_flats_loader.py index b7ae7ed9..059e5d73 100644 --- a/tradein-mvp/backend/app/services/gar_flats_loader.py +++ b/tradein-mvp/backend/app/services/gar_flats_loader.py @@ -603,11 +603,17 @@ def default_city_filter_for_region(region_code: str | None) -> str | None: # НЕ разрешается угадыванием: такой canon вообще не матчится (безопасная деградация — # пропущенный дом лучше неверно приписанного). Предикат `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( """ WITH gar_scope AS ( 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 WHERE flat_count > 0 AND tradein_canon_addr(norm_address) <> '' @@ -622,15 +628,15 @@ _MATCH_SQL = text( ), gar_ranked AS ( SELECT - canon, house_guid, flat_count, + canon, house_guid, flat_count, region_code, 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, - COUNT(*) OVER (PARTITION BY canon) AS canon_hits + COUNT(*) OVER (PARTITION BY region_code, canon) AS canon_hits FROM gar_scope ), gar_pick AS ( - SELECT canon, house_guid, flat_count + SELECT canon, house_guid, flat_count, region_code FROM gar_ranked WHERE rn = 1 AND (CAST(:city AS text) IS NOT NULL OR canon_hits = 1) @@ -644,10 +650,33 @@ _MATCH_SQL = text( WHERE tradein_canon_addr( COALESCE(h.short_address, h.full_address, h.address) ) = gp.canon + AND CAST(h.region_code AS text) = gp.region_code 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( db: Session, @@ -666,11 +695,14 @@ def match_houses_to_gar( """ if isinstance(city_filter, CityFilterAutoType): 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}) matched = result.rowcount 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, + cleared, region_code, city_filter, ) diff --git a/tradein-mvp/backend/tests/skip_allowlist.txt b/tradein-mvp/backend/tests/skip_allowlist.txt index 69c61d4b..a75832d6 100644 --- a/tradein-mvp/backend/tests/skip_allowlist.txt +++ b/tradein-mvp/backend/tests/skip_allowlist.txt @@ -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_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_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_fias_pass_cross_guard_and_identity_carryover tests/test_house_dedup_merge.py::test_real_fias_pass_ignores_geo_guard diff --git a/tradein-mvp/backend/tests/test_gar_flats_loader.py b/tradein-mvp/backend/tests/test_gar_flats_loader.py index dbeb700d..381996b9 100644 --- a/tradein-mvp/backend/tests/test_gar_flats_loader.py +++ b/tradein-mvp/backend/tests/test_gar_flats_loader.py @@ -236,7 +236,8 @@ def test_match_distinct_on_tiebreak_max_flat_count() -> None: # Канонический ключ (мигр. 144), а не точное равенство norm_address. assert "tradein_canon_addr(norm_address) AS canon" in flat # 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_side = ( "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( "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" @@ -522,8 +524,8 @@ def test_upsert_and_canon_match_populates_gar_flat_count(gar_dir: str) -> None: # (flat=0) не матч. «ул. Мира,5» — для теста ЕКБ-фильтра (см. ниже). db.execute( text( - "INSERT INTO houses (short_address) VALUES " - "('ул. Шаумяна,10'), ('ул Ленина, 7'), ('ул. Мира,5')" + "INSERT INTO houses (short_address, region_code) VALUES " + "('ул. Шаумяна,10', 66), ('ул Ленина, 7', 66), ('ул. Мира,5', 66)" ) ) @@ -613,6 +615,7 @@ def test_no_city_filter_ambiguous_canon_not_matched() -> None: 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" @@ -633,7 +636,10 @@ def test_no_city_filter_ambiguous_canon_not_matched() -> None: ) ) 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) @@ -652,3 +658,90 @@ def test_no_city_filter_ambiguous_canon_not_matched() -> None: finally: db.rollback() 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() diff --git a/tradein-mvp/docs/gar-flats-runbook.md b/tradein-mvp/docs/gar-flats-runbook.md index c7c97af4..06202b80 100644 --- a/tradein-mvp/docs/gar-flats-runbook.md +++ b/tradein-mvp/docs/gar-flats-runbook.md @@ -178,10 +178,14 @@ LIMIT 20; одного — canon НЕ матчится вовсе (пропущенный дом лучше неверно приписанного). С city-фильтром (регион 66 по умолчанию) это ограничение не действует — коллизия там уже закрыта сужением по городу, поведение 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 однозначен без фильтра), матчер берёт строку с **максимальным `flat_count`** (при равенстве — - лексикографически меньший `house_guid`), через `ROW_NUMBER() OVER (PARTITION BY canon ORDER - BY flat_count DESC, house_guid)`. + лексикографически меньший `house_guid`), через `ROW_NUMBER() OVER (PARTITION BY region_code, canon + ORDER BY flat_count DESC, house_guid)`. - **Дома с 0 квартир** грузятся (`flat_count=0`), но матчер их игнорирует (`flat_count > 0`). Под `tradein_canon_addr(norm_address) WHERE flat_count > 0` создан функциональный индекс `gar_house_flats_canon_idx` (мигр. 144) под JOIN матчера.