feat(tradein): канонический матч ГАР↔houses (0%→41% yield) + tradein_canon_addr + --match-only
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped

This commit is contained in:
bot-backend 2026-06-28 18:44:22 +03:00
parent 83ae6f8a47
commit c729a39446
5 changed files with 423 additions and 60 deletions

View file

@ -530,49 +530,71 @@ def upsert_gar_houses(
# ─────────────────────────────────────────────────────────────────────────────
# Матчер ГАР → houses
# ─────────────────────────────────────────────────────────────────────────────
# Идемпотентный set-based UPDATE. На каждый norm_address берём ОДНУ ГАР-строку
# (DISTINCT ON … ORDER BY flat_count DESC, house_guid) — детерминированный tie-break:
# максимальный flat_count, при равенстве — лексикографически меньший house_guid.
# Houses-сторона: tradein_normalize_short_addr(COALESCE(short_address, full_address, address))
# — тот же нормализатор, что и в gar_house_flats.norm_address. Предикат
# Идемпотентный set-based UPDATE по КАНОНИЧЕСКОМУ ключу адреса tradein_canon_addr
# (мигр. 144), а не по точному равенству нормализованного адреса: tradein_normalize_short_addr
# почти ничего не канонизирует → houses-адрес «ул. Шаумяна,20» никогда не равен ГАР
# «ул. Шаумяна, 20» и точный матч давал 0. Канон агрессивно схлопывает тип улицы / пунктуацию
# (см. tradein_canon_addr) → 0→~41% (2769/6808 вторички, ЕКБ-restricted).
#
# На каждый canon берём ОДНУ ГАР-строку (DISTINCT ON … 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` → повторный прогон не трогает уже совпавшие строки.
_MATCH_SQL = text(
"""
WITH gar_pick AS (
SELECT DISTINCT ON (norm_address)
norm_address, house_guid, flat_count
SELECT DISTINCT ON (canon)
tradein_canon_addr(norm_address) AS canon, house_guid, flat_count
FROM gar_house_flats
WHERE flat_count > 0
AND norm_address IS NOT NULL
AND norm_address <> ''
AND tradein_canon_addr(norm_address) <> ''
AND (
CAST(:region AS text) IS NULL
OR region_code = CAST(:region AS text)
)
ORDER BY norm_address, flat_count DESC, house_guid
AND (
CAST(:city AS text) IS NULL
OR full_address ILIKE '%' || CAST(:city AS text) || '%'
)
ORDER BY canon, flat_count DESC, house_guid
)
UPDATE houses h
SET gar_house_guid = gp.house_guid,
gar_flat_count = gp.flat_count,
gar_matched_at = now(),
gar_match_method = 'norm_address'
gar_match_method = 'canon_addr'
FROM gar_pick gp
WHERE tradein_normalize_short_addr(
WHERE tradein_canon_addr(
COALESCE(h.short_address, h.full_address, h.address)
) = gp.norm_address
) = gp.canon
AND h.gar_flat_count IS DISTINCT FROM gp.flat_count
"""
)
def match_houses_to_gar(db: Session, *, region_code: str | None = None) -> int:
"""Матч gar_house_flats → houses по нормализованному адресу. НЕ коммитит (caller коммитит).
def match_houses_to_gar(
db: Session,
*,
region_code: str | None = None,
city_filter: str | None = "Екатеринбург",
) -> int:
"""Матч gar_house_flats → houses по КАНОНИЧЕСКОМУ адресу (мигр. 144). НЕ коммитит (caller).
Возвращает число обновлённых домов. Идемпотентно (plain UPDATE, IS DISTINCT FROM gate).
city_filter (умолч. «Екатеринбург») ограничивает ГАР-сторону по full_address ILIKE
защита от cross-town коллизий внутри region 66; None отключает фильтр (city-aware матч
за пределами ЕКБ future work). Возвращает число обновлённых домов. Идемпотентно
(plain UPDATE, IS DISTINCT FROM gate).
"""
result = db.execute(_MATCH_SQL, {"region": region_code})
result = db.execute(_MATCH_SQL, {"region": region_code, "city": city_filter})
matched = result.rowcount
logger.info("gar match: проставлено gar_flat_count домам=%d (region=%s)", matched, region_code)
logger.info(
"gar match: проставлено gar_flat_count домам=%d (region=%s, city=%s)",
matched,
region_code,
city_filter,
)
return matched

View file

@ -1,4 +1,4 @@
"""CLI: загрузка ГАР-знаменателя «квартир на дом» + матч к houses (мигр. 143).
"""CLI: загрузка ГАР-знаменателя «квартир на дом» + матч к houses (мигр. 143/144).
Запуск (каталог УЖЕ распакованный ГАР региона, напр. папка `66/` из gar_xml.zip):
@ -6,7 +6,13 @@
Делает два шага в одной транзакционной сессии:
1. load_gar_region стриминговый парс XML UPSERT gar_house_flats (коммит).
2. match_houses_to_gar UPDATE houses.gar_flat_count по нормализованному адресу (коммит).
2. match_houses_to_gar UPDATE houses.gar_flat_count по КАНОНИЧЕСКОМУ адресу (мигр. 144).
Ре-матч без повторного парса многогигабайтного XML (gar_house_flats уже загружена):
python -m app.tasks.gar_flats_load --match-only --region 66
В режиме `--match-only` шаг парса/загрузки пропускается целиком; `--dir` не требуется.
Многогигабайтный ДАМП качается/распаковывается отдельно (ops-шаг, см.
docs/gar-flats-runbook.md) этот лоадер потребляет уже распакованные XML локально.
@ -23,13 +29,23 @@ from app.services.gar_flats_loader import 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) -> dict[str, int]:
def run_gar_flats_load(
dir_path: str,
region_code: str,
gar_version: str,
*,
city_filter: str | None = DEFAULT_CITY_FILTER,
) -> dict[str, int]:
"""Парс+UPSERT (load_gar_region) затем матч (match_houses_to_gar). Возвращает счётчики."""
db = SessionLocal()
try:
load = load_gar_region(dir_path, region_code, gar_version, db=db)
matched = match_houses_to_gar(db, region_code=region_code)
matched = match_houses_to_gar(db, region_code=region_code, city_filter=city_filter)
db.commit()
finally:
db.close()
@ -50,24 +66,73 @@ def run_gar_flats_load(dir_path: str, region_code: str, gar_version: str) -> dic
return counts
def main() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
def run_gar_match_only(
region_code: str, *, city_filter: str | None = DEFAULT_CITY_FILTER
) -> dict[str, int]:
"""Только ре-матч уже загруженного gar_house_flats → houses (без парса XML).
Идемпотентно (match_houses_to_gar plain UPDATE с IS DISTINCT FROM gate).
"""
db = SessionLocal()
try:
matched = match_houses_to_gar(db, region_code=region_code, city_filter=city_filter)
db.commit()
finally:
db.close()
logger.info(
"gar_flats_load --match-only DONE: region=%s city=%s houses_matched=%d",
region_code,
city_filter,
matched,
)
return {"houses_matched": matched}
def build_parser() -> argparse.ArgumentParser:
"""Парсер CLI (вынесен для тестируемости флагов без запуска main)."""
parser = argparse.ArgumentParser(
description="ГАР loader: квартир на дом → gar_house_flats + match к houses"
)
parser.add_argument("--dir", required=True, help="каталог с распакованными ГАР XML региона")
# --dir не required: в режиме --match-only парс пропускается (валидируем в main).
parser.add_argument("--dir", help="каталог с распакованными ГАР XML региона")
parser.add_argument("--region", default="66", help="код региона (по умолчанию 66)")
parser.add_argument(
"--version",
default=date.today().isoformat(),
help="версия ГАР-дампа YYYY-MM-DD (по умолчанию сегодня)",
)
parser.add_argument(
"--match-only",
action="store_true",
help="пропустить парс/загрузку ГАР, только пере-матчить gar_house_flats к houses",
)
parser.add_argument(
"--city",
default=DEFAULT_CITY_FILTER,
help="город-фильтр матча (ILIKE по full_address); пусто = без ограничения (не-ЕКБ)",
)
return parser
def main() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
parser = build_parser()
args = parser.parse_args()
run_gar_flats_load(args.dir, args.region, args.version)
# Пустой --city → None (фильтр отключён).
city_filter = args.city or None
if args.match_only:
run_gar_match_only(args.region, city_filter=city_filter)
return
if not args.dir:
parser.error("--dir обязателен, кроме режима --match-only")
run_gar_flats_load(args.dir, args.region, args.version, city_filter=city_filter)
if __name__ == "__main__":

View file

@ -0,0 +1,69 @@
-- 144_gar_canon_addr_match.sql
--
-- CONTEXT: матч ГАР↔houses (мигр. 143) по ТОЧНОМУ равенству нормализованного адреса
-- (tradein_normalize_short_addr) на проде даёт 0 совпадений: нормализатор почти ничего
-- не канонизирует, поэтому houses-адрес «ул. Шаумяна,20» НИКОГДА не равен ГАР-строке
-- «ул. Шаумяна, 20» (тип улицы «ул.» vs «ул», запятая/пробел, точка). Знаменатель
-- «доля квартир дома в продаже» (v_building_sale_share) оставался пустым.
--
-- WHAT: канонический ключ адреса tradein_canon_addr(text) — агрессивно схлопывает:
-- 1. lower() + ё→е;
-- 2. срезает префикс района «р-н ...,»;
-- 3. удаляет типы улиц (улица/ул/переулок/пер/проспект/.../микрорайон/мкр), но ТОЛЬКО
-- как самостоятельные токены на границе не-кириллических символов — буква в составе
-- названия («ПРоспект» внутри слова) не трогается;
-- 4. оставляет только кириллицу-нижний-регистр + цифры (выбрасывает пробелы, точки,
-- запятые, дефисы, слэши).
-- Так «ул. Шаумяна,20» и «ул. Шаумяна, 20» → оба «шаумяна20» (совпадают),
-- «р-н Ленинский, улица Цвиллинга, 7/6» → «цвиллинга76». А «6Б» НЕ схлопывается до «6»
-- (буква корпуса остаётся кириллицей → другое здание).
--
-- Эмпирически на live-данных (ГАР region 66, 68 406 строк с flat_count>0) канон-ключ
-- поднимает матч с 0 до 2 769 из 6 808 вторичных домов (~41%, при ЕКБ-ограничении).
-- ЕКБ-ограничение (full_address ILIKE '%Екатеринбург%') обязательно: без него
-- «Машиностроителей 6» существует в 4 населённых пунктах region 66 → ложные коллизии.
-- Сам ре-матч (app/services/gar_flats_loader.match_houses_to_gar) — отдельный шаг после
-- деплоя (CLI `python -m app.tasks.gar_flats_load --match-only`); этот файл — только
-- функция + функциональный индекс под её JOIN.
--
-- DEPENDENCIES (existing prod objects):
-- - gar_house_flats (norm_address, full_address, flat_count, house_guid) — мигр. 143.
-- - houses.gar_* provenance-колонки — мигр. 143.
--
-- SAFETY / IDEMPOTENCY:
-- - CREATE OR REPLACE FUNCTION + CREATE INDEX IF NOT EXISTS → повторный прогон no-op.
-- - Функция IMMUTABLE (обязательно для функционального индекса).
-- - Раннер деплоя (deploy-tradein.yml) гонит файл через psql ON_ERROR_STOP=on БЕЗ
-- --single-transaction → транзакцию открывает сам файл (BEGIN/COMMIT ниже), как 143.
--
-- NB по нумерации: на forgejo/main последний — 143_building_sale_share_schema.sql
-- (139142 заняты), следующий свободный sequential = 144 (проверено `ls | grep '^14'`).
--
-- Deploy order: после 143_building_sale_share_schema.sql.
BEGIN;
-- Канонический ключ адреса для устойчивого ГАР↔houses матча (см. шапку файла).
CREATE OR REPLACE FUNCTION tradein_canon_addr(s text) RETURNS text AS $func$
SELECT regexp_replace(
regexp_replace(
regexp_replace(
translate(lower(coalesce(s, '')), 'ё', 'е'),
'р-н[^,]*,', '', 'g'),
'(^|[^а-я])(улица|ул|переулок|пер|проспект|пркт|пр|бульвар|бр|шоссе|ш|проезд|набережная|наб|площадь|пл|тупик|туп|аллея|микрорайон|мкр)([^а-я]|$)',
'\1\3', 'g'),
'[^а-я0-9]', '', 'g');
$func$ LANGUAGE sql IMMUTABLE;
COMMENT ON FUNCTION tradein_canon_addr(text) IS
'Канонический ключ адреса для ГАР↔houses матча (мигр. 144): lower+ё→е, срез «р-н ...,», '
'удаление типов улиц как токенов на границе не-кириллицы, оставляет только [а-я0-9]. '
'Поднял матч с 0 до ~41%% (2769/6808 вторички, ЕКБ-restricted). См. gar-flats-runbook.md.';
-- Функциональный индекс под JOIN gar_pick (canon = tradein_canon_addr(norm_address)),
-- partial по flat_count>0 — матчим только дома с известным знаменателем.
CREATE INDEX IF NOT EXISTS gar_house_flats_canon_idx
ON gar_house_flats (tradein_canon_addr(norm_address))
WHERE flat_count > 0;
COMMIT;

View file

@ -15,6 +15,7 @@ from __future__ import annotations
import inspect
import os
import re
import sys
from pathlib import Path
from typing import Any
@ -232,17 +233,21 @@ 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)
# Tie-break: на norm_address — строка с макс flat_count, затем меньший house_guid.
assert "DISTINCT ON (norm_address)" in flat
assert "ORDER BY norm_address, flat_count DESC, house_guid" in flat
# houses-сторона: тот же нормализатор поверх COALESCE(short/full/address).
assert (
"tradein_normalize_short_addr( COALESCE(h.short_address, h.full_address, h.address) )"
in flat
# Канонический ключ (мигр. 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
# 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 коллизий).
assert "full_address ILIKE '%' || CAST(:city AS text) || '%'" in flat
# Идемпотентность повторного прогона.
assert "h.gar_flat_count IS DISTINCT FROM gp.flat_count" in flat
assert "gar_match_method = 'norm_address'" in flat
assert "gar_match_method = 'canon_addr'" in flat
# Только дома с известным знаменателем матчим.
assert "flat_count > 0" in flat
@ -260,6 +265,123 @@ def test_upsert_uses_savepoint_per_batch() -> None:
assert "db.commit()" not in src
# ─────────────────────────────────────────────────────────────────────────────
# Канонический ключ адреса tradein_canon_addr (мигр. 144) — DB-free мирроринг
# ─────────────────────────────────────────────────────────────────────────────
# Чистый Python-зеркало SQL-функции tradein_canon_addr (мигр. 144) для юнит-теста БЕЗ БД.
# Канонический источник — SQL-функция в data/sql/144; зеркало точно повторяет её regexp-шаги
# и проверено на тех же кейсах (порядок альтернатив «длинная|короткая» совпадает с POSIX
# longest-match Postgres; границы [^а-я] идентичны после lower()+ё→е). Для ground-truth на
# живой БД те же утверждения дублируются в real-Postgres-тесте ниже.
_CANON_TYPES = (
"улица|ул|переулок|пер|проспект|пркт|пр|бульвар|бр|шоссе|ш|проезд|"
"набережная|наб|площадь|пл|тупик|туп|аллея|микрорайон|мкр"
)
_CANON_RN_RE = re.compile(r"р-н[^,]*,")
_CANON_TYPE_RE = re.compile(r"(^|[^а-я])(" + _CANON_TYPES + r")([^а-я]|$)")
_CANON_KEEP_RE = re.compile(r"[^а-я0-9]")
def _canon_addr_py(s: str | None) -> str:
"""Python-зеркало SQL tradein_canon_addr(text) (мигр. 144)."""
s = (s or "").lower().replace("ё", "е")
s = _CANON_RN_RE.sub("", s)
s = _CANON_TYPE_RE.sub(r"\1\3", s)
s = _CANON_KEEP_RE.sub("", s)
return s
def test_canon_addr_street_type_and_punct_collapse() -> None:
# «ул. Шаумяна,20» и «ул. Шаумяна, 20» канон-равны (тип улицы + запятая/пробел схлопнуты).
assert _canon_addr_py("ул. Шаумяна,20") == _canon_addr_py("ул. Шаумяна, 20")
assert _canon_addr_py("ул. Шаумяна,20") == "шаумяна20"
def test_canon_addr_strips_rayon_and_korpus_slash() -> None:
# «р-н Ленинский, улица Цвиллинга, 7/6» → «цвиллинга76» (район срезан, слэш выброшен).
assert _canon_addr_py("р-н Ленинский, улица Цвиллинга, 7/6") == "цвиллинга76"
def test_canon_addr_keeps_letter_suffix_distinct() -> None:
# Литера корпуса «6Б» — ДРУГОЕ здание: не должно схлопываться до «6».
a = _canon_addr_py("улица Машиностроителей, 6Б")
b = _canon_addr_py("ул. Машиностроителей, 6")
assert a != b
assert a == "машиностроителей6б"
assert b == "машиностроителей6"
def test_canon_addr_handles_none_and_empty() -> None:
assert _canon_addr_py(None) == ""
assert _canon_addr_py("") == ""
# Строка из одного типа улицы без названия схлопывается в пустую.
assert _canon_addr_py("улица") == ""
# ─────────────────────────────────────────────────────────────────────────────
# CLI: --match-only флаг (app/tasks/gar_flats_load.py)
# ─────────────────────────────────────────────────────────────────────────────
def test_match_only_does_not_require_dir() -> None:
"""--match-only парсится без --dir (парс/загрузка пропускаются)."""
from app.tasks import gar_flats_load as task
args = task.build_parser().parse_args(["--match-only", "--region", "66"])
assert args.match_only is True
assert args.dir is None
assert args.region == "66"
def test_main_match_only_skips_load(monkeypatch: pytest.MonkeyPatch) -> None:
"""main(--match-only) вызывает только run_gar_match_only, НЕ run_gar_flats_load."""
from app.tasks import gar_flats_load as task
calls: dict[str, Any] = {}
def fake_match_only(region_code: str, *, city_filter: str | None = None) -> dict[str, int]:
calls["match_only"] = (region_code, city_filter)
return {"houses_matched": 5}
def fake_load(*args: Any, **kwargs: Any) -> dict[str, int]:
calls["load"] = True
return {}
monkeypatch.setattr(task, "run_gar_match_only", fake_match_only)
monkeypatch.setattr(task, "run_gar_flats_load", fake_load)
monkeypatch.setattr(sys, "argv", ["gar_flats_load", "--match-only", "--region", "66"])
task.main()
assert "load" not in calls # парс/загрузка пропущены
assert calls["match_only"] == ("66", "Екатеринбург")
def test_main_match_only_empty_city_disables_filter(monkeypatch: pytest.MonkeyPatch) -> None:
"""Пустой --city → city_filter=None (фильтр отключён, матч за пределами ЕКБ)."""
from app.tasks import gar_flats_load as task
captured: dict[str, Any] = {}
def fake_match_only(region_code: str, *, city_filter: str | None = None) -> dict[str, int]:
captured["city_filter"] = city_filter
return {"houses_matched": 0}
monkeypatch.setattr(task, "run_gar_match_only", fake_match_only)
monkeypatch.setattr(sys, "argv", ["gar_flats_load", "--match-only", "--city", ""])
task.main()
assert captured["city_filter"] is None
def test_main_requires_dir_without_match_only(monkeypatch: pytest.MonkeyPatch) -> None:
"""Без --match-only и без --dir — parser.error → SystemExit."""
from app.tasks import gar_flats_load as task
monkeypatch.setattr(sys, "argv", ["gar_flats_load", "--region", "66"])
with pytest.raises(SystemExit):
task.main()
# ─────────────────────────────────────────────────────────────────────────────
# Опциональный real-Postgres тест upsert+match (self-skip без БД)
# ─────────────────────────────────────────────────────────────────────────────
@ -299,16 +421,36 @@ RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
$$;
"""
# Канон-fn мигр. 144 — создаём в транзакции теста, откатываем. raw-строка: \1\3 литеральны.
_CANON_FN = r"""
CREATE OR REPLACE FUNCTION tradein_canon_addr(s text) RETURNS text AS $func$
SELECT regexp_replace(
regexp_replace(
regexp_replace(
translate(lower(coalesce(s, '')), 'ё', 'е'),
'р-н[^,]*,', '', 'g'),
'(^|[^а-я])(улица|ул|переулок|пер|проспект|пркт|пр|бульвар|бр|шоссе|ш|проезд|набережная|наб|площадь|пл|тупик|туп|аллея|микрорайон|мкр)([^а-я]|$)',
'\1\3', 'g'),
'[^а-я0-9]', '', 'g');
$func$ LANGUAGE sql IMMUTABLE;
"""
@pytest.mark.skipif(_live_session() is None, reason="нет доступной Postgres test-БД")
def test_upsert_and_match_populates_gar_flat_count(gar_dir: str) -> None:
"""С реальной Postgres: upsert ГАР-строк + match → houses.gar_flat_count проставлен."""
def test_upsert_and_canon_match_populates_gar_flat_count(gar_dir: str) -> None:
"""С реальной Postgres: upsert ГАР + КАНОН-match → houses.gar_flat_count (мигр. 144).
Проверяет: канон бридит формат-расхождение, которого точный матч не видел
(«ул. Шаумяна,10» ГАР «ул Шаумяна, 10»); ЕКБ-фильтр исключает same-canon не-ЕКБ
строку; идемпотентность повторного прогона; метод 'canon_addr'.
"""
from sqlalchemy import text
db = _live_session()
assert db is not None
try:
db.execute(text(_NORMALIZER_FN))
db.execute(text(_CANON_FN))
db.execute(
text(
"CREATE TEMP TABLE gar_house_flats ("
@ -327,10 +469,13 @@ def test_upsert_and_match_populates_gar_flat_count(gar_dir: str) -> None:
") ON COMMIT DROP"
)
)
# Дом «ул Шаумяна, 10» совпадёт с ГАР h-a (flat_count=3); «ул Ленина, 7» — h-c (0) не матч.
# Format-divergent адреса: «ул. Шаумяна,10» (точка/без пробела) канон-совпадёт с ГАР
# h-a «ул Шаумяна, 10» (flat=3), хотя точный матч их РАЗЛИЧИЛ бы. «ул Ленина, 7» — h-c
# (flat=0) не матч. «ул. Мира,5» — для теста ЕКБ-фильтра (см. ниже).
db.execute(
text(
"INSERT INTO houses (short_address) VALUES " "('ул Шаумяна, 10'), ('ул Ленина, 7')"
"INSERT INTO houses (short_address) VALUES "
"('ул. Шаумяна,10'), ('ул Ленина, 7'), ('ул. Мира,5')"
)
)
@ -338,12 +483,13 @@ def test_upsert_and_match_populates_gar_flat_count(gar_dir: str) -> None:
upserted = gfl.upsert_gar_houses(db, rows, batch_size=2)
assert upserted == 3
# norm_address посчитан SQL-fn = «ул Шаумяна, 10».
# norm_address посчитан SQL-fn = «ул Шаумяна, 10» (точный матч с houses провалился бы).
norm = db.execute(
text("SELECT norm_address FROM gar_house_flats WHERE house_guid='h-a'")
).scalar()
assert norm == "ул Шаумяна, 10"
# Канон-матч (ЕКБ default): Шаумяна канон-совпал, хотя формат адреса различался.
matched = gfl.match_houses_to_gar(db, region_code="66")
assert matched == 1
# Идемпотентность: повторный матч ничего не трогает.
@ -352,16 +498,41 @@ def test_upsert_and_match_populates_gar_flat_count(gar_dir: str) -> None:
shaumyana = db.execute(
text(
"SELECT gar_flat_count, gar_match_method FROM houses "
"WHERE short_address='ул Шаумяна, 10'"
"WHERE short_address='ул. Шаумяна,10'"
)
).fetchone()
assert shaumyana is not None and shaumyana[0] == 3
assert shaumyana[1] == "norm_address"
assert shaumyana[1] == "canon_addr"
# Дом без квартир (flat_count=0) НЕ матчится.
lenina = db.execute(
text("SELECT gar_flat_count FROM houses WHERE short_address='ул Ленина, 7'")
).scalar()
assert lenina is None
# ── ЕКБ-фильтр: не-ЕКБ ГАР-строка с тем же каноном «мира5» (Берёзовский, flat=4) ──
db.execute(
text(
"INSERT INTO gar_house_flats "
"(house_guid, region_code, flat_count, full_address, norm_address) VALUES "
"('h-berez', '66', 4, 'обл Свердловская, г Берёзовский, ул Мира, 5', 'ул Мира, 5')"
)
)
# Default ЕКБ-фильтр: full_address без «Екатеринбург» исключён → Мира НЕ матчится.
assert gfl.match_houses_to_gar(db, region_code="66") == 0
mira_before = db.execute(
text("SELECT gar_flat_count FROM houses WHERE short_address='ул. Мира,5'")
).scalar()
assert mira_before is None
# city_filter=None отключает фильтр → не-ЕКБ строка матчится (canon «мира5»).
assert gfl.match_houses_to_gar(db, region_code="66", city_filter=None) == 1
mira_after = db.execute(
text(
"SELECT gar_flat_count, gar_match_method FROM houses "
"WHERE short_address='ул. Мира,5'"
)
).fetchone()
assert mira_after is not None and mira_after[0] == 4
assert mira_after[1] == "canon_addr"
finally:
db.rollback()
db.close()

View file

@ -1,8 +1,9 @@
# ГАР/ФИАС loader знаменателя «квартир на дом» (runbook)
Наполняет таблицу `gar_house_flats` числом помещений-квартир под каждым домом из
официального дампа ГАР/ФИАС и матчит к `houses` по нормализованному адресу, проставляя
`houses.gar_flat_count`. Это «знаменатель» для страницы **«доля квартир дома в продаже»**
официального дампа ГАР/ФИАС и матчит к `houses` по **каноническому ключу адреса**
(`tradein_canon_addr`, мигр. 144), проставляя `houses.gar_flat_count`. Это «знаменатель»
для страницы **«доля квартир дома в продаже»**
(`active_vtorichka_listings / total_apartments_in_building`, view `v_building_sale_share`,
мигр. 143).
@ -57,19 +58,39 @@ DATABASE_URL=postgresql+psycopg://<user>:<pw>@localhost:<port>/tradein \
```
Аргументы:
- `--dir` (обязательный) — каталог с распакованными ГАР XML региона.
- `--dir` — каталог с распакованными ГАР XML региона (обязателен, КРОМЕ режима `--match-only`).
- `--region` (умолч. `66`) — код региона; пишется в `gar_house_flats.region_code`.
- `--version` (умолч. сегодня, `YYYY-MM-DD`) — метка версии дампа (`gar_house_flats.gar_version`).
- `--match-only` — пропустить парс/загрузку XML, гнать только ре-матч (см. выше).
- `--city` (умолч. `Екатеринбург`) — город-фильтр матча (`ILIKE` по `full_address`); пусто = без ограничения.
Что делает (две фазы, обе коммитятся):
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 = …` по нормализованному адресу
(`gar_match_method='norm_address'`), идемпотентно (`IS DISTINCT FROM` gate).
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).
В логах — сводка: `houses`, `apartments` (учтено под домами), `upserted`, `houses_matched`.
### Ре-матч без повторного парса XML (`--match-only`)
Знаменатель «всего квартир в доме» почти статичен, а `gar_house_flats` уже загружена на
проде — пере-прогонять многогигабайтный парс XML, чтобы только обновить матч к `houses`
(напр. после правки канон-логики или добавления домов в `houses`), не нужно:
```bash
# в контейнере tradein-backend — только ре-матч, --dir НЕ требуется
docker exec -it tradein-backend \
python -m app.tasks.gar_flats_load --match-only --region 66
```
Флаг `--match-only` пропускает шаги парса/загрузки целиком и гоняет только
`match_houses_to_gar` против уже загруженной `gar_house_flats`. `--city` (умолч.
`Екатеринбург`) задаёт город-фильтр ГАР-стороны; пустой `--city ''` отключает фильтр
(матч за пределами ЕКБ, см. § 4).
## 3. Проверка результата
```sql
@ -92,21 +113,36 @@ LIMIT 20;
`PARENTOBJID` в иерархии == `OBJECTID` дома. Тип помещения (`APARTTYPE`) НЕ фильтруется —
это «всего помещений в доме» (квартиры + нежилые), сознательное приближение знаменателя.
- **Адрес/норма.** `norm_address` = `tradein_normalize_short_addr(«<улица>, <номер>»)`
тот же нормализатор, что и для `houses` при матче. Строка строится из «улица + номер»
(не из полной цепочки) для устойчивости: для ЕКБ эквивалентно `fn(full_address)` (fn срезает
регион/город), но не оставляет префикс для не-«г.» населённых пунктов. `full_address` хранится
отдельно как человекочитаемая provenance-цепочка (регион→город→улица→номер).
- **Tie-break.** Если несколько ГАР-строк дают один `norm_address`, матчер берёт строку с
человекочитаемый нормализованный адрес. Строка строится из «улица + номер» (не из полной
цепочки) для устойчивости. `full_address` хранится отдельно как provenance-цепочка
(регион→город→улица→номер) и используется матчем для ЕКБ-фильтра (`ILIKE '%Екатеринбург%'`).
- **Канонический матч (мигр. 144).** Точное равенство `norm_address` на проде давало **0**
совпадений: `tradein_normalize_short_addr` почти ничего не канонизирует, поэтому
houses-адрес `ул. Шаумяна,20` никогда не равен ГАР `ул. Шаумяна, 20`. Матч идёт по
каноническому ключу `tradein_canon_addr(...)`, который агрессивно схлопывает: `lower`+ё→е,
срез района `р-н ...,`, удаление типов улиц (`улица`/`ул`/`пер`/…/`мкр`) как самостоятельных
токенов, и оставляет только `[а-я0-9]`. Так `ул. Шаумяна,20` и `ул. Шаумяна, 20`оба
аумяна20`; `р-н Ленинский, улица Цвиллинга, 7/6``цвиллинга76`.
**Измеренный yield: 0 → ~41% (2 769 из 6 808 вторичных домов, ЕКБ-restricted).**
- **Литера/корпус — намеренно НЕ схлопываются.** `6Б``6`, `5к1``5` — литера и корпус
остаются в каноне (буква — кириллица, цифра корпуса — цифра), т.к. это РАЗНЫЕ здания.
Это сознательное поведение, не баг матча.
- **ЕКБ-ограничение (`city_filter='Екатеринбург'` по умолчанию).** Матч фильтрует ГАР-сторону
по `full_address ILIKE '%Екатеринбург%'` — без этого канон ашиностроителей6` совпал бы с
«Машиностроителей 6», который существует в 4 населённых пунктах region 66 → ложные
cross-town коллизии. **City-aware матч за пределами ЕКБ** (по нескольким городам без ложных
коллизий) — **future work**; временно отключить фильтр можно `--city ''` (на свой риск).
- **Tie-break.** Если несколько ГАР-строк дают один канон, матчер берёт строку с
**максимальным `flat_count`** (при равенстве — лексикографически меньший `house_guid`),
через `DISTINCT ON (norm_address) ORDER BY norm_address, flat_count DESC, house_guid`.
через `DISTINCT ON (canon) ORDER BY canon, flat_count DESC, house_guid`.
- **Дома с 0 квартир** грузятся (`flat_count=0`), но матчер их игнорирует (`flat_count > 0`).
- **Покрытие матча — точное равенство нормализованного адреса.** Расхождения формата типа
улицы (`ул` vs `ул.` vs `улица`) и корпусов (`5 к1` vs `5/1`) не совпадут точно. Это
ожидаемо: под `gar_house_flats.norm_address` уже создан GIN-trgm индекс (мигр. 143) для
будущего нечёткого (fuzzy) прохода — он вне scope этого лоадера.
Под `tradein_canon_addr(norm_address) WHERE flat_count > 0` создан функциональный индекс
`gar_house_flats_canon_idx` (мигр. 144) под JOIN матчера.
## 5. Идемпотентность / ре-прогон
- `gar_house_flats``ON CONFLICT (house_guid) DO UPDATE` → повторный прогон обновляет, не дублит.
- Матчер — plain `UPDATE` с `IS DISTINCT FROM` → повторный прогон ничего не трогает.
- Ре-прогон с новым `--version` безопасен; рекомендуемая частота — раз в квартал.
- Матчер — plain `UPDATE` с `IS DISTINCT FROM gp.flat_count` → повторный прогон (в т.ч.
`--match-only`) ничего не трогает, если знаменатель не изменился.
- Ре-прогон с новым `--version` безопасен; рекомендуемая частота полного парса — раз в квартал.
`--match-only` дёшев → запускай после правки канон-логики (мигр. 144) или добавления домов.