"""Celery task: собрать полный PDF-отчёт ПТИЦА в фоне (эпик #2259 PR-D). Сборка полного PDF `/site-finder/analysis/{cad}` (§1–§7 + карты) занимает секунды-десятки секунд (WeasyPrint-рендер + OSM-тайлы карт + server-side концепция), поэтому НЕ крутится inline на синхронном эндпоинте — здесь фоновая on-demand таска, оркестрирующая :func:`build_full_report` (full_report_pdf.py) и пишущая PDF-файл + метадата-ран. ПОТОК: `SessionLocal()` → `build_full_report(db, cad)` → метадата-dict (pdf_path/…) → close. Best-effort: нет analyze-рана → ValueError внутри оркестратора → ловим, warning, return None (таска не валит воркер — отчётировать нечего). Прочий сбой → logger.exception + None (карты/концепция/форсайт сами best-effort внутри оркестратора). Mirror conventions (forecast.py): `@celery_app.task`, `SessionLocal()` + try/finally close, `logger` (не print), синхронный `def`. Очередь — дефолтная `celery` (как forecast-таска: у неё нет `queue=` в celery_app routing → default; воркер слушает `--queues=celery,scrape_kn,geo`). TIME LIMIT: WeasyPrint-рендер + карты могут занять до ~минуты на тяжёлом участке — щедрый per-task лимит (time_limit=600 / soft_time_limit=540), как у forecast-таски. REGISTRATION: добавлено в `include=[...]` celery_app.py (зеркало forecast). Beat НЕТ — таска enqueue-on-demand (триггер из POST /{cad}/report). """ from __future__ import annotations import logging from typing import Any from app.core.db import SessionLocal from app.services.exporters.full_report_pdf import build_full_report from app.workers.celery_app import celery_app logger = logging.getLogger(__name__) @celery_app.task( bind=True, name="tasks.full_report.build_full_report", time_limit=600, soft_time_limit=540, ) def build_full_report_task(self: Any, cad: str) -> dict[str, Any] | None: """Собрать полный PDF-отчёт участка в фоне и записать файл + метадата-ран. Оркестрация целиком в :func:`build_full_report` (full_report_pdf.py): analyze-ран (нет → ValueError → None), forecast-ран (нет → Part B «нет данных»), connection-capacity и концепция best-effort, карты + WeasyPrint-рендер → файл на volume `/app/reports/` + метадата-ран `report-pdf-1.0`. Кэш по (analyze_run_id, forecast_run_id): тот же ключ + файл на месте → cache-hit, не пере-рендерит. Args: cad: кадастровый номер участка. Returns: Метадата отчёта {pdf_path, analyze_run_id, forecast_run_id, generated_at, size_bytes} (+ `cache_hit` при кэш-хите), либо None если нет analyze-рана / неожиданный сбой (таска failed gracefully, воркер не падает). """ db = SessionLocal() try: logger.info("build_full_report_task: старт cad=%s", cad) result = build_full_report(db, cad) logger.info( "build_full_report_task: готово cad=%s path=%s cache_hit=%s", cad, result.get("pdf_path"), result.get("cache_hit", False), ) return result except ValueError as exc: # Нет analyze-рана — отчётировать нечего (graceful, не crash воркера). logger.warning("build_full_report_task: нечего собирать cad=%s: %s", cad, exc) return None except Exception: # Неожиданный сбой рендера/записи: traceback + None (карты/концепция/форсайт # уже best-effort внутри оркестратора — сюда всплывает только фатальное). logger.exception("build_full_report_task failed for cad=%s (returning None)", cad) return None finally: db.close()