From c729a39446e31130565e1e48d82dfcc0efb58e50 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 28 Jun 2026 18:44:22 +0300 Subject: [PATCH] =?UTF-8?q?feat(tradein):=20=D0=BA=D0=B0=D0=BD=D0=BE=D0=BD?= =?UTF-8?q?=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B9=20=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=D1=87=20=D0=93=D0=90=D0=A0=E2=86=94houses=20(0%=E2=86=9241%=20?= =?UTF-8?q?yield)=20+=20tradein=5Fcanon=5Faddr=20+=20--match-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/services/gar_flats_loader.py | 58 +++-- .../backend/app/tasks/gar_flats_load.py | 85 +++++++- .../data/sql/144_gar_canon_addr_match.sql | 69 ++++++ .../backend/tests/test_gar_flats_loader.py | 201 ++++++++++++++++-- tradein-mvp/docs/gar-flats-runbook.md | 70 ++++-- 5 files changed, 423 insertions(+), 60 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/144_gar_canon_addr_match.sql diff --git a/tradein-mvp/backend/app/services/gar_flats_loader.py b/tradein-mvp/backend/app/services/gar_flats_loader.py index d85d1194..fd3c4538 100644 --- a/tradein-mvp/backend/app/services/gar_flats_loader.py +++ b/tradein-mvp/backend/app/services/gar_flats_loader.py @@ -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 diff --git a/tradein-mvp/backend/app/tasks/gar_flats_load.py b/tradein-mvp/backend/app/tasks/gar_flats_load.py index 9f2c9a0c..bb84691d 100644 --- a/tradein-mvp/backend/app/tasks/gar_flats_load.py +++ b/tradein-mvp/backend/app/tasks/gar_flats_load.py @@ -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__": diff --git a/tradein-mvp/backend/data/sql/144_gar_canon_addr_match.sql b/tradein-mvp/backend/data/sql/144_gar_canon_addr_match.sql new file mode 100644 index 00000000..bafbd75c --- /dev/null +++ b/tradein-mvp/backend/data/sql/144_gar_canon_addr_match.sql @@ -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 +-- (139–142 заняты), следующий свободный 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; diff --git a/tradein-mvp/backend/tests/test_gar_flats_loader.py b/tradein-mvp/backend/tests/test_gar_flats_loader.py index 16b11e61..e98fc4c5 100644 --- a/tradein-mvp/backend/tests/test_gar_flats_loader.py +++ b/tradein-mvp/backend/tests/test_gar_flats_loader.py @@ -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() diff --git a/tradein-mvp/docs/gar-flats-runbook.md b/tradein-mvp/docs/gar-flats-runbook.md index 1f9d4ab8..53b366ed 100644 --- a/tradein-mvp/docs/gar-flats-runbook.md +++ b/tradein-mvp/docs/gar-flats-runbook.md @@ -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://:@localhost:/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) или добавления домов.