feat(sf): прогрев zone_regulation_cache по терзонам ЕКБ (#1059/C8b) #1106

Merged
bot-reviewer merged 1 commit from feat/1059-zone-regulation-refresh into main 2026-06-06 21:04:53 +00:00
4 changed files with 212 additions and 0 deletions

View file

@ -350,4 +350,13 @@ def build_beat_schedule() -> dict:
"options": {"queue": "celery"},
}
# Прогрев кэша ПЗЗ-регламента зон ЕКБ (#1059/C8b): WFS-перечисление терзон → urbanCard →
# zone_regulation_cache. 1-е число месяца 06:30 МСК — после planning (06:00). Регламент
# меняется только при правке ПЗЗ, ежемесячно достаточно. Наполняет кэш C8b проактивно.
schedule["zone-regulation-refresh-monthly"] = {
"task": "tasks.zone_regulation_refresh.refresh_zone_regulations",
"schedule": _parse_cron("30 6 1 * *"),
"options": {"queue": "celery"},
}
return schedule

View file

@ -66,6 +66,7 @@ celery_app = Celery(
"app.workers.tasks.ird_harvest",
"app.workers.tasks.opportunity_harvest",
"app.workers.tasks.planning_harvest",
"app.workers.tasks.zone_regulation_refresh",
],
)
celery_app.conf.timezone = "Europe/Moscow"

View file

@ -0,0 +1,92 @@
"""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()

View file

@ -0,0 +1,110 @@
"""Тесты refresh_zone_regulations (#1059/C8b) — прогрев кэша регламента зон ЕКБ.
Сеть/БД не дёргаются: мокаем EKBGeoportalClient + SessionLocal + upsert_zone_regulation.
Проверяем дедуп по urban_index, представительную точку, skip пустых/битых, счётчики.
"""
from __future__ import annotations
from typing import Any
from app.services.scrapers.ekb_geoportal_client import ZoneRegulation
from app.workers.tasks import zone_regulation_refresh as zrr
class _Feat:
def __init__(self, geometry: dict[str, Any] | None, properties: dict[str, Any]) -> None:
self.geometry = geometry
self.properties = properties
def _poly() -> dict[str, Any]:
return {"type": "Polygon", "coordinates": [[[60, 56], [60.1, 56], [60.1, 56.1], [60, 56]]]}
def _reg(idx: str) -> ZoneRegulation:
return ZoneRegulation(
zone_index=idx,
zone_full_name=f"{idx} зона",
main_vri=[],
conditional_vri=[],
auxiliary_vri=[],
limit_params=[],
)
class _FakeClient:
def __init__(self, feats: list[_Feat]) -> None:
self._feats = feats
self.resolved: list[tuple[float, float]] = []
def features_in_bbox(self, layer: str, bbox: Any) -> list[_Feat]:
return self._feats
def zone_regulation_at(self, lon: float, lat: float) -> ZoneRegulation:
self.resolved.append((lon, lat))
return _reg("Ц-1")
class _DB:
def __init__(self) -> None:
self.committed = False
self.closed = False
def commit(self) -> None:
self.committed = True
def close(self) -> None:
self.closed = True
def _patch(monkeypatch: Any, feats: list[_Feat]) -> tuple[_FakeClient, list[Any]]:
client = _FakeClient(feats)
upserts: list[Any] = []
monkeypatch.setattr(zrr, "EKBGeoportalClient", lambda *a, **kw: client)
monkeypatch.setattr(zrr, "SessionLocal", lambda: _DB())
monkeypatch.setattr(
zrr, "upsert_zone_regulation", lambda db, reg, **kw: upserts.append(reg) or {}
)
return client, upserts
def test_dedup_by_urban_index(monkeypatch: Any) -> None:
"""Две фичи одного индекса → резолвим/кэшируем зону один раз."""
feats = [
_Feat(_poly(), {"urban_index": "Ц-1"}),
_Feat(_poly(), {"urban_index": "Ц-1"}),
_Feat(_poly(), {"urban_index": "Ж-2"}),
]
client, upserts = _patch(monkeypatch, feats)
result = zrr.refresh_zone_regulations()
assert result == {"zones": 2, "cached": 2}
assert len(client.resolved) == 2 # уникальных индексов
assert len(upserts) == 2
def test_skips_blank_index_and_bad_geometry(monkeypatch: Any) -> None:
feats = [
_Feat(_poly(), {"urban_index": ""}), # пустой индекс
_Feat(None, {"urban_index": "Ц-3"}), # нет геометрии
]
client, upserts = _patch(monkeypatch, feats)
result = zrr.refresh_zone_regulations()
assert result == {"zones": 1, "cached": 0} # Ц-3 в seen, но geom=None → не резолвится
assert client.resolved == []
assert upserts == []
def test_representative_point_inside_polygon() -> None:
lon, lat = zrr._representative_point(_poly()) # type: ignore[misc]
assert 60.0 <= lon <= 60.1
assert 56.0 <= lat <= 56.1
def test_representative_point_none_on_bad_geom() -> None:
assert zrr._representative_point(None) is None
assert zrr._representative_point({"type": "Polygon", "coordinates": "garbage"}) is None