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

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'ы.
This commit is contained in:
lekss361 2026-05-16 14:34:20 +03:00
parent efa66a9f68
commit 59d7300c9c
5 changed files with 928 additions and 0 deletions

View file

@ -0,0 +1,346 @@
"""ЕКБ РНС/РВЭ xlsx parser (Issue #105).
Source: https://xn--80acgfbsl1azdqr.xn--p1ai/дляработы/гиз/градостроительство/разрешение
Format: Form 3 (РНС) + Form 4 (РВЭ) стандартные Росстат шаблоны.
Структура xlsx (проверена на всех файлах 2022-2026):
Лист «реестр разрешений на строительс» РНС (Таблица 3)
Лист «реестр разрешений на ввод» РВЭ (Таблица 4)
Расположение заголовков:
2022: строки 5-7 заголовок/подзаголовок/номера; данные с row 8
2023+: строки 4-6 заголовок/подзаголовок/номера; данные с row 7
Колонки (0-based) одинаковы во всех годах:
0 developer_name Наименование застройщика
1 developer_inn ИНН (int или str)
2 developer_address Адрес застройщика (не сохраняем)
3 object_type Тип строительного объекта
4 object_name Наименование объекта КС
5 cadastral_number Кадастровый номер ЗУ
6 raw_coord_x X (СКФ-66, местная СК Свердловской обл.)
7 raw_coord_y Y (СКФ-66)
8 construction_address Адрес объекта
9 permit_number Номер разрешения на строительство
10 issue_date Дата разрешения на строительство
11 expiry_date Дата окончания разрешения
12 total_area_sqm Общая площадь, м²
13 living_area_sqm Площадь жилых помещений по проекту, м²
-- дополнительно только для РВЭ:
14 living_area_fact_sqm Площадь жилых помещений фактически, м²
15 rve_number Номер разрешения на ввод
16 rve_date Дата разрешения на ввод
Координаты raw_coord_x / raw_coord_y хранятся как строки CRS неизвестна (предположительно
СНСК-66 / СКФ-66 Свердловская обл., local offset system). Геокодирование через construction_address
планируется в Phase 3 (отдельный PR).
"""
from __future__ import annotations
import logging
import re
from collections.abc import Iterator
from dataclasses import dataclass, field
from datetime import date, datetime
from io import BytesIO
from typing import Any
import httpx
from openpyxl import load_workbook
logger = logging.getLogger(__name__)
EKBURG_PERMITS_URLS: dict[int, str] = {
2026: "https://xn--80acgfbsl1azdqr.xn--p1ai/file/70bf01bf31538ee9dd82dadfc47192a0",
2025: "https://xn--80acgfbsl1azdqr.xn--p1ai/file/6a0a18c9ee327e6e4f76c32a5385a6bd",
2024: "https://xn--80acgfbsl1azdqr.xn--p1ai/file/907bfa0cf78d5a93c6ccafed1af51fc5",
2023: "https://xn--80acgfbsl1azdqr.xn--p1ai/file/3b2ef86bf5673adaa65263672f0c623f",
2022: "https://xn--80acgfbsl1azdqr.xn--p1ai/file/51a7e5654d2fa018bc6db402ab4d5775",
}
# Листы которые нужно пропустить (справочник, вспомогательные)
_SKIP_SHEETS = {"справочник", "лист1", "sheet1"}
# Паттерн для проверки что строка — строка данных (первая ячейка непустая и не число-нумерация)
_INN_RE = re.compile(r"\d{10,12}")
@dataclass
class PermitRow:
"""Parsed row from РНС/РВЭ xlsx."""
permit_type: str # "RNS" | "RVE"
permit_number: str
issue_date: date | None
expiry_date: date | None
developer_inn: str | None
developer_name: str | None
object_name: str | None
object_type: str | None
construction_address: str | None
cadastral_number: str | None
total_area_sqm: float | None
living_area_sqm: float | None
living_area_fact_sqm: float | None # только РВЭ
rve_number: str | None # только РВЭ
rve_date: date | None # только РВЭ
raw_coord_x: str | None
raw_coord_y: str | None
source_year: int
source_url: str
raw_row: dict[str, Any] = field(default_factory=dict)
# ── helpers ──────────────────────────────────────────────────────────────────
def _to_str(v: Any) -> str | None:
"""Привести значение ячейки к строке, убрать лишние пробелы. None если пусто."""
if v is None:
return None
s = str(v).strip().replace("\xa0", " ")
return s if s and s != "-" else None
def _to_date(v: Any) -> date | None:
"""Привести значение ячейки к date.
Принимает: datetime (openpyxl), date, строки 'DD.MM.YYYY', 'YYYY-MM-DD'.
"""
if v is None:
return None
if isinstance(v, datetime):
return v.date()
if isinstance(v, date):
return v
if isinstance(v, str):
s = v.strip()
for fmt in ("%d.%m.%Y", "%Y-%m-%d", "%d-%m-%Y"):
try:
return datetime.strptime(s, fmt).date()
except ValueError:
continue
return None
def _to_float(v: Any) -> float | None:
"""Привести значение ячейки к float. Обрабатывает запятую как разделитель."""
if v is None:
return None
if isinstance(v, int | float):
return float(v) if not isinstance(v, bool) else None
s = str(v).strip().replace(",", ".").replace("\xa0", "").replace(" ", "")
if not s or s == "-":
return None
try:
return float(s)
except ValueError:
return None
def _clean_inn(v: Any) -> str | None:
"""Извлечь ИНН из значения ячейки. ИНН — 10 или 12 цифр."""
if v is None:
return None
s = str(v).strip().replace("\xa0", "")
# Если ячейка — число (openpyxl выдаёт int/float)
if isinstance(v, int | float) and not isinstance(v, bool):
digits = str(int(v))
if 10 <= len(digits) <= 12:
return digits
return None
m = _INN_RE.search(s)
return m.group(0) if m else None
def _detect_permit_type(sheet_name: str) -> str | None:
"""Определить тип разрешения по имени листа."""
name = sheet_name.lower()
if "строит" in name:
return "RNS"
if "ввод" in name:
return "RVE"
return None
def _detect_header_row(sheet: Any) -> int:
"""Найти строку с заголовком 'Наименование застройщика' (1-based).
Возвращает номер строки-данных (header_row + 3, так как за заголовком идут
две строки подзаголовков и нумерация).
"""
for row_idx, row in enumerate(
sheet.iter_rows(min_row=1, max_row=10, values_only=True), start=1
):
if row and row[0] and "застройщик" in str(row[0]).lower():
# +3: подзаголовок (X/Y), нумерация (1,2,3...), первая данных
return row_idx + 3
# Fallback: стандартные позиции
# 2022: header=5, данные с 8; 2023+: header=4, данные с 7
return 7
def _is_data_row(row: tuple[Any, ...]) -> bool:
"""Вернуть True если строка содержит реальные данные (не пустая, не заголовок)."""
if not row or all(v is None for v in row):
return False
first = row[0]
if first is None:
# Продолжение предыдущей записи (merged cells) — пропускаем
return False
# Проверяем, что первая ячейка — не номер (нумерация столбцов в header)
if isinstance(first, int | float) and not isinstance(first, bool):
val = int(first)
if 1 <= val <= 30:
return False
return True
def _parse_row(
row: tuple[Any, ...],
permit_type: str,
year: int,
source_url: str,
) -> PermitRow | None:
"""Распарсить одну строку данных в PermitRow.
Возвращает None если строка не содержит номера разрешения (обязательное поле).
"""
# permit_number — колонка 9 (0-based)
permit_number = _to_str(row[9]) if len(row) > 9 else None
if not permit_number:
return None
raw: dict[str, Any] = {str(i): str(v) for i, v in enumerate(row) if v is not None}
return PermitRow(
permit_type=permit_type,
permit_number=permit_number,
issue_date=_to_date(row[10]) if len(row) > 10 else None,
expiry_date=_to_date(row[11]) if len(row) > 11 else None,
developer_inn=_clean_inn(row[1]) if len(row) > 1 else None,
developer_name=_to_str(row[0]) if len(row) > 0 else None,
object_name=_to_str(row[4]) if len(row) > 4 else None,
object_type=_to_str(row[3]) if len(row) > 3 else None,
construction_address=_to_str(row[8]) if len(row) > 8 else None,
cadastral_number=_to_str(row[5]) if len(row) > 5 else None,
total_area_sqm=_to_float(row[12]) if len(row) > 12 else None,
living_area_sqm=_to_float(row[13]) if len(row) > 13 else None,
living_area_fact_sqm=_to_float(row[14]) if permit_type == "RVE" and len(row) > 14 else None,
rve_number=_to_str(row[15]) if permit_type == "RVE" and len(row) > 15 else None,
rve_date=_to_date(row[16]) if permit_type == "RVE" and len(row) > 16 else None,
raw_coord_x=_to_str(row[6]) if len(row) > 6 else None,
raw_coord_y=_to_str(row[7]) if len(row) > 7 else None,
source_year=year,
source_url=source_url,
raw_row=raw,
)
# ── client ───────────────────────────────────────────────────────────────────
class EkburgPermitsClient:
"""Client для загрузки + парсинга РНС/РВЭ xlsx с екатеринбург.рф."""
DEFAULT_TIMEOUT = 60.0
USER_AGENT = "GenDesign/1.0 (+https://gendsgn.ru) Site Finder permits scraper"
def __init__(self, *, timeout: float = DEFAULT_TIMEOUT) -> None:
self._client = httpx.Client(
timeout=timeout,
follow_redirects=True,
headers={"User-Agent": self.USER_AGENT},
)
def __enter__(self) -> EkburgPermitsClient:
return self
def __exit__(self, *_: Any) -> None:
self._client.close()
def download_xlsx(self, year: int) -> bytes:
"""GET xlsx для заданного года. Поднимает ValueError для неизвестного года."""
url = EKBURG_PERMITS_URLS.get(year)
if not url:
raise ValueError(f"No URL configured for year={year}")
response = self._client.get(url)
response.raise_for_status()
logger.info("Downloaded ekburg permits xlsx %d: %d bytes", year, len(response.content))
return response.content
def parse_xlsx(self, content: bytes, year: int, source_url: str) -> Iterator[PermitRow]:
"""Parse xlsx bytes → yield PermitRow.
Автоматически определяет тип листа (РНС/РВЭ) по названию.
Пропускает листы «Справочник», «Лист1» и неизвестные.
"""
wb = load_workbook(BytesIO(content), read_only=True, data_only=True)
for sheet_name in wb.sheetnames:
if sheet_name.lower() in _SKIP_SHEETS:
continue
permit_type = _detect_permit_type(sheet_name)
if permit_type is None:
logger.debug("Skipping unknown sheet %r in year %d", sheet_name, year)
continue
sheet = wb[sheet_name]
data_start = _detect_header_row(sheet)
logger.info(
"Parsing sheet %r (%s) year=%d, data starts at row %d",
sheet_name,
permit_type,
year,
data_start,
)
yield from self._parse_sheet(sheet, permit_type, year, source_url, data_start)
def _parse_sheet(
self,
sheet: Any,
permit_type: str,
year: int,
source_url: str,
data_start: int,
) -> Iterator[PermitRow]:
"""Parse один лист → yield PermitRow per data row."""
row_count = 0
skip_count = 0
for row_idx, row in enumerate(
sheet.iter_rows(min_row=data_start, values_only=True), start=data_start
):
if not _is_data_row(row):
skip_count += 1
continue
try:
permit = _parse_row(row, permit_type, year, source_url)
except Exception as exc:
logger.warning(
"Failed to parse row %d sheet %s year %d: %s",
row_idx,
permit_type,
year,
exc,
)
skip_count += 1
continue
if permit is None:
skip_count += 1
continue
row_count += 1
yield permit
logger.info(
"Sheet %s year=%d: parsed=%d skipped=%d",
permit_type,
year,
row_count,
skip_count,
)

