Some checks failed
Co-authored-by: lekss361 <lekss361@gendsgn.local> Co-committed-by: lekss361 <lekss361@gendsgn.local>
150 lines
6.1 KiB
Python
150 lines
6.1 KiB
Python
"""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()
|