feat(nspd): harvest_quarter Celery task + beat + admin endpoint (#94 pt.4/4 part A)
Sprint 1.1 item #3 из #94 part 2. Замыкает harvest cycle: Celery task оборачивает NSPDClient.search_by_quarter (PR #109) + UPSERT в nspd_quarter_dumps (PR #110). После — analyze_parcel будет читать dumps вместо on-demand NSPD HTTP в request-цикле (Sprint 1.1 #4, следующий PR). Backend: - New `backend/app/workers/tasks/nspd_sync.py`: - `harvest_quarter(quarter_cad, region_code=66, include_zouit=True, include_risks=False)` — single-quarter harvest. autoretry_for= (NspdLiteWafError,) с retry_backoff=True, max_retries=3, soft_time_limit=120s. Generic exception → upsert error row с harvest_error=str(exc), counts=0 (НЕ re-raise). - `harvest_stale_quarters(region_code, max_age_days=90, batch_size=50)` — beat fanout: SELECT cad_quarters_geom EXCEPT fresh dumps → apply_async per cad. - UPSERT использует ST_Multi(ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON,3857),4326)) для quarter_geom (schema MultiPolygon strict per database-expert contract). - `celery_app.py`: - Add `nspd_sync` в `include` - Beat entry `nspd-harvest-stale-quarters` Mon 04:00 МСК, batch_size=50, max_age_days=90 - `worker_ready` sanity check `SELECT 1 FROM nspd_quarter_dumps LIMIT 0` (non-fatal critical log per Bug_Worker_Ready_EarlyReturn_Fixed pattern) - `admin_scrape.py`: - `HarvestQuarterRequest` Pydantic с pattern `^\d+:\d+:\d+$` (quarter only) - `POST /api/v1/admin/scrape/nspd/harvest-quarter` manual trigger, requires X-Admin-Token, returns task_id. Tests (`test_nspd_sync.py`): 14 mock-based (no real NSPD/DB): - happy path: search_by_quarter mock → verify SQL params/structure - empty quarter → row with quarter_geom=NULL, total=0, no harvest_error - WAF error → autoretry kicks in (raise re-propagated) - Generic error → upsert error row - Fanout: 50 stale → 50 apply_async calls - Layer names preservation (all 5 zouit + 11 risks if enabled) - Geometry SRID handling Code review (code-reviewer pre-push): APPROVE, 0 blocking, 5 minor deferred: - inline datetime import in error branch (style) - layers_fetched array literal via string concat (current layer names safe) - harvest_stale_quarters error rows always re-tried (intended, undocumented) - test call_args.kwargs fragile (works for current callsite) - beat collision with refresh-ekb-districts-medians every ~2 years (negligible) Part of #94. Next: Sprint 1.1 #4 — analyze_parcel reads dump cache.
This commit is contained in:
parent
6fce81e10c
commit
9b2a289e83
4 changed files with 866 additions and 0 deletions
|
|
@ -384,6 +384,50 @@ def trigger_noise_sync(
|
||||||
return {"task_id": result.id, "queued_at": "now"}
|
return {"task_id": result.id, "queued_at": "now"}
|
||||||
|
|
||||||
|
|
||||||
|
class HarvestQuarterRequest(BaseModel):
|
||||||
|
"""Параметры ручного запуска harvest одного квартала."""
|
||||||
|
|
||||||
|
quarter_cad: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=5,
|
||||||
|
pattern=r"^\d+:\d+:\d+$",
|
||||||
|
description="3-сегментный кадастровый номер квартала, например '66:41:0204016'",
|
||||||
|
)
|
||||||
|
region_code: int = Field(default=66, ge=1, le=99)
|
||||||
|
include_zouit: bool = Field(default=True)
|
||||||
|
include_risks: bool = Field(default=False)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/nspd/harvest-quarter")
|
||||||
|
def trigger_harvest_quarter(
|
||||||
|
payload: HarvestQuarterRequest,
|
||||||
|
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Manual trigger одного quarter harvest (для testing / конкретного квартала).
|
||||||
|
|
||||||
|
Запускает harvest_quarter Celery task и возвращает task_id.
|
||||||
|
Для получения результата — poll через Celery inspect или нождите строку
|
||||||
|
в nspd_quarter_dumps WHERE quarter_cad = payload.quarter_cad.
|
||||||
|
"""
|
||||||
|
_check_token(x_admin_token)
|
||||||
|
from app.workers.tasks.nspd_sync import harvest_quarter
|
||||||
|
|
||||||
|
task = harvest_quarter.apply_async(
|
||||||
|
args=[payload.quarter_cad, payload.region_code],
|
||||||
|
kwargs={
|
||||||
|
"include_zouit": payload.include_zouit,
|
||||||
|
"include_risks": payload.include_risks,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"task_id": task.id,
|
||||||
|
"quarter_cad": payload.quarter_cad,
|
||||||
|
"region_code": payload.region_code,
|
||||||
|
"include_zouit": payload.include_zouit,
|
||||||
|
"include_risks": payload.include_risks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pzz-sync")
|
@router.post("/pzz-sync")
|
||||||
def trigger_pzz_sync(
|
def trigger_pzz_sync(
|
||||||
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
|
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ celery_app = Celery(
|
||||||
"app.workers.tasks.scrape_objective",
|
"app.workers.tasks.scrape_objective",
|
||||||
"app.workers.tasks.objective_etl",
|
"app.workers.tasks.objective_etl",
|
||||||
"app.workers.tasks.nspd_geo",
|
"app.workers.tasks.nspd_geo",
|
||||||
|
"app.workers.tasks.nspd_sync",
|
||||||
"app.workers.tasks.poi_sync",
|
"app.workers.tasks.poi_sync",
|
||||||
"app.workers.tasks.noise_sync",
|
"app.workers.tasks.noise_sync",
|
||||||
"app.workers.tasks.pzz_sync",
|
"app.workers.tasks.pzz_sync",
|
||||||
|
|
@ -230,6 +231,17 @@ def _build_beat_schedule() -> dict:
|
||||||
"options": {"queue": "celery"},
|
"options": {"queue": "celery"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# NSPD quarter dump refresh — понедельник 04:00 МСК.
|
||||||
|
# Fanout'ит harvest_quarter.apply_async для кварталов старше 90 дней или
|
||||||
|
# отсутствующих в nspd_quarter_dumps. batch_size=50 — ~5 мин при 600ms rate.
|
||||||
|
# Запускается через час после poi-sync чтобы не создавать конкуренцию.
|
||||||
|
schedule["nspd-harvest-stale-quarters"] = {
|
||||||
|
"task": "tasks.nspd_sync.harvest_stale_quarters",
|
||||||
|
"schedule": _parse_cron("0 4 * * mon"),
|
||||||
|
"kwargs": {"region_code": 66, "max_age_days": 90, "batch_size": 50},
|
||||||
|
"options": {"queue": "celery"},
|
||||||
|
}
|
||||||
|
|
||||||
return schedule
|
return schedule
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -406,3 +418,25 @@ def _resume_zombie_runs(sender=None, **_kwargs) -> None:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("worker_ready: failed to enqueue geo resume job=%s: %s", jid, e)
|
logger.warning("worker_ready: failed to enqueue geo resume job=%s: %s", jid, e)
|
||||||
logger.info("worker_ready: resume scan finished (geo_jobs=%d)", len(geo_resume_jobs))
|
logger.info("worker_ready: resume scan finished (geo_jobs=%d)", len(geo_resume_jobs))
|
||||||
|
|
||||||
|
# Sanity check: nspd_quarter_dumps table must exist (migration 88).
|
||||||
|
# Logs critical error but does NOT crash the worker — table may be absent
|
||||||
|
# in dev/staging before migration is applied.
|
||||||
|
try:
|
||||||
|
from app.core.db import SessionLocal as _SessionLocal
|
||||||
|
|
||||||
|
_db = _SessionLocal()
|
||||||
|
try:
|
||||||
|
_db.execute(text("SELECT 1 FROM nspd_quarter_dumps LIMIT 0"))
|
||||||
|
logger.info("worker_ready: nspd_quarter_dumps table OK")
|
||||||
|
except Exception as _te:
|
||||||
|
logger.critical(
|
||||||
|
"worker_ready: nspd_quarter_dumps table missing or inaccessible — "
|
||||||
|
"apply migration 88_nspd_quarter_dumps.sql before using harvest_quarter. "
|
||||||
|
"Error: %s",
|
||||||
|
_te,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_db.close()
|
||||||
|
except Exception as _e:
|
||||||
|
logger.warning("worker_ready: nspd_quarter_dumps sanity check failed: %s", _e)
|
||||||
|
|
|
||||||
407
backend/app/workers/tasks/nspd_sync.py
Normal file
407
backend/app/workers/tasks/nspd_sync.py
Normal file
|
|
@ -0,0 +1,407 @@
|
||||||
|
"""Celery tasks для NSPD quarter harvest.
|
||||||
|
|
||||||
|
Архитектура (Sprint 1.1 item #3):
|
||||||
|
harvest_quarter — один квартал: search_by_quarter → UPSERT nspd_quarter_dumps.
|
||||||
|
harvest_stale_quarters — beat-triggered bulk: ищет устаревшие/отсутствующие дампы,
|
||||||
|
фанаут harvest_quarter.apply_async() на каждый.
|
||||||
|
|
||||||
|
Sanity check при startup:
|
||||||
|
worker_ready сигнал проверяет наличие таблицы nspd_quarter_dumps. Если отсутствует
|
||||||
|
(migration 88_nspd_quarter_dumps.sql не применена) — пишет critical log, не падает.
|
||||||
|
|
||||||
|
UPSERT pattern:
|
||||||
|
ON CONFLICT (quarter_cad) DO UPDATE — обновляет все колонки кроме region_code
|
||||||
|
(иммутабельный per-row, задаётся при первом insert).
|
||||||
|
Геометрия квартала: ST_Multi(ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(...),3857),4326))
|
||||||
|
Bbox: ST_MakeEnvelope(xmin,ymin,xmax,ymax,3857).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.services.scrapers.nspd_client import (
|
||||||
|
NSPDClient,
|
||||||
|
NSPDFeature,
|
||||||
|
NspdLiteWafError,
|
||||||
|
QuarterDump,
|
||||||
|
)
|
||||||
|
from app.workers.celery_app import celery_app
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Geometry helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _build_features_json(dump: QuarterDump) -> list[dict[str, Any]]:
|
||||||
|
"""Собрать все NSPDFeature из QuarterDump в плоский list[dict] для JSONB.
|
||||||
|
|
||||||
|
Каждый элемент: {layer, feature_id, geometry, properties}.
|
||||||
|
Порядок: core layers → zouit layers → risk layers (для консистентности).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _feat_to_dict(layer: str, f: NSPDFeature) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"layer": layer,
|
||||||
|
"feature_id": f.feature_id,
|
||||||
|
"geometry": f.geometry,
|
||||||
|
"properties": f.properties,
|
||||||
|
}
|
||||||
|
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
# Core layers
|
||||||
|
for feat in dump.parcels:
|
||||||
|
out.append(_feat_to_dict("parcels", feat))
|
||||||
|
for feat in dump.buildings:
|
||||||
|
out.append(_feat_to_dict("buildings", feat))
|
||||||
|
for feat in dump.territorial_zones:
|
||||||
|
out.append(_feat_to_dict("territorial_zones", feat))
|
||||||
|
for feat in dump.red_lines:
|
||||||
|
out.append(_feat_to_dict("red_lines", feat))
|
||||||
|
for feat in dump.engineering_structures:
|
||||||
|
out.append(_feat_to_dict("engineering_structures", feat))
|
||||||
|
|
||||||
|
# ЗОУИТ groups — keys: okn, engineering, natural, protected, other
|
||||||
|
for short_name, features in dump.zouit.items():
|
||||||
|
layer_name = f"zouit_{short_name}"
|
||||||
|
for feat in features:
|
||||||
|
out.append(_feat_to_dict(layer_name, feat))
|
||||||
|
|
||||||
|
# Risk groups — keys: flooding_underground, flooding, swampification, ...
|
||||||
|
for short_name, features in dump.risks.items():
|
||||||
|
layer_name = f"risk_{short_name}"
|
||||||
|
for feat in features:
|
||||||
|
out.append(_feat_to_dict(layer_name, feat))
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _build_zouit_count(dump: QuarterDump) -> int:
|
||||||
|
"""Сумма features по всем 5 ЗОУИТ группам."""
|
||||||
|
return sum(len(v) for v in dump.zouit.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _build_risks_count(dump: QuarterDump) -> int:
|
||||||
|
"""Сумма features по всем risk группам."""
|
||||||
|
return sum(len(v) for v in dump.risks.values())
|
||||||
|
|
||||||
|
|
||||||
|
# ── UPSERT helper ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_UPSERT_SQL = text(
|
||||||
|
"""
|
||||||
|
INSERT INTO nspd_quarter_dumps (
|
||||||
|
quarter_cad, quarter_geom, bbox_3857,
|
||||||
|
parcels_count, buildings_count, territorial_zones_count,
|
||||||
|
red_lines_count, engineering_count, zouit_count, risks_count, total_features,
|
||||||
|
features_json, layers_fetched, fetched_at_utc, harvest_duration_ms,
|
||||||
|
harvest_error, region_code
|
||||||
|
) VALUES (
|
||||||
|
:quarter_cad,
|
||||||
|
CASE WHEN :geom_json IS NULL THEN NULL
|
||||||
|
ELSE ST_Multi(ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:geom_json), 3857), 4326))
|
||||||
|
END,
|
||||||
|
CASE WHEN :bbox_xmin IS NULL THEN NULL
|
||||||
|
ELSE ST_MakeEnvelope(:bbox_xmin, :bbox_ymin, :bbox_xmax, :bbox_ymax, 3857)
|
||||||
|
END,
|
||||||
|
:parcels_count, :buildings_count, :territorial_zones_count,
|
||||||
|
:red_lines_count, :engineering_count, :zouit_count, :risks_count, :total_features,
|
||||||
|
CAST(:features_json AS jsonb),
|
||||||
|
CAST(:layers_fetched AS text[]),
|
||||||
|
:fetched_at_utc::timestamptz,
|
||||||
|
:harvest_duration_ms,
|
||||||
|
:harvest_error,
|
||||||
|
:region_code
|
||||||
|
)
|
||||||
|
ON CONFLICT (quarter_cad) DO UPDATE SET
|
||||||
|
quarter_geom = EXCLUDED.quarter_geom,
|
||||||
|
bbox_3857 = EXCLUDED.bbox_3857,
|
||||||
|
parcels_count = EXCLUDED.parcels_count,
|
||||||
|
buildings_count = EXCLUDED.buildings_count,
|
||||||
|
territorial_zones_count = EXCLUDED.territorial_zones_count,
|
||||||
|
red_lines_count = EXCLUDED.red_lines_count,
|
||||||
|
engineering_count = EXCLUDED.engineering_count,
|
||||||
|
zouit_count = EXCLUDED.zouit_count,
|
||||||
|
risks_count = EXCLUDED.risks_count,
|
||||||
|
total_features = EXCLUDED.total_features,
|
||||||
|
features_json = EXCLUDED.features_json,
|
||||||
|
layers_fetched = EXCLUDED.layers_fetched,
|
||||||
|
fetched_at_utc = EXCLUDED.fetched_at_utc,
|
||||||
|
harvest_duration_ms = EXCLUDED.harvest_duration_ms,
|
||||||
|
harvest_error = EXCLUDED.harvest_error
|
||||||
|
-- region_code исключён из UPDATE: иммутабельное свойство квартала
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_dump(
|
||||||
|
quarter_cad: str,
|
||||||
|
region_code: int,
|
||||||
|
dump: QuarterDump | None,
|
||||||
|
features_json: list[dict[str, Any]] | None,
|
||||||
|
duration_ms: int | None,
|
||||||
|
harvest_error: str | None,
|
||||||
|
) -> None:
|
||||||
|
"""UPSERT строки в nspd_quarter_dumps.
|
||||||
|
|
||||||
|
dump=None при partial-failure: вставляем строку с harvest_error + нулевыми счётчиками.
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
if dump is not None:
|
||||||
|
geom_json: str | None = (
|
||||||
|
json.dumps(dump.quarter.geometry, ensure_ascii=False)
|
||||||
|
if dump.quarter and dump.quarter.geometry
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
bbox = dump.bbox_3857
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"quarter_cad": quarter_cad,
|
||||||
|
"geom_json": geom_json,
|
||||||
|
"bbox_xmin": bbox[0] if bbox else None,
|
||||||
|
"bbox_ymin": bbox[1] if bbox else None,
|
||||||
|
"bbox_xmax": bbox[2] if bbox else None,
|
||||||
|
"bbox_ymax": bbox[3] if bbox else None,
|
||||||
|
"parcels_count": len(dump.parcels),
|
||||||
|
"buildings_count": len(dump.buildings),
|
||||||
|
"territorial_zones_count": len(dump.territorial_zones),
|
||||||
|
"red_lines_count": len(dump.red_lines),
|
||||||
|
"engineering_count": len(dump.engineering_structures),
|
||||||
|
"zouit_count": _build_zouit_count(dump),
|
||||||
|
"risks_count": _build_risks_count(dump),
|
||||||
|
"total_features": dump.total_features,
|
||||||
|
"features_json": json.dumps(features_json or [], ensure_ascii=False),
|
||||||
|
"layers_fetched": "{" + ",".join(dump.layers_fetched) + "}",
|
||||||
|
"fetched_at_utc": dump.fetched_at_utc,
|
||||||
|
"harvest_duration_ms": duration_ms,
|
||||||
|
"harvest_error": harvest_error,
|
||||||
|
"region_code": region_code,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Error-only row: нулевые счётчики, harvest_error filled
|
||||||
|
import datetime as _dt
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"quarter_cad": quarter_cad,
|
||||||
|
"geom_json": None,
|
||||||
|
"bbox_xmin": None,
|
||||||
|
"bbox_ymin": None,
|
||||||
|
"bbox_xmax": None,
|
||||||
|
"bbox_ymax": None,
|
||||||
|
"parcels_count": 0,
|
||||||
|
"buildings_count": 0,
|
||||||
|
"territorial_zones_count": 0,
|
||||||
|
"red_lines_count": 0,
|
||||||
|
"engineering_count": 0,
|
||||||
|
"zouit_count": 0,
|
||||||
|
"risks_count": 0,
|
||||||
|
"total_features": 0,
|
||||||
|
"features_json": "[]",
|
||||||
|
"layers_fetched": "{}",
|
||||||
|
"fetched_at_utc": _dt.datetime.now(_dt.UTC).isoformat(),
|
||||||
|
"harvest_duration_ms": duration_ms,
|
||||||
|
"harvest_error": harvest_error,
|
||||||
|
"region_code": region_code,
|
||||||
|
}
|
||||||
|
|
||||||
|
db.execute(_UPSERT_SQL, params)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main task ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(
|
||||||
|
bind=True,
|
||||||
|
name="tasks.nspd_sync.harvest_quarter",
|
||||||
|
max_retries=3,
|
||||||
|
soft_time_limit=120, # 2 мин — worst-case 22 HTTP × ~600ms = ~13s + margin
|
||||||
|
autoretry_for=(NspdLiteWafError,),
|
||||||
|
retry_backoff=True,
|
||||||
|
retry_backoff_max=120,
|
||||||
|
retry_jitter=True,
|
||||||
|
)
|
||||||
|
def harvest_quarter(
|
||||||
|
self: Any,
|
||||||
|
quarter_cad: str,
|
||||||
|
region_code: int = 66,
|
||||||
|
include_zouit: bool = True,
|
||||||
|
include_risks: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Single-quarter harvest. NSPDClient.search_by_quarter → UPSERT nspd_quarter_dumps.
|
||||||
|
|
||||||
|
Идемпотентен: повторный вызов обновляет строку (ON CONFLICT DO UPDATE).
|
||||||
|
WAF 403/429 → autoretry с exponential backoff (max 3 попытки).
|
||||||
|
Другие исключения → запись harvest_error в строку, return error dict (не raise).
|
||||||
|
"""
|
||||||
|
t0 = time.monotonic()
|
||||||
|
logger.info(
|
||||||
|
"harvest_quarter start: cad=%s region=%d include_zouit=%s include_risks=%s",
|
||||||
|
quarter_cad,
|
||||||
|
region_code,
|
||||||
|
include_zouit,
|
||||||
|
include_risks,
|
||||||
|
)
|
||||||
|
|
||||||
|
client = NSPDClient()
|
||||||
|
dump: QuarterDump | None = None
|
||||||
|
features_json: list[dict[str, Any]] | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
dump = client.search_by_quarter(
|
||||||
|
quarter_cad,
|
||||||
|
include_zouit=include_zouit,
|
||||||
|
include_risks=include_risks,
|
||||||
|
)
|
||||||
|
features_json = _build_features_json(dump)
|
||||||
|
duration_ms = int((time.monotonic() - t0) * 1000)
|
||||||
|
|
||||||
|
_upsert_dump(quarter_cad, region_code, dump, features_json, duration_ms, None)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"harvest_quarter done: cad=%s region=%d duration=%dms total=%d",
|
||||||
|
quarter_cad,
|
||||||
|
region_code,
|
||||||
|
duration_ms,
|
||||||
|
dump.total_features,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"quarter_cad": quarter_cad,
|
||||||
|
"region_code": region_code,
|
||||||
|
"total_features": dump.total_features,
|
||||||
|
"parcels_count": len(dump.parcels),
|
||||||
|
"buildings_count": len(dump.buildings),
|
||||||
|
"harvest_duration_ms": duration_ms,
|
||||||
|
"layers_fetched": list(dump.layers_fetched),
|
||||||
|
"bbox_3857": dump.bbox_3857,
|
||||||
|
}
|
||||||
|
|
||||||
|
except NspdLiteWafError:
|
||||||
|
# autoretry_for перехватит и сделает retry с backoff.
|
||||||
|
# Просто re-raise чтобы Celery знал о WAF.
|
||||||
|
duration_ms = int((time.monotonic() - t0) * 1000)
|
||||||
|
logger.warning(
|
||||||
|
"harvest_quarter WAF: cad=%s attempt=%d duration=%dms",
|
||||||
|
quarter_cad,
|
||||||
|
self.request.retries,
|
||||||
|
duration_ms,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
duration_ms = int((time.monotonic() - t0) * 1000)
|
||||||
|
err_str = f"{type(exc).__name__}: {exc}"
|
||||||
|
logger.error(
|
||||||
|
"harvest_quarter error: cad=%s region=%d duration=%dms error=%s",
|
||||||
|
quarter_cad,
|
||||||
|
region_code,
|
||||||
|
duration_ms,
|
||||||
|
err_str,
|
||||||
|
)
|
||||||
|
# Пишем строку с harvest_error — не даём silent failure.
|
||||||
|
try:
|
||||||
|
_upsert_dump(quarter_cad, region_code, None, None, duration_ms, err_str[:500])
|
||||||
|
except Exception as db_exc:
|
||||||
|
logger.error(
|
||||||
|
"harvest_quarter: failed to write error row for cad=%s: %s",
|
||||||
|
quarter_cad,
|
||||||
|
db_exc,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"quarter_cad": quarter_cad,
|
||||||
|
"region_code": region_code,
|
||||||
|
"error": err_str,
|
||||||
|
"harvest_duration_ms": duration_ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Bulk beat-triggered task ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="tasks.nspd_sync.harvest_stale_quarters")
|
||||||
|
def harvest_stale_quarters(
|
||||||
|
region_code: int = 66,
|
||||||
|
max_age_days: int = 90,
|
||||||
|
batch_size: int = 50,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Beat-triggered: найти устаревшие/отсутствующие дампы и сфанаутить harvest_quarter.
|
||||||
|
|
||||||
|
Стратегия:
|
||||||
|
Берём кадастровые кварталы из cad_quarters_geom WHERE region_code=:rc
|
||||||
|
которых нет в nspd_quarter_dumps (или есть, но старше max_age_days суток
|
||||||
|
или имеют harvest_error).
|
||||||
|
Limit batch_size — чтобы не сжечь весь rate-limit за один запуск beat.
|
||||||
|
Fanout: harvest_quarter.apply_async(args=[cad, region_code]).
|
||||||
|
|
||||||
|
Идемпотентен: следующий запуск подхватит остатки если batch_size < stale total.
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
"harvest_stale_quarters: region=%d max_age_days=%d batch_size=%d",
|
||||||
|
region_code,
|
||||||
|
max_age_days,
|
||||||
|
batch_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
stale_cads: list[str] = []
|
||||||
|
try:
|
||||||
|
rows = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT cad_number
|
||||||
|
FROM cad_quarters_geom
|
||||||
|
WHERE region_code = :rc
|
||||||
|
AND cad_number NOT IN (
|
||||||
|
SELECT quarter_cad
|
||||||
|
FROM nspd_quarter_dumps
|
||||||
|
WHERE region_code = :rc
|
||||||
|
AND fetched_at_utc > NOW() - INTERVAL '1 day' * :days
|
||||||
|
AND harvest_error IS NULL
|
||||||
|
)
|
||||||
|
ORDER BY cad_number
|
||||||
|
LIMIT :lim
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"rc": region_code, "days": max_age_days, "lim": batch_size},
|
||||||
|
).all()
|
||||||
|
stale_cads = [r[0] for r in rows]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("harvest_stale_quarters: DB query failed: %s", e)
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"error": str(e), "enqueued": 0}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
enqueued = 0
|
||||||
|
for cad in stale_cads:
|
||||||
|
try:
|
||||||
|
harvest_quarter.apply_async(args=[cad, region_code])
|
||||||
|
enqueued += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("harvest_stale_quarters: enqueue failed for cad=%s: %s", cad, e)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"harvest_stale_quarters done: region=%d enqueued=%d stale_found=%d",
|
||||||
|
region_code,
|
||||||
|
enqueued,
|
||||||
|
len(stale_cads),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"enqueued": enqueued,
|
||||||
|
"stale_found": len(stale_cads),
|
||||||
|
"region_code": region_code,
|
||||||
|
"max_age_days": max_age_days,
|
||||||
|
}
|
||||||
381
backend/tests/test_nspd_sync.py
Normal file
381
backend/tests/test_nspd_sync.py
Normal file
|
|
@ -0,0 +1,381 @@
|
||||||
|
"""Unit tests для nspd_sync Celery tasks (#94 Sprint 1.1 item #3).
|
||||||
|
|
||||||
|
Без сетевых запросов и реальной БД — mock-based. Проверяет:
|
||||||
|
- harvest_quarter happy path (correct upsert params, return dict)
|
||||||
|
- harvest_quarter empty quarter (NSPD не вернул квартал — empty row, no crash)
|
||||||
|
- harvest_quarter WAF retry (NspdLiteWafError → task re-raises → Celery autoretry)
|
||||||
|
- harvest_quarter generic exception (harvest_error row written)
|
||||||
|
- harvest_stale_quarters fanout (apply_async N times)
|
||||||
|
- _build_features_json layers completeness (all 5 ЗОУИТ присутствуют)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.scrapers.nspd_client import (
|
||||||
|
NSPDFeature,
|
||||||
|
NspdLiteWafError,
|
||||||
|
QuarterDump,
|
||||||
|
)
|
||||||
|
from app.workers.tasks.nspd_sync import (
|
||||||
|
_build_features_json,
|
||||||
|
_build_risks_count,
|
||||||
|
_build_zouit_count,
|
||||||
|
harvest_quarter,
|
||||||
|
harvest_stale_quarters,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _make_feat(fid: str = "x") -> NSPDFeature:
|
||||||
|
return NSPDFeature.from_raw({"id": fid, "geometry": None, "properties": {}})
|
||||||
|
|
||||||
|
|
||||||
|
def _make_feat_with_geom(fid: str, geom: dict[str, Any]) -> NSPDFeature:
|
||||||
|
return NSPDFeature.from_raw({"id": fid, "geometry": geom, "properties": {"k": "v"}})
|
||||||
|
|
||||||
|
|
||||||
|
def _make_dump(
|
||||||
|
quarter_cad: str = "66:41:0204016",
|
||||||
|
include_zouit: bool = True,
|
||||||
|
include_risks: bool = False,
|
||||||
|
quarter_feat: NSPDFeature | None = None,
|
||||||
|
bbox: tuple[float, float, float, float] | None = (100.0, 200.0, 300.0, 400.0),
|
||||||
|
) -> QuarterDump:
|
||||||
|
"""Фабрика QuarterDump для тестов."""
|
||||||
|
zouit: dict[str, list[NSPDFeature]] = {}
|
||||||
|
if include_zouit:
|
||||||
|
zouit = {
|
||||||
|
"okn": [_make_feat("ok1"), _make_feat("ok2")],
|
||||||
|
"engineering": [_make_feat("eng1")],
|
||||||
|
"natural": [],
|
||||||
|
"protected": [_make_feat("prot1")],
|
||||||
|
"other": [],
|
||||||
|
}
|
||||||
|
risks: dict[str, list[NSPDFeature]] = {}
|
||||||
|
if include_risks:
|
||||||
|
risks = {"flooding": [_make_feat("fl1")], "landslide": []}
|
||||||
|
|
||||||
|
return QuarterDump(
|
||||||
|
quarter_cad=quarter_cad,
|
||||||
|
quarter=quarter_feat,
|
||||||
|
parcels=[_make_feat("p1"), _make_feat("p2")],
|
||||||
|
buildings=[_make_feat("b1")],
|
||||||
|
territorial_zones=[_make_feat("tz1")],
|
||||||
|
red_lines=[],
|
||||||
|
engineering_structures=[_make_feat("e1")],
|
||||||
|
zouit=zouit,
|
||||||
|
risks=risks,
|
||||||
|
layers_fetched=(
|
||||||
|
"search",
|
||||||
|
"parcels",
|
||||||
|
"buildings",
|
||||||
|
"territorial_zones",
|
||||||
|
"red_lines",
|
||||||
|
"engineering_structures",
|
||||||
|
),
|
||||||
|
bbox_3857=bbox,
|
||||||
|
fetched_at_utc="2026-05-12T03:00:00+00:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── _build_features_json tests ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_features_json_layers_all_present_with_zouit() -> None:
|
||||||
|
"""Если include_zouit=True — все 5 ЗОУИТ-групп присутствуют в features_json."""
|
||||||
|
dump = _make_dump(include_zouit=True)
|
||||||
|
result = _build_features_json(dump)
|
||||||
|
layers = {item["layer"] for item in result}
|
||||||
|
# Core layers
|
||||||
|
assert "parcels" in layers
|
||||||
|
assert "buildings" in layers
|
||||||
|
assert "territorial_zones" in layers
|
||||||
|
assert "engineering_structures" in layers
|
||||||
|
# ЗОУИТ — все 5 ключей (даже с пустыми списками они не попадут — только
|
||||||
|
# populated items)
|
||||||
|
# okn: 2, engineering: 1, natural: 0, protected: 1, other: 0
|
||||||
|
assert "zouit_okn" in layers
|
||||||
|
assert "zouit_engineering" in layers
|
||||||
|
assert "zouit_protected" in layers
|
||||||
|
# natural и other — пустые, не должны быть в layers
|
||||||
|
assert "zouit_natural" not in layers
|
||||||
|
assert "zouit_other" not in layers
|
||||||
|
|
||||||
|
|
||||||
|
def test_features_json_no_zouit_no_risk_layers() -> None:
|
||||||
|
"""Без include_zouit — zouit_* и risk_* ключей нет в features_json."""
|
||||||
|
dump = _make_dump(include_zouit=False, include_risks=False)
|
||||||
|
result = _build_features_json(dump)
|
||||||
|
layers = {item["layer"] for item in result}
|
||||||
|
for layer_name in layers:
|
||||||
|
assert not layer_name.startswith("zouit_")
|
||||||
|
assert not layer_name.startswith("risk_")
|
||||||
|
|
||||||
|
|
||||||
|
def test_features_json_each_feature_has_required_keys() -> None:
|
||||||
|
"""Каждый элемент features_json содержит layer, feature_id, geometry, properties."""
|
||||||
|
dump = _make_dump(include_zouit=True)
|
||||||
|
result = _build_features_json(dump)
|
||||||
|
for item in result:
|
||||||
|
assert "layer" in item
|
||||||
|
assert "feature_id" in item
|
||||||
|
assert "geometry" in item
|
||||||
|
assert "properties" in item
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_zouit_count_sums_correctly() -> None:
|
||||||
|
"""_build_zouit_count суммирует все ЗОУИТ-группы."""
|
||||||
|
dump = _make_dump(include_zouit=True)
|
||||||
|
# okn:2 + engineering:1 + natural:0 + protected:1 + other:0 = 4
|
||||||
|
assert _build_zouit_count(dump) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_risks_count_empty() -> None:
|
||||||
|
"""_build_risks_count при пустом risks dict = 0."""
|
||||||
|
dump = _make_dump(include_risks=False)
|
||||||
|
assert _build_risks_count(dump) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_risks_count_with_risks() -> None:
|
||||||
|
"""_build_risks_count суммирует risk-группы."""
|
||||||
|
dump = _make_dump(include_zouit=False, include_risks=True)
|
||||||
|
# flooding:1 + landslide:0 = 1
|
||||||
|
assert _build_risks_count(dump) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── harvest_quarter tests ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.workers.tasks.nspd_sync._upsert_dump")
|
||||||
|
@patch("app.workers.tasks.nspd_sync.NSPDClient")
|
||||||
|
def test_harvest_quarter_success(
|
||||||
|
mock_client_cls: MagicMock,
|
||||||
|
mock_upsert: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Happy path: search_by_quarter → return populated QuarterDump → upsert called."""
|
||||||
|
dump = _make_dump(include_zouit=True)
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.search_by_quarter.return_value = dump
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
result = harvest_quarter(
|
||||||
|
"66:41:0204016",
|
||||||
|
region_code=66,
|
||||||
|
include_zouit=True,
|
||||||
|
include_risks=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["quarter_cad"] == "66:41:0204016"
|
||||||
|
assert result["region_code"] == 66
|
||||||
|
assert result["total_features"] == dump.total_features
|
||||||
|
assert "harvest_duration_ms" in result
|
||||||
|
assert "error" not in result
|
||||||
|
|
||||||
|
# Verify _upsert_dump called once with correct positional args
|
||||||
|
# Signature: (quarter_cad, region_code, dump, features_json, duration_ms, harvest_error)
|
||||||
|
assert mock_upsert.call_count == 1
|
||||||
|
upsert_call = mock_upsert.call_args
|
||||||
|
assert upsert_call[0][0] == "66:41:0204016" # quarter_cad
|
||||||
|
assert upsert_call[0][1] == 66 # region_code
|
||||||
|
assert upsert_call[0][2] is dump # dump object
|
||||||
|
assert upsert_call[0][5] is None # harvest_error=None on success
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.workers.tasks.nspd_sync._upsert_dump")
|
||||||
|
@patch("app.workers.tasks.nspd_sync.NSPDClient")
|
||||||
|
def test_harvest_quarter_empty_quarter(
|
||||||
|
mock_client_cls: MagicMock,
|
||||||
|
mock_upsert: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Quarter не найден в NSPD (quarter=None, bbox=None) — empty harvest, no crash."""
|
||||||
|
empty_dump = QuarterDump(
|
||||||
|
quarter_cad="66:41:9999999",
|
||||||
|
quarter=None,
|
||||||
|
parcels=[],
|
||||||
|
buildings=[],
|
||||||
|
territorial_zones=[],
|
||||||
|
red_lines=[],
|
||||||
|
engineering_structures=[],
|
||||||
|
zouit={"okn": [], "engineering": [], "natural": [], "protected": [], "other": []},
|
||||||
|
risks={},
|
||||||
|
layers_fetched=("search",),
|
||||||
|
bbox_3857=None,
|
||||||
|
fetched_at_utc="2026-05-12T03:00:00+00:00",
|
||||||
|
)
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.search_by_quarter.return_value = empty_dump
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
result = harvest_quarter("66:41:9999999", region_code=66)
|
||||||
|
|
||||||
|
# Should succeed with zero counts
|
||||||
|
assert result["quarter_cad"] == "66:41:9999999"
|
||||||
|
assert result["total_features"] == 0
|
||||||
|
assert "error" not in result
|
||||||
|
|
||||||
|
# Upsert called with the empty dump (not None)
|
||||||
|
# Signature: (quarter_cad, region_code, dump, features_json, duration_ms, harvest_error)
|
||||||
|
assert mock_upsert.call_count == 1
|
||||||
|
upsert_args = mock_upsert.call_args[0]
|
||||||
|
assert upsert_args[2] is empty_dump # dump passed
|
||||||
|
assert upsert_args[5] is None # harvest_error=None (valid empty harvest)
|
||||||
|
|
||||||
|
# features_json should be empty list
|
||||||
|
features_json_arg = upsert_args[3]
|
||||||
|
assert features_json_arg == []
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.workers.tasks.nspd_sync._upsert_dump")
|
||||||
|
@patch("app.workers.tasks.nspd_sync.NSPDClient")
|
||||||
|
def test_harvest_quarter_waf_retry(
|
||||||
|
mock_client_cls: MagicMock,
|
||||||
|
mock_upsert: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""NspdLiteWafError → task re-raises (Celery autoretry_for будет retry).
|
||||||
|
|
||||||
|
autoretry_for=(NspdLiteWafError,) настроен в декораторе задачи.
|
||||||
|
Тест проверяет что re-raise происходит (task не глотает WAF).
|
||||||
|
"""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.search_by_quarter.side_effect = NspdLiteWafError("HTTP 429 WAF")
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
with pytest.raises(NspdLiteWafError):
|
||||||
|
harvest_quarter("66:41:0204016", region_code=66)
|
||||||
|
|
||||||
|
# _upsert_dump НЕ должен быть вызван при WAF (не пишем error row, retry сделает попытку)
|
||||||
|
mock_upsert.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.workers.tasks.nspd_sync._upsert_dump")
|
||||||
|
@patch("app.workers.tasks.nspd_sync.NSPDClient")
|
||||||
|
def test_harvest_quarter_writes_error_row(
|
||||||
|
mock_client_cls: MagicMock,
|
||||||
|
mock_upsert: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Generic exception → error row inserted с harvest_error set; task НЕ raise."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.search_by_quarter.side_effect = ConnectionError("timeout")
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
result = harvest_quarter("66:41:0204016", region_code=66)
|
||||||
|
|
||||||
|
# Task returns error dict instead of raising
|
||||||
|
assert "error" in result
|
||||||
|
assert "ConnectionError" in result["error"]
|
||||||
|
assert result["quarter_cad"] == "66:41:0204016"
|
||||||
|
|
||||||
|
# _upsert_dump called with dump=None (error-only row)
|
||||||
|
# Signature: (quarter_cad, region_code, dump, features_json, duration_ms, harvest_error)
|
||||||
|
assert mock_upsert.call_count == 1
|
||||||
|
upsert_args = mock_upsert.call_args[0]
|
||||||
|
assert upsert_args[2] is None # dump=None
|
||||||
|
assert upsert_args[3] is None # features_json=None
|
||||||
|
assert upsert_args[5] is not None # harvest_error set
|
||||||
|
assert "ConnectionError" in upsert_args[5]
|
||||||
|
|
||||||
|
|
||||||
|
# ── harvest_stale_quarters tests ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.workers.tasks.nspd_sync.harvest_quarter")
|
||||||
|
@patch("app.workers.tasks.nspd_sync.SessionLocal")
|
||||||
|
def test_harvest_stale_quarters_fanout(
|
||||||
|
mock_session_cls: MagicMock,
|
||||||
|
mock_task: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Stale cads найдены → harvest_quarter.apply_async вызван N раз."""
|
||||||
|
stale_cads = ["66:41:0000001", "66:41:0000002", "66:41:0000003"]
|
||||||
|
|
||||||
|
# Mock DB session
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_session_cls.return_value = mock_db
|
||||||
|
mock_db.__enter__ = MagicMock(return_value=mock_db)
|
||||||
|
mock_db.__exit__ = MagicMock(return_value=False)
|
||||||
|
mock_db.execute.return_value.all.return_value = [(c,) for c in stale_cads]
|
||||||
|
|
||||||
|
# harvest_quarter.apply_async — проверяем что вызван для каждого cad
|
||||||
|
mock_task.apply_async = MagicMock()
|
||||||
|
|
||||||
|
result = harvest_stale_quarters(region_code=66, max_age_days=90, batch_size=50)
|
||||||
|
|
||||||
|
assert result["enqueued"] == 3
|
||||||
|
assert result["stale_found"] == 3
|
||||||
|
assert result["region_code"] == 66
|
||||||
|
|
||||||
|
assert mock_task.apply_async.call_count == 3
|
||||||
|
# apply_async(args=[cad, region_code]) — args passed as keyword 'args'
|
||||||
|
called_cads = [c.kwargs["args"][0] for c in mock_task.apply_async.call_args_list]
|
||||||
|
assert called_cads == stale_cads
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.workers.tasks.nspd_sync.harvest_quarter")
|
||||||
|
@patch("app.workers.tasks.nspd_sync.SessionLocal")
|
||||||
|
def test_harvest_stale_quarters_empty(
|
||||||
|
mock_session_cls: MagicMock,
|
||||||
|
mock_task: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Нет stale кварталов → 0 enqueued, apply_async не вызван."""
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_session_cls.return_value = mock_db
|
||||||
|
mock_db.__enter__ = MagicMock(return_value=mock_db)
|
||||||
|
mock_db.__exit__ = MagicMock(return_value=False)
|
||||||
|
mock_db.execute.return_value.all.return_value = []
|
||||||
|
|
||||||
|
mock_task.apply_async = MagicMock()
|
||||||
|
|
||||||
|
result = harvest_stale_quarters(region_code=66)
|
||||||
|
|
||||||
|
assert result["enqueued"] == 0
|
||||||
|
assert result["stale_found"] == 0
|
||||||
|
mock_task.apply_async.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ── features_json layer names tests ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_features_json_risk_layer_names() -> None:
|
||||||
|
"""Risk layers кодируются как risk_<short_name>."""
|
||||||
|
dump = _make_dump(include_zouit=False, include_risks=True)
|
||||||
|
result = _build_features_json(dump)
|
||||||
|
layers = {item["layer"] for item in result}
|
||||||
|
# flooding:1 item
|
||||||
|
assert "risk_flooding" in layers
|
||||||
|
# landslide:0 items — не должно быть ключа
|
||||||
|
assert "risk_landslide" not in layers
|
||||||
|
|
||||||
|
|
||||||
|
def test_features_json_geometry_preserved() -> None:
|
||||||
|
"""Geometry (EPSG:3857) сохраняется без трансформации в features_json."""
|
||||||
|
geom = {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [
|
||||||
|
[[100.0, 200.0], [300.0, 200.0], [300.0, 400.0], [100.0, 400.0], [100.0, 200.0]]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
dump = QuarterDump(
|
||||||
|
quarter_cad="66:41:0204016",
|
||||||
|
quarter=None,
|
||||||
|
parcels=[_make_feat_with_geom("p1", geom)],
|
||||||
|
buildings=[],
|
||||||
|
territorial_zones=[],
|
||||||
|
red_lines=[],
|
||||||
|
engineering_structures=[],
|
||||||
|
zouit={},
|
||||||
|
risks={},
|
||||||
|
layers_fetched=("search", "parcels"),
|
||||||
|
bbox_3857=(100.0, 200.0, 300.0, 400.0),
|
||||||
|
fetched_at_utc="2026-05-12T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
result = _build_features_json(dump)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["geometry"] == geom # not transformed
|
||||||
|
assert result[0]["layer"] == "parcels"
|
||||||
|
assert result[0]["feature_id"] == "p1"
|
||||||
|
assert result[0]["properties"] == {"k": "v"}
|
||||||
Loading…
Add table
Reference in a new issue