View file

@ -27,6 +27,7 @@ celery_app = Celery(
"app.workers.tasks.noise_sync",
"app.workers.tasks.pzz_sync",
"app.workers.tasks.scrape_cadastre",
"app.workers.tasks.ekburg_permits_sync",
],
)
celery_app.conf.timezone = "Europe/Moscow"

View file

@ -0,0 +1,151 @@
"""Celery task: monthly refresh ekburg permits xlsx (Issue #105).
Запускается через beat (1-е число каждого месяца в 05:00 МСК).
Добавить в beat_schedule через job_settings или hardcoded entry (Phase 2 followup).
"""
from __future__ import annotations
import json
import logging
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
from app.services.scrapers.ekburg_permits import (
EKBURG_PERMITS_URLS,
EkburgPermitsClient,
PermitRow,
)
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
def _upsert_permit(db: Session, row: PermitRow) -> None:
"""UPSERT одной строки разрешения в ekburg_construction_permits."""
db.execute(
text("""
INSERT INTO ekburg_construction_permits (
permit_type, permit_number,
issue_date, expiry_date,
developer_inn, developer_name,
object_name, object_type,
construction_address, cadastral_number,
total_area_sqm, living_area_sqm, living_area_fact_sqm,
rve_number, rve_date,
raw_coord_x, raw_coord_y,
source_year, source_url, raw_row
)
VALUES (
:permit_type, :permit_number,
:issue_date, :expiry_date,
:developer_inn, :developer_name,
:object_name, :object_type,
:construction_address, :cadastral_number,
:total_area_sqm, :living_area_sqm, :living_area_fact_sqm,
:rve_number, :rve_date,
:raw_coord_x, :raw_coord_y,
:source_year, :source_url, CAST(:raw_row AS jsonb)
)
ON CONFLICT (permit_type, permit_number) DO UPDATE SET
issue_date = EXCLUDED.issue_date,
expiry_date = EXCLUDED.expiry_date,
developer_inn = EXCLUDED.developer_inn,
developer_name = EXCLUDED.developer_name,
object_name = EXCLUDED.object_name,
object_type = EXCLUDED.object_type,
construction_address = EXCLUDED.construction_address,
cadastral_number = EXCLUDED.cadastral_number,
total_area_sqm = EXCLUDED.total_area_sqm,
living_area_sqm = EXCLUDED.living_area_sqm,
living_area_fact_sqm = EXCLUDED.living_area_fact_sqm,
rve_number = EXCLUDED.rve_number,
rve_date = EXCLUDED.rve_date,
raw_coord_x = EXCLUDED.raw_coord_x,
raw_coord_y = EXCLUDED.raw_coord_y,
raw_row = EXCLUDED.raw_row,
fetched_at = NOW()
"""),
{
"permit_type": row.permit_type,
"permit_number": row.permit_number,
"issue_date": row.issue_date,
"expiry_date": row.expiry_date,
"developer_inn": row.developer_inn,
"developer_name": row.developer_name,
"object_name": row.object_name,
"object_type": row.object_type,
"construction_address": row.construction_address,
"cadastral_number": row.cadastral_number,
"total_area_sqm": row.total_area_sqm,
"living_area_sqm": row.living_area_sqm,
"living_area_fact_sqm": row.living_area_fact_sqm,
"rve_number": row.rve_number,
"rve_date": row.rve_date,
"raw_coord_x": row.raw_coord_x,
"raw_coord_y": row.raw_coord_y,
"source_year": row.source_year,
"source_url": row.source_url,
"raw_row": json.dumps(row.raw_row, ensure_ascii=False),
},
)
@celery_app.task(name="tasks.ekburg_permits_sync.refresh_year", queue="celery")
def refresh_year(year: int) -> dict[str, int]:
"""Скачать + распарсить + upsert РНС/РВЭ за один год.
Возвращает {"inserted": N, "errors": N}.
"""
inserted = 0
errors = 0
url = EKBURG_PERMITS_URLS.get(year)
if not url:
logger.error("ekburg_permits_sync: no URL for year=%d", year)
return {"inserted": 0, "errors": 1}
with EkburgPermitsClient() as client:
try:
content = client.download_xlsx(year)
except Exception as exc:
logger.error("ekburg_permits_sync: download failed year=%d: %s", year, exc)
return {"inserted": 0, "errors": 1}
with SessionLocal() as db:
for row in client.parse_xlsx(content, year, url):
try:
with db.begin_nested():
_upsert_permit(db, row)
inserted += 1
except Exception as exc:
logger.warning(
"ekburg_permits_sync: upsert failed %s/%s year=%d: %s",
row.permit_type,
row.permit_number,
year,
exc,
)
errors += 1
db.commit()
logger.info(
"ekburg_permits_sync: year=%d done inserted=%d errors=%d",
year,
inserted,
errors,
)
return {"inserted": inserted, "errors": errors}
@celery_app.task(name="tasks.ekburg_permits_sync.refresh_all", queue="celery")
def refresh_all() -> dict[str, dict[str, int]]:
"""Обновить все 5 лет (2022-2026). Планируется через Celery beat ежемесячно."""
results: dict[str, dict[str, int]] = {}
for year in sorted(EKBURG_PERMITS_URLS.keys()):
logger.info("ekburg_permits_sync: starting year=%d", year)
results[str(year)] = refresh_year(year)
logger.info("ekburg_permits_sync: refresh_all done: %s", results)
return results

