"""Celery task: harvest ЗОУИТ ЕКБ-геопортала (gknspecial_zone) → ``ird_overlays`` (м.132). #1067 GG-форсайт: закрывает ИРД-пункты: #1063 — сервитут (Зона публичного сервитута) #1064 — водоохранные зоны (Водоохранная зона / Прибрежная защитная полоса / Зона санитарной охраны / Санитарно-защитная зона) #1065 — приаэродромная территория (Приаэродромная территория — ЕГРН) #1066 — ОКН-зоны/ЗРЗ (Зона охраны объекта культурного / Зона регулирования застройки) Источник: ЕКБ-геопортал, WFS-слой ``portal:portal_geo_gknspecial_zone`` (ключ 'zouit'). Геометрия уже в EPSG:4326 — ST_Transform НЕ нужен. Sentinel source_layer_id'ы (вне диапазона НСПД): 990001–990008 + 990099 (unmatched). Conventions (зеркало ird_harvest.py): • SessionLocal() + try/finally close. • SAVEPOINT per-row (with db.begin_nested():) — одна битая фича не валит батч. • db.commit() per-prefix (durability) — WorkerLost в середине прогона не теряет уже записанные префиксы (#1116). • CAST(:x AS type) — НИКОГДА :x::type (psycopg v3). • ON CONFLICT (source_layer_id, geom_data_id) DO UPDATE. • content_* пишем мы же (геопортал как источник); content НЕ трогается НСПД-harvest'ом. • Кириллица в combined-CQL WFS ломает запрос → фильтр zone_state_v client-side. Расписание: beat_schedule.py → gknspecial-harvest-weekly (пн 05:30 МСК). """ from __future__ import annotations import json import logging import re from datetime import UTC, datetime from typing import Any from sqlalchemy import text from sqlalchemy.orm import Session from app.core.db import SessionLocal from app.services.scrapers.ekb_geoportal_client import EKBGeoportalClient from app.workers.celery_app import celery_app logger = logging.getLogger(__name__) # ── Mapping: full_name-префикс → (layer_kind, sentinel source_layer_id) ───── # Sentinels вне диапазона НСПД (990001+), чтобы не конфликтовать с реальными layer_id. _PREFIX_MAP: list[tuple[str, str, int]] = [ ("Зона публичного сервитута", "servitude", 990001), # #1063 ("Водоохранная зона", "water_protection", 990002), # #1064 ("Прибрежная защитная полоса", "coastal_strip", 990003), # #1064 ("Зона санитарной охраны", "water_sanitary", 990004), # #1064 ("Санитарно-защитная зона", "sanitary_protection", 990005), # #1064 ("Зона охраны объекта культурного", "okn_protection_zone", 990006), # #1066 ("Зона регулирования застройки", "zrz", 990007), # #1066 ("Приаэродромная территория", "airfield", 990008), # #1065 ] # Sentinel для фич, чей full_name не попал ни под один префикс (defensive). _UNMATCHED_LAYER_KIND = "gknspecial_unmatched" _UNMATCHED_SOURCE_LAYER_ID = 990099 # Regex для извлечения reg_numb_border из full_name (формат «66:41-6.4125»). _REG_NUMB_RE = re.compile(r"\d{2}:\d{2}-\d+\.\d+") # UPSERT: геометрия уже EPSG:4326 → ST_SetSRID(ST_GeomFromGeoJSON, 4326), без ST_Transform. # content_* поля обновляются в DO UPDATE (геопортал — их источник). _UPSERT_SQL = text( """ INSERT INTO ird_overlays ( source_layer_id, layer_kind, geom_data_id, reg_numb_border, category, subcategory, geom, raw_props, zone_type_name, content, content_source, content_fetched_at, fetched_at ) VALUES ( CAST(:source_layer_id AS integer), CAST(:layer_kind AS text), CAST(:geom_data_id AS bigint), CAST(:reg_numb_border AS text), CAST(:category AS integer), CAST(:subcategory AS integer), ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geojson AS text)), 4326), CAST(:raw_props AS jsonb), CAST(:zone_type_name AS text), CAST(:content AS jsonb), CAST(:content_source AS text), CAST(:content_fetched_at AS timestamptz), CAST(:fetched_at AS timestamptz) ) ON CONFLICT (source_layer_id, geom_data_id) DO UPDATE SET layer_kind = EXCLUDED.layer_kind, reg_numb_border = EXCLUDED.reg_numb_border, geom = EXCLUDED.geom, raw_props = EXCLUDED.raw_props, zone_type_name = EXCLUDED.zone_type_name, content = EXCLUDED.content, content_source = EXCLUDED.content_source, content_fetched_at = EXCLUDED.content_fetched_at, fetched_at = EXCLUDED.fetched_at """ ) def _classify_full_name(full_name: str) -> tuple[str, int] | None: """Сопоставить full_name с записью _PREFIX_MAP → (layer_kind, source_layer_id) или None.""" for prefix, layer_kind, source_layer_id in _PREFIX_MAP: if full_name.startswith(prefix): return layer_kind, source_layer_id return None def _extract_reg_numb(full_name: str) -> str | None: """Извлечь reg_numb_border из full_name по паттерну \\d{2}:\\d{2}-\\d+\\.\\d+.""" m = _REG_NUMB_RE.search(full_name) return m.group(0) if m else None def _extract_geom_data_id(feature_id: str | None) -> int | None: """Trailing int из WFS feature_id (формат 'portal_geo_gknspecial_zone.NNNN') или None.""" if not feature_id: return None parts = feature_id.split(".") if len(parts) < 2: return None try: return int(parts[-1]) except (ValueError, TypeError): return None def _build_content(props: dict[str, Any]) -> str: """Собрать content-jsonb из свойств фичи (только присутствующие ключи, ensure_ascii=False).""" mapping: dict[str, str] = { "restrictions": "gkn_specialz_contentrestrictions", "protected_object": "gkn_specialz_protectedobject", "authority_decision": "authority_decision", "document": "gkn_document", "registration_date": "registration_date", "area": "area", "zone_state": "zone_state_v", } content: dict[str, Any] = {} for out_key, prop_key in mapping.items(): val = props.get(prop_key) if val is not None: content[out_key] = val return json.dumps(content, ensure_ascii=False) def _upsert_feature( db: Session, *, source_layer_id: int, layer_kind: str, geom_data_id: int, props: dict[str, Any], geom: dict[str, Any], fetched_at: str, ) -> bool: """UPSERT одной фичи в ird_overlays под SAVEPOINT. True если записана.""" full_name: str = str(props.get("full_name") or "") reg_numb = _extract_reg_numb(full_name) content_json = _build_content(props) raw_props_json = json.dumps(props, ensure_ascii=False) try: with db.begin_nested(): db.execute( _UPSERT_SQL, { "source_layer_id": source_layer_id, "layer_kind": layer_kind, "geom_data_id": geom_data_id, "reg_numb_border": reg_numb, "category": None, "subcategory": None, "geojson": json.dumps(geom), "raw_props": raw_props_json, "zone_type_name": full_name or None, "content": content_json, "content_source": "ekb_geoportal", "content_fetched_at": fetched_at, "fetched_at": fetched_at, }, ) return True except Exception as exc: logger.warning( "gknspecial_harvest: upsert failed layer=%s geom_data_id=%s: %s", layer_kind, geom_data_id, exc, ) return False @celery_app.task(name="tasks.gknspecial_harvest.harvest_gknspecial_zones") def harvest_gknspecial_zones() -> dict[str, int]: """Harvest ЗОУИТ-слоя ЕКБ-геопортала (gknspecial_zone) → ird_overlays. Для каждого full_name-префикса запрашивает WFS с CQL ``full_name LIKE 'X%'``, фильтрует архивные записи client-side (zone_state_v != 'Действующий'), UPSERT-ит в ird_overlays под SAVEPOINT per-row. Фичи, чей full_name не попал ни под один известный префикс, пишутся под layer_kind='gknspecial_unmatched' (source_layer_id=990099) — defensive, чтобы новые типы зон не терялись молча. Returns: {'types': N_prefixes_fetched, 'features': M_upserted} """ fetched_at = datetime.now(UTC).isoformat() client = EKBGeoportalClient() db = SessionLocal() n_types = 0 n_features = 0 try: for prefix, expected_layer_kind, _ in _PREFIX_MAP: cql = f"full_name LIKE '{prefix}%'" try: feats = client.features_by_cql("zouit", cql) except Exception as exc: logger.warning( "gknspecial_harvest: WFS failed prefix=%r layer_kind=%s: %s", prefix, expected_layer_kind, exc, ) continue n_types += 1 n_written = 0 for feat in feats: props = feat.properties or {} # Фильтр активности client-side: пропускаем архивные зоны. # Если поля нет вообще — не скипаем (неизвестное = считаем активным). zone_state = props.get("zone_state_v") if zone_state is not None and zone_state != "Действующий": continue geom = feat.geometry if not geom: logger.debug( "gknspecial_harvest: нет геометрии layer=%s feature_id=%s, skip", expected_layer_kind, feat.feature_id, ) continue geom_data_id = _extract_geom_data_id(feat.feature_id) if geom_data_id is None: logger.debug( "gknspecial_harvest: нет geom_data_id feature_id=%s, skip", feat.feature_id, ) continue # Роутинг: классифицируем по full_name (единый источник истины). # Defensive: WFS мог вернуть фичу с неожиданным full_name — пишем в unmatched. full_name: str = str(props.get("full_name") or "") cls = _classify_full_name(full_name) if cls is None: logger.warning( "gknspecial_harvest: unmatched full_name=%r geom_data_id=%s, " "пишем в unmatched", full_name, geom_data_id, ) actual_layer_kind = _UNMATCHED_LAYER_KIND actual_source_layer_id = _UNMATCHED_SOURCE_LAYER_ID else: actual_layer_kind, actual_source_layer_id = cls if _upsert_feature( db, source_layer_id=actual_source_layer_id, layer_kind=actual_layer_kind, geom_data_id=geom_data_id, props=props, geom=geom, fetched_at=fetched_at, ): n_written += 1 n_features += 1 logger.info( "gknspecial_harvest: prefix=%r layer_kind=%s fetched=%d written=%d", prefix, expected_layer_kind, len(feats), n_written, ) # Durable per-prefix commit: WorkerLostError (OOM / рестарт хоста) в середине # прогона не теряет уже записанные префиксы (#1116). Mirror ird_harvest.py. db.commit() db.commit() # финальный — no-op если всё уже коммитнуто per-prefix (подстраховка) logger.info("gknspecial_harvest: готово, types=%d features=%d", n_types, n_features) return {"types": n_types, "features": n_features} finally: db.close()