feat(tradein): канонический матч ГАР↔houses (0%→41% yield) + tradein_canon_addr + --match-only (#2063)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m31s
Deploy Trade-In / build-backend (push) Successful in 1m6s
Deploy Trade-In / deploy (push) Successful in 54s

This commit is contained in:
lekss361 2026-06-28 15:50:07 +00:00
parent 83ae6f8a47
commit 579536b4ad
5 changed files with 423 additions and 60 deletions

View file

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

View file

@ -1,4 +1,4 @@
"""CLI: загрузка ГАР-знаменателя «квартир на дом» + матч к houses (мигр. 143). """CLI: загрузка ГАР-знаменателя «квартир на дом» + матч к houses (мигр. 143/144).
Запуск (каталог УЖЕ распакованный ГАР региона, напр. папка `66/` из gar_xml.zip): Запуск (каталог УЖЕ распакованный ГАР региона, напр. папка `66/` из gar_xml.zip):
@ -6,7 +6,13 @@
Делает два шага в одной транзакционной сессии: Делает два шага в одной транзакционной сессии:
1. load_gar_region стриминговый парс XML UPSERT gar_house_flats (коммит). 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-шаг, см. Многогигабайтный ДАМП качается/распаковывается отдельно (ops-шаг, см.
docs/gar-flats-runbook.md) этот лоадер потребляет уже распакованные XML локально. 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__) 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). Возвращает счётчики.""" """Парс+UPSERT (load_gar_region) затем матч (match_houses_to_gar). Возвращает счётчики."""
db = SessionLocal() db = SessionLocal()
try: try:
load = load_gar_region(dir_path, region_code, gar_version, db=db) 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() db.commit()
finally: finally:
db.close() db.close()
@ -50,24 +66,73 @@ def run_gar_flats_load(dir_path: str, region_code: str, gar_version: str) -> dic
return counts return counts
def main() -> None: def run_gar_match_only(
logging.basicConfig( region_code: str, *, city_filter: str | None = DEFAULT_CITY_FILTER
level=logging.INFO, ) -> dict[str, int]:
format="%(asctime)s %(levelname)s %(name)s: %(message)s", """Только ре-матч уже загруженного 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( parser = argparse.ArgumentParser(
description="ГАР loader: квартир на дом → gar_house_flats + match к houses" 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("--region", default="66", help="код региона (по умолчанию 66)")
parser.add_argument( parser.add_argument(
"--version", "--version",
default=date.today().isoformat(), default=date.today().isoformat(),
help="версия ГАР-дампа YYYY-MM-DD (по умолчанию сегодня)", 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() 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__": 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 inspect
import os import os
import re import re
import sys
from pathlib import Path from pathlib import Path
from typing import Any 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: def test_match_distinct_on_tiebreak_max_flat_count() -> None:
flat = re.sub(r"\s+", " ", _MATCH_SQL) flat = re.sub(r"\s+", " ", _MATCH_SQL)
# Tie-break: на norm_address — строка с макс flat_count, затем меньший house_guid. # Канонический ключ (мигр. 144), а не точное равенство norm_address.
assert "DISTINCT ON (norm_address)" in flat assert "DISTINCT ON (canon)" in flat
assert "ORDER BY norm_address, flat_count DESC, house_guid" in flat assert "tradein_canon_addr(norm_address) AS canon" in flat
# houses-сторона: тот же нормализатор поверх COALESCE(short/full/address). # Tie-break: на canon — строка с макс flat_count, затем меньший house_guid.
assert ( assert "ORDER BY canon, flat_count DESC, house_guid" in flat
"tradein_normalize_short_addr( COALESCE(h.short_address, h.full_address, h.address) )" # houses-сторона: тот же канон поверх COALESCE(short/full/address).
in flat 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 "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 assert "flat_count > 0" in flat
@ -260,6 +265,123 @@ def test_upsert_uses_savepoint_per_batch() -> None:
assert "db.commit()" not in src 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 без БД) # Опциональный 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-БД") @pytest.mark.skipif(_live_session() is None, reason="нет доступной Postgres test-БД")
def test_upsert_and_match_populates_gar_flat_count(gar_dir: str) -> None: def test_upsert_and_canon_match_populates_gar_flat_count(gar_dir: str) -> None:
"""С реальной Postgres: upsert ГАР-строк + match → houses.gar_flat_count проставлен.""" """С реальной Postgres: upsert ГАР + КАНОН-match → houses.gar_flat_count (мигр. 144).
Проверяет: канон бридит формат-расхождение, которого точный матч не видел
(«ул. Шаумяна,10» ГАР «ул Шаумяна, 10»); ЕКБ-фильтр исключает same-canon не-ЕКБ
строку; идемпотентность повторного прогона; метод 'canon_addr'.
"""
from sqlalchemy import text from sqlalchemy import text
db = _live_session() db = _live_session()
assert db is not None assert db is not None
try: try:
db.execute(text(_NORMALIZER_FN)) db.execute(text(_NORMALIZER_FN))
db.execute(text(_CANON_FN))
db.execute( db.execute(
text( text(
"CREATE TEMP TABLE gar_house_flats (" "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" ") 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( db.execute(
text( 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) upserted = gfl.upsert_gar_houses(db, rows, batch_size=2)
assert upserted == 3 assert upserted == 3
# norm_address посчитан SQL-fn = «ул Шаумяна, 10». # norm_address посчитан SQL-fn = «ул Шаумяна, 10» (точный матч с houses провалился бы).
norm = db.execute( norm = db.execute(
text("SELECT norm_address FROM gar_house_flats WHERE house_guid='h-a'") text("SELECT norm_address FROM gar_house_flats WHERE house_guid='h-a'")
).scalar() ).scalar()
assert norm == "ул Шаумяна, 10" assert norm == "ул Шаумяна, 10"
# Канон-матч (ЕКБ default): Шаумяна канон-совпал, хотя формат адреса различался.
matched = gfl.match_houses_to_gar(db, region_code="66") matched = gfl.match_houses_to_gar(db, region_code="66")
assert matched == 1 assert matched == 1
# Идемпотентность: повторный матч ничего не трогает. # Идемпотентность: повторный матч ничего не трогает.
@ -352,16 +498,41 @@ def test_upsert_and_match_populates_gar_flat_count(gar_dir: str) -> None:
shaumyana = db.execute( shaumyana = db.execute(
text( text(
"SELECT gar_flat_count, gar_match_method FROM houses " "SELECT gar_flat_count, gar_match_method FROM houses "
"WHERE short_address='ул Шаумяна, 10'" "WHERE short_address='ул. Шаумяна,10'"
) )
).fetchone() ).fetchone()
assert shaumyana is not None and shaumyana[0] == 3 assert shaumyana is not None and shaumyana[0] == 3
assert shaumyana[1] == "norm_address" assert shaumyana[1] == "canon_addr"
# Дом без квартир (flat_count=0) НЕ матчится. # Дом без квартир (flat_count=0) НЕ матчится.
lenina = db.execute( lenina = db.execute(
text("SELECT gar_flat_count FROM houses WHERE short_address='ул Ленина, 7'") text("SELECT gar_flat_count FROM houses WHERE short_address='ул Ленина, 7'")
).scalar() ).scalar()
assert lenina is None 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: finally:
db.rollback() db.rollback()
db.close() db.close()

View file

@ -1,8 +1,9 @@
# ГАР/ФИАС loader знаменателя «квартир на дом» (runbook) # ГАР/ФИАС loader знаменателя «квартир на дом» (runbook)
Наполняет таблицу `gar_house_flats` числом помещений-квартир под каждым домом из Наполняет таблицу `gar_house_flats` числом помещений-квартир под каждым домом из
официального дампа ГАР/ФИАС и матчит к `houses` по нормализованному адресу, проставляя официального дампа ГАР/ФИАС и матчит к `houses` по **каноническому ключу адреса**
`houses.gar_flat_count`. Это «знаменатель» для страницы **«доля квартир дома в продаже»** (`tradein_canon_addr`, мигр. 144), проставляя `houses.gar_flat_count`. Это «знаменатель»
для страницы **«доля квартир дома в продаже»**
(`active_vtorichka_listings / total_apartments_in_building`, view `v_building_sale_share`, (`active_vtorichka_listings / total_apartments_in_building`, view `v_building_sale_share`,
мигр. 143). мигр. 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`. - `--region` (умолч. `66`) — код региона; пишется в `gar_house_flats.region_code`.
- `--version` (умолч. сегодня, `YYYY-MM-DD`) — метка версии дампа (`gar_house_flats.gar_version`). - `--version` (умолч. сегодня, `YYYY-MM-DD`) — метка версии дампа (`gar_house_flats.gar_version`).
- `--match-only` — пропустить парс/загрузку XML, гнать только ре-матч (см. выше).
- `--city` (умолч. `Екатеринбург`) — город-фильтр матча (`ILIKE` по `full_address`); пусто = без ограничения.
Что делает (две фазы, обе коммитятся): Что делает (две фазы, обе коммитятся):
1. **load** — стримит XML → считает помещения на дом (по иерархии) → собирает адрес → 1. **load** — стримит XML → считает помещения на дом (по иерархии) → собирает адрес →
UPSERT в `gar_house_flats` (`ON CONFLICT (house_guid)`, идемпотентно). `norm_address` UPSERT в `gar_house_flats` (`ON CONFLICT (house_guid)`, идемпотентно). `norm_address`
считает SQL-fn `tradein_normalize_short_addr` поверх «улица, номер». считает SQL-fn `tradein_normalize_short_addr` поверх «улица, номер».
2. **match**`UPDATE houses SET gar_flat_count = …` по нормализованному адресу 2. **match**`UPDATE houses SET gar_flat_count = …` по КАНОНИЧЕСКОМУ ключу адреса
(`gar_match_method='norm_address'`), идемпотентно (`IS DISTINCT FROM` gate). `tradein_canon_addr` (`gar_match_method='canon_addr'`, мигр. 144), ЕКБ-restricted
(`full_address ILIKE '%Екатеринбург%'`), идемпотентно (`IS DISTINCT FROM` gate).
В логах — сводка: `houses`, `apartments` (учтено под домами), `upserted`, `houses_matched`. В логах — сводка: `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. Проверка результата ## 3. Проверка результата
```sql ```sql
@ -92,21 +113,36 @@ LIMIT 20;
`PARENTOBJID` в иерархии == `OBJECTID` дома. Тип помещения (`APARTTYPE`) НЕ фильтруется — `PARENTOBJID` в иерархии == `OBJECTID` дома. Тип помещения (`APARTTYPE`) НЕ фильтруется —
это «всего помещений в доме» (квартиры + нежилые), сознательное приближение знаменателя. это «всего помещений в доме» (квартиры + нежилые), сознательное приближение знаменателя.
- **Адрес/норма.** `norm_address` = `tradein_normalize_short_addr(«<улица>, <номер>»)` - **Адрес/норма.** `norm_address` = `tradein_normalize_short_addr(«<улица>, <номер>»)`
тот же нормализатор, что и для `houses` при матче. Строка строится из «улица + номер» человекочитаемый нормализованный адрес. Строка строится из «улица + номер» (не из полной
(не из полной цепочки) для устойчивости: для ЕКБ эквивалентно `fn(full_address)` (fn срезает цепочки) для устойчивости. `full_address` хранится отдельно как provenance-цепочка
регион/город), но не оставляет префикс для не-«г.» населённых пунктов. `full_address` хранится (регион→город→улица→номер) и используется матчем для ЕКБ-фильтра (`ILIKE '%Екатеринбург%'`).
отдельно как человекочитаемая provenance-цепочка (регион→город→улица→номер). - **Канонический матч (мигр. 144).** Точное равенство `norm_address` на проде давало **0**
- **Tie-break.** Если несколько ГАР-строк дают один `norm_address`, матчер берёт строку с совпадений: `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`), **максимальным `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`). - **Дома с 0 квартир** грузятся (`flat_count=0`), но матчер их игнорирует (`flat_count > 0`).
- **Покрытие матча — точное равенство нормализованного адреса.** Расхождения формата типа Под `tradein_canon_addr(norm_address) WHERE flat_count > 0` создан функциональный индекс
улицы (`ул` vs `ул.` vs `улица`) и корпусов (`5 к1` vs `5/1`) не совпадут точно. Это `gar_house_flats_canon_idx` (мигр. 144) под JOIN матчера.
ожидаемо: под `gar_house_flats.norm_address` уже создан GIN-trgm индекс (мигр. 143) для
будущего нечёткого (fuzzy) прохода — он вне scope этого лоадера.
## 5. Идемпотентность / ре-прогон ## 5. Идемпотентность / ре-прогон
- `gar_house_flats``ON CONFLICT (house_guid) DO UPDATE` → повторный прогон обновляет, не дублит. - `gar_house_flats``ON CONFLICT (house_guid) DO UPDATE` → повторный прогон обновляет, не дублит.
- Матчер — plain `UPDATE` с `IS DISTINCT FROM` → повторный прогон ничего не трогает. - Матчер — plain `UPDATE` с `IS DISTINCT FROM gp.flat_count` → повторный прогон (в т.ч.
- Ре-прогон с новым `--version` безопасен; рекомендуемая частота — раз в квартал. `--match-only`) ничего не трогает, если знаменатель не изменился.
- Ре-прогон с новым `--version` безопасен; рекомендуемая частота полного парса — раз в квартал.
`--match-only` дёшев → запускай после правки канон-логики (мигр. 144) или добавления домов.