gendesign/backend/app/workers/tasks/nspd_sync.py
lekss361 ed3c128528 feat(nspd): TIER 4 opportunity layers + red lines (#94 PR2 of 4)
- NSPDClient: QUARTER_OPPORTUNITY_LAYERS (auction/scheme/free/future/oopt)
  + QuarterDump.opportunity field
- nspd_sync: harvest_quarter accepts include_opportunity, denorm cols
  has_auction_parcels + opportunity_count in UPSERT
- quarter_dump_lookup:
  - _get_opportunity_parcels (sort by distance, early-exit on count=0)
  - _get_red_lines (query existing dump.red_lines core path, layer='red_lines')
- SQL 89_*: has_auction_parcels + opportunity_count + partial index
- Pydantic: OpportunityParcel + RedLine schemas
- /analyze: nspd_opportunity_parcels + nspd_red_lines fields
- Frontend: NspdOpportunityBlock + NspdRedLinesBlock + LandTab integration
- Tests: 28 total (11 PR1 + 17 PR2), all pass

Red lines uses existing core harvest path (layer 879243 already in dump.red_lines
from PR1+core) — no duplicate harvest, no red_lines_count_v2 column needed.

Part of #94
2026-05-16 19:06:22 +03:00

446 lines
17 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 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 datetime as _dt
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))
# TIER 4 opportunity groups — keys: auction_parcels, scheme_parcels, ...
for short_name, features in dump.opportunity.items():
layer_name = f"opportunity_{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())
def _build_opportunity_count(dump: QuarterDump) -> int:
"""Сумма features по всем TIER 4 opportunity слоям (issue #94 PR2)."""
return sum(len(v) for v in dump.opportunity.values())
def _build_has_auction_parcels(dump: QuarterDump) -> bool:
"""True если квартал содержит >= 1 feature auction_parcels (layer 37299)."""
return len(dump.opportunity.get("auction_parcels", [])) > 0
# ── 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,
has_auction_parcels, opportunity_count,
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,
:has_auction_parcels, :opportunity_count,
CAST(:features_json AS jsonb),
CAST(:layers_fetched AS text[]),
CAST(:fetched_at_utc AS 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,
has_auction_parcels = EXCLUDED.has_auction_parcels,
opportunity_count = EXCLUDED.opportunity_count,
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),
"has_auction_parcels": _build_has_auction_parcels(dump),
"opportunity_count": _build_opportunity_count(dump),
"total_features": dump.total_features,
"features_json": json.dumps(features_json or [], ensure_ascii=False),
"layers_fetched": list(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
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,
"has_auction_parcels": False,
"opportunity_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()
except Exception:
db.rollback()
raise
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,
include_opportunity: 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).
Args:
include_opportunity: Фетчить TIER 4 opportunity layers (+5 HTTP запросов).
"""
t0 = time.monotonic()
logger.info(
"harvest_quarter start: cad=%s region=%d include_zouit=%s "
"include_risks=%s include_opportunity=%s",
quarter_cad,
region_code,
include_zouit,
include_risks,
include_opportunity,
)
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,
include_opportunity=include_opportunity,
)
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 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],
kwargs={
"include_zouit": True,
"include_risks": True,
"include_opportunity": True,
},
)
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,
}