"""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.services.scrapers.nspd_denorm import denorm_dump 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 CAST(:geom_json AS text) IS NULL THEN NULL ELSE ST_Multi(ST_Transform( ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geom_json AS text)), 3857), 4326)) END, CASE WHEN CAST(:bbox_xmin AS double precision) IS NULL THEN NULL ELSE ST_MakeEnvelope( CAST(:bbox_xmin AS double precision), CAST(:bbox_ymin AS double precision), CAST(:bbox_xmax AS double precision), CAST(:bbox_ymax AS double precision), 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=600, # 10 мин — grid-walk: 11 layers × 49 cells × ~70ms ≈ 40s + retries 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) # Inline denorm: разложить parcels/buildings из dump в nspd_parcels/nspd_buildings. # Ошибка denorm не должна фейлить весь harvest — только warning. try: denorm_db = SessionLocal() try: denorm_counts = denorm_dump( denorm_db, quarter_cad=quarter_cad, features=features_json or [], ) logger.info( "harvest_quarter denorm: cad=%s parcels=%d buildings=%d errors=%d", quarter_cad, denorm_counts["parcels"], denorm_counts["buildings"], denorm_counts["errors"], ) finally: denorm_db.close() except Exception as denorm_exc: logger.warning( "harvest_quarter denorm failed (non-fatal): cad=%s error=%s", quarter_cad, denorm_exc, ) 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, }