gendesign/tradein-mvp/docs/gar-flats-runbook.md
bot-backend 55819aa821
All checks were successful
CI / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
feat(tradein): ГАР-loader знаменателя — apartments-per-house → gar_house_flats + match к houses
2026-06-28 17:40:22 +03:00

8.2 KiB
Raw Blame History

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

Наполняет таблицу gar_house_flats числом помещений-квартир под каждым домом из официального дампа ГАР/ФИАС и матчит к houses по нормализованному адресу, проставляя 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).

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

Лоадер потребляет уже распакованные XML локально — многогигабайтную загрузку/распаковку делаем отдельно (это не часть лоадера).

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

    • Полный РФ-архив gar_xml.zip — десятки ГБ. Нам нужен только регион 66 (Свердловская обл.).
    • Внутри архива объекты разложены по папкам с кодом региона: распаковываем только папку 66/. Пример (извлечь одну папку без распаковки всего архива):
      unzip gar_xml.zip '66/*' -d /data/gar
      # → /data/gar/66/AS_ADDR_OBJ_*.XML, AS_HOUSES_*.XML, AS_APARTMENTS_*.XML,
      #   AS_MUN_HIERARCHY_*.XML, ...
      
    • Размер распакованной папки 66/: ориентир — несколько ГБ (крупнейшие файлы — AS_APARTMENTS_* и AS_MUN_HIERARCHY_*, по сотни МБ — единицы ГБ). Поэтому парсер стримит (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

Локально:

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 региона.
  • --region (умолч. 66) — код региона; пишется в gar_house_flats.region_code.
  • --version (умолч. сегодня, YYYY-MM-DD) — метка версии дампа (gar_house_flats.gar_version).

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

  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 = … по нормализованному адресу (gar_match_method='norm_address'), идемпотентно (IS DISTINCT FROM gate).

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

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(«<улица>, <номер>») — тот же нормализатор, что и для houses при матче. Строка строится из «улица + номер» (не из полной цепочки) для устойчивости: для ЕКБ эквивалентно fn(full_address) (fn срезает регион/город), но не оставляет префикс для не-«г.» населённых пунктов. full_address хранится отдельно как человекочитаемая provenance-цепочка (регион→город→улица→номер).
  • Tie-break. Если несколько ГАР-строк дают один norm_address, матчер берёт строку с максимальным flat_count (при равенстве — лексикографически меньший house_guid), через DISTINCT ON (norm_address) ORDER BY norm_address, 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 этого лоадера.

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

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