feat(scrapers): реестр РНС/РВЭ ГИСОГД-СО из открытого API — миграция + лоадер (#2367 PR-1) #2370

Merged
bot-backend merged 2 commits from feat/gisogd66-permits-loader into main 2026-07-04 03:26:08 +00:00
8 changed files with 1089 additions and 0 deletions

View file

@ -497,6 +497,21 @@ def trigger_poi_sync() -> dict[str, Any]:
return {"task_id": result.id, "queued_at": "now"}
@router.post("/gisogd-permits-sync")
def trigger_gisogd_permits_sync() -> dict[str, Any]:
"""Manual trigger инкрементальной загрузки РНС/РВЭ ГИСОГД-СО → gisogd_permits (#2367).
Обычно запускается еженедельно через beat (вторник 06:30 МСК). Этот endpoint
для ad-hoc запуска (например после деплоя миграции 187_gisogd_permits.sql или для
внеочередного обновления). Инкрементально: карточки тянутся только для новых/
изменившихся документов, повторный запуск дёшев.
"""
from app.workers.tasks.gisogd_permits_sync import sync_gisogd_permits
result = sync_gisogd_permits.apply_async()
return {"task_id": result.id, "queued_at": "now"}
class TriggerObjectiveEtlRequest(BaseModel):
sqlite_path: str | None = Field(
default=None,
@ -1420,6 +1435,20 @@ _FRESHNESS_SOURCES: list[FreshnessSource] = [
fresh_days=14.0,
stale_days=45.0,
),
FreshnessSource(
# #2367: реестр РНС/РВЭ ГИСОГД-СО. Data-table режим (плоская таблица без run-
# ledger): свежесть = MAX(fetched_at), upd_24h/_7d = COUNT(*) по окну. Источник
# обновляется ежедневно, тянем еженедельно (beat вторник) → fresh<14d, stale<45d
# (широкий запас на пропуск одного-двух вторников, как cadastre). critical=False.
source="gisogd_permits",
label="ГИСОГД-СО РНС/РВЭ (реестр разрешений)",
table="gisogd_permits",
timestamp_col="fetched_at",
# В timestamp-режиме work_col не используется — валидное имя колонки.
work_col="id",
fresh_days=14.0,
stale_days=45.0,
),
]

View file

@ -0,0 +1,440 @@
"""Загрузчик реестра РНС/РВЭ Свердловской области из открытого API ГИСОГД-СО (#2367).
Источник: ``gisogd66.midural.ru`` региональная ГИС органов гос. власти Свердловской
области. Открытый JSON-API раздела 13 («Сведения о документах в деле о земельном
участке»), БЕЗ авторизации (bare curl 200, разведка 2026-07-04).
Три эндпоинта (все GET JSON, base ``https://gisogd66.midural.ru/api/v1/{schema}``):
* ``/gisogddocgroups/razdel13`` группы документов раздела (DocRS/DocRV/);
* ``/gisogddocgroupdocs/razdel13/{group}`` весь список группы одним ответом (без
пагинации): key, name, docNum, dateDoc, numReg, dateReg;
* ``/gisogddocs/razdel13/{key}`` карточка документа: spatialUnit (кад.,
может быть несколько через запятую / отсутствовать), infosetGeoJson (GeoJSON Polygon
WGS84 lon/lat, может отсутствовать hasGeometry=false), approvedOrganization, docNum,
dateDoc, dateReg.
Две схемы (тянем обе): ``agate_ekbgo`` (ЕКБ, свежая) и ``agate_sverdregion`` (регион-
агрегат, отстаёт ~неделю). Один документ может встретиться в обеих бизнес-ключ
``(doc_group, doc_num)``; при конфликте предпочитаем запись с более поздним ``date_reg``.
Инкрементальность: список группы сверяется с БД по (source_schema, source_key, date_reg);
карточки (второй запрос дорогой) тянутся ТОЛЬКО для новых / изменившихся документов.
Politeness: единый httpx-клиент с limits(max_connections=5), timeout=20с, 2 ретрая на 5xx,
малый sleep между карточками. UPSERT per-row SAVEPOINT (битая строка не валит батч).
"""
from __future__ import annotations
import json
import logging
import re
import time
from datetime import date, datetime
from typing import Any
import httpx
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
logger = logging.getLogger(__name__)
# ── Константы источника ───────────────────────────────────────────────────────
GISOGD_BASE = "https://gisogd66.midural.ru/api/v1"
_SECTION = "razdel13"
# Схемы источника — обе тянем (ekbgo свежее, sverdregion — регион-агрегат).
SCHEMAS: tuple[str, ...] = ("agate_ekbgo", "agate_sverdregion")
# Группа источника (doc group key) → наш doc_group-код в БД (CHECK IN ('RS','RV')).
GROUP_CODE: dict[str, str] = {"DocRS": "RS", "DocRV": "RV"}
_HTTP_TIMEOUT = 20.0
_MAX_CONNECTIONS = 5
_MAX_RETRIES = 2 # ретраи на 5xx (helper делает суммарно до 1 + _MAX_RETRIES попыток)
_RETRY_BACKOFF_S = 1.5
_CARD_SLEEP_S = 0.05 # вежливая пауза между карточками (второй, «дорогой» запрос)
USER_AGENT = "GenDesign/1.0 (+https://gendsgn.ru) Site Finder gisogd permits loader"
# Валидация кадастрового номера: NN:NN:NNNNNN(N):N (6-7 знаков в третьем блоке).
_CAD_RE = re.compile(r"^\d{2}:\d{2}:\d{6,7}:\d+$")
# ── HTTP helpers (module-level — мокаются в тестах) ───────────────────────────
def _make_client() -> httpx.Client:
"""httpx.Client с пулом (max_connections=5), таймаутом и UA. Закрывать вызывающим."""
return httpx.Client(
base_url=GISOGD_BASE,
timeout=_HTTP_TIMEOUT,
follow_redirects=True,
headers={"User-Agent": USER_AGENT},
limits=httpx.Limits(max_connections=_MAX_CONNECTIONS),
)
def _get_json(client: httpx.Client, path: str) -> Any:
"""GET path → JSON с ретраями на 5xx / сетевых ошибках.
До (1 + _MAX_RETRIES) попыток с линейным backoff. 4xx сразу raise (не ретраим).
"""
last_exc: Exception | None = None
for attempt in range(_MAX_RETRIES + 1):
try:
resp = client.get(path)
if resp.status_code >= 500:
# 5xx — транзиентная ошибка сервера, ретраим.
last_exc = httpx.HTTPStatusError(
f"{resp.status_code} on {path}", request=resp.request, response=resp
)
logger.warning(
"gisogd66: %s (попытка %d) на %s", resp.status_code, attempt + 1, path
)
time.sleep(_RETRY_BACKOFF_S * (attempt + 1))
continue
resp.raise_for_status()
return resp.json()
except (httpx.TimeoutException, httpx.TransportError) as exc:
last_exc = exc
logger.warning(
"gisogd66: сетевая ошибка (попытка %d) на %s: %s", attempt + 1, path, exc
)
time.sleep(_RETRY_BACKOFF_S * (attempt + 1))
assert last_exc is not None
raise last_exc
def fetch_group_list(client: httpx.Client, schema: str, group: str) -> list[dict[str, Any]]:
"""Весь список документов группы (без пагинации) → list of dict.
Поля элемента: key, name, docNum, dateDoc, numReg, dateReg. Пустой список при
неожиданном (не-list) ответе.
"""
data = _get_json(client, f"/{schema}/gisogddocgroupdocs/{_SECTION}/{group}")
if not isinstance(data, list):
logger.warning("gisogd66: %s/%s список не-list (%s)", schema, group, type(data).__name__)
return []
logger.info("gisogd66: %s/%s список документов=%d", schema, group, len(data))
return data
def fetch_doc_card(client: httpx.Client, schema: str, key: str | int) -> dict[str, Any] | None:
"""Карточка документа → dict (или None при не-dict / ошибке карточки).
Поля: spatialUnit, infosetGeoJson, hasGeometry, approvedOrganization, docNum,
dateDoc, dateReg, name.
"""
data = _get_json(client, f"/{schema}/gisogddocs/{_SECTION}/{key}")
if not isinstance(data, dict):
logger.warning("gisogd66: карточка %s/%s не-dict (%s)", schema, key, type(data).__name__)
return None
return data
# ── Парсинг полей ─────────────────────────────────────────────────────────────
def _parse_date(value: Any) -> date | None:
"""ISO-datetime / date-строка источника → date. None если пусто / не парсится.
Источник отдаёт «2026-06-02T00:00:00» (dateDoc) и «2026-06-19T15:55:11» (dateReg).
"""
if not value:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
s = str(value).strip()
if not s:
return None
# Обрезаем таймзону-суффикс 'Z' если вдруг придёт, парсим ISO.
try:
return datetime.fromisoformat(s.replace("Z", "+00:00")).date()
except ValueError:
pass
for fmt in ("%Y-%m-%d", "%d.%m.%Y"):
try:
return datetime.strptime(s[:10], fmt).date()
except ValueError:
continue
logger.debug("gisogd66: не распарсил дату %r", value)
return None
def parse_cad_nums(spatial_unit: Any) -> list[str]:
"""spatialUnit → список валидных кадастровых номеров (может быть несколько через запятую).
Split по запятой, strip, валидация по _CAD_RE. Невалидные (мусор) отбрасываются с
warning-логом. Отсутствие / пусто []. Дубликаты схлопываются (сохраняя порядок).
"""
if not spatial_unit:
return []
out: list[str] = []
seen: set[str] = set()
for part in str(spatial_unit).split(","):
cad = part.strip()
if not cad:
continue
if not _CAD_RE.match(cad):
logger.warning(
"gisogd66: невалидный кадастровый номер %r в spatialUnit — отброшен", cad
)
continue
if cad not in seen:
seen.add(cad)
out.append(cad)
return out
def extract_geojson(card: dict[str, Any]) -> str | None:
"""infosetGeoJson карточки → GeoJSON-строка (для ST_GeomFromGeoJSON) или None.
Источник кладёт GeoJSON КАК СТРОКУ в поле infosetGeoJson. Пусто / hasGeometry=false
/ невалидный JSON None (лоадер запишет geom=NULL). Приведение PolygonMultiPolygon
делается в SQL через ST_Multi здесь возвращаем сырой GeoJSON как есть.
"""
raw = card.get("infosetGeoJson")
if not raw:
return None
if isinstance(raw, dict):
# На случай если источник когда-нибудь отдаст объект, а не строку.
return json.dumps(raw)
s = str(raw).strip()
if not s:
return None
try:
json.loads(s) # sanity-парс: битую строку не отправляем в PostGIS
except (ValueError, TypeError) as exc:
logger.warning("gisogd66: битый infosetGeoJson (%s) — geom=NULL", exc)
return None
return s
# ── Инкрементальность ─────────────────────────────────────────────────────────
def _existing_reg_dates(db: Session, schema: str) -> dict[str, date | None]:
"""{source_key: date_reg} для уже загруженных из этой схемы записей.
Используется для инкрементальной сверки: карточку тянем только если key новый ИЛИ
date_reg в списке отличается от сохранённого.
"""
rows = db.execute(
text("SELECT source_key, date_reg FROM gisogd_permits WHERE source_schema = :schema"),
{"schema": schema},
).all()
return {str(r[0]): r[1] for r in rows}
# ── UPSERT ────────────────────────────────────────────────────────────────────
def _upsert_permit(db: Session, rec: dict[str, Any]) -> str:
"""UPSERT одной записи в gisogd_permits по (doc_group, doc_num). Per-row SAVEPOINT.
Конфликт-резолв: при коллизии бизнес-ключа обновляем ТОЛЬКО если у новой записи
date_reg НЕ старше сохранённой (EXCLUDED.date_reg >= existing) предпочитаем
более позднюю регистрацию (или запись без даты не затирает датированную).
Returns: 'inserted' | 'updated' | 'skipped_unchanged'.
"""
result = db.execute(
text("""
INSERT INTO gisogd_permits
(doc_group, doc_num, doc_name, date_doc, date_reg,
approved_organization, source_schema, source_key,
cad_nums, geom, fetched_at, updated_at)
VALUES (
:doc_group, :doc_num, :doc_name, :date_doc, :date_reg,
:approved_org, :source_schema, :source_key,
CAST(:cad_nums AS text[]),
CASE WHEN :geojson IS NULL THEN NULL
ELSE ST_Multi(ST_SetSRID(ST_GeomFromGeoJSON(:geojson), 4326)) END,
NOW(), NOW()
)
ON CONFLICT (doc_group, doc_num) DO UPDATE
SET doc_name = EXCLUDED.doc_name,
date_doc = EXCLUDED.date_doc,
date_reg = EXCLUDED.date_reg,
approved_organization = EXCLUDED.approved_organization,
source_schema = EXCLUDED.source_schema,
source_key = EXCLUDED.source_key,
cad_nums = EXCLUDED.cad_nums,
geom = EXCLUDED.geom,
updated_at = NOW()
WHERE EXCLUDED.date_reg IS NOT DISTINCT FROM gisogd_permits.date_reg
OR EXCLUDED.date_reg > gisogd_permits.date_reg
OR gisogd_permits.date_reg IS NULL
RETURNING (xmax = 0) AS is_insert
"""),
{
"doc_group": rec["doc_group"],
"doc_num": rec["doc_num"],
"doc_name": rec["doc_name"],
"date_doc": rec["date_doc"],
"date_reg": rec["date_reg"],
"approved_org": rec["approved_organization"],
"source_schema": rec["source_schema"],
"source_key": rec["source_key"],
"cad_nums": rec["cad_nums"],
"geojson": rec["geojson"],
},
).scalar()
if result is None:
# WHERE-гейт не пропустил апдейт (у нас более старый date_reg) → без изменений.
return "skipped_unchanged"
return "inserted" if result else "updated"
# ── Оркестрация ───────────────────────────────────────────────────────────────
def load_gisogd_permits(db: Session | None = None) -> dict[str, int]:
"""Инкрементально загрузить РНС/РВЭ ГИСОГД-СО (обе схемы, обе группы) в gisogd_permits.
Для каждой (schema, group): список сверка с БД по (source_key, date_reg)
карточки только для новых/изменённых UPSERT по (doc_group, doc_num) с
предпочтением позднего date_reg. ``db`` для совместимости сигнатуры; None своя
SessionLocal.
Returns: счётчики {listed, fetched_details, inserted, updated, skipped_unchanged,
no_cad, no_geom}.
"""
owns_session = db is None
if db is None:
db = SessionLocal()
counts = {
"listed": 0,
"fetched_details": 0,
"inserted": 0,
"updated": 0,
"skipped_unchanged": 0,
"no_cad": 0,
"no_geom": 0,
}
client = _make_client()
try:
for schema in SCHEMAS:
existing = _existing_reg_dates(db, schema)
for group_key, doc_group in GROUP_CODE.items():
try:
items = fetch_group_list(client, schema, group_key)
except Exception as exc:
logger.exception("gisogd66: список %s/%s упал: %s", schema, group_key, exc)
continue
counts["listed"] += len(items)
_sync_group(db, client, schema, doc_group, items, existing, counts)
db.commit()
except Exception as exc:
db.rollback()
logger.exception("gisogd66: outer tx rolled back: %s", exc)
raise
finally:
client.close()
if owns_session:
db.close()
logger.info("load_gisogd_permits done: %s", counts)
return counts
def _sync_group(
db: Session,
client: httpx.Client,
schema: str,
doc_group: str,
items: list[dict[str, Any]],
existing: dict[str, date | None],
counts: dict[str, int],
) -> None:
"""Обработать одну (schema, group): инкрементальная сверка + карточки + UPSERT."""
for item in items:
key = item.get("key")
if key is None:
continue
source_key = str(key)
list_reg = _parse_date(item.get("dateReg"))
# Инкрементальность: пропускаем карточку, если key уже есть И date_reg совпал.
if source_key in existing and existing[source_key] == list_reg:
counts["skipped_unchanged"] += 1
continue
try:
card = fetch_doc_card(client, schema, source_key)
except Exception as exc:
logger.warning("gisogd66: карточка %s/%s упала: %s", schema, source_key, exc)
continue
finally:
time.sleep(_CARD_SLEEP_S)
if card is None:
continue
counts["fetched_details"] += 1
rec = _build_record(item, card, schema, doc_group, source_key, list_reg)
if rec is None:
continue
if not rec["cad_nums"]:
counts["no_cad"] += 1
if rec["geojson"] is None:
counts["no_geom"] += 1
try:
with db.begin_nested(): # SAVEPOINT — откат только этой строки
outcome = _upsert_permit(db, rec)
except Exception as exc:
logger.warning("gisogd66: upsert %s %r упал: %s", doc_group, rec["doc_num"], exc)
continue
counts[outcome] += 1
def _build_record(
item: dict[str, Any],
card: dict[str, Any],
schema: str,
doc_group: str,
source_key: str,
list_reg: date | None,
) -> dict[str, Any] | None:
"""Список-элемент + карточка → нормализованная запись для UPSERT. None если нет doc_num.
doc_num берём из карточки, fallback на список; strip обязателен (в списке ~25%
номеров с ведущими/хвостовыми пробелами). Даты предпочитаем из карточки, fallback
на список. cad_nums из spatialUnit карточки, geojson из infosetGeoJson.
"""
raw_num = card.get("docNum") or item.get("docNum")
doc_num = str(raw_num).strip() if raw_num else ""
if not doc_num:
logger.warning("gisogd66: карточка %s/%s без docNum — пропуск", schema, source_key)
return None
return {
"doc_group": doc_group,
"doc_num": doc_num,
"doc_name": card.get("name") or item.get("name"),
"date_doc": _parse_date(card.get("dateDoc")) or _parse_date(item.get("dateDoc")),
"date_reg": _parse_date(card.get("dateReg")) or list_reg,
"approved_organization": card.get("approvedOrganization"),
"source_schema": schema,
"source_key": source_key,
"cad_nums": parse_cad_nums(card.get("spatialUnit")),
"geojson": extract_geojson(card),
}
__all__ = [
"GROUP_CODE",
"SCHEMAS",
"extract_geojson",
"fetch_doc_card",
"fetch_group_list",
"load_gisogd_permits",
"parse_cad_nums",
]

View file

@ -361,6 +361,18 @@ def build_beat_schedule() -> dict:
"options": {"queue": "celery"},
}
# #2367: реестр РНС/РВЭ Свердловской области из открытого API ГИСОГД-СО
# (gisogd66.midural.ru, раздел 13). Источник обновляется ежедневно, но лоадер
# инкрементален (карточки тянутся только для новых/изменившихся) → еженедельно с
# запасом. Источник БЕЗ гео-блока и БЕЗ авторизации — не привязан к прод-IP. 06:30
# МСК вторник — в том же вторник-кластере, что и capacity-sync (последний — heat в
# 06:00), сразу после него, вне monday-нагрузки. Celery conf.timezone=Europe/Moscow.
schedule["gisogd-permits-weekly"] = {
"task": "tasks.gisogd_permits_sync.sync_gisogd_permits",
"schedule": _parse_cron("30 6 * * tue"), # 06:30 МСК, вторник
"options": {"queue": "celery"},
}
# Developer registry refresh (#1088 «GG-форсайт») — ежемесячно 1-го числа в 05:30 МСК,
# через 30 мин ПОСЛЕ ekburg-permits-monthly (05:00), чтобы permits-сторона реестра
# обновлялась на свежих РНС/РВЭ. fn_refresh_developer_registry() → REFRESH MATERIALIZED

