"""Celery task: прогрев кэша ПЗЗ-регламента зон ЕКБ → ``zone_regulation_cache`` (#1059 / C8b). Регламент ПЗЗ идентичен для всех участков одной терзоны (~100 зон на ЕКБ). C8b (#1099) умеет резолвить регламент по точке (`zone_regulation_at` → urbanCard) и кэшировать по `zone_index`, но кэш наполняется лениво — первый запрос участка в незакэшированной зоне платит за live-urbanCard. Эта задача наполняет кэш ПРОАКТИВНО: WFS-перечисление всех терзон ЕКБ → по одной репрезентативной точке на уникальный `urban_index` → `zone_regulation_at` → `upsert_zone_regulation`. После прогона analyze отдаёт `ird.zone_regulation` из кэша (быстро), без live-геопортала в hot-пути. Дедуп по `urban_index`: регламент одинаков для всех полигонов зоны → резолвим индекс один раз. Репрезентативная точка (`shapely.representative_point`) гарантированно внутри полигона (в отличие от centroid для вогнутых) → searchByGeom находит терзону. Mirror conventions (planning_harvest.py / backend.md): SessionLocal()+try/finally; per-zone try/except (сбой одной зоны не валит прогон); commit в конце. Расписание — ежемесячно (регламент меняется только при правке ПЗЗ). """ from __future__ import annotations import logging from typing import Any from shapely.geometry import shape from app.core.db import SessionLocal from app.services.scrapers.ekb_geoportal_client import EKBGeoportalClient from app.services.site_finder.zone_regulation import upsert_zone_regulation from app.workers.celery_app import celery_app logger = logging.getLogger(__name__) # bbox агломерации ЕКБ (minlon, minlat, maxlon, maxlat) EPSG:4326 — как в planning_harvest. EKB_AGGLOMERATION_BBOX: tuple[float, float, float, float] = (60.0, 56.6, 61.1, 57.1) def _representative_point(geometry: dict[str, Any] | None) -> tuple[float, float] | None: """Точка (lon, lat) гарантированно внутри полигона зоны. None при невалидной геометрии.""" if not geometry: return None try: pt = shape(geometry).representative_point() return (float(pt.x), float(pt.y)) except Exception as exc: # битая геометрия фичи — пропускаем зону logger.warning("zone_regulation_refresh: bad geometry, skip: %s", exc) return None @celery_app.task(name="tasks.zone_regulation_refresh.refresh_zone_regulations") def refresh_zone_regulations( bbox: tuple[float, float, float, float] | None = None, ) -> dict[str, int]: """Прогрев кэша регламента по терзонам ЕКБ. Args: bbox: (minlon, minlat, maxlon, maxlat) EPSG:4326. None → bbox агломерации ЕКБ. Returns: {'zones': N, 'cached': M} — уникальных индексов зон / успешно закэшировано регламентов. """ target_bbox = bbox if bbox is not None else EKB_AGGLOMERATION_BBOX client = EKBGeoportalClient() db = SessionLocal() seen: set[str] = set() n_cached = 0 try: feats = client.features_in_bbox("territorial_zone", target_bbox) logger.info("zone_regulation_refresh: терзон в bbox=%d", len(feats)) for feat in feats: idx = feat.properties.get("urban_index") if not idx or str(idx) in seen: continue seen.add(str(idx)) point = _representative_point(feat.geometry) if point is None: continue lon, lat = point try: reg = client.zone_regulation_at(lon, lat) except Exception as exc: logger.warning("zone_regulation_refresh: resolve failed idx=%s: %s", idx, exc) continue if reg is None or not reg.zone_index: continue if upsert_zone_regulation(db, reg) is not None: n_cached += 1 db.commit() logger.info("zone_regulation_refresh: зон=%d закэшировано=%d", len(seen), n_cached) return {"zones": len(seen), "cached": n_cached} finally: db.close()