gendesign/backend/app/workers/tasks/zone_regulation_refresh.py
lekss361 dee7d64ac8
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m23s
Deploy / deploy (push) Successful in 1m10s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m24s
feat(sf): прогрев zone_regulation_cache по терзонам ЕКБ (#1106)
zone_regulation_refresh.py — Celery refresh_zone_regulations: WFS-перечисление терзон ЕКБ → дедуп по urban_index → shapely.representative_point → zone_regulation_at (urbanCard) → upsert в zone_regulation_cache (м.133). Проактивный прогрев кэша → analyze отдаёт ird.zone_regulation из кэша без live-urbanCard в hot-пути (снимает latency #1102). beat monthly (1-е 06:30). Без миграции. Зависит от #1103. 4 теста.

Refs #1059.
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-06-06 21:04:52 +00:00

92 lines
4.6 KiB
Python

"""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()