Фильтры матча стояли только на стороне ГАР, 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>
199 lines
17 KiB
Markdown
199 lines
17 KiB
Markdown
# ГАР/ФИАС 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Б` ≠ `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) или добавления домов.
|