feat(tradein): канонический матч ГАР↔houses (0%→41% yield) + tradein_canon_addr + --match-only #2063
5 changed files with 423 additions and 60 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
69
tradein-mvp/backend/data/sql/144_gar_canon_addr_match.sql
Normal file
69
tradein-mvp/backend/data/sql/144_gar_canon_addr_match.sql
Normal 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
|
||||
-- (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;
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) или добавления домов.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue