"""Celery task: sync функциональных зон генплана ЕКБ-2045 из ГИСОГД-СО WFS (#1058). Загружает ~6436 полигонов функциональных зон из WFS ГИСОГД-СО и UPSERT-ит их в ``ekb_genplan_functional_zone`` (м.139) по ключу ``globalid`` (UUID объекта). Зеркалит конвенции ird_harvest.py / supply_layers_refresh.py: • SessionLocal() + try/finally close; logger (не print). • SAVEPOINT per-row (``with db.begin_nested():``) — одна битая фича не валит батч. • CAST(:x AS type) — НИКОГДА :x::type (psycopg v3). • Геометрия: ST_MakeValid(ST_SetSRID(ST_GeomFromGeoJSON, 4326)) — без transform (уже 4326); ST_MakeValid исправляет self-intersecting/ring-order дефекты геопортала (#1156). • ON CONFLICT (globalid) DO UPDATE: обновляет все поля кроме id/fetched_at (latest wins). • commit один раз в конце. Beat: «genplan-zones-sync-weekly» (понедельник 06:30 МСК) — после supply-layers-refresh. Admin trigger: при необходимости ручного запуска можно сделать POST-эндпоинт. """ from __future__ import annotations import json import logging 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.gisogd_so_client import fetch_functional_zones from app.workers.celery_app import celery_app logger = logging.getLogger(__name__) # UPSERT одной фичи. geom = ST_MakeValid(ST_SetSRID(ST_GeomFromGeoJSON, 4326)) — # ST_MakeValid оборачивает результат чтобы исправить self-intersecting/ring-order # дефекты из источника геопортала (881/6436 полигонов имели ST_IsValid=false, #1156). # ON CONFLICT обновляет все аналитические поля; fetched_at = excluded (latest wins). # CAST(:x AS type) — psycopg v3 (НИКОГДА :x::type). _UPSERT_SQL = text( """ INSERT INTO ekb_genplan_functional_zone ( globalid, zone_type, zone_class_code, status, area_ha, doc_ref, geom, raw_props, fetched_at ) VALUES ( CAST(:globalid AS text), CAST(:zone_type AS text), CAST(:zone_class_code AS text), CAST(:status AS text), CAST(:area_ha AS numeric), CAST(:doc_ref AS text), ST_MakeValid(ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geojson AS text)), 4326)), CAST(:raw_props AS jsonb), CAST(:fetched_at AS timestamptz) ) ON CONFLICT (globalid) DO UPDATE SET zone_type = EXCLUDED.zone_type, zone_class_code = EXCLUDED.zone_class_code, status = EXCLUDED.status, area_ha = EXCLUDED.area_ha, doc_ref = EXCLUDED.doc_ref, geom = EXCLUDED.geom, raw_props = EXCLUDED.raw_props, fetched_at = EXCLUDED.fetched_at """ ) def _extract_params(feature: dict[str, Any], fetched_at: str) -> dict[str, Any] | None: """Извлекает параметры UPSERT из GeoJSON-фичи. Возвращает None если фича не пригодна для записи (нет geometry или globalid). """ geom = feature.get("geometry") props: dict[str, Any] = feature.get("properties") or {} globalid = props.get("urban10_globalid") or props.get("globalid") if not geom or not globalid: return None # zone_type: приоритет full_name, fallback urban10_classid_v. zone_type = props.get("full_name") or props.get("urban10_classid_v") zone_class_code_raw = props.get("urban10_classid") zone_class_code = str(zone_class_code_raw) if zone_class_code_raw is not None else None area_raw = props.get("urban10_area") try: area_ha = float(area_raw) if area_raw is not None else None except (TypeError, ValueError): area_ha = None return { "globalid": str(globalid), "zone_type": str(zone_type) if zone_type is not None else None, "zone_class_code": zone_class_code, "status": props.get("urban10_status_v"), "area_ha": area_ha, "doc_ref": props.get("info_set_key_ref"), "geojson": json.dumps(geom), "raw_props": json.dumps(props, ensure_ascii=False), "fetched_at": fetched_at, } def _upsert_feature(db: Session, params: dict[str, Any]) -> bool: """UPSERT одной фичи под SAVEPOINT. True если записана, False при ошибке.""" try: with db.begin_nested(): db.execute(_UPSERT_SQL, params) return True except Exception as exc: logger.warning( "genplan_zones_sync: upsert failed globalid=%s: %s", params.get("globalid"), exc, ) return False @celery_app.task(name="tasks.genplan_zones_sync.sync_functional_zones") def sync_functional_zones() -> dict[str, int]: """Загружает функц.зоны из ГИСОГД-СО WFS → UPSERT ekb_genplan_functional_zone. Returns: ``{"zones": N}`` — количество успешно записанных зон. """ fetched_at = datetime.now(UTC).isoformat() db = SessionLocal() n_zones = 0 n_skip = 0 try: logger.info("genplan_zones_sync: старт загрузки функциональных зон ГИСОГД-СО") features = fetch_functional_zones() logger.info("genplan_zones_sync: получено фич=%d", len(features)) for feat in features: params = _extract_params(feat, fetched_at) if params is None: n_skip += 1 continue if _upsert_feature(db, params): n_zones += 1 else: n_skip += 1 db.commit() logger.info("genplan_zones_sync: готово, zones=%d skip=%d", n_zones, n_skip) return {"zones": n_zones} finally: db.close()