gendesign/tradein-mvp/docs/gar-flats-runbook.md
lekss361 579536b4ad
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m31s
Deploy Trade-In / build-backend (push) Successful in 1m6s
Deploy Trade-In / deploy (push) Successful in 54s
feat(tradein): канонический матч ГАР↔houses (0%→41% yield) + tradein_canon_addr + --match-only (#2063)
2026-06-28 15:50:07 +00:00

148 lines
12 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` по **каноническому ключу адреса**
(`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`, `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) или добавления домов.