# ГАР/ФИАС 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`).

## 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 региона (обязателен, КРОМЕ режима `--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 = …` по КАНОНИЧЕСКОМУ ключу адреса
   `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
-- сколько домов получили ГАР-знаменатель
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Б` ≠ `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 (canon) ORDER BY canon, 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_flats` — `ON CONFLICT (house_guid) DO UPDATE` → повторный прогон обновляет, не дублит.
- Матчер — plain `UPDATE` с `IS DISTINCT FROM gp.flat_count` → повторный прогон (в т.ч.
  `--match-only`) ничего не трогает, если знаменатель не изменился.
- Ре-прогон с новым `--version` безопасен; рекомендуемая частота полного парса — раз в квартал.
  `--match-only` дёшев → запускай после правки канон-логики (мигр. 144) или добавления домов.
