"""One-shot backfill: denormalize all existing nspd_quarter_dumps → nspd_parcels/buildings. Запускается вручную через admin endpoint POST /api/v1/admin/etl/nspd-denorm-backfill или через Celery CLI: celery -A app.workers.celery_app call nspd_denorm.backfill_all_dumps Идемпотентен: повторный запуск обновляет строки через ON CONFLICT DO UPDATE. """ from __future__ import annotations import logging from celery import shared_task from sqlalchemy import text from app.core.db import SessionLocal from app.services.scrapers.nspd_denorm import denorm_dump logger = logging.getLogger(__name__) @shared_task( name="nspd_denorm.backfill_all_dumps", bind=True, soft_time_limit=3600, # 1 час — много кварталов × ~10ms per parcel max_retries=0, # One-shot, не retry ) def backfill_all_dumps(self: object, *, limit: int | None = None) -> dict[str, int]: # type: ignore[misc] """Backfill all existing nspd_quarter_dumps → nspd_parcels / nspd_buildings. Читает все строки nspd_quarter_dumps (или limit строк), и для каждой вызывает denorm_dump. Возвращает агрегированные счётчики по всем кварталам. Args: limit: если задан — обработать только первые N кварталов (для тестирования). Returns: dict { "parcels": суммарно вставлено/обновлено parcel строк, "buildings": суммарно вставлено/обновлено building строк, "errors": суммарно пропусков (нет cad_num, DB ошибки), "quarters_processed": количество обработанных кварталов, } """ totals: dict[str, int] = { "parcels": 0, "buildings": 0, "errors": 0, "quarters_processed": 0, } db = SessionLocal() try: sql = ( "SELECT quarter_cad, features_json " "FROM nspd_quarter_dumps " "WHERE total_features > 0 " "ORDER BY quarter_cad" ) if limit is not None: # Используем параметр через text() чтобы избежать SQL-injection. rows = db.execute( text(sql + " LIMIT CAST(:lim AS integer)"), {"lim": int(limit)} ).fetchall() else: rows = db.execute(text(sql)).fetchall() logger.info("backfill_all_dumps: starting, quarters=%d limit=%s", len(rows), limit) for row in rows: quarter_cad: str = row[0] features_json = row[1] # features_json — уже list[dict] через psycopg v3 JSON decoding. features: list[dict] = features_json if isinstance(features_json, list) else [] try: counts = denorm_dump(db, quarter_cad=quarter_cad, features=features) totals["parcels"] += counts["parcels"] totals["buildings"] += counts["buildings"] totals["errors"] += counts["errors"] totals["quarters_processed"] += 1 except Exception as e: logger.warning("backfill_all_dumps: quarter=%s failed: %s", quarter_cad, e) totals["errors"] += 1 # Продолжаем с остальными кварталами try: db.rollback() except Exception: pass logger.info("backfill_all_dumps: done totals=%s", totals) finally: db.close() return totals