gendesign/tradein-mvp/docs/gar-flats-runbook.md
lekss361 123717994b
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m36s
Deploy Trade-In / build-frontend (push) Successful in 4m43s
Deploy Trade-In / build-backend (push) Successful in 4m44s
Deploy Trade-In / deploy (push) Successful in 2m15s
feat(tradein): ГАР-loader знаменателя — apartments-per-house → gar_house_flats + match к houses (#2059)
2026-06-28 14:54:43 +00:00

112 lines
8.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ГАР/ФИАС 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://<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. **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` безопасен; рекомендуемая частота — раз в квартал.