gendesign/backend/app/workers/tasks/genplan_zones_sync.py
lekss361 3921180d07
Some checks failed
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 5s
Deploy / build-worker (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
fix(sf): ST_MakeValid genplan geom + backfill 881 invalid polygons (#1157)
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-06-07 17:59:04 +00:00

150 lines
6.1 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: 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()