From 282c92747e80dbe0e578da6d891857abd6713828 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 4 Jul 2026 03:26:07 +0000 Subject: [PATCH] =?UTF-8?q?feat(scrapers):=20=D1=80=D0=B5=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D1=80=20=D0=A0=D0=9D=D0=A1/=D0=A0=D0=92=D0=AD=20=D0=93?= =?UTF-8?q?=D0=98=D0=A1=D0=9E=D0=93=D0=94-=D0=A1=D0=9E=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D1=82=D0=BE=D0=B3=D0=BE=20API=20?= =?UTF-8?q?=E2=80=94=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?+=20=D0=BB=D0=BE=D0=B0=D0=B4=D0=B5=D1=80=20(#2367=20PR-1)=20(#2?= =?UTF-8?q?370)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/admin_scrape.py | 29 ++ backend/app/services/scrapers/gisogd66.py | 440 ++++++++++++++++++ backend/app/workers/beat_schedule.py | 12 + backend/app/workers/celery_app.py | 1 + .../app/workers/tasks/gisogd_permits_sync.py | 69 +++ backend/tests/test_gisogd66_loader.py | 392 ++++++++++++++++ data/sql/187_gisogd_permits.sql | 99 ++++ frontend/src/lib/api-types.ts | 47 ++ 8 files changed, 1089 insertions(+) create mode 100644 backend/app/services/scrapers/gisogd66.py create mode 100644 backend/app/workers/tasks/gisogd_permits_sync.py create mode 100644 backend/tests/test_gisogd66_loader.py create mode 100644 data/sql/187_gisogd_permits.sql diff --git a/backend/app/api/v1/admin_scrape.py b/backend/app/api/v1/admin_scrape.py index d4c4bb9f..d84ce126 100644 --- a/backend/app/api/v1/admin_scrape.py +++ b/backend/app/api/v1/admin_scrape.py @@ -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, + ), ] diff --git a/backend/app/services/scrapers/gisogd66.py b/backend/app/services/scrapers/gisogd66.py new file mode 100644 index 00000000..a62691d8 --- /dev/null +++ b/backend/app/services/scrapers/gisogd66.py @@ -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", +] diff --git a/backend/app/workers/beat_schedule.py b/backend/app/workers/beat_schedule.py index c24b3b62..28dd70ec 100644 --- a/backend/app/workers/beat_schedule.py +++ b/backend/app/workers/beat_schedule.py @@ -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 diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index 8478d154..3bc3a232 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -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" diff --git a/backend/app/workers/tasks/gisogd_permits_sync.py b/backend/app/workers/tasks/gisogd_permits_sync.py new file mode 100644 index 00000000..393441a9 --- /dev/null +++ b/backend/app/workers/tasks/gisogd_permits_sync.py @@ -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 diff --git a/backend/tests/test_gisogd66_loader.py b/backend/tests/test_gisogd66_loader.py new file mode 100644 index 00000000..058eec20 --- /dev/null +++ b/backend/tests/test_gisogd66_loader.py @@ -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} diff --git a/data/sql/187_gisogd_permits.sql b/data/sql/187_gisogd_permits.sql new file mode 100644 index 00000000..a1fb714b --- /dev/null +++ b/data/sql/187_gisogd_permits.sql @@ -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; diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index 780690bb..31fa62f7 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -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;