gendesign/backend/app/workers/tasks/nspd_denorm_backfill.py
lekss361 db1ff5b1fc feat(nspd): denorm nspd_parcels + nspd_buildings tables (#94 PR3 of 4)
- data/sql/99_nspd_entities_denorm.sql: 2 tables (cad_num PK) + 6 indexes
  (quarter_cad, GIST geom, partial WHERE purpose ILIKE '%многокв%')
- nspd_denorm.py: denorm_parcel_feature / denorm_building_feature / denorm_dump
  - SAVEPOINT per row via with db.begin_nested()
  - ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:geom), 3857), 4326)
  - json.dumps(geom, ensure_ascii=False) for psycopg bind
  - All CAST(:x AS type) — no :type
- nspd_sync.py: inline denorm after _upsert_dump in harvest_quarter (non-fatal)
- nspd_denorm_backfill.py: Celery one-shot task (per-quarter commit)
- admin_etl.py: POST /api/v1/admin/etl/nspd-denorm-backfill (AdminTokenAuth)
- 19 tests: coerce helpers, denorm parcel/building, aggregate counts

Foundation for downstream features (МКД neighbors lookup, parcel attrs).

Part of #94
2026-05-16 20:20:03 +03:00

96 lines
3.7 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.

"""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