gendesign/tradein-mvp/docs/gar-flats-runbook.md
lekss361 d7aaa00326
All checks were successful
Deploy Trade-In / test (push) Successful in 4m0s
Deploy Trade-In / build-backend (push) Successful in 1m12s
Deploy Trade-In / deploy (push) Successful in 1m47s
Deploy Trade-In / deploy-status (push) Successful in 3s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m46s
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Матч ГАР работает по любому региону, а не только по Екатеринбургу (#3523)
2026-09-15 06:59:01 +00:00

195 lines
17 KiB
Markdown
Raw Permalink 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`).
**Текущее состояние (замер 13.09.2026, до загрузки 77/50):** `gar_house_flats` наполнена
ТОЛЬКО по региону 66 (919 341 строка). По домам: 66 — 10 159 домов, `gar_house_guid` у 7 779,
`flat_count` у 4 821 (47%); 77 — 17 453 дома, `gar_house_guid` у 0 (ГАР ещё не загружен),
`flat_count` у 388 (2,2%, вероятно другой источник знаменателя); 50 — 8 964 дома,
`gar_house_guid` у 0, `flat_count` у 32. Команды загрузки 77/50 — § 2.
## 1. Получить ГАР-дамп региона (ops-шаг, вручную)
Лоадер потребляет **уже распакованные** XML локально — многогигабайтную загрузку/распаковку
делаем отдельно (это не часть лоадера). Поддерживаемые сейчас регионы продукта: **66**
(Свердловская обл.), **77** (Москва), **50** (Московская обл.) — см. `app.services.regions.REGIONS`.
1. Источник: ГАР (Государственный адресный реестр), портал ФНС — https://fias.nalog.ru/
(раздел «Скачать»). Берём «ГАР. Версия со всеми объектами» — формат XML, `gar_xml.zip`.
- **URL меняется с каждым обновлением ФИАС (примерно еженедельно) — НЕ хардкодить.**
Актуальный прямой URL отдаёт `GET https://fias.nalog.ru/WebServices/Public/GetLastDownloadFileInfo`
в поле `GarXMLFullURL` (JSON). На дату написания раздела (проверено 13.09.2026):
`https://fias-file.nalog.ru/downloads/2026.09.11/gar_xml.zip`, версия дампа ФИАС
**2026-09-11**, размер **53,6 ГБ** — используй это значение только как пример формата
URL, перед реальным запуском запроси эндпоинт заново.
- Полный РФ-архив `gar_xml.zip` — десятки ГБ. Нам нужны только папки нужных регионов.
- Внутри архива объекты разложены по папкам с кодом региона: распаковываем **только нужную
папку(и)** (можно за один проход, если качаем сразу под несколько регионов):
```bash
unzip gar_xml.zip '66/*' -d /data/gar # Свердловская обл.
unzip gar_xml.zip '77/*' -d /data/gar # Москва
unzip gar_xml.zip '50/*' -d /data/gar # Московская обл.
# → /data/gar/<regionN>/AS_ADDR_OBJ_*.XML, AS_HOUSES_*.XML, AS_APARTMENTS_*.XML,
# AS_MUN_HIERARCHY_*.XML, ...
```
- Размер распакованной папки региона: ориентир для 66 — несколько ГБ (крупнейшие файлы —
`AS_APARTMENTS_*` и `AS_MUN_HIERARCHY_*`, по сотни МБ — единицы ГБ). Регион 77 (Москва)
и особенно 50 (область, ~10 121 текстовое имя города против 612 у Москвы — #2996) —
папки СУЩЕСТВЕННО крупнее 66 (на порядок больше домов/помещений), закладывай запас по
диску и времени парса. Парсер стримит (`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
# регион 77 (Москва) — без city-фильтра по умолчанию (город один, фильтровать нечем)
docker exec -it tradein-backend \
python -m app.tasks.gar_flats_load --dir /data/gar/77 --region 77 --version 2026-09-11
# регион 50 (Московская обл.) — без city-фильтра по умолчанию (нет доминирующего города)
docker exec -it tradein-backend \
python -m app.tasks.gar_flats_load --dir /data/gar/50 --region 50 --version 2026-09-11
```
Локально:
```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`). **По умолчанию берётся ПО
РЕГИОНУ**, не константой (см. `app.services.gar_flats_loader.default_city_filter_for_region`):
регион **66** → `Екатеринбург` (byte-for-byte прежнее поведение — много сопоставимых по
названиям городов внутри области, см. `REGIONS[66].cities`); регионы **77/50** → без
фильтра (77 — город один, фильтровать нечем; 50 — нет доминирующего города, фильтр по
одному городу отрезал бы почти весь регион). `--city ''` явно отключает фильтр для
ЛЮБОГО региона (в т.ч. 66); `--city "Имя"` — явный override.
Что делает (две фазы, обе коммитятся):
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), с city-фильтром по
умолчанию для 66 (`full_address ILIKE '%Екатеринбург%'`), без фильтра для 77/50 (ambiguity
одноимённых улиц закрыта иначе, см. § 4), идемпотентно (`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
docker exec -it tradein-backend \
python -m app.tasks.gar_flats_load --match-only --region 77
docker exec -it tradein-backend \
python -m app.tasks.gar_flats_load --match-only --region 50
```
Флаг `--match-only` пропускает шаги парса/загрузки целиком и гоняет только
`match_houses_to_gar` против уже загруженной `gar_house_flats`. `--city` без явного значения
резолвится ПО РЕГИОНУ (см. § 2 выше: 66 → `Екатеринбург`, 77/50 → без фильтра); пустой
`--city ''` отключает фильтр для любого региона.
## 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-фильтр по умолчанию — ПО РЕГИОНУ, не хардкод.** Для 66 матч фильтрует ГАР-сторону по
`full_address ILIKE '%Екатеринбург%'` — без этого канон `машиностроителей6` совпал бы с
«Машиностроителей 6», который существует в 4 населённых пунктах region 66 → ложные
cross-town коллизии. Для 77 (Москва) фильтр не нужен — город один. Для 50 (область) фильтр
по одному городу был бы вреден (отрезал бы почти весь регион) — ambiguity одноимённых улиц
РАЗНЫХ городов там закрыта не фильтром, а следующим пунктом.
- **Ambiguity-guard без city-фильтра (canon_hits).** Когда `city_filter IS NULL` (77, 50 и
любой будущий регион без one-city ограничения), `tradein_canon_addr` НЕ несёт населённый
пункт (режет всё, кроме улицы и номера — мигр. 144) — «Ленина 5» существует в десятках
городов области. Матчер СЧИТАЕТ, сколько РАЗНЫХ ГАР-домов дают один canon в
отфильтрованной (по региону) выборке (`COUNT(*) OVER (PARTITION BY canon)`); если больше
одного — canon НЕ матчится вовсе (пропущенный дом лучше неверно приписанного). С
city-фильтром (регион 66 по умолчанию) это ограничение не действует — коллизия там уже
закрыта сужением по городу, поведение byte-for-byte прежнее.
- **Tie-break.** Если несколько ГАР-строк дают один канон И (city-фильтр задан ИЛИ canon
однозначен без фильтра), матчер берёт строку с **максимальным `flat_count`** (при равенстве —
лексикографически меньший `house_guid`), через `ROW_NUMBER() OVER (PARTITION BY canon ORDER
BY 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) или добавления домов.