# ГАР/ФИАС 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/`**. Пример (извлечь одну папку без распаковки всего архива): ```bash 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` — путь к распакованной папке региона. ```bash # в контейнере 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 ``` Локально: ```bash cd tradein-mvp/backend DATABASE_URL=postgresql+psycopg://:@localhost:/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. **match** — `UPDATE houses SET gar_flat_count = …` по нормализованному адресу (`gar_match_method='norm_address'`), идемпотентно (`IS DISTINCT FROM` gate). В логах — сводка: `houses`, `apartments` (учтено под домами), `upserted`, `houses_matched`. ## 3. Проверка результата ```sql -- сколько домов получили ГАР-знаменатель 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_flats` — `ON CONFLICT (house_guid) DO UPDATE` → повторный прогон обновляет, не дублит. - Матчер — plain `UPDATE` с `IS DISTINCT FROM` → повторный прогон ничего не трогает. - Ре-прогон с новым `--version` безопасен; рекомендуемая частота — раз в квартал.