View file

@ -89,6 +89,7 @@ celery_app = Celery(
"app.workers.tasks.mv_sales_tracker_refresh",
"app.workers.tasks.scrape_freshness_check",
"app.workers.tasks.connection_capacity_sync",
"app.workers.tasks.gisogd_permits_sync",
],
)
celery_app.conf.timezone = "Europe/Moscow"

View file

@ -0,0 +1,69 @@
"""Celery task: инкрементальная синхронизация реестра РНС/РВЭ ГИСОГД-СО (#2367).
Источник: открытый JSON-API gisogd66.midural.ru (раздел 13), БЕЗ авторизации и БЕЗ
гео-блока задача может выполняться и локально, и на проде. Держим weekly (обновляется
ежедневно, но лоадер инкрементален и дёшев при отсутствии изменений). Persistent
breadcrumbs в nspd_geo_log (как connection_capacity_sync). Import-light: тяжёлый
загрузчик импортируется лениво внутри таска.
"""
import logging
from sqlalchemy import text
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
def _log_breadcrumb(stage: str, level: str, message: str) -> None:
"""Записать строку в nspd_geo_log (job_id=NULL) — диагностика без SSH."""
try:
from app.core.db import SessionLocal
db = SessionLocal()
try:
db.execute(
text(
"INSERT INTO nspd_geo_log (job_id, level, stage, message) "
"VALUES (NULL, :lvl, :stage, :msg)"
),
{"lvl": level, "stage": stage, "msg": message[:500]},
)
db.commit()
finally:
db.close()
except Exception as e:
logger.warning("gisogd_permits breadcrumb failed: %s", e)
@celery_app.task(
name="tasks.gisogd_permits_sync.sync_gisogd_permits",
queue="celery",
# Холодный прогон ~6k карточек (2 схемы × RS+RV) при politeness-sleep укладывается
# в ~25 мин; щедрый лимит по конвенции full_report/forecast, чтобы beat-ран не убили
# mid-upsert. Инкрементальные прогоны — минуты.
time_limit=3600,
soft_time_limit=3300,
)
def sync_gisogd_permits() -> dict:
"""Инкрементально загрузить РНС/РВЭ ГИСОГД-СО (обе схемы, обе группы) → gisogd_permits.
Список группы сверка с БД по (source_key, date_reg) карточки только для
новых/изменившихся UPSERT по (doc_group, doc_num) с предпочтением позднего date_reg.
Returns: счётчики {listed, fetched_details, inserted, updated, skipped_unchanged,
no_cad, no_geom}.
"""
_log_breadcrumb("gisogd_permits_sync", "info", "task started")
try:
from app.services.scrapers.gisogd66 import load_gisogd_permits
result = load_gisogd_permits()
logger.info("sync_gisogd_permits done: %s", result)
_log_breadcrumb("gisogd_permits_sync", "info", f"done: {result}")
return result
except Exception as e:
logger.exception("sync_gisogd_permits failed: %s", e)
_log_breadcrumb("gisogd_permits_sync", "error", f"{type(e).__name__}: {e}")
raise

