Матч ГАР работает по любому региону, а не только по Екатеринбургу #3523

Merged
lekss361 merged 1 commit from feat/gar-flats-per-region into main 2026-09-15 06:59:03 +00:00
5 changed files with 317 additions and 52 deletions

View file

@ -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[<code>].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(

View file

@ -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)

View file

@ -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

View file

@ -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()

View file

@ -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/<regionN>/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://<user>:<pw>@localhost:<port>/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://<user>:<pw>@localhost:<port>/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 матчера.