gendesign/backend/app/workers/tasks/gknspecial_harvest.py
bot-backend 2b439521cd
All checks were successful
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 6s
CI / backend-tests (push) Successful in 6m0s
CI / backend-tests (pull_request) Successful in 5m56s
feat(sf): gknspecial_zone ИРД-harvest → ird_overlays (#1063/#1064/#1065/#1066)
2026-06-07 12:39:47 +03:00

287 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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'ы (вне диапазона НСПД): 990001990008 + 990099 (unmatched).
Conventions (зеркало ird_harvest.py):
• SessionLocal() + try/finally close.
• SAVEPOINT per-row (with db.begin_nested():) — одна битая фича не валит батч.
• 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,
)
db.commit()
logger.info("gknspecial_harvest: готово, types=%d features=%d", n_types, n_features)
return {"types": n_types, "features": n_features}
finally:
db.close()