"""Celery task: еженедельный harvest КРТ-геометрий из геопортала ЕКБ (#1130). Слой portal_geo_oks_krt («Здания в границах КРТ», ~48 WFS-фич) — UPSERT в ekb_krt_geometry. После harvest analyze._krt_at читает из БД (ST_Intersects) вместо живого WFS-вызова. UPSERT ON CONFLICT (feature_id): повторный запуск идемпотентен — обновляет геометрию/атрибуты. SAVEPOINT per-row: одна упавшая фича не откатывает всю транзакцию. """ from __future__ import annotations import json import logging from sqlalchemy import text 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__) # Bbox ЕКБ-агломерации (EPSG:4326): minlon, minlat, maxlon, maxlat. # Охватывает весь муниципальный округ Екатеринбург с запасом; все ~48 КРТ-фич попадают. _EKB_BBOX: tuple[float, float, float, float] = (60.3, 56.6, 61.1, 57.1) _UPSERT_SQL = text(""" INSERT INTO ekb_krt_geometry ( feature_id, num_oks_krt, full_name, status, geom, area, raw_props, fetched_at ) VALUES ( :feature_id, CAST(:num_oks_krt AS integer), :full_name, :status, ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geom_json AS text)), 4326), CAST(:area AS numeric), CAST(:raw_props AS jsonb), now() ) ON CONFLICT (feature_id) DO UPDATE SET num_oks_krt = EXCLUDED.num_oks_krt, full_name = EXCLUDED.full_name, status = EXCLUDED.status, geom = EXCLUDED.geom, area = EXCLUDED.area, raw_props = EXCLUDED.raw_props, fetched_at = now() """) @celery_app.task(name="tasks.krt_geometry_sync.sync_krt_geometry") def sync_krt_geometry() -> dict[str, int]: """Fetch + UPSERT геометрий КРТ-слоя геопортала ЕКБ → ekb_krt_geometry. Один bbox-запрос по всей агломерации ЕКБ (~48 фич). ON CONFLICT idempotent. Returns: {"krt": N} — число обработанных фич. """ logger.info("krt_geometry_sync: start (bbox=%s)", _EKB_BBOX) client = EKBGeoportalClient() try: features = client.features_in_bbox("krt", _EKB_BBOX) except Exception as exc: logger.error("krt_geometry_sync: WFS fetch failed: %s", exc) raise if not features: logger.warning( "krt_geometry_sync: WFS returned 0 features — слой пуст или геопортал недоступен" ) return {"krt": 0} logger.info("krt_geometry_sync: fetched %d features from WFS", len(features)) upserted = 0 failed = 0 db = SessionLocal() try: for feat in features: if feat.geometry is None: logger.warning( "krt_geometry_sync: feature_id=%s has no geometry — пропускаем", feat.feature_id ) failed += 1 continue props = feat.properties or {} params = { "feature_id": feat.feature_id, "num_oks_krt": props.get("num_oks_krt"), "full_name": props.get("full_name"), "status": props.get("status"), "geom_json": json.dumps(feat.geometry, ensure_ascii=False), "area": props.get("area"), "raw_props": json.dumps(props, ensure_ascii=False), } try: with db.begin_nested(): db.execute(_UPSERT_SQL, params) upserted += 1 except Exception as exc: failed += 1 logger.warning( "krt_geometry_sync: UPSERT failed for feature_id=%s: %s", feat.feature_id, exc, ) db.commit() except Exception: db.rollback() raise finally: db.close() logger.info( "krt_geometry_sync: done — upserted=%d failed=%d", upserted, failed, ) return {"krt": upserted}