From 5e963c4379011853dca8e6406d28be73869b5787 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 13 Sep 2026 15:53:36 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein):=20=D0=BC=D0=B0=D1=82=D1=87=20?= =?UTF-8?q?=D0=93=D0=90=D0=A0-=D0=B7=D0=BD=D0=B0=D0=BC=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8F=20=D0=B2=D1=8B=D0=B1=D0=B8=D1=80=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=20city-=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=80=D0=B5=D0=B3=D0=B8=D0=BE=D0=BD=D1=83,=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BF=D0=BE=20=D1=85=D0=B0=D1=80=D0=B4=D0=BA?= =?UTF-8?q?=D0=BE=D0=B4=D1=83=20=D0=95=D0=9A=D0=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEFAULT_CITY_FILTER="Екатеринбург" был жёсткой CLI-константой, поэтому запуск лоадера для региона 77 (Москва) или 50 (область) молча резал бы матч под несуществующий город. Теперь default_city_filter_for_region() решает по региону (источник имени — REGIONS[66].city_token из regions.py): 66 получает byte-for-byte прежний фильтр, 77/50 — без фильтра (объяснено комментарием у REGIONS_REQUIRING_CITY_FILTER). Заодно закрыт реальный, а не декоративный риск: без city-фильтра canon-ключ адреса (tradein_canon_addr, мигр. 144) не несёт населённый пункт, поэтому у области (50, без одного доминирующего города) одноимённые улицы разных городов схлопывались бы в один canon и матчер молча приписывал бы дом одного города дому другого. _MATCH_SQL теперь считает canon_hits (число разных ГАР-домов на canon в выборке) и матчит только однозначные canon — неоднозначные пропускаются, а не угадываются по max(flat_count). С city-фильтром (регион 66) это ограничение не действует, поведение не меняется. Раннбук дополнен шагами для 77/50 (какие папки архива распаковывать) и актуальным источником URL дампа ФИАС (GetLastDownloadFileInfo, т.к. прямой URL меняется еженедельно). --- .../backend/app/services/gar_flats_loader.py | 110 ++++++++++++++-- .../backend/app/tasks/gar_flats_load.py | 43 +++++-- tradein-mvp/backend/tests/skip_allowlist.txt | 1 + .../backend/tests/test_gar_flats_loader.py | 118 +++++++++++++++++- tradein-mvp/docs/gar-flats-runbook.md | 97 ++++++++++---- 5 files changed, 317 insertions(+), 52 deletions(-) diff --git a/tradein-mvp/backend/app/services/gar_flats_loader.py b/tradein-mvp/backend/app/services/gar_flats_loader.py index 5b978515..b7ae7ed9 100644 --- a/tradein-mvp/backend/app/services/gar_flats_loader.py +++ b/tradein-mvp/backend/app/services/gar_flats_loader.py @@ -46,6 +46,8 @@ from lxml import etree from sqlalchemy import text from sqlalchemy.orm import Session +from app.services.regions import REGIONS + logger = logging.getLogger(__name__) # Глобы файлов ГАР (матчим case-insensitive: .XML/.xml). @@ -520,6 +522,62 @@ def upsert_gar_houses( return upserted +# ───────────────────────────────────────────────────────────────────────────── +# Город-фильтр матча: выбирается ПО РЕГИОНУ, а не задаётся руками на каждый запуск +# ───────────────────────────────────────────────────────────────────────────── +# Раньше был жёсткой CLI-константой "Екатеринбург" (годилось только для region 66). +# Регионы принципиально разные по форме коллизии одноимённых улиц: +# - 66 (область): ОДИН доминирующий город в инвентаре (истор. запуск продукта был +# ЕКБ-only) + >20 сопоставимых по названиям городов-соседей в REGIONS[66].cities — +# без фильтра «Машиностроителей 6» из 4 городов сливается в одну строку. Фильтр +# нужен и достаточен. +# - 77 (Москва): город ровно один — фильтровать нечем и незачем (см. REGIONS[77]). +# - 50 (область): НЕТ доминирующего города (20 сопоставимых по объёму городов-спутников, +# см. docstring REGIONS[50]) — фильтр по ОДНОМУ городу был бы не защитой, а порчей +# знаменателя (отрежет почти весь регион). Риск коллизии одноимённых улиц РАЗНЫХ +# городов при отсутствии фильтра закрыт не им, а guard'ом на стороне SQL +# (см. _MATCH_SQL: canon_hits) — неоднозначный canon не матчится вовсе, а не +# угадывается по max(flat_count). +# REGIONS_REQUIRING_CITY_FILTER — явный, единственный источник этого продуктового +# решения (какие регионы НУЖДАЮТСЯ в one-city ограничении); САМО значение фильтра +# берётся из REGIONS[].city_token (реестр regions.py, ЕДИНСТВЕННОЕ место границ +# покрытия) — не второй раз хардкодится строкой "Екатеринбург". +REGIONS_REQUIRING_CITY_FILTER: frozenset[int] = frozenset({66}) + + +class CityFilterAutoType: + """Маркер «city_filter не передан явно» — резолвится по региону в match_houses_to_gar.""" + + __slots__ = () + + def __repr__(self) -> str: + return "CITY_FILTER_AUTO" + + +# Сентинел default'а (не None — None остаётся легитимным явным «фильтр отключён»). +CITY_FILTER_AUTO = CityFilterAutoType() + + +def default_city_filter_for_region(region_code: str | None) -> str | None: + """Город-фильтр GAR-матча по умолчанию для региона (см. REGIONS_REQUIRING_CITY_FILTER). + + region 66 → "Екатеринбург" (byte-for-byte прежнее поведение, значение из + REGIONS[66].city_token). Любой другой/неизвестный/отсутствующий регион → None + (без ограничения). region_code принимает и None, и нечисловую строку — не + ошибка, просто «не знаем региона» → без фильтра. + """ + try: + code = int(region_code) if region_code is not None else None + except (TypeError, ValueError): + return None + if code is None or code not in REGIONS_REQUIRING_CITY_FILTER: + return None + region = REGIONS.get(code) + if region is None: + return None + return region.city_token.capitalize() + + # ───────────────────────────────────────────────────────────────────────────── # Матчер ГАР → houses # ───────────────────────────────────────────────────────────────────────────── @@ -529,16 +587,26 @@ def upsert_gar_houses( # «ул. Шаумяна, 20» и точный матч давал 0. Канон агрессивно схлопывает тип улицы / пунктуацию # (см. tradein_canon_addr) → 0→~41% (2769/6808 вторички, ЕКБ-restricted). # -# На каждый canon берём ОДНУ ГАР-строку (DISTINCT ON … ORDER BY flat_count DESC, house_guid) — +# На каждый canon берём ОДНУ ГАР-строку (ROW_NUMBER … ORDER BY flat_count DESC, house_guid) — # детерминированный tie-break: максимальный flat_count, при равенстве — лексикографически # меньший house_guid. Houses-сторона: tradein_canon_addr(COALESCE(short/full/address)) — тот же -# канон, что и в gar_pick. ЕКБ-ограничение (:city ILIKE по full_address) обязательно: без него -# «Машиностроителей 6» в 4 городах region 66 даёт ложные коллизии. Предикат -# `gar_flat_count IS DISTINCT FROM` → повторный прогон не трогает уже совпавшие строки. +# канон, что и в gar_pick. С city-фильтром (:city IS NOT NULL, region 66) поведение +# byte-for-byte прежнее — ILIKE по full_address обязателен: без него «Машиностроителей 6» в +# 4 городах region 66 даёт ложные коллизии. +# +# БЕЗ city-фильтра (:city IS NULL — многогородские регионы без одного доминирующего города, +# напр. область 50) canon НЕ несёт населённый пункт (tradein_canon_addr режет всё, кроме улицы +# и номера дома — см. мигр. 144) — «Ленина 5» существует в десятках городов области. Молча +# брать «лучший по flat_count» здесь означало бы РАНДОМНО пришить дом одного города к дому +# другого. Вместо этого canon_hits (COUNT(*) OVER PARTITION BY canon в рамках уже +# region/city-отфильтрованной выборки) — если у canon >1 разных GAR-домов, ambiguity +# НЕ разрешается угадыванием: такой canon вообще не матчится (безопасная деградация — +# пропущенный дом лучше неверно приписанного). Предикат `gar_flat_count IS DISTINCT FROM` → +# повторный прогон не трогает уже совпавшие строки. _MATCH_SQL = text( """ - WITH gar_pick AS ( - SELECT DISTINCT ON (canon) + WITH gar_scope AS ( + SELECT tradein_canon_addr(norm_address) AS canon, house_guid, flat_count FROM gar_house_flats WHERE flat_count > 0 @@ -551,7 +619,21 @@ _MATCH_SQL = text( CAST(:city AS text) IS NULL OR full_address ILIKE '%' || CAST(:city AS text) || '%' ) - ORDER BY canon, flat_count DESC, house_guid + ), + gar_ranked AS ( + SELECT + canon, house_guid, flat_count, + ROW_NUMBER() OVER ( + PARTITION BY canon ORDER BY flat_count DESC, house_guid + ) AS rn, + COUNT(*) OVER (PARTITION BY canon) AS canon_hits + FROM gar_scope + ), + gar_pick AS ( + SELECT canon, house_guid, flat_count + FROM gar_ranked + WHERE rn = 1 + AND (CAST(:city AS text) IS NOT NULL OR canon_hits = 1) ) UPDATE houses h SET gar_house_guid = gp.house_guid, @@ -571,15 +653,19 @@ def match_houses_to_gar( db: Session, *, region_code: str | None = None, - city_filter: str | None = "Екатеринбург", + city_filter: str | CityFilterAutoType | None = CITY_FILTER_AUTO, ) -> int: """Матч gar_house_flats → houses по КАНОНИЧЕСКОМУ адресу (мигр. 144). НЕ коммитит (caller). - city_filter (умолч. «Екатеринбург») ограничивает ГАР-сторону по full_address ILIKE — - защита от cross-town коллизий внутри region 66; None отключает фильтр (city-aware матч - за пределами ЕКБ — future work). Возвращает число обновлённых домов. Идемпотентно - (plain UPDATE, IS DISTINCT FROM gate). + city_filter: CITY_FILTER_AUTO (умолч.) → резолвится по region_code через + default_city_filter_for_region (region 66 → «Екатеринбург», иначе None). Явный + None отключает фильтр НЕЗАВИСИМО от региона; явная строка — ILIKE-override + (любой регион). Без фильтра ambiguity одноимённых улиц разных городов закрыта + отдельно — см. _MATCH_SQL (canon_hits). Возвращает число обновлённых домов. + Идемпотентно (plain UPDATE, IS DISTINCT FROM gate). """ + if isinstance(city_filter, CityFilterAutoType): + city_filter = default_city_filter_for_region(region_code) result = db.execute(_MATCH_SQL, {"region": region_code, "city": city_filter}) matched = result.rowcount logger.info( diff --git a/tradein-mvp/backend/app/tasks/gar_flats_load.py b/tradein-mvp/backend/app/tasks/gar_flats_load.py index bb84691d..27adbb11 100644 --- a/tradein-mvp/backend/app/tasks/gar_flats_load.py +++ b/tradein-mvp/backend/app/tasks/gar_flats_load.py @@ -3,6 +3,8 @@ Запуск (каталог — УЖЕ распакованный ГАР региона, напр. папка `66/` из gar_xml.zip): python -m app.tasks.gar_flats_load --dir /data/gar/66 --region 66 --version 2026-06-01 + python -m app.tasks.gar_flats_load --dir /data/gar/77 --region 77 --version 2026-09-11 + python -m app.tasks.gar_flats_load --dir /data/gar/50 --region 50 --version 2026-09-11 Делает два шага в одной транзакционной сессии: 1. load_gar_region — стриминговый парс XML → UPSERT gar_house_flats (коммит). @@ -11,11 +13,19 @@ Ре-матч без повторного парса многогигабайтного XML (gar_house_flats уже загружена): python -m app.tasks.gar_flats_load --match-only --region 66 + python -m app.tasks.gar_flats_load --match-only --region 77 + python -m app.tasks.gar_flats_load --match-only --region 50 В режиме `--match-only` шаг парса/загрузки пропускается целиком; `--dir` не требуется. Многогигабайтный ДАМП качается/распаковывается отдельно (ops-шаг, см. docs/gar-flats-runbook.md) — этот лоадер потребляет уже распакованные XML локально. + +Город-фильтр матча (--city) по умолчанию НЕ вводится руками на каждый запуск — берётся +ПО РЕГИОНУ (см. app.services.gar_flats_loader.default_city_filter_for_region): region 66 +получает byte-for-byte прежний фильтр «Екатеринбург», остальные регионы (77, 50 и любой +новый) — без фильтра. `--city ""` явно отключает фильтр для ЛЮБОГО региона (в т.ч. 66); +`--city "Имя"` — явный override. """ from __future__ import annotations @@ -25,21 +35,23 @@ import logging from datetime import date from app.core.db import SessionLocal -from app.services.gar_flats_loader import load_gar_region, match_houses_to_gar +from app.services.gar_flats_loader import ( + CITY_FILTER_AUTO, + CityFilterAutoType, + default_city_filter_for_region, + load_gar_region, + match_houses_to_gar, +) logger = logging.getLogger(__name__) -# Город-фильтр матча по умолчанию (см. match_houses_to_gar): ЕКБ-restricted против -# cross-town коллизий в region 66. Пустая строка в --city → None (фильтр отключён). -DEFAULT_CITY_FILTER = "Екатеринбург" - def run_gar_flats_load( dir_path: str, region_code: str, gar_version: str, *, - city_filter: str | None = DEFAULT_CITY_FILTER, + city_filter: str | CityFilterAutoType | None = CITY_FILTER_AUTO, ) -> dict[str, int]: """Парс+UPSERT (load_gar_region) затем матч (match_houses_to_gar). Возвращает счётчики.""" db = SessionLocal() @@ -67,7 +79,7 @@ def run_gar_flats_load( def run_gar_match_only( - region_code: str, *, city_filter: str | None = DEFAULT_CITY_FILTER + region_code: str, *, city_filter: str | CityFilterAutoType | None = CITY_FILTER_AUTO ) -> dict[str, int]: """Только ре-матч уже загруженного gar_house_flats → houses (без парса XML). @@ -108,8 +120,12 @@ def build_parser() -> argparse.ArgumentParser: ) parser.add_argument( "--city", - default=DEFAULT_CITY_FILTER, - help="город-фильтр матча (ILIKE по full_address); пусто = без ограничения (не-ЕКБ)", + default=None, + help=( + "город-фильтр матча (ILIKE по full_address); по умолчанию берётся ПО РЕГИОНУ " + "(--region) — см. default_city_filter_for_region; пустая строка явно отключает " + "фильтр для ЛЮБОГО региона" + ), ) return parser @@ -122,8 +138,13 @@ def main() -> None: parser = build_parser() args = parser.parse_args() - # Пустой --city → None (фильтр отключён). - city_filter = args.city or None + # --city не передан явно (argparse default=None) → дефолт ПО РЕГИОНУ (66 → «Екатеринбург» + # byte-for-byte как раньше, остальные — без фильтра). Передан явно (в т.ч. "") → + # уважаем волю вызывающего: "" → None (фильтр отключён), непустая строка → override. + if args.city is None: + city_filter: str | None = default_city_filter_for_region(args.region) + else: + city_filter = args.city or None if args.match_only: run_gar_match_only(args.region, city_filter=city_filter) diff --git a/tradein-mvp/backend/tests/skip_allowlist.txt b/tradein-mvp/backend/tests/skip_allowlist.txt index befc0302..02194917 100644 --- a/tradein-mvp/backend/tests/skip_allowlist.txt +++ b/tradein-mvp/backend/tests/skip_allowlist.txt @@ -32,6 +32,7 @@ tests/tasks/test_backfill_house_coords_from_listings.py::test_real_transfers_agr 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_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 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 62b2e9a3..dbeb700d 100644 --- a/tradein-mvp/backend/tests/test_gar_flats_loader.py +++ b/tradein-mvp/backend/tests/test_gar_flats_loader.py @@ -234,17 +234,19 @@ def test_insert_is_idempotent_on_conflict() -> None: def test_match_distinct_on_tiebreak_max_flat_count() -> None: flat = re.sub(r"\s+", " ", _MATCH_SQL) # Канонический ключ (мигр. 144), а не точное равенство norm_address. - assert "DISTINCT ON (canon)" in flat assert "tradein_canon_addr(norm_address) AS canon" in flat - # Tie-break: на canon — строка с макс flat_count, затем меньший house_guid. - assert "ORDER BY canon, flat_count DESC, house_guid" in flat + # Tie-break на canon (PARTITION BY): строка с макс flat_count, затем меньший house_guid. + assert "PARTITION BY 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" ) assert houses_side in flat - # ЕКБ-ограничение по full_address (защита от cross-town коллизий). + # city-ограничение по full_address (защита от cross-town коллизий, region-зависимо). assert "full_address ILIKE '%' || CAST(:city AS text) || '%'" in flat + # Без city-фильтра неоднозначный canon (>1 разных GAR-домов) НЕ матчится (безопасная + # деградация вместо угадывания через max(flat_count)) — см. commet у _MATCH_SQL. + assert "CAST(:city AS text) IS NOT NULL OR canon_hits = 1" in flat # Идемпотентность повторного прогона. assert "h.gar_flat_count IS DISTINCT FROM gp.flat_count" in flat assert "gar_match_method = 'canon_addr'" in flat @@ -252,6 +254,46 @@ def test_match_distinct_on_tiebreak_max_flat_count() -> None: assert "flat_count > 0" in flat +# ───────────────────────────────────────────────────────────────────────────── +# Город-фильтр ПО РЕГИОНУ (не хардкод "Екатеринбург" на все регионы) +# ───────────────────────────────────────────────────────────────────────────── +def test_default_city_filter_region_66_matches_old_constant() -> None: + """region 66 → «Екатеринбург» — byte-for-byte прежний DEFAULT_CITY_FILTER.""" + assert gfl.default_city_filter_for_region("66") == "Екатеринбург" + + +def test_default_city_filter_region_77_and_50_disabled() -> None: + """Москва (один город) и область (нет доминирующего города) — без фильтра.""" + assert gfl.default_city_filter_for_region("77") is None + assert gfl.default_city_filter_for_region("50") is None + + +def test_default_city_filter_unknown_region_disabled() -> None: + assert gfl.default_city_filter_for_region("99") is None + assert gfl.default_city_filter_for_region(None) is None + assert gfl.default_city_filter_for_region("not-a-number") is None + + +def test_match_houses_to_gar_auto_resolves_by_region(monkeypatch: pytest.MonkeyPatch) -> None: + """city_filter=CITY_FILTER_AUTO (умолч.) резолвится через default_city_filter_for_region.""" + captured: dict[str, Any] = {} + + class _FakeResult: + rowcount = 0 + + def fake_execute(_sql: Any, params: dict[str, Any]) -> _FakeResult: + captured.update(params) + return _FakeResult() + + fake_db = type("FakeDB", (), {"execute": staticmethod(fake_execute)})() + gfl.match_houses_to_gar(fake_db, region_code="66") # city_filter не передан → AUTO + assert captured["city"] == "Екатеринбург" + + captured.clear() + gfl.match_houses_to_gar(fake_db, region_code="77") + assert captured["city"] is None + + def test_no_psycopg_v3_colon_colon_cast() -> None: # Только исполняемый SQL (модульный docstring специально содержит анти-паттерн как памятку). assert not re.search(r":\w+::", _INSERT_SQL) @@ -542,3 +584,71 @@ def test_upsert_and_canon_match_populates_gar_flat_count(gar_dir: str) -> None: finally: db.rollback() db.close() + + +@pytest.mark.skipif(_live_session() is None, reason="нет доступной Postgres test-БД") +def test_no_city_filter_ambiguous_canon_not_matched() -> None: + """Без city-фильтра (регион без доминирующего города, напр. 50): одинаковый canon у ДВУХ + разных ГАР-домов (разные города, общее название улицы) не резолвится угадыванием — + canon_hits>1 → строка НЕ матчится вовсе (см. коммент у _MATCH_SQL). Отдельный + невзаимодействующий canon в том же прогоне при этом матчится нормально.""" + 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," + " gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz," + " gar_match_method text" + ") ON COMMIT DROP" + ) + ) + # Две РАЗНЫЕ улицы «Ленина, 5» в двух разных городах области (50) — одинаковый + # canon «ленина5» (canon не несёт населённый пункт, мигр. 144). Плюс одна + # однозначная улица «Мира, 1» без коллизии. + db.execute( + text( + "INSERT INTO gar_house_flats " + "(house_guid, region_code, flat_count, full_address, norm_address) VALUES " + "('h-town-a', '50', 10, 'обл Московская, г Балашиха, ул Ленина, 5', " + "'ул Ленина, 5'), " + "('h-town-b', '50', 20, 'обл Московская, г Химки, ул Ленина, 5', " + "'ул Ленина, 5'), " + "('h-unique', '50', 7, 'обл Московская, г Химки, ул Мира, 1', 'ул Мира, 1')" + ) + ) + db.execute( + text("INSERT INTO houses (short_address) VALUES ('ул. Ленина,5'), ('ул. Мира,1')") + ) + + matched = gfl.match_houses_to_gar(db, region_code="50", city_filter=None) + # Только однозначный canon «мира1» матчится; «ленина5» (2 разных дома) — пропущен. + assert matched == 1 + + lenina = db.execute( + text("SELECT gar_flat_count FROM houses WHERE short_address='ул. Ленина,5'") + ).scalar() + assert lenina is None # неоднозначность НЕ разрешена угадыванием + + mira = db.execute( + text("SELECT gar_flat_count FROM houses WHERE short_address='ул. Мира,1'") + ).scalar() + assert mira == 7 # однозначный canon матчится как обычно + finally: + db.rollback() + db.close() diff --git a/tradein-mvp/docs/gar-flats-runbook.md b/tradein-mvp/docs/gar-flats-runbook.md index 53b366ed..c7c97af4 100644 --- a/tradein-mvp/docs/gar-flats-runbook.md +++ b/tradein-mvp/docs/gar-flats-runbook.md @@ -11,27 +11,45 @@ - `tradein-mvp/backend/app/services/gar_flats_loader.py` — стриминговый парсер + upsert + матчер. - `tradein-mvp/backend/app/tasks/gar_flats_load.py` — CLI (`python -m app.tasks.gar_flats_load`). -## 1. Получить ГАР-дамп региона 66 (ops-шаг, вручную) +**Текущее состояние (замер 13.09.2026, до загрузки 77/50):** `gar_house_flats` наполнена +ТОЛЬКО по региону 66 (919 341 строка). По домам: 66 — 10 159 домов, `gar_house_guid` у 7 779, +`flat_count` у 4 821 (47%); 77 — 17 453 дома, `gar_house_guid` у 0 (ГАР ещё не загружен), +`flat_count` у 388 (2,2%, вероятно другой источник знаменателя); 50 — 8 964 дома, +`gar_house_guid` у 0, `flat_count` у 32. Команды загрузки 77/50 — § 2. + +## 1. Получить ГАР-дамп региона (ops-шаг, вручную) Лоадер потребляет **уже распакованные** XML локально — многогигабайтную загрузку/распаковку -делаем отдельно (это не часть лоадера). +делаем отдельно (это не часть лоадера). Поддерживаемые сейчас регионы продукта: **66** +(Свердловская обл.), **77** (Москва), **50** (Московская обл.) — см. `app.services.regions.REGIONS`. 1. Источник: ГАР (Государственный адресный реестр), портал ФНС — https://fias.nalog.ru/ (раздел «Скачать»). Берём «ГАР. Версия со всеми объектами» — формат XML, `gar_xml.zip`. - - Полный РФ-архив `gar_xml.zip` — десятки ГБ. Нам нужен **только регион 66** (Свердловская обл.). - - Внутри архива объекты разложены по папкам с кодом региона: распаковываем **только папку `66/`**. - Пример (извлечь одну папку без распаковки всего архива): + - **URL меняется с каждым обновлением ФИАС (примерно еженедельно) — НЕ хардкодить.** + Актуальный прямой URL отдаёт `GET https://fias.nalog.ru/WebServices/Public/GetLastDownloadFileInfo` + в поле `GarXMLFullURL` (JSON). На дату написания раздела (проверено 13.09.2026): + `https://fias-file.nalog.ru/downloads/2026.09.11/gar_xml.zip`, версия дампа ФИАС + **2026-09-11**, размер **53,6 ГБ** — используй это значение только как пример формата + URL, перед реальным запуском запроси эндпоинт заново. + - Полный РФ-архив `gar_xml.zip` — десятки ГБ. Нам нужны только папки нужных регионов. + - Внутри архива объекты разложены по папкам с кодом региона: распаковываем **только нужную + папку(и)** (можно за один проход, если качаем сразу под несколько регионов): ```bash - unzip gar_xml.zip '66/*' -d /data/gar - # → /data/gar/66/AS_ADDR_OBJ_*.XML, AS_HOUSES_*.XML, AS_APARTMENTS_*.XML, + unzip gar_xml.zip '66/*' -d /data/gar # Свердловская обл. + unzip gar_xml.zip '77/*' -d /data/gar # Москва + unzip gar_xml.zip '50/*' -d /data/gar # Московская обл. + # → /data/gar//AS_ADDR_OBJ_*.XML, AS_HOUSES_*.XML, AS_APARTMENTS_*.XML, # AS_MUN_HIERARCHY_*.XML, ... ``` - - Размер распакованной папки `66/`: ориентир — несколько ГБ (крупнейшие файлы — - `AS_APARTMENTS_*` и `AS_MUN_HIERARCHY_*`, по сотни МБ — единицы ГБ). Поэтому парсер - стримит (`lxml.etree.iterparse` + очистка элементов), а не грузит дерево целиком. + - Размер распакованной папки региона: ориентир для 66 — несколько ГБ (крупнейшие файлы — + `AS_APARTMENTS_*` и `AS_MUN_HIERARCHY_*`, по сотни МБ — единицы ГБ). Регион 77 (Москва) + и особенно 50 (область, ~10 121 текстовое имя города против 612 у Москвы — #2996) — + папки СУЩЕСТВЕННО крупнее 66 (на порядок больше домов/помещений), закладывай запас по + диску и времени парса. Парсер стримит (`lxml.etree.iterparse` + очистка элементов), а не + грузит дерево целиком — память не зависит от размера файла. - Альтернатива: ГАР обновляется примерно еженедельно дельтами; нам достаточно полного - среза. Знаменатель «всего квартир в доме» почти статичен → **ре-прогон раз в квартал** ок. + ГАР обновляется примерно еженедельно дельтами; нам достаточно полного среза на регион. + Знаменатель «всего квартир в доме» почти статичен → **ре-прогон раз в квартал** ок. Нужные файлы в папке региона (остальные лоадер игнорирует): - `AS_ADDR_OBJ_*.XML` — адресные объекты (регион/город/улица). @@ -48,6 +66,14 @@ # в контейнере tradein-backend (DATABASE_URL уже выставлен) docker exec -it tradein-backend \ python -m app.tasks.gar_flats_load --dir /data/gar/66 --region 66 --version 2026-06-01 + +# регион 77 (Москва) — без city-фильтра по умолчанию (город один, фильтровать нечем) +docker exec -it tradein-backend \ + python -m app.tasks.gar_flats_load --dir /data/gar/77 --region 77 --version 2026-09-11 + +# регион 50 (Московская обл.) — без city-фильтра по умолчанию (нет доминирующего города) +docker exec -it tradein-backend \ + python -m app.tasks.gar_flats_load --dir /data/gar/50 --region 50 --version 2026-09-11 ``` Локально: @@ -62,15 +88,22 @@ DATABASE_URL=postgresql+psycopg://:@localhost:/tradein \ - `--region` (умолч. `66`) — код региона; пишется в `gar_house_flats.region_code`. - `--version` (умолч. сегодня, `YYYY-MM-DD`) — метка версии дампа (`gar_house_flats.gar_version`). - `--match-only` — пропустить парс/загрузку XML, гнать только ре-матч (см. выше). -- `--city` (умолч. `Екатеринбург`) — город-фильтр матча (`ILIKE` по `full_address`); пусто = без ограничения. +- `--city` — город-фильтр матча (`ILIKE` по `full_address`). **По умолчанию берётся ПО + РЕГИОНУ**, не константой (см. `app.services.gar_flats_loader.default_city_filter_for_region`): + регион **66** → `Екатеринбург` (byte-for-byte прежнее поведение — много сопоставимых по + названиям городов внутри области, см. `REGIONS[66].cities`); регионы **77/50** → без + фильтра (77 — город один, фильтровать нечем; 50 — нет доминирующего города, фильтр по + одному городу отрезал бы почти весь регион). `--city ''` явно отключает фильтр для + ЛЮБОГО региона (в т.ч. 66); `--city "Имя"` — явный override. Что делает (две фазы, обе коммитятся): 1. **load** — стримит XML → считает помещения на дом (по иерархии) → собирает адрес → UPSERT в `gar_house_flats` (`ON CONFLICT (house_guid)`, идемпотентно). `norm_address` считает SQL-fn `tradein_normalize_short_addr` поверх «улица, номер». 2. **match** — `UPDATE houses SET gar_flat_count = …` по КАНОНИЧЕСКОМУ ключу адреса - `tradein_canon_addr` (`gar_match_method='canon_addr'`, мигр. 144), ЕКБ-restricted - (`full_address ILIKE '%Екатеринбург%'`), идемпотентно (`IS DISTINCT FROM` gate). + `tradein_canon_addr` (`gar_match_method='canon_addr'`, мигр. 144), с city-фильтром по + умолчанию для 66 (`full_address ILIKE '%Екатеринбург%'`), без фильтра для 77/50 (ambiguity + одноимённых улиц закрыта иначе, см. § 4), идемпотентно (`IS DISTINCT FROM` gate). В логах — сводка: `houses`, `apartments` (учтено под домами), `upserted`, `houses_matched`. @@ -84,12 +117,16 @@ DATABASE_URL=postgresql+psycopg://:@localhost:/tradein \ # в контейнере tradein-backend — только ре-матч, --dir НЕ требуется docker exec -it tradein-backend \ python -m app.tasks.gar_flats_load --match-only --region 66 +docker exec -it tradein-backend \ + python -m app.tasks.gar_flats_load --match-only --region 77 +docker exec -it tradein-backend \ + python -m app.tasks.gar_flats_load --match-only --region 50 ``` Флаг `--match-only` пропускает шаги парса/загрузки целиком и гоняет только -`match_houses_to_gar` против уже загруженной `gar_house_flats`. `--city` (умолч. -`Екатеринбург`) задаёт город-фильтр ГАР-стороны; пустой `--city ''` отключает фильтр -(матч за пределами ЕКБ, см. § 4). +`match_houses_to_gar` против уже загруженной `gar_house_flats`. `--city` без явного значения +резолвится ПО РЕГИОНУ (см. § 2 выше: 66 → `Екатеринбург`, 77/50 → без фильтра); пустой +`--city ''` отключает фильтр для любого региона. ## 3. Проверка результата @@ -127,14 +164,24 @@ LIMIT 20; - **Литера/корпус — намеренно НЕ схлопываются.** `6Б` ≠ `6`, `5к1` ≠ `5` — литера и корпус остаются в каноне (буква — кириллица, цифра корпуса — цифра), т.к. это РАЗНЫЕ здания. Это сознательное поведение, не баг матча. -- **ЕКБ-ограничение (`city_filter='Екатеринбург'` по умолчанию).** Матч фильтрует ГАР-сторону - по `full_address ILIKE '%Екатеринбург%'` — без этого канон `машиностроителей6` совпал бы с +- **City-фильтр по умолчанию — ПО РЕГИОНУ, не хардкод.** Для 66 матч фильтрует ГАР-сторону по + `full_address ILIKE '%Екатеринбург%'` — без этого канон `машиностроителей6` совпал бы с «Машиностроителей 6», который существует в 4 населённых пунктах region 66 → ложные - cross-town коллизии. **City-aware матч за пределами ЕКБ** (по нескольким городам без ложных - коллизий) — **future work**; временно отключить фильтр можно `--city ''` (на свой риск). -- **Tie-break.** Если несколько ГАР-строк дают один канон, матчер берёт строку с - **максимальным `flat_count`** (при равенстве — лексикографически меньший `house_guid`), - через `DISTINCT ON (canon) ORDER BY canon, flat_count DESC, house_guid`. + cross-town коллизии. Для 77 (Москва) фильтр не нужен — город один. Для 50 (область) фильтр + по одному городу был бы вреден (отрезал бы почти весь регион) — ambiguity одноимённых улиц + РАЗНЫХ городов там закрыта не фильтром, а следующим пунктом. +- **Ambiguity-guard без city-фильтра (canon_hits).** Когда `city_filter IS NULL` (77, 50 и + любой будущий регион без one-city ограничения), `tradein_canon_addr` НЕ несёт населённый + пункт (режет всё, кроме улицы и номера — мигр. 144) — «Ленина 5» существует в десятках + городов области. Матчер СЧИТАЕТ, сколько РАЗНЫХ ГАР-домов дают один canon в + отфильтрованной (по региону) выборке (`COUNT(*) OVER (PARTITION BY canon)`); если больше + одного — canon НЕ матчится вовсе (пропущенный дом лучше неверно приписанного). С + city-фильтром (регион 66 по умолчанию) это ограничение не действует — коллизия там уже + закрыта сужением по городу, поведение byte-for-byte прежнее. +- **Tie-break.** Если несколько ГАР-строк дают один канон И (city-фильтр задан ИЛИ canon + однозначен без фильтра), матчер берёт строку с **максимальным `flat_count`** (при равенстве — + лексикографически меньший `house_guid`), через `ROW_NUMBER() OVER (PARTITION BY 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 матчера. -- 2.45.3