View file

@ -0,0 +1,339 @@
"""Тесты для ekburg_permits.py (Issue #105).
Использует mock httpx + синтетические данные xlsx (openpyxl в памяти).
Не требует реального PostgreSQL или сетевого доступа.
"""
from __future__ import annotations
from datetime import date, datetime
from io import BytesIO
from typing import Any
from unittest.mock import MagicMock
import pytest
from openpyxl import Workbook
from app.services.scrapers.ekburg_permits import (
EKBURG_PERMITS_URLS,
EkburgPermitsClient,
_clean_inn,
_detect_permit_type,
_to_date,
_to_float,
_to_str,
)
# ── helpers ───────────────────────────────────────────────────────────────────
def _make_xlsx_bytes(sheets: dict[str, list[tuple[Any, ...]]]) -> bytes:
"""Создать xlsx в памяти с заданными листами и строками."""
wb = Workbook()
wb.remove(wb.active) # удалить дефолтный лист
for sheet_name, rows in sheets.items():
ws = wb.create_sheet(sheet_name)
for row in rows:
ws.append(list(row))
buf = BytesIO()
wb.save(buf)
return buf.getvalue()
def _rns_sheet_rows() -> list[tuple[Any, ...]]:
"""Синтетические строки для листа РНС (Form 3).
Воспроизводит структуру 2024-2026: header row 4, данные с row 7.
"""
return [
# row 1: пусто
(None,) * 14,
# row 2: заголовок таблицы
(
"Таблица 3. Реестр выданных разрешений на строительство "
"объектов капитального строительства",
)
+ (None,) * 13,
# row 3: пусто
(None,) * 14,
# row 4: заголовки колонок
(
"Наименование застройщика",
"ИНН",
"Адрес застройщика",
"Тип строительного объекта1",
"Наименование объекта КС",
"Кадастровый номер ЗУ",
"Координаты X",
None,
"Адрес объекта",
"Реквизиты разрешения (номер)",
None,
"Дата окончания",
"Общая площадь, м2",
"Площадь жилых помещений по проекту, м2",
),
# row 5: подзаголовки
(None,) * 7 + ("X", "Y") + (None,) * 3 + ("номер", "дата") + (None,) * 2,
# row 6: нумерация столбцов
tuple(range(1, 15)),
# row 7: первая строка данных (МКД — ООО "Тест")
(
'ООО "Специализированный застройщик "Тест"',
6685000001,
"620000, г. Екатеринбург, ул. Тестовая, д. 1",
"многоквартирные жилые дома;",
"Многоквартирный жилой дом (№ 1 по ПЗУ)",
"66:41:0101001:123",
"1530000.0000",
"380000.0000",
"Свердловская обл., г. Екатеринбург, ул. Тестовая",
"66-41-99-2024",
datetime(2024, 3, 15),
datetime(2027, 3, 15),
12345.67,
8000.0,
),
# row 8: строка без permit_number (должна быть пропущена)
(
"ООО «Продолжение»",
6685000002,
"620001, г. Екатеринбург",
"производственные здания;",
"Производственный корпус",
"66:41:0101002:456",
"1531000.0000",
"381000.0000",
"Свердловская обл., г. Екатеринбург, ул. Другая",
None, # no permit_number → skip
datetime(2024, 5, 20),
datetime(2026, 5, 20),
500.0,
None,
),
# row 9: empty row (all None) — should be skipped
(None,) * 14,
]
def _rve_sheet_rows() -> list[tuple[Any, ...]]:
"""Синтетические строки для листа РВЭ (Form 4)."""
return [
(None,) * 19,
("Таблица 4. Реестр выданных разрешений на ввод в эксплуатацию",) + (None,) * 18,
(None,) * 19,
# row 4: заголовки
(
"Наименование застройщика",
"ИНН",
"Адрес застройщика",
"Тип строительного объекта1",
"Наименование объекта КС",
"Кадастровый номер ЗУ",
"Координаты X",
None,
"Адрес объекта",
"Реквизиты РНС (номер)",
None,
"Дата окончания РНС",
"Общая площадь, м2",
"Площадь жилых по проекту, м2",
"Площадь жилых фактически, м2",
"Реквизиты РВЭ (номер)",
None,
"Введённые мощности",
None,
),
(None,) * 7 + ("X", "Y") + (None,) * 5 + ("номер", "дата") + (None,) * 4,
tuple(range(1, 19)),
# row 7: первая строка данных
(
'ООО "Застройщик Ввода"',
6658000002,
"620100, г. Екатеринбург, ул. Вводная, д. 2",
"многоквартирные жилые дома;",
"Жилой дом (1 очередь) (№ 1 по ПЗУ)",
"66:41:0303001:789",
"1535000.0000",
"385000.0000",
"Свердловская обл., г. Екатеринбург, ул. Вводная",
"66-41-50-2022",
datetime(2022, 10, 1),
datetime(2026, 1, 1),
30000.0,
20000.0,
19800.5, # living_area_fact
"66-41-5-2026",
datetime(2026, 1, 13),
None,
None,
),
]
# ── unit tests: helpers ───────────────────────────────────────────────────────
class TestHelpers:
def test_to_date_datetime(self) -> None:
dt = datetime(2024, 3, 15, 0, 0)
assert _to_date(dt) == date(2024, 3, 15)
def test_to_date_string_dot(self) -> None:
assert _to_date("15.03.2024") == date(2024, 3, 15)
def test_to_date_none(self) -> None:
assert _to_date(None) is None
def test_to_date_invalid(self) -> None:
assert _to_date("not-a-date") is None
def test_to_float_comma(self) -> None:
assert _to_float("12345,67") == pytest.approx(12345.67)
def test_to_float_dash(self) -> None:
assert _to_float("-") is None
def test_to_float_int(self) -> None:
assert _to_float(1490) == pytest.approx(1490.0)
def test_to_str_nbsp(self) -> None:
assert _to_str("г.\xa0Екатеринбург") == "г. Екатеринбург"
def test_to_str_none(self) -> None:
assert _to_str(None) is None
def test_clean_inn_int(self) -> None:
assert _clean_inn(6685180480) == "6685180480"
def test_clean_inn_float(self) -> None:
# openpyxl иногда выдаёт float для длинных чисел
assert _clean_inn(6685180480.0) == "6685180480"
def test_clean_inn_string(self) -> None:
assert _clean_inn("6685180480") == "6685180480"
def test_clean_inn_none(self) -> None:
assert _clean_inn(None) is None
def test_clean_inn_short(self) -> None:
# 9 цифр — не ИНН
assert _clean_inn(123456789) is None
def test_detect_permit_type_rns(self) -> None:
assert _detect_permit_type("реестр разрешений на строительс") == "RNS"
def test_detect_permit_type_rve(self) -> None:
assert _detect_permit_type("реестр разрешений на ввод") == "RVE"
def test_detect_permit_type_unknown(self) -> None:
assert _detect_permit_type("Справочник") is None
# ── unit tests: xlsx parsing ──────────────────────────────────────────────────
class TestParseXlsx:
def _make_client(self) -> EkburgPermitsClient:
client = EkburgPermitsClient.__new__(EkburgPermitsClient)
# Создаём mock httpx.Client чтобы не открывать реальные соединения
client._client = MagicMock()
return client
def test_parse_rns_yields_permit_row(self) -> None:
content = _make_xlsx_bytes(
{
"реестр разрешений на строительс": _rns_sheet_rows(),
"Справочник": [("многоквартирные жилые дома;",)],
}
)
client = self._make_client()
rows = list(client.parse_xlsx(content, 2024, EKBURG_PERMITS_URLS[2024]))
# Должна быть ровно одна строка (вторая без permit_number пропущена)
assert len(rows) == 1
row = rows[0]
assert row.permit_type == "RNS"
assert row.permit_number == "66-41-99-2024"
assert row.issue_date == date(2024, 3, 15)
assert row.expiry_date == date(2027, 3, 15)
assert row.developer_inn == "6685000001"
assert row.object_type == "многоквартирные жилые дома;"
assert row.total_area_sqm == pytest.approx(12345.67)
assert row.living_area_sqm == pytest.approx(8000.0)
assert row.source_year == 2024
def test_parse_rve_yields_permit_row_with_rve_fields(self) -> None:
content = _make_xlsx_bytes({"реестр разрешений на ввод": _rve_sheet_rows()})
client = self._make_client()
rows = list(client.parse_xlsx(content, 2026, EKBURG_PERMITS_URLS[2026]))
assert len(rows) == 1
row = rows[0]
assert row.permit_type == "RVE"
assert row.permit_number == "66-41-50-2022"
assert row.living_area_fact_sqm == pytest.approx(19800.5)
assert row.rve_number == "66-41-5-2026"
assert row.rve_date == date(2026, 1, 13)
def test_parse_skips_справочник_sheet(self) -> None:
content = _make_xlsx_bytes(
{
"Справочник": [("многоквартирные жилые дома;",)],
"Лист1": [(None,)],
}
)
client = self._make_client()
rows = list(client.parse_xlsx(content, 2026, EKBURG_PERMITS_URLS[2026]))
assert rows == []
def test_raw_row_stored(self) -> None:
content = _make_xlsx_bytes({"реестр разрешений на строительс": _rns_sheet_rows()})
client = self._make_client()
rows = list(client.parse_xlsx(content, 2024, EKBURG_PERMITS_URLS[2024]))
assert len(rows) == 1
# raw_row должен содержать dict с string keys
assert isinstance(rows[0].raw_row, dict)
assert "9" in rows[0].raw_row # колонка 9 = permit_number
# ── unit tests: download_xlsx ─────────────────────────────────────────────────
class TestDownloadXlsx:
def _client_with_mock_http(self, mock_response: MagicMock) -> EkburgPermitsClient:
"""Создать EkburgPermitsClient с замоканным внутренним httpx.Client."""
client = EkburgPermitsClient.__new__(EkburgPermitsClient)
mock_http = MagicMock()
mock_http.get.return_value = mock_response
client._client = mock_http
return client
def test_download_returns_bytes(self) -> None:
fake_content = b"PK\x03\x04fake_xlsx_content"
mock_response = MagicMock()
mock_response.content = fake_content
mock_response.raise_for_status = MagicMock()
client = self._client_with_mock_http(mock_response)
result = client.download_xlsx(2026)
assert result == fake_content
def test_download_raises_for_unknown_year(self) -> None:
client = EkburgPermitsClient.__new__(EkburgPermitsClient)
client._client = MagicMock()
with pytest.raises(ValueError, match="year=9999"):
client.download_xlsx(9999)
def test_download_raises_on_http_error(self) -> None:
import httpx as httpx_mod
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = httpx_mod.HTTPStatusError(
"404", request=MagicMock(), response=MagicMock()
)
client = self._client_with_mock_http(mock_response)
with pytest.raises(httpx_mod.HTTPStatusError):
client.download_xlsx(2026)

