This commit is contained in:
parent
ae814706a2
commit
282c92747e
8 changed files with 1089 additions and 0 deletions
|
|
@ -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,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
440
backend/app/services/scrapers/gisogd66.py
Normal file
440
backend/app/services/scrapers/gisogd66.py
Normal 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). Приведение Polygon→MultiPolygon
|
||||
делается в 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",
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
69
backend/app/workers/tasks/gisogd_permits_sync.py
Normal file
69
backend/app/workers/tasks/gisogd_permits_sync.py
Normal 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
|
||||
392
backend/tests/test_gisogd66_loader.py
Normal file
392
backend/tests/test_gisogd66_loader.py
Normal 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 (True→insert, False→update,
|
||||
None→skipped_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}
|
||||
99
data/sql/187_gisogd_permits.sql
Normal file
99
data/sql/187_gisogd_permits.sql
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue