feat(permits): ЕКБ РНС/РВЭ xlsx parser + schema + ETL (#105 PR A) #211
5 changed files with 928 additions and 0 deletions
346
backend/app/services/scrapers/ekburg_permits.py
Normal file
346
backend/app/services/scrapers/ekburg_permits.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
151
backend/app/workers/tasks/ekburg_permits_sync.py
Normal file
151
backend/app/workers/tasks/ekburg_permits_sync.py
Normal 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
|
||||
339
backend/tests/services/test_ekburg_permits.py
Normal file
339
backend/tests/services/test_ekburg_permits.py
Normal 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)
|
||||
91
data/sql/96_ekburg_construction_permits.sql
Normal file
91
data/sql/96_ekburg_construction_permits.sql
Normal 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;
|
||||
Loading…
Add table
Reference in a new issue