Матч ГАР работает по любому региону, а не только по Екатеринбургу (#3523)
All checks were successful
Deploy Trade-In / test (push) Successful in 4m0s
Deploy Trade-In / build-backend (push) Successful in 1m12s
Deploy Trade-In / deploy (push) Successful in 1m47s
Deploy Trade-In / deploy-status (push) Successful in 3s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m46s
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
All checks were successful
Deploy Trade-In / test (push) Successful in 4m0s
Deploy Trade-In / build-backend (push) Successful in 1m12s
Deploy Trade-In / deploy (push) Successful in 1m47s
Deploy Trade-In / deploy-status (push) Successful in 3s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m46s
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
This commit is contained in:
parent
3d114672fe
commit
d7aaa00326
5 changed files with 317 additions and 52 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 матчера.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue