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.noise_sync",
|
||||||
"app.workers.tasks.pzz_sync",
|
"app.workers.tasks.pzz_sync",
|
||||||
"app.workers.tasks.scrape_cadastre",
|
"app.workers.tasks.scrape_cadastre",
|
||||||
|
"app.workers.tasks.ekburg_permits_sync",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
celery_app.conf.timezone = "Europe/Moscow"
|
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