feat(permits): ЕКБ РНС/РВЭ xlsx parser + schema + ETL (#105 PR A) #211

Merged
lekss361 merged 1 commit from feat/105-ekburg-permits-parser into main 2026-05-16 11:41:21 +00:00
Owner

Summary

Issue #105 PR A (Phase 1+2): xlsx parser + schema + Celery ETL для свежих РНС/РВЭ ЕКБ 2022-2026 с екатеринбург.рф. Closes part of #103 (ИСОГД scraper) + #104 (open.midural РНС stale).

Files (5, ~928 LOC total)

File LOC Что
data/sql/96_ekburg_construction_permits.sql 91 Table ekburg_construction_permits + 4 indexes (gist geom, issue_date, developer_inn, source_year). UNIQUE(permit_type, permit_number).
backend/app/services/scrapers/ekburg_permits.py 346 EkburgPermitsClient + PermitRow dataclass. Live-verified xlsx structure по всем 5 файлам (2022-2026)
backend/app/workers/tasks/ekburg_permits_sync.py 151 Celery tasks refresh_year(year) + refresh_all(). UPSERT через CAST(:raw_row AS jsonb) (psycopg-v3). SAVEPOINT per-row через begin_nested()
backend/app/workers/celery_app.py +1 Registry: include ekburg_permits_sync
backend/tests/services/test_ekburg_permits.py 321 24 unit tests, all pass

Real xlsx structure (live-verified, 0-based column indices)

Col Поле Notes
0 developer_name
1 developer_inn int (2024-2026) / str (2022-2023) — _clean_inn helper
5 cadastral_number
6/7 raw_coord_x / raw_coord_y СНСК-66 Свердловской обл. — CRS resolve TBD
9/10/11 permit_number / issue_date / expiry_date
12/13 total_area_sqm / living_area_sqm comma decimal в 2022 — _to_float handler
14/15/16 living_area_fact_sqm / rve_number / rve_date только РВЭ

Implementation details

  • Autodetect sheet по имени: строительс → RNS, ввод → RVE
  • Autodetect header row: 2022 = row 8, 2023+ = row 7 (через поиск «застройщик»)
  • Robust value parsing: _clean_inn handles int/float/str из openpyxl; _to_float handles comma decimal
  • SAVEPOINT-per-row upsert: failed rows logged + counted, не abort transaction

Что НЕ в PR (Phase 3-5 follow-ups)

  • Phase 3: Geocoding via Nominatim (construction_address → lat/lon → geom). Сейчас geom NULL.
  • Phase 4: Celery beat schedule (monthly refresh — job_settings entry).
  • Phase 5: /analyze integration → engineering.recent_permits_500m + cross-check с domrf_kn_objects.
  • CRS resolve для raw_coord_x/y (СНСК-66) — нужен специалист по местным СК.

Test plan

  • schema verified live (postgres MCP — таблица создаётся, indexes валидные)
  • xlsx structure live-verified per all 5 years
  • 24 unit tests pass (parsers, helpers, mock httpx)
  • ruff (UP038, E501) + ruff-format clean
  • psycopg-v3 compliant (CAST(:x AS jsonb), никакого :x::type)
  • qa-tester post-deploy:
    • SELECT COUNT(*) FROM ekburg_construction_permits после первого refresh_all() triggered manually → ≥3000 rows (5 лет × ~700 per year per issue acceptance)
    • Sample row matches xlsx structure
    • Monthly beat schedule (Phase 4) можно добавить отдельным PR

Closes part of #105 (full close after Phase 3-5 PRs).

Related: #103 (ИСОГД scraper — superseded), #104 (open.midural РНС — superseded), #36 D4 (pipeline 24mo cross-check для Phase 5).

## Summary **Issue #105 PR A** (Phase 1+2): xlsx parser + schema + Celery ETL для свежих РНС/РВЭ ЕКБ 2022-2026 с **екатеринбург.рф**. Closes part of #103 (ИСОГД scraper) + #104 (open.midural РНС stale). ### Files (5, ~928 LOC total) | File | LOC | Что | |---|---|---| | `data/sql/96_ekburg_construction_permits.sql` | 91 | Table `ekburg_construction_permits` + 4 indexes (gist geom, issue_date, developer_inn, source_year). `UNIQUE(permit_type, permit_number)`. | | `backend/app/services/scrapers/ekburg_permits.py` | 346 | `EkburgPermitsClient` + `PermitRow` dataclass. **Live-verified xlsx structure** по всем 5 файлам (2022-2026) | | `backend/app/workers/tasks/ekburg_permits_sync.py` | 151 | Celery tasks `refresh_year(year)` + `refresh_all()`. UPSERT через `CAST(:raw_row AS jsonb)` (psycopg-v3). SAVEPOINT per-row через `begin_nested()` | | `backend/app/workers/celery_app.py` | +1 | Registry: include `ekburg_permits_sync` | | `backend/tests/services/test_ekburg_permits.py` | 321 | **24 unit tests**, all pass | ### Real xlsx structure (live-verified, 0-based column indices) | Col | Поле | Notes | |---|---|---| | 0 | developer_name | | | 1 | developer_inn | int (2024-2026) / str (2022-2023) — `_clean_inn` helper | | 5 | cadastral_number | | | 6/7 | raw_coord_x / raw_coord_y | СНСК-66 Свердловской обл. — CRS resolve TBD | | 9/10/11 | permit_number / issue_date / expiry_date | | | 12/13 | total_area_sqm / living_area_sqm | comma decimal в 2022 — `_to_float` handler | | 14/15/16 | living_area_fact_sqm / rve_number / rve_date | только РВЭ | ### Implementation details - **Autodetect sheet** по имени: `строительс` → RNS, `ввод` → RVE - **Autodetect header row:** 2022 = row 8, 2023+ = row 7 (через поиск «застройщик») - **Robust value parsing:** `_clean_inn` handles int/float/str из openpyxl; `_to_float` handles comma decimal - **SAVEPOINT-per-row** upsert: failed rows logged + counted, не abort transaction ### Что НЕ в PR (Phase 3-5 follow-ups) - **Phase 3:** Geocoding via Nominatim (construction_address → lat/lon → geom). Сейчас geom NULL. - **Phase 4:** Celery beat schedule (monthly refresh — `job_settings` entry). - **Phase 5:** `/analyze` integration → `engineering.recent_permits_500m` + cross-check с `domrf_kn_objects`. - **CRS resolve** для raw_coord_x/y (СНСК-66) — нужен специалист по местным СК. ## Test plan - [x] schema verified live (postgres MCP — таблица создаётся, indexes валидные) - [x] xlsx structure live-verified per all 5 years - [x] 24 unit tests pass (parsers, helpers, mock httpx) - [x] ruff (UP038, E501) + ruff-format clean - [x] psycopg-v3 compliant (`CAST(:x AS jsonb)`, никакого `:x::type`) - [ ] **qa-tester post-deploy:** - `SELECT COUNT(*) FROM ekburg_construction_permits` после первого `refresh_all()` triggered manually → ≥3000 rows (5 лет × ~700 per year per issue acceptance) - Sample row matches xlsx structure - Monthly beat schedule (Phase 4) можно добавить отдельным PR Closes part of #105 (full close after Phase 3-5 PRs). Related: #103 (ИСОГД scraper — superseded), #104 (open.midural РНС — superseded), #36 D4 (pipeline 24mo cross-check для Phase 5).
lekss361 added 1 commit 2026-05-16 11:35:00 +00:00
Phase 1+2: parser + schema + Celery ETL. xlsx структура live-verified
по всем 5 файлам 2022-2026. 24 unit tests pass.

Phase 3 (geocoding), 4 (beat), 5 (analyze integration) — отдельные PR'ы.
Author
Owner

Deep Code Review — verdict

Summary

  • Status: APPROVE (auto-merge BLOCKED per scope policy — needs human merge)
  • Files reviewed: 5 (P0: 1 SQL · P1: 2 services/workers · P3: 1 test · trivial: 1 celery_app)
  • Lines: +928 / -0
  • Head SHA: 59d7300 (matches scan)
  • Mergeable: true · Conflicts: none · CI: not gated

🔴 Critical

нет

🟠 High

нет

🟡 Medium (желательно fix, не блокирует)

  • backend/app/services/scrapers/ekburg_permits.py:142-155_clean_inn regex \d{10,12} без word-boundary: на строке "6685000001-12345" вернёт 668500000112 (12 цифр, не реальный ИНН). На текущих 5 xlsx-файлах ловушка не триггерится (live-verified), но fragile.
    _INN_RE = re.compile(r"(?<!\d)\d{10}(?:\d{2})?(?!\d)")
    
  • backend/app/services/scrapers/ekburg_permits.py:175_detect_header_row fallback to 7 молча при отсутствии «застройщик» в первых 10 строках. Стоит logger.warning(...) или хотя бы DEBUG, иначе формат-дрейф источника проявится только как «парсится 0 строк».
  • backend/app/workers/tasks/ekburg_permits_sync.py:128-130refresh_all вызывает refresh_year(year) напрямую (не .delay()), значит весь цикл из 5 лет занимает один worker slot последовательно. Для monthly cron OK, но при ad-hoc запуске блокирует очередь celery на ~5-10 мин. Рассмотри celery.group([refresh_year.s(y) for y in years]).apply_async() в follow-up.

🟢 Low / nits

  • ekburg_permits.py:127-139_to_str обрабатывает только \xa0 NBSP, но не U+202F (narrow NBSP) и U+2007 (figure space). Edge case при copy-paste из Word, на Росстат-формах не встречалось.
  • ekburg_permits.py:225__exit__(*_: Any) -> None — технически OK, но менее conventional чем (exc_type, exc, tb).
  • ekburg_permits_sync.py:97@celery_app.task без acks_late=True + retry policy. UPSERT идемпотентен, но при kill worker'а посредине задача теряется до next monthly run. Не блокирующее для refresh-семантики.
  • ekburg_permits.py:148-152_clean_inn для float: str(int(6685180480.0)) правильно, но float > 2^53 потеряет точность (12-digit ИНН max ~10^12 < 2^53=9·10^15, так что safe — оставляю как nit).
  • data/sql/96_ekburg_construction_permits.sql:46-48geom GEOMETRY(POINT, 4326) будет NULL до Phase 3 (geocoding). GIST index на колонке с 100% NULL — no-op, не страшно, но в COMMENT'е стоит явно прописать «filled in Phase 3».

Cross-file impact analysis

  • backend/app/workers/celery_app.py:30 — добавление "app.workers.tasks.ekburg_permits_sync" в include. Не ломает existing tasks (поминутно проверил список — registry-only change).
  • Новая таблица ekburg_construction_permits — нет existing consumers (grep engineering\.|ekburg_construction_permits backend/ → только сам task). Phase 5 engineering.recent_permits_500m integration в отдельном PR.
  • Файл 96_*.sql следующий свободный после merged 95_mv_quarter_price.sql.
  • Auto-deploy (.forgejo/workflows/deploy.yml:208: ls data/sql/*.sql | sort) применит файл при merge — CREATE TABLE IF NOT EXISTS + IF NOT EXISTS на indexes делают это безопасно при re-run.
  • celery_app.py подхватит tasks.ekburg_permits_sync.refresh_year / refresh_all после рестарта worker'а.

Vault cross-check

  • Pattern совпадает с vault code/modules/ekburg_permits_parser.md (создан в этой же сессии — корректно следует CLAUDE.md rule #6).
  • SAVEPOINT pattern with db.begin_nested(): — повторяет precedent в domrf_kn.py:427/452/472/723/756/781/821, соответствует vault Bug_Pzz_Loader_Missing_Savepoint_May14.
  • CAST(:raw_row AS jsonb) — psycopg v3 compliant (vault Pattern_CAST_AS_Type, Bug_SQLAlchemy_DoubleColon_Cast). Нет :x::type constructs.
  • Источник подтверждён в research/OpenData_Sources_Sverdl_May12.md ("АБСОЛЮТНОЕ ОТКРЫТИЕ — Актуальные РНС/РВЭ для ЕКБ найдены на ekburg.rf").

Conventions check

  • httpx (not requests), logger.* (not print), from __future__ import annotations, async def N/A (sync Celery task — корректно).
  • Ruff line ≤100 соблюдён.
  • psycopg v3: CAST(:raw_row AS jsonb), parametrized binds через dict — нет SQL injection vectors.
  • BEGIN; ... COMMIT; wrap на DDL.
  • Idempotency: CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS, ON CONFLICT ... DO UPDATE.
  • Pre-flight: на feature branch feat/105-ekburg-permits-parser, нет --no-verify/--force/--amend в коммите.

Performance findings

  • Schema reasonable: GIST на geom, btree на issue_date / developer_inn / source_year + composite (permit_type, source_year). Покрывает ожидаемые запросы по году/застройщику/диапазону дат.
  • Volume: ~5 лет × ~700 rows = ~3.5k rows total → seq scan тоже OK, но индексы дешёвые и будут полезны при cross-join с domrf_kn_objects (Phase 5).
  • Memory: openpyxl.load_workbook(BytesIO, read_only=True) — streaming, не загружает весь sheet в память.
  • Network: 5 xlsx × ~200KB = ~1MB total per refresh_all — pingable за <30s, timeout 60s достаточен.

Positive observations

  • live-verified xlsx structure — все 5 файлов 2022-2026 распарсены вручную (per PR body)
  • 24 unit-теста с синтетическим xlsx через openpyxl.Workbook() в памяти — не требуют network/DB
  • _SKIP_SHEETS блокирует «Справочник»/«Лист1»
  • Autodetect permit_type по имени листа + autodetect header row — robust к format-дрейфу между 2022 и 2023+
  • _to_float корректно обрабатывает запятую decimal separator (формат 2022)
  • _is_data_row фильтрует numeric-only нумерацию столбцов (1-30)
  • Single-commit clean PR, conventional message, gpg-unsigned (repo norm)
  1. (Optional medium) tighten _INN_RE с word-boundary — single-line fix
  2. (Optional medium) logger.warning в _detect_header_row fallback
  3. После merge: вручную trigger tasks.ekburg_permits_sync.refresh_year(2026) для smoke-теста, проверить SELECT COUNT(*) FROM ekburg_construction_permits WHERE source_year=2026 ≥ 100
  4. Phase 4 PR: beat schedule entry ekburg-permits-monthly в job_settings (1-е число месяца, 05:00 МСК)
  5. Phase 3 PR: Nominatim geocoding construction_addressgeom (либо resolve СКФ-66 → 4326)
  6. Phase 5 PR: /analyze integration через engineering.recent_permits_500m

Complexity / blast radius score

  • Risk: Low — additive only (new table, new scraper, new task, registry entry)
  • Reversibility: Easy — DROP TABLE ekburg_construction_permits + revert celery include + delete files. No data loss outside scraped data which is re-fetchable.
  • Recommended merge window: anytime (no prod-impact пока beat schedule не добавлен; new task требует manual trigger)

Auto-merge: BLOCKED per scope policy

PR touches blocked paths: data/sql/**, backend/app/services/scrapers/**, backend/app/workers/**. Per .claude/agents/deep-code-reviewer.md Auto-merge policy → needs human merge.

@lekss361 verdict APPROVE — готово к merge, нужна твоя команда.

## Deep Code Review — verdict <!-- gendesign-review-bot: sha=59d7300 verdict=approve --> ### Summary - **Status**: ✅ APPROVE (auto-merge BLOCKED per scope policy — needs human merge) - **Files reviewed**: 5 (P0: 1 SQL · P1: 2 services/workers · P3: 1 test · trivial: 1 celery_app) - **Lines**: +928 / -0 - **Head SHA**: `59d7300` (matches scan) - **Mergeable**: true · **Conflicts**: none · **CI**: not gated ### 🔴 Critical _нет_ ### 🟠 High _нет_ ### 🟡 Medium (желательно fix, не блокирует) - [ ] `backend/app/services/scrapers/ekburg_permits.py:142-155` — `_clean_inn` regex `\d{10,12}` без word-boundary: на строке `"6685000001-12345"` вернёт `668500000112` (12 цифр, не реальный ИНН). На текущих 5 xlsx-файлах ловушка не триггерится (live-verified), но fragile. ```python _INN_RE = re.compile(r"(?<!\d)\d{10}(?:\d{2})?(?!\d)") ``` - [ ] `backend/app/services/scrapers/ekburg_permits.py:175` — `_detect_header_row` fallback to `7` молча при отсутствии «застройщик» в первых 10 строках. Стоит `logger.warning(...)` или хотя бы DEBUG, иначе формат-дрейф источника проявится только как «парсится 0 строк». - [ ] `backend/app/workers/tasks/ekburg_permits_sync.py:128-130` — `refresh_all` вызывает `refresh_year(year)` напрямую (не `.delay()`), значит весь цикл из 5 лет занимает один worker slot последовательно. Для monthly cron OK, но при ad-hoc запуске блокирует очередь `celery` на ~5-10 мин. Рассмотри `celery.group([refresh_year.s(y) for y in years]).apply_async()` в follow-up. ### 🟢 Low / nits - [ ] `ekburg_permits.py:127-139` — `_to_str` обрабатывает только `\xa0` NBSP, но не U+202F (narrow NBSP) и U+2007 (figure space). Edge case при copy-paste из Word, на Росстат-формах не встречалось. - [ ] `ekburg_permits.py:225` — `__exit__(*_: Any) -> None` — технически OK, но менее conventional чем `(exc_type, exc, tb)`. - [ ] `ekburg_permits_sync.py:97` — `@celery_app.task` без `acks_late=True` + retry policy. UPSERT идемпотентен, но при kill worker'а посредине задача теряется до next monthly run. Не блокирующее для refresh-семантики. - [ ] `ekburg_permits.py:148-152` — `_clean_inn` для float: `str(int(6685180480.0))` правильно, но `float` > 2^53 потеряет точность (12-digit ИНН max ~10^12 < 2^53=9·10^15, так что safe — оставляю как nit). - [ ] `data/sql/96_ekburg_construction_permits.sql:46-48` — `geom GEOMETRY(POINT, 4326)` будет NULL до Phase 3 (geocoding). GIST index на колонке с 100% NULL — no-op, не страшно, но в COMMENT'е стоит явно прописать «filled in Phase 3». ### Cross-file impact analysis - ✅ `backend/app/workers/celery_app.py:30` — добавление `"app.workers.tasks.ekburg_permits_sync"` в `include`. Не ломает existing tasks (поминутно проверил список — registry-only change). - ✅ Новая таблица `ekburg_construction_permits` — нет existing consumers (grep `engineering\.|ekburg_construction_permits` backend/ → только сам task). Phase 5 `engineering.recent_permits_500m` integration в отдельном PR. - ✅ Файл `96_*.sql` следующий свободный после merged `95_mv_quarter_price.sql`. - ✅ Auto-deploy (`.forgejo/workflows/deploy.yml:208`: `ls data/sql/*.sql | sort`) применит файл при merge — `CREATE TABLE IF NOT EXISTS` + `IF NOT EXISTS` на indexes делают это безопасно при re-run. - ✅ `celery_app.py` подхватит `tasks.ekburg_permits_sync.refresh_year` / `refresh_all` после рестарта worker'а. ### Vault cross-check - ✅ Pattern совпадает с vault `code/modules/ekburg_permits_parser.md` (создан в этой же сессии — корректно следует CLAUDE.md rule #6). - ✅ SAVEPOINT pattern `with db.begin_nested():` — повторяет precedent в `domrf_kn.py:427/452/472/723/756/781/821`, соответствует vault `Bug_Pzz_Loader_Missing_Savepoint_May14`. - ✅ `CAST(:raw_row AS jsonb)` — psycopg v3 compliant (vault `Pattern_CAST_AS_Type`, `Bug_SQLAlchemy_DoubleColon_Cast`). Нет `:x::type` constructs. - ✅ Источник подтверждён в `research/OpenData_Sources_Sverdl_May12.md` ("АБСОЛЮТНОЕ ОТКРЫТИЕ — Актуальные РНС/РВЭ для ЕКБ найдены на ekburg.rf"). ### Conventions check - ✅ `httpx` (not `requests`), `logger.*` (not `print`), `from __future__ import annotations`, `async def` N/A (sync Celery task — корректно). - ✅ Ruff line ≤100 соблюдён. - ✅ `psycopg v3`: `CAST(:raw_row AS jsonb)`, parametrized binds через dict — нет SQL injection vectors. - ✅ `BEGIN; ... COMMIT;` wrap на DDL. - ✅ Idempotency: `CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, `ON CONFLICT ... DO UPDATE`. - ✅ Pre-flight: на feature branch `feat/105-ekburg-permits-parser`, нет `--no-verify`/`--force`/`--amend` в коммите. ### Performance findings - Schema reasonable: GIST на `geom`, btree на `issue_date` / `developer_inn` / `source_year` + composite `(permit_type, source_year)`. Покрывает ожидаемые запросы по году/застройщику/диапазону дат. - Volume: ~5 лет × ~700 rows = ~3.5k rows total → seq scan тоже OK, но индексы дешёвые и будут полезны при cross-join с `domrf_kn_objects` (Phase 5). - Memory: `openpyxl.load_workbook(BytesIO, read_only=True)` — streaming, не загружает весь sheet в память. - Network: 5 xlsx × ~200KB = ~1MB total per `refresh_all` — pingable за <30s, timeout 60s достаточен. ### Positive observations - ✅ `live-verified xlsx structure` — все 5 файлов 2022-2026 распарсены вручную (per PR body) - ✅ 24 unit-теста с синтетическим xlsx через `openpyxl.Workbook()` в памяти — не требуют network/DB - ✅ `_SKIP_SHEETS` блокирует «Справочник»/«Лист1» - ✅ Autodetect `permit_type` по имени листа + autodetect header row — robust к format-дрейфу между 2022 и 2023+ - ✅ `_to_float` корректно обрабатывает запятую decimal separator (формат 2022) - ✅ `_is_data_row` фильтрует numeric-only нумерацию столбцов (1-30) - ✅ Single-commit clean PR, conventional message, gpg-unsigned (repo norm) ### Recommended next steps 1. (Optional medium) tighten `_INN_RE` с word-boundary — single-line fix 2. (Optional medium) `logger.warning` в `_detect_header_row` fallback 3. После merge: вручную trigger `tasks.ekburg_permits_sync.refresh_year(2026)` для smoke-теста, проверить `SELECT COUNT(*) FROM ekburg_construction_permits WHERE source_year=2026` ≥ 100 4. Phase 4 PR: beat schedule entry `ekburg-permits-monthly` в `job_settings` (1-е число месяца, 05:00 МСК) 5. Phase 3 PR: Nominatim geocoding `construction_address` → `geom` (либо resolve СКФ-66 → 4326) 6. Phase 5 PR: `/analyze` integration через `engineering.recent_permits_500m` ### Complexity / blast radius score - **Risk**: Low — additive only (new table, new scraper, new task, registry entry) - **Reversibility**: Easy — `DROP TABLE ekburg_construction_permits` + revert celery include + delete files. No data loss outside scraped data which is re-fetchable. - **Recommended merge window**: anytime (no prod-impact пока beat schedule не добавлен; new task требует manual trigger) --- ### Auto-merge: ❌ BLOCKED per scope policy PR touches blocked paths: `data/sql/**`, `backend/app/services/scrapers/**`, `backend/app/workers/**`. Per `.claude/agents/deep-code-reviewer.md` Auto-merge policy → **needs human merge**. @lekss361 verdict ✅ APPROVE — готово к merge, нужна твоя команда.
lekss361 merged commit adc45f5724 into main 2026-05-16 11:41:21 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#211
No description provided.