View file

@ -0,0 +1,91 @@
-- 96_ekburg_construction_permits.sql
-- Issue #105 — РНС/РВЭ ЕКБ 2022-2026 (xlsx от екатеринбург.рф, Форма 3 и 4 Росстата).
--
-- Источник: https://xn--80acgfbsl1azdqr.xn--p1ai/дляработы/гиз/градостроительство/разрешение
-- Формат: XLSX с двумя листами:
-- «реестр разрешений на строительс» — Таблица 3 (РНС)
-- «реестр разрешений на ввод» — Таблица 4 (РВЭ)
--
-- Refresh: monthly via tasks.ekburg_permits_sync.refresh_all
-- Geocoding: raw_coord_x / raw_coord_y — местная СКФ-66 (Свердловская обл.),
-- geocoded_lat / geocoded_lon заполняются отдельной задачей (Phase 3).
BEGIN;
CREATE TABLE IF NOT EXISTS ekburg_construction_permits (
id BIGSERIAL PRIMARY KEY,
permit_type TEXT NOT NULL CHECK (permit_type IN ('RNS', 'RVE')),
-- Реквизиты разрешения
permit_number TEXT NOT NULL,
issue_date DATE,
expiry_date DATE,
-- Застройщик
developer_inn TEXT,
developer_name TEXT,
-- Объект
object_name TEXT,
object_type TEXT, -- из Справочника: многоквартирные жилые дома; и т.д.
construction_address TEXT,
cadastral_number TEXT, -- кадастровый номер ЗУ (колонка 6)
-- Площади
total_area_sqm NUMERIC, -- общая площадь по проекту, м²
living_area_sqm NUMERIC, -- площадь жилых помещений по проекту, м²
living_area_fact_sqm NUMERIC, -- площадь жилых помещений фактически (только РВЭ)
-- Координаты (местная СКФ-66 Свердловской обл. — CRS TBD Phase 3)
raw_coord_x TEXT,
raw_coord_y TEXT,
-- РВЭ-специфичные поля
rve_number TEXT, -- номер разрешения на ввод
rve_date DATE, -- дата разрешения на ввод
-- Геокодирование (Phase 3)
geocoded_lat NUMERIC,
geocoded_lon NUMERIC,
geom GEOMETRY(POINT, 4326),
-- Метаданные загрузки
source_year INT NOT NULL,
source_url TEXT,
raw_row JSONB,
fetched_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (permit_type, permit_number)
);
CREATE INDEX IF NOT EXISTS gist_ekburg_permits_geom
ON ekburg_construction_permits USING GIST (geom);
CREATE INDEX IF NOT EXISTS idx_ekburg_permits_issue_date
ON ekburg_construction_permits (issue_date);
CREATE INDEX IF NOT EXISTS idx_ekburg_permits_developer
ON ekburg_construction_permits (developer_inn);
CREATE INDEX IF NOT EXISTS idx_ekburg_permits_year
ON ekburg_construction_permits (source_year);
CREATE INDEX IF NOT EXISTS idx_ekburg_permits_type_year
ON ekburg_construction_permits (permit_type, source_year);
COMMENT ON TABLE ekburg_construction_permits IS
'РНС/РВЭ ЕКБ 2022-2026 (Форма 3/4 Росстата). '
'Source: екатеринбург.рф xlsx. Refresh: monthly via tasks/ekburg_permits_sync. '
'Координаты raw_coord_x/y в местной СКФ-66 (CRS TBD). '
'Геокодирование через Nominatim — Phase 3 (отдельный PR).';
COMMENT ON COLUMN ekburg_construction_permits.raw_coord_x IS
'X-координата характерной точки ЗУ (местная система координат СКФ-66 / СНСК-66).';
COMMENT ON COLUMN ekburg_construction_permits.raw_coord_y IS
'Y-координата характерной точки ЗУ (местная система координат СКФ-66 / СНСК-66).';
COMMENT ON COLUMN ekburg_construction_permits.living_area_fact_sqm IS
'Заполняется только для РВЭ (Таблица 4, колонка 15).';
COMMIT;