diff --git a/backend/app/services/scrapers/ekburg_permits.py b/backend/app/services/scrapers/ekburg_permits.py new file mode 100644 index 00000000..6cf3387f --- /dev/null +++ b/backend/app/services/scrapers/ekburg_permits.py @@ -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, + ) diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index 9bf49e43..4afd7107 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -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" diff --git a/backend/app/workers/tasks/ekburg_permits_sync.py b/backend/app/workers/tasks/ekburg_permits_sync.py new file mode 100644 index 00000000..9f9d6fc2 --- /dev/null +++ b/backend/app/workers/tasks/ekburg_permits_sync.py @@ -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 diff --git a/backend/tests/services/test_ekburg_permits.py b/backend/tests/services/test_ekburg_permits.py new file mode 100644 index 00000000..74738d55 --- /dev/null +++ b/backend/tests/services/test_ekburg_permits.py @@ -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) diff --git a/data/sql/96_ekburg_construction_permits.sql b/data/sql/96_ekburg_construction_permits.sql new file mode 100644 index 00000000..15d93512 --- /dev/null +++ b/data/sql/96_ekburg_construction_permits.sql @@ -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;