gendesign/tradein-mvp/docs/gar-flats-runbook.md
lekss361 d7aaa00326
All checks were successful
Deploy Trade-In / test (push) Successful in 4m0s
Deploy Trade-In / build-backend (push) Successful in 1m12s
Deploy Trade-In / deploy (push) Successful in 1m47s
Deploy Trade-In / deploy-status (push) Successful in 3s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m46s
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Матч ГАР работает по любому региону, а не только по Екатеринбургу (#3523)
2026-09-15 06:59:01 +00:00

17 KiB
Raw Permalink Blame History

ГАР/ФИАС loader знаменателя «квартир на дом» (runbook)

Наполняет таблицу gar_house_flats числом помещений-квартир под каждым домом из официального дампа ГАР/ФИАС и матчит к houses по каноническому ключу адреса (tradein_canon_addr, мигр. 144), проставляя houses.gar_flat_count. Это «знаменатель» для страницы «доля квартир дома в продаже» (active_vtorichka_listings / total_apartments_in_building, view v_building_sale_share, мигр. 143).

Код:

  • tradein-mvp/backend/app/services/gar_flats_loader.py — стриминговый парсер + upsert + матчер.
  • tradein-mvp/backend/app/tasks/gar_flats_load.py — CLI (python -m app.tasks.gar_flats_load).

Текущее состояние (замер 13.09.2026, до загрузки 77/50): gar_house_flats наполнена ТОЛЬКО по региону 66 (919 341 строка). По домам: 66 — 10 159 домов, gar_house_guid у 7 779, flat_count у 4 821 (47%); 77 — 17 453 дома, gar_house_guid у 0 (ГАР ещё не загружен), flat_count у 388 (2,2%, вероятно другой источник знаменателя); 50 — 8 964 дома, gar_house_guid у 0, flat_count у 32. Команды загрузки 77/50 — § 2.

1. Получить ГАР-дамп региона (ops-шаг, вручную)

Лоадер потребляет уже распакованные XML локально — многогигабайтную загрузку/распаковку делаем отдельно (это не часть лоадера). Поддерживаемые сейчас регионы продукта: 66 (Свердловская обл.), 77 (Москва), 50 (Московская обл.) — см. app.services.regions.REGIONS.

  1. Источник: ГАР (Государственный адресный реестр), портал ФНС — https://fias.nalog.ru/ (раздел «Скачать»). Берём «ГАР. Версия со всеми объектами» — формат XML, gar_xml.zip.

    • URL меняется с каждым обновлением ФИАС (примерно еженедельно) — НЕ хардкодить. Актуальный прямой URL отдаёт GET https://fias.nalog.ru/WebServices/Public/GetLastDownloadFileInfo в поле GarXMLFullURL (JSON). На дату написания раздела (проверено 13.09.2026): https://fias-file.nalog.ru/downloads/2026.09.11/gar_xml.zip, версия дампа ФИАС 2026-09-11, размер 53,6 ГБ — используй это значение только как пример формата URL, перед реальным запуском запроси эндпоинт заново.
    • Полный РФ-архив gar_xml.zip — десятки ГБ. Нам нужны только папки нужных регионов.
    • Внутри архива объекты разложены по папкам с кодом региона: распаковываем только нужную папку(и) (можно за один проход, если качаем сразу под несколько регионов):
      unzip gar_xml.zip '66/*' -d /data/gar   # Свердловская обл.
      unzip gar_xml.zip '77/*' -d /data/gar   # Москва
      unzip gar_xml.zip '50/*' -d /data/gar   # Московская обл.
      # → /data/gar/<regionN>/AS_ADDR_OBJ_*.XML, AS_HOUSES_*.XML, AS_APARTMENTS_*.XML,
      #   AS_MUN_HIERARCHY_*.XML, ...
      
    • Размер распакованной папки региона: ориентир для 66 — несколько ГБ (крупнейшие файлы — AS_APARTMENTS_* и AS_MUN_HIERARCHY_*, по сотни МБ — единицы ГБ). Регион 77 (Москва) и особенно 50 (область, ~10 121 текстовое имя города против 612 у Москвы — #2996) — папки СУЩЕСТВЕННО крупнее 66 (на порядок больше домов/помещений), закладывай запас по диску и времени парса. Парсер стримит (lxml.etree.iterparse + очистка элементов), а не грузит дерево целиком — память не зависит от размера файла.

    ГАР обновляется примерно еженедельно дельтами; нам достаточно полного среза на регион. Знаменатель «всего квартир в доме» почти статичен → ре-прогон раз в квартал ок.

Нужные файлы в папке региона (остальные лоадер игнорирует):

  • AS_ADDR_OBJ_*.XML — адресные объекты (регион/город/улица).
  • AS_HOUSES_*.XML — дома.
  • AS_APARTMENTS_*.XML — помещения/квартиры.
  • AS_MUN_HIERARCHY_*.XML — иерархия (приоритетна; при отсутствии используется AS_ADM_HIERARCHY_*).

2. Запуск лоадера

Где: в окружении с доступом к БД tradein (контейнер tradein-backend или локальный venv с DATABASE_URL на tradein). Каталог --dir — путь к распакованной папке региона.

# в контейнере tradein-backend (DATABASE_URL уже выставлен)
docker exec -it tradein-backend \
  python -m app.tasks.gar_flats_load --dir /data/gar/66 --region 66 --version 2026-06-01

# регион 77 (Москва) — без city-фильтра по умолчанию (город один, фильтровать нечем)
docker exec -it tradein-backend \
  python -m app.tasks.gar_flats_load --dir /data/gar/77 --region 77 --version 2026-09-11

# регион 50 (Московская обл.) — без city-фильтра по умолчанию (нет доминирующего города)
docker exec -it tradein-backend \
  python -m app.tasks.gar_flats_load --dir /data/gar/50 --region 50 --version 2026-09-11

Локально:

cd tradein-mvp/backend
DATABASE_URL=postgresql+psycopg://<user>:<pw>@localhost:<port>/tradein \
  uv run python -m app.tasks.gar_flats_load --dir /data/gar/66 --region 66 --version 2026-06-01

Аргументы:

  • --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). По умолчанию берётся ПО РЕГИОНУ, не константой (см. app.services.gar_flats_loader.default_city_filter_for_region): регион 66Екатеринбург (byte-for-byte прежнее поведение — много сопоставимых по названиям городов внутри области, см. REGIONS[66].cities); регионы 77/50 → без фильтра (77 — город один, фильтровать нечем; 50 — нет доминирующего города, фильтр по одному городу отрезал бы почти весь регион). --city '' явно отключает фильтр для ЛЮБОГО региона (в т.ч. 66); --city "Имя" — явный override.

Что делает (две фазы, обе коммитятся):

  1. load — стримит XML → считает помещения на дом (по иерархии) → собирает адрес → UPSERT в gar_house_flats (ON CONFLICT (house_guid), идемпотентно). norm_address считает SQL-fn tradein_normalize_short_addr поверх «улица, номер».
  2. matchUPDATE houses SET gar_flat_count = … по КАНОНИЧЕСКОМУ ключу адреса tradein_canon_addr (gar_match_method='canon_addr', мигр. 144), с city-фильтром по умолчанию для 66 (full_address ILIKE '%Екатеринбург%'), без фильтра для 77/50 (ambiguity одноимённых улиц закрыта иначе, см. § 4), идемпотентно (IS DISTINCT FROM gate).

В логах — сводка: houses, apartments (учтено под домами), upserted, houses_matched.

Ре-матч без повторного парса XML (--match-only)

Знаменатель «всего квартир в доме» почти статичен, а gar_house_flats уже загружена на проде — пере-прогонять многогигабайтный парс XML, чтобы только обновить матч к houses (напр. после правки канон-логики или добавления домов в houses), не нужно:

# в контейнере tradein-backend — только ре-матч, --dir НЕ требуется
docker exec -it tradein-backend \
  python -m app.tasks.gar_flats_load --match-only --region 66
docker exec -it tradein-backend \
  python -m app.tasks.gar_flats_load --match-only --region 77
docker exec -it tradein-backend \
  python -m app.tasks.gar_flats_load --match-only --region 50

Флаг --match-only пропускает шаги парса/загрузки целиком и гоняет только match_houses_to_gar против уже загруженной gar_house_flats. --city без явного значения резолвится ПО РЕГИОНУ (см. § 2 выше: 66 → Екатеринбург, 77/50 → без фильтра); пустой --city '' отключает фильтр для любого региона.

3. Проверка результата

-- сколько домов получили ГАР-знаменатель
SELECT count(*) FILTER (WHERE gar_flat_count IS NOT NULL) AS matched,
       count(*) AS total
FROM houses;

-- топ домов по доле квартир в продаже (эффективный знаменатель из ГАР)
SELECT short_address, active_secondary, flat_count_effective, sale_share_pct, gar_match_method
FROM v_building_sale_share
WHERE gar_flat_count IS NOT NULL
ORDER BY sale_share_pct DESC NULLS LAST
LIMIT 20;

4. Семантика и ограничения

  • flat_count = число активных (ISACTUAL=1 AND ISACTIVE=1) помещений ГАР, чей PARENTOBJID в иерархии == OBJECTID дома. Тип помещения (APARTTYPE) НЕ фильтруется — это «всего помещений в доме» (квартиры + нежилые), сознательное приближение знаменателя.
  • Адрес/норма. norm_address = tradein_normalize_short_addr(«<улица>, <номер>») — человекочитаемый нормализованный адрес. Строка строится из «улица + номер» (не из полной цепочки) для устойчивости. 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, 5к15 — литера и корпус остаются в каноне (буква — кириллица, цифра корпуса — цифра), т.к. это РАЗНЫЕ здания. Это сознательное поведение, не баг матча.
  • City-фильтр по умолчанию — ПО РЕГИОНУ, не хардкод. Для 66 матч фильтрует ГАР-сторону по full_address ILIKE '%Екатеринбург%' — без этого канон машиностроителей6 совпал бы с «Машиностроителей 6», который существует в 4 населённых пунктах region 66 → ложные cross-town коллизии. Для 77 (Москва) фильтр не нужен — город один. Для 50 (область) фильтр по одному городу был бы вреден (отрезал бы почти весь регион) — ambiguity одноимённых улиц РАЗНЫХ городов там закрыта не фильтром, а следующим пунктом.
  • Ambiguity-guard без city-фильтра (canon_hits). Когда city_filter IS NULL (77, 50 и любой будущий регион без one-city ограничения), tradein_canon_addr НЕ несёт населённый пункт (режет всё, кроме улицы и номера — мигр. 144) — «Ленина 5» существует в десятках городов области. Матчер СЧИТАЕТ, сколько РАЗНЫХ ГАР-домов дают один canon в отфильтрованной (по региону) выборке (COUNT(*) OVER (PARTITION BY canon)); если больше одного — canon НЕ матчится вовсе (пропущенный дом лучше неверно приписанного). С city-фильтром (регион 66 по умолчанию) это ограничение не действует — коллизия там уже закрыта сужением по городу, поведение byte-for-byte прежнее.
  • Tie-break. Если несколько ГАР-строк дают один канон И (city-фильтр задан ИЛИ canon однозначен без фильтра), матчер берёт строку с максимальным flat_count (при равенстве — лексикографически меньший house_guid), через ROW_NUMBER() OVER (PARTITION BY canon ORDER BY flat_count DESC, house_guid).
  • Дома с 0 квартир грузятся (flat_count=0), но матчер их игнорирует (flat_count > 0). Под tradein_canon_addr(norm_address) WHERE flat_count > 0 создан функциональный индекс gar_house_flats_canon_idx (мигр. 144) под JOIN матчера.

5. Идемпотентность / ре-прогон

  • gar_house_flatsON CONFLICT (house_guid) DO UPDATE → повторный прогон обновляет, не дублит.
  • Матчер — plain UPDATE с IS DISTINCT FROM gp.flat_count → повторный прогон (в т.ч. --match-only) ничего не трогает, если знаменатель не изменился.
  • Ре-прогон с новым --version безопасен; рекомендуемая частота полного парса — раз в квартал. --match-only дёшев → запускай после правки канон-логики (мигр. 144) или добавления домов.