gendesign/tradein-mvp/docs/gar-flats-runbook.md
bot-backend b9c89641d1 fix(tradein/gar): дом получает ГАР-guid только своего региона (#2583 H5)
Фильтры матча стояли только на стороне ГАР, UPDATE houses шёл по канону
улица+дом по всем домам всех регионов. После #3523 (матч для 77 и 50) это
стало порчей: прод 2026-09-17 — 578 домов региона 66 с guid региона 50,
340 — с guid 77, 1371 дом 50 с guid 77, 497 домов 77 с guid 50. По чужому
guid ЖКХ/ФРТ/капремонт-загрузчики тянут данные другого дома.

Матч сверяет регион дома с регионом ГАР-строки и ранжирует канон внутри
региона. Перед матчем снимаются уже проставленные canon_addr-guid чужого
региона (дом без региона не трогаем) — иначе дом без пары в своём регионе
так и остался бы с чужим. Эффект на проде — после ops-прогона --match-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:40:59 +05:00

199 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 прежнее.
- **Регион дома (#2583 H5).** Дом получает guid только ГАР-строки своего региона
(`houses.region_code`), канон ранжируется внутри региона. Перед матчем снимаются уже
проставленные `canon_addr`-guid чужого региона (дом без `region_code` не трогаем) — лог
пишет «снято guid чужого региона=N». После выкатки прогнать `--match-only` для 66, 77 и 50.
- **Tie-break.** Если несколько ГАР-строк дают один канон И (city-фильтр задан ИЛИ canon
однозначен без фильтра), матчер берёт строку с **максимальным `flat_count`** (при равенстве —
лексикографически меньший `house_guid`), через `ROW_NUMBER() OVER (PARTITION BY region_code, 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) или добавления домов.