View file

@ -0,0 +1,392 @@
"""Unit-тесты лоадера реестра РНС/РВЭ ГИСОГД-СО (#2367 PR-1).
Pure / mock-based без реальной сети и БД. Фикстуры реальные фрагменты ответов
API (list-элемент + карточка), снятые при разведке 2026-07-04. Покрывает:
- _parse_date: ISO-datetime источника date, пусто/мусор None.
- parse_cad_nums: split по запятой, strip, валидация формата, отброс мусора, дедуп.
- extract_geojson: строка-GeoJSON, отсутствие (hasGeometry=false), битый JSON, dict.
- _build_record: docNum strip (список с пробелами), fallback полей, RS/RV-код.
- инкрементальность: известный key с тем же date_reg карточка НЕ тянется (skip).
- _upsert_permit: insert / update / skipped_unchanged (по scalar-заглушке).
- _sync_group: счётчики no_cad/no_geom, Polygon(SQL ST_Multi) не падает без geom/cad.
- конфликт двух схем по (doc_group, doc_num): поздний date_reg параметризуется в UPSERT.
"""
from __future__ import annotations
from contextlib import contextmanager
from datetime import date
from typing import Any
from app.services.scrapers import gisogd66 as g
# ── Реальные фрагменты API (разведка 2026-07-04) ──────────────────────────────
# Элемент списка группы DocRS (/gisogddocgroupdocs/razdel13/DocRS).
_LIST_ITEM = {
"key": 1000210001947595,
"gisogdSectionId": "razdel13",
"name": "Разрешение на строительство № 66-45-08-2026 от 02.06.2026",
"docNum": "66-45-08-2026",
"dateDoc": "2026-06-02T00:00:00",
"numReg": "65740000-13-2026-548",
"dateReg": "2026-06-19T15:55:11",
}
# Карточка (/gisogddocs/razdel13/{key}).
_CARD = {
"spatialUnit": "66:45:0200124:343",
"key": 1000210001947595,
"name": "Разрешение на строительство № 66-45-08-2026 от 02.06.2026",
"docNum": "66-45-08-2026",
"dateDoc": "2026-06-02T00:00:00",
"numReg": "65740000-13-2026-548",
"dateReg": "2026-06-19T15:55:11",
"infosetGeoJson": (
'{"type":"Polygon","coordinates":[[[61.942031258,56.368423297],'
"[61.941099943,56.368422956],[61.941111179,56.367992961],"
"[61.942039129,56.367981137],[61.942031258,56.368423297]]]}"
),
"hasGeometry": True,
"approvedOrganization": (
'Орган местного самоуправления "Комитет по архитектуре и '
'градостроительству Каменск-Уральского городского округа"'
),
"textFiles": [],
}
# ── _parse_date ───────────────────────────────────────────────────────────────
def test_parse_date_iso_datetime() -> None:
assert g._parse_date("2026-06-19T15:55:11") == date(2026, 6, 19)
assert g._parse_date("2026-06-02T00:00:00") == date(2026, 6, 2)
def test_parse_date_empty_and_garbage() -> None:
assert g._parse_date(None) is None
assert g._parse_date("") is None
assert g._parse_date("не дата") is None
# ── parse_cad_nums ────────────────────────────────────────────────────────────
def test_parse_cad_nums_single() -> None:
assert g.parse_cad_nums("66:45:0200124:343") == ["66:45:0200124:343"]
def test_parse_cad_nums_multi_with_whitespace() -> None:
"""Несколько кадастровых номеров через запятую + пробелы вокруг → strip."""
su = " 66:45:0200124:343 , 66:41:0704002:12 "
assert g.parse_cad_nums(su) == ["66:45:0200124:343", "66:41:0704002:12"]
def test_parse_cad_nums_six_and_seven_digit_block() -> None:
"""Третий блок кадастрового квартала может быть 6 ИЛИ 7 знаков — оба валидны.
Реальные данные Свердловской обл. 7 знаков (66:45:0200124:343). Спец. регекс
\\d{6,7}: 8-значный блок (не встречается) отбраковывается как мусор.
"""
assert g.parse_cad_nums("66:45:020012:343") == ["66:45:020012:343"] # 6 знаков
assert g.parse_cad_nums("66:45:0200124:343") == ["66:45:0200124:343"] # 7 знаков
assert g.parse_cad_nums("66:45:02001240:343") == [] # 8 знаков → отброшен
def test_parse_cad_nums_drops_garbage() -> None:
"""Невалидные токены (мусор) отбрасываются, валидные остаются."""
su = "66:45:0200124:343, мусор, 12345, 66:41:0704002:12"
assert g.parse_cad_nums(su) == ["66:45:0200124:343", "66:41:0704002:12"]
def test_parse_cad_nums_dedup_preserves_order() -> None:
su = "66:45:0200124:343, 66:45:0200124:343, 66:41:0704002:12"
assert g.parse_cad_nums(su) == ["66:45:0200124:343", "66:41:0704002:12"]
def test_parse_cad_nums_absent() -> None:
assert g.parse_cad_nums(None) == []
assert g.parse_cad_nums("") == []
assert g.parse_cad_nums(", , ") == []
# ── extract_geojson ───────────────────────────────────────────────────────────
def test_extract_geojson_polygon_string() -> None:
"""infosetGeoJson приходит СТРОКОЙ — возвращаем её как есть (валидный JSON)."""
out = g.extract_geojson(_CARD)
assert out is not None
assert out.startswith('{"type":"Polygon"')
def test_extract_geojson_absent() -> None:
"""hasGeometry=false / нет поля → None (geom=NULL)."""
assert g.extract_geojson({"hasGeometry": False}) is None
assert g.extract_geojson({"infosetGeoJson": None}) is None
assert g.extract_geojson({"infosetGeoJson": ""}) is None
def test_extract_geojson_broken_json() -> None:
"""Битая GeoJSON-строка → None (не отправляем мусор в PostGIS)."""
assert g.extract_geojson({"infosetGeoJson": "{not json"}) is None
def test_extract_geojson_dict_form() -> None:
"""Если источник когда-нибудь отдаст объект (не строку) — сериализуем."""
obj = {"type": "Polygon", "coordinates": [[[60.0, 56.0], [60.1, 56.0], [60.0, 56.1]]]}
out = g.extract_geojson({"infosetGeoJson": obj})
assert out is not None
assert '"Polygon"' in out
# ── _build_record ─────────────────────────────────────────────────────────────
def test_build_record_from_real_fixtures() -> None:
rec = g._build_record(_LIST_ITEM, _CARD, "agate_ekbgo", "RS", "1000210001947595", None)
assert rec is not None
assert rec["doc_group"] == "RS"
assert rec["doc_num"] == "66-45-08-2026"
assert rec["date_doc"] == date(2026, 6, 2)
assert rec["date_reg"] == date(2026, 6, 19)
assert rec["cad_nums"] == ["66:45:0200124:343"]
assert rec["geojson"] is not None
assert rec["source_schema"] == "agate_ekbgo"
assert rec["source_key"] == "1000210001947595"
assert "Каменск-Уральск" in rec["approved_organization"]
def test_build_record_strips_docnum_whitespace() -> None:
"""~25% номеров в списке с ведущими/хвостовыми пробелами → strip обязателен."""
item = {**_LIST_ITEM, "docNum": " 66-RU66333000-832-2016 "}
card = {**_CARD, "docNum": " 66-RU66333000-832-2016 "}
rec = g._build_record(item, card, "agate_sverdregion", "RS", "1", None)
assert rec is not None
assert rec["doc_num"] == "66-RU66333000-832-2016"
def test_build_record_no_docnum_returns_none() -> None:
rec = g._build_record({}, {"spatialUnit": "66:45:0200124:343"}, "agate_ekbgo", "RS", "9", None)
assert rec is None
def test_build_record_no_cad_no_geom() -> None:
"""Карточка без spatialUnit и без geometry — запись строится, не падает."""
card = {"docNum": "66-99-1-2026", "dateReg": "2026-06-01T10:00:00", "hasGeometry": False}
rec = g._build_record({}, card, "agate_ekbgo", "RV", "77", None)
assert rec is not None
assert rec["cad_nums"] == []
assert rec["geojson"] is None
assert rec["doc_group"] == "RV"
def test_build_record_list_datereg_fallback() -> None:
"""dateReg берётся из карточки; если её нет — fallback на list_reg из списка."""
card = {"docNum": "66-1-1-2026", "spatialUnit": "66:45:0200124:343"}
rec = g._build_record({}, card, "agate_ekbgo", "RS", "5", date(2026, 5, 1))
assert rec is not None
assert rec["date_reg"] == date(2026, 5, 1)
# ── Fake DB session (как в test_gas_grs_loader) ───────────────────────────────
class _FakeResult:
def __init__(self, scalar_value: Any = True, rows: list[Any] | None = None) -> None:
self._scalar = scalar_value
self._rows = rows or []
def scalar(self) -> Any:
return self._scalar
def all(self) -> list[Any]:
return self._rows
class _FakeSession:
"""Мини-заглушка Session: execute + begin_nested SAVEPOINT-контекст.
scalar_value управляет исходом _upsert_permit (Trueinsert, Falseupdate,
Noneskipped_unchanged). select_rows отдаётся из .all() для _existing_reg_dates.
"""
def __init__(
self,
scalar_value: Any = True,
select_rows: list[Any] | None = None,
) -> None:
self.calls: list[tuple[str, Any]] = []
self._scalar = scalar_value
self._select_rows = select_rows or []
self.committed = False
@contextmanager
def begin_nested(self): # type: ignore[no-untyped-def]
yield
def execute(self, stmt: Any, params: Any = None) -> _FakeResult:
sql = str(stmt)
self.calls.append((sql, params))
if sql.strip().upper().startswith("SELECT"):
return _FakeResult(self._scalar, self._select_rows)
return _FakeResult(self._scalar)
def commit(self) -> None:
self.committed = True
def rollback(self) -> None:
pass
def close(self) -> None:
pass
# ── _upsert_permit: insert / update / skipped_unchanged ───────────────────────
def _sample_rec(**over: Any) -> dict[str, Any]:
base = g._build_record(_LIST_ITEM, _CARD, "agate_ekbgo", "RS", "1000210001947595", None)
assert base is not None
base.update(over)
return base
def test_upsert_permit_insert() -> None:
db = _FakeSession(scalar_value=True) # xmax=0 → insert
assert g._upsert_permit(db, _sample_rec()) == "inserted" # type: ignore[arg-type]
# cad_nums параметризуется как list (→ CAST(:cad_nums AS text[]) в SQL).
assert db.calls[0][1]["cad_nums"] == ["66:45:0200124:343"]
assert db.calls[0][1]["doc_group"] == "RS"
def test_upsert_permit_update() -> None:
db = _FakeSession(scalar_value=False) # xmax<>0 → update
assert g._upsert_permit(db, _sample_rec()) == "updated" # type: ignore[arg-type]
def test_upsert_permit_skipped_unchanged() -> None:
"""WHERE-гейт не пропустил апдейт (более старый date_reg) → RETURNING NULL → skip."""
db = _FakeSession(scalar_value=None)
assert g._upsert_permit(db, _sample_rec()) == "skipped_unchanged" # type: ignore[arg-type]
def test_upsert_permit_no_geom_passes_null() -> None:
"""geojson=None → в params передаётся None (SQL CASE запишет geom=NULL), не падает."""
db = _FakeSession(scalar_value=True)
rec = _sample_rec(geojson=None, cad_nums=[])
assert g._upsert_permit(db, rec) == "inserted" # type: ignore[arg-type]
assert db.calls[0][1]["geojson"] is None
assert db.calls[0][1]["cad_nums"] == []
# ── Инкрементальность через _sync_group ───────────────────────────────────────
class _RecordingClient:
"""Заглушка httpx-клиента: считает, сколько карточек запрошено (fetch_doc_card)."""
def _patch_card_fetch(monkeypatch: Any, calls: list[Any]) -> None:
def _fake(client: Any, schema: str, key: Any) -> dict[str, Any]:
calls.append(key)
return dict(_CARD)
monkeypatch.setattr(g, "fetch_doc_card", _fake)
def test_sync_group_incremental_skip(monkeypatch: Any) -> None:
"""Известный key с ТЕМ ЖЕ date_reg → карточка НЕ тянется, счётчик skipped_unchanged."""
fetched: list[Any] = []
_patch_card_fetch(monkeypatch, fetched)
db = _FakeSession(scalar_value=True)
existing = {"1000210001947595": date(2026, 6, 19)} # тот же dateReg, что в списке
counts = dict.fromkeys(
("inserted", "updated", "skipped_unchanged", "fetched_details", "no_cad", "no_geom"), 0
)
g._sync_group(db, _RecordingClient(), "agate_ekbgo", "RS", [dict(_LIST_ITEM)], existing, counts) # type: ignore[arg-type]
assert fetched == [] # карточку не тянули
assert counts["skipped_unchanged"] == 1
assert counts["fetched_details"] == 0
def test_sync_group_changed_datereg_fetches(monkeypatch: Any) -> None:
"""Известный key, но date_reg в списке ИЗМЕНИЛСЯ → карточка тянется + upsert."""
fetched: list[Any] = []
_patch_card_fetch(monkeypatch, fetched)
db = _FakeSession(scalar_value=False) # update
existing = {"1000210001947595": date(2026, 6, 10)} # старее списочного 06-19
counts = dict.fromkeys(
("inserted", "updated", "skipped_unchanged", "fetched_details", "no_cad", "no_geom"), 0
)
g._sync_group(db, _RecordingClient(), "agate_ekbgo", "RS", [dict(_LIST_ITEM)], existing, counts) # type: ignore[arg-type]
assert fetched == ["1000210001947595"] # карточку тянули
assert counts["fetched_details"] == 1
assert counts["updated"] == 1
def test_sync_group_new_key_inserts_and_counts_geom(monkeypatch: Any) -> None:
"""Новый key → fetch + insert; карточка с geom+cad → no_cad/no_geom НЕ инкрементятся."""
fetched: list[Any] = []
_patch_card_fetch(monkeypatch, fetched)
db = _FakeSession(scalar_value=True) # insert
counts = dict.fromkeys(
("inserted", "updated", "skipped_unchanged", "fetched_details", "no_cad", "no_geom"), 0
)
g._sync_group(db, _RecordingClient(), "agate_ekbgo", "RS", [dict(_LIST_ITEM)], {}, counts) # type: ignore[arg-type]
assert counts["inserted"] == 1
assert counts["fetched_details"] == 1
assert counts["no_cad"] == 0 # карточка с cad
assert counts["no_geom"] == 0 # карточка с geom
def test_sync_group_no_cad_no_geom_counts(monkeypatch: Any) -> None:
"""Карточка без spatialUnit и без geometry → no_cad и no_geom += 1, upsert идёт."""
def _fake(client: Any, schema: str, key: Any) -> dict[str, Any]:
return {"docNum": "66-99-1-2026", "dateReg": "2026-06-01T10:00:00", "hasGeometry": False}
monkeypatch.setattr(g, "fetch_doc_card", _fake)
db = _FakeSession(scalar_value=True)
item = {**_LIST_ITEM, "key": 42, "dateReg": "2026-06-01T10:00:00"}
counts = dict.fromkeys(
("inserted", "updated", "skipped_unchanged", "fetched_details", "no_cad", "no_geom"), 0
)
g._sync_group(db, _RecordingClient(), "agate_ekbgo", "RV", [item], {}, counts) # type: ignore[arg-type]
assert counts["no_cad"] == 1
assert counts["no_geom"] == 1
assert counts["inserted"] == 1
# ── Конфликт двух схем: поздний date_reg параметризуется в UPSERT ──────────────
def test_conflict_resolution_prefers_later_datereg() -> None:
"""Обе схемы содержат один (doc_group, doc_num); UPSERT-параметры несут date_reg
записи WHERE-гейт в SQL (EXCLUDED.date_reg > existing) выберет позднюю.
Проверяем именно то, что лоадер параметризует date_reg корректно (SQL-гейт покрыт
на уровне БД). ekbgo (позднее 06-19) vs sverdregion (раннее 06-10).
"""
db = _FakeSession(scalar_value=False)
ekb = g._build_record(_LIST_ITEM, _CARD, "agate_ekbgo", "RS", "1", None)
sverd_card = {**_CARD, "dateReg": "2026-06-10T09:00:00"}
sverd = g._build_record(_LIST_ITEM, sverd_card, "agate_sverdregion", "RS", "2", None)
assert ekb is not None and sverd is not None
g._upsert_permit(db, ekb) # type: ignore[arg-type]
g._upsert_permit(db, sverd) # type: ignore[arg-type]
assert db.calls[0][1]["date_reg"] == date(2026, 6, 19) # ekbgo — поздняя
assert db.calls[1][1]["date_reg"] == date(2026, 6, 10) # sverdregion — ранняя
# Оба несут одинаковый бизнес-ключ (doc_group, doc_num).
assert db.calls[0][1]["doc_num"] == db.calls[1][1]["doc_num"] == "66-45-08-2026"
# ── _existing_reg_dates ───────────────────────────────────────────────────────
def test_existing_reg_dates_maps_key_to_datereg() -> None:
rows = [("100", date(2026, 6, 1)), ("200", None)]
db = _FakeSession(select_rows=rows)
out = g._existing_reg_dates(db, "agate_ekbgo") # type: ignore[arg-type]
assert out == {"100": date(2026, 6, 1), "200": None}

