All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m11s
CI / backend-tests (pull_request) Successful in 15m5s
Второй прод-прогон: 11235 карточек скачано, 0 вставлено — все upsert'ы падали psycopg.errors.AmbiguousParameter ($10): ни одно вхождение :geojson (CASE WHEN + перегруженная ST_GeomFromGeoJSON text/json/jsonb) не давало Postgres тип параметра. Тесты не ловили — БД в них замокана. Refs #2367
501 lines
25 KiB
Python
501 lines
25 KiB
Python
"""Загрузчик реестра РНС/РВЭ Свердловской области из открытого 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.
|
||
|
||
Две схемы (тянем обе, sverdregion первым): ``agate_sverdregion`` (регион-агрегат,
|
||
стабильные карточки, покрывает и ЕКБ) и ``agate_ekbgo`` (ЕКБ, свежее на ~неделю, но
|
||
часть старых ключей битая на портале). Один документ может встретиться в обеих →
|
||
бизнес-ключ ``(doc_group, doc_num)``; при конфликте предпочитаем запись с более
|
||
поздним ``date_reg``.
|
||
|
||
Инкрементальность: список группы сверяется с БД по (source_schema, source_key, date_reg);
|
||
карточки (второй запрос — дорогой) тянутся ТОЛЬКО для новых / изменившихся документов.
|
||
|
||
Politeness: единый httpx-клиент с limits(max_connections=5), timeout=20с, 2 ретрая на
|
||
сетевых ошибках. Карточки (5xx-битые записи портала) НЕ ретраятся — deterministically
|
||
broken, не транзиент. Circuit breaker: 50 карточек подряд с ошибкой → прерываем группу
|
||
(битый сегмент). UPSERT — per-row SAVEPOINT (битая строка не валит батч). Периодический
|
||
db.commit() (каждые 200 записей + после каждой schema/group), чтобы партиал пережил
|
||
soft_time_limit-kill.
|
||
"""
|
||
|
||
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"
|
||
|
||
# Схемы источника — обе тянем. Порядок ВАЖЕН: sverdregion первым.
|
||
# sverdregion (регион-агрегат) отдаёт карточки стабильно и покрывает в т.ч. ЕКБ-доки
|
||
# через агрегацию, поэтому он — базовый источник. ekbgo вторым как freshness-энхансер
|
||
# (свежее на ~неделю), НО у него большой сегмент старых ключей (префикс ~100001007xxx)
|
||
# детерминированно отдаёт 500 на карточках (сломано на стороне портала, 2026-07-04) —
|
||
# гоняем его после того, как регион уже покрыл основную массу (см. circuit breaker ниже).
|
||
SCHEMAS: tuple[str, ...] = ("agate_sverdregion", "agate_ekbgo")
|
||
|
||
# Группа источника (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 # ретраи (helper делает суммарно до 1 + _MAX_RETRIES попыток)
|
||
_RETRY_BACKOFF_S = 1.5
|
||
_CARD_SLEEP_S = 0.05 # вежливая пауза между карточками (второй, «дорогой» запрос)
|
||
_COMMIT_EVERY = 200 # периодический commit внутри группы (партиал переживает time-limit)
|
||
# Circuit breaker: столько карточек ПОДРЯД с ошибкой fetch → прерываем группу (портал
|
||
# отдаёт битый сегмент старых ключей детерминированным 5xx; остаток догоним будущими ранами).
|
||
_CARD_FAIL_BREAKER = 50
|
||
|
||
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, *, retries_5xx: int = _MAX_RETRIES) -> Any:
|
||
"""GET path → JSON с ретраями на 5xx / сетевых ошибках.
|
||
|
||
Сетевые Timeout/TransportError ретраятся всегда (до 1 + _MAX_RETRIES попыток) —
|
||
это транзиент. 5xx ретраятся ``retries_5xx`` раз: списки группы — с дефолтом
|
||
(транзиентная нагрузка портала), карточки — с ``retries_5xx=0`` (5xx карточки
|
||
детерминированно битые записи сегмента портала, не транзиент). 4xx → сразу raise.
|
||
"""
|
||
last_exc: Exception | None = None
|
||
for attempt in range(_MAX_RETRIES + 1):
|
||
try:
|
||
resp = client.get(path)
|
||
if resp.status_code >= 500:
|
||
last_exc = httpx.HTTPStatusError(
|
||
f"{resp.status_code} on {path}", request=resp.request, response=resp
|
||
)
|
||
# 5xx: ретраим только пока attempt в пределах retries_5xx (0 → сразу raise).
|
||
if attempt >= retries_5xx:
|
||
raise last_exc
|
||
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.
|
||
|
||
5xx карточки НЕ ретраятся (retries_5xx=0): битый сегмент старых ключей портала
|
||
отдаёт детерминированный 500 — 3 попытки с backoff = ~12с потерянного времени/карточку.
|
||
"""
|
||
data = _get_json(client, f"/{schema}/gisogddocs/{_SECTION}/{key}", retries_5xx=0)
|
||
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[]),
|
||
-- CAST обязателен: без него ни одно вхождение :geojson не даёт Postgres
|
||
-- тип параметра (ST_GeomFromGeoJSON перегружена text/json/jsonb) →
|
||
-- psycopg.errors.AmbiguousParameter на КАЖДОЙ строке (прод-прогон #2367).
|
||
CASE WHEN CAST(:geojson AS text) IS NULL THEN NULL
|
||
ELSE ST_Multi(ST_SetSRID(
|
||
ST_GeomFromGeoJSON(CAST(:geojson AS text)), 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, card_failed, group_aborted}.
|
||
"""
|
||
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,
|
||
"card_failed": 0, # каждая упавшая карточка (5xx / сеть / не-dict)
|
||
"group_aborted": 0, # групп прервано circuit breaker'ом (битый сегмент портала)
|
||
}
|
||
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)
|
||
# Commit после КАЖДОЙ (schema, group), а не один раз в конце: партиал
|
||
# должен пережить soft_time_limit-kill (иначе теряем весь прогон).
|
||
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.
|
||
|
||
Периодический commit каждые ``_COMMIT_EVERY`` обработанных записей (партиал переживёт
|
||
time-limit kill). Circuit breaker: ``_CARD_FAIL_BREAKER`` карточек ПОДРЯД с ошибкой
|
||
fetch → прерываем группу (битый сегмент портала — остаток догоним будущими ранами).
|
||
Любая успешная карточка сбрасывает счётчик подряд-фейлов.
|
||
"""
|
||
processed = 0 # успешно обработанных (upsert/skip) с последнего commit
|
||
consecutive_fail = 0 # карточек подряд с ошибкой fetch (для circuit breaker)
|
||
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
|
||
consecutive_fail = 0 # skip — не фейл карточки, сбрасываем breaker
|
||
processed += 1
|
||
if processed % _COMMIT_EVERY == 0:
|
||
db.commit()
|
||
continue
|
||
|
||
card: dict[str, Any] | None
|
||
try:
|
||
card = fetch_doc_card(client, schema, source_key)
|
||
except Exception as exc:
|
||
logger.warning("gisogd66: карточка %s/%s упала: %s", schema, source_key, exc)
|
||
card = None
|
||
finally:
|
||
time.sleep(_CARD_SLEEP_S)
|
||
if card is None:
|
||
# Неуспех fetch (5xx / сеть / не-dict): считаем и двигаем circuit breaker.
|
||
counts["card_failed"] += 1
|
||
consecutive_fail += 1
|
||
if consecutive_fail >= _CARD_FAIL_BREAKER:
|
||
logger.error(
|
||
"gisogd66: %s/%s: %d карточек подряд 5xx — прерываю группу "
|
||
"(битый сегмент портала)",
|
||
schema,
|
||
doc_group,
|
||
_CARD_FAIL_BREAKER,
|
||
)
|
||
counts["group_aborted"] += 1
|
||
return
|
||
continue
|
||
consecutive_fail = 0 # успешная карточка сбрасывает breaker
|
||
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
|
||
processed += 1
|
||
if processed % _COMMIT_EVERY == 0:
|
||
db.commit()
|
||
|
||
|
||
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",
|
||
]
|