View file

@ -0,0 +1,99 @@
-- 187_gisogd_permits.sql
-- Forgejo #2367 PR-1 (Schema) — реестр разрешений на строительство (РНС) и ввод
-- в эксплуатацию (РВЭ) Свердловской области из ОТКРЫТОГО API ГИСОГД-СО.
--
-- Одна таблица gisogd_permits: карточки документов раздела 13 («Сведения о
-- документах в деле о земельном участке») региональной ГИС органов гос. власти
-- Свердловской области (gisogd66.midural.ru). Открытый JSON-API без авторизации.
--
-- Источник (карточки/списки) отдаёт данные без гео-блокировки (bare curl 200), но
-- код лоадера всё равно держим устойчивым к отсутствию полей (spatialUnit / geometry
-- у части документов отсутствуют — hasGeometry=false).
--
-- Две схемы источника:
-- agate_ekbgo — ЕКБ, самая свежая выгрузка;
-- agate_sverdregion — регион-агрегат, отстаёт ~неделю.
-- Один документ может встретиться в ОБЕИХ схемах → бизнес-ключ = (doc_group, doc_num);
-- при конфликте предпочитаем запись с более поздним date_reg (лоадер, PR-2).
--
-- doc_group:
-- 'RS' — Разрешение на строительство (группа источника DocRS);
-- 'RV' — Разрешение на ввод в эксплуатацию (группа источника DocRV).
--
-- cad_nums — распарсенный spatialUnit (может быть НЕСКОЛЬКО кадастровых номеров
-- через запятую, может отсутствовать → пустой массив). geom — из infosetGeoJson
-- (GeoJSON Polygon WGS84 lon/lat, лоадер приводит к MultiPolygon), NULL когда
-- hasGeometry=false. source_schema/source_key фиксируют, откуда прилетела запись
-- (для инкрементальной сверки по (source_schema, source_key, date_reg)).
--
-- Порядок применения: миграция ПЕРЕД деплоем кода (schema-first, sql.md).
-- Таблица НОВАЯ → deploy.yml гоняет всё в одной транзакции; CREATE INDEX
-- CONCURRENTLY здесь не нужен (и запрещён внутри транзакции).
BEGIN;
-- ── gisogd_permits ────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS gisogd_permits (
id BIGSERIAL PRIMARY KEY,
doc_group TEXT NOT NULL, -- 'RS' (РНС) | 'RV' (РВЭ) — см. CHECK
doc_num TEXT NOT NULL, -- № документа, напр. «66-45-08-2026» (strip'нут)
doc_name TEXT, -- name карточки, напр. «Разрешение на строительство № … от …»
date_doc DATE, -- dateDoc — дата выдачи документа
date_reg DATE, -- dateReg — дата регистрации в реестре (для conflict-резолва)
approved_organization TEXT, -- approvedOrganization — выдавший орган
source_schema TEXT NOT NULL, -- 'agate_ekbgo' | 'agate_sverdregion'
source_key TEXT NOT NULL, -- key карточки в схеме источника (для инкрементальной сверки)
cad_nums TEXT[] NOT NULL DEFAULT '{}', -- распарсенный spatialUnit ('{}' если нет)
geom GEOMETRY(MultiPolygon, 4326), -- из infosetGeoJson; NULL если hasGeometry=false
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Только 'RS'/'RV' допустимы (защита от опечаток в лоадере).
CONSTRAINT chk_gisogd_permits_doc_group CHECK (doc_group IN ('RS', 'RV')),
-- Бизнес-ключ: один документ (группа + номер) хранится ОДНОЙ строкой, даже если
-- прилетел из обеих схем. Лоадер UPSERT-ит по нему, предпочитая поздний date_reg.
UNIQUE (doc_group, doc_num)
);
-- Пространственный индекс — §-запросы «РНС/РВЭ рядом с участком».
CREATE INDEX IF NOT EXISTS gist_gisogd_permits_geom
ON gisogd_permits USING GIST (geom);
-- GIN по массиву кадастровых номеров — матч «разрешение на этом участке».
CREATE INDEX IF NOT EXISTS gin_gisogd_permits_cad_nums
ON gisogd_permits USING GIN (cad_nums);
-- Дата документа — фильтры/сортировки по свежести разрешений.
CREATE INDEX IF NOT EXISTS idx_gisogd_permits_date_doc
ON gisogd_permits (date_doc DESC);
COMMENT ON TABLE gisogd_permits IS
'Реестр разрешений на строительство (РНС) и ввод в эксплуатацию (РВЭ) '
'Свердловской области из открытого API ГИСОГД-СО (gisogd66.midural.ru, раздел 13). '
'Бизнес-ключ (doc_group, doc_num); при конфликте двух схем источника '
'(agate_ekbgo / agate_sverdregion) предпочитается поздний date_reg. Refresh: weekly '
'/ on-demand. Forgejo #2367.';
COMMENT ON COLUMN gisogd_permits.doc_group IS
'''RS'' = разрешение на строительство (группа источника DocRS), '
'''RV'' = разрешение на ввод в эксплуатацию (группа источника DocRV).';
COMMENT ON COLUMN gisogd_permits.cad_nums IS
'Распарсенный spatialUnit карточки: список кадастровых номеров (несколько через '
'запятую в источнике). Пустой массив ''{}'' когда spatialUnit отсутствует или все '
'значения не прошли валидацию формата кадастрового номера.';
COMMENT ON COLUMN gisogd_permits.geom IS
'MultiPolygon (WGS84) из infosetGeoJson карточки (Polygon приведён к MultiPolygon '
'лоадером). NULL когда hasGeometry=false / infosetGeoJson отсутствует.';
COMMENT ON COLUMN gisogd_permits.source_schema IS
'Схема источника, из которой прилетела запись: ''agate_ekbgo'' (ЕКБ, свежая) или '
'''agate_sverdregion'' (регион-агрегат, отстаёт ~неделю).';
COMMENT ON COLUMN gisogd_permits.source_key IS
'key карточки в схеме источника. Вместе с (source_schema, date_reg) используется '
'лоадером для инкрементальной сверки: карточки тянутся только для новых/изменённых.';
COMMIT;

View file

@ -1538,6 +1538,31 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/admin/scrape/gisogd-permits-sync": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Trigger Gisogd Permits Sync
* @description Manual trigger инкрементальной загрузки РНС/РВЭ ГИСОГД-СО gisogd_permits (#2367).
*
* Обычно запускается еженедельно через beat (вторник 06:30 МСК). Этот endpoint
* для ad-hoc запуска (например после деплоя миграции 187_gisogd_permits.sql или для
* внеочередного обновления). Инкрементально: карточки тянутся только для новых/
* изменившихся документов, повторный запуск дёшев.
*/
post: operations["trigger_gisogd_permits_sync_api_v1_admin_scrape_gisogd_permits_sync_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/scrape/objective": {
parameters: {
query?: never;
@ -7671,6 +7696,28 @@ export interface operations {
};
};
};
trigger_gisogd_permits_sync_api_v1_admin_scrape_gisogd_permits_sync_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
};
};
trigger_objective_etl_api_v1_admin_scrape_objective_post: {
parameters: {
query?: never;