"""Оркестратор полного PDF-отчёта ПТИЦА (эпик #2259 PR-D). Собирает единый PDF `/site-finder/analysis/{cad}` из уже-персистнутых ранов + дотянутых на лету блоков (connection-capacity, концепция), рендерит карты (PR-C) и пишет файл на volume `/app/reports/` с метадата-строкой в `analysis_runs` (schema `report-pdf-1.0`). ПОТОК (:func:`build_full_report`): 1. analyze-ран (`latest_run_for(..., schema_version=ANALYZE_SCHEMA_VERSION)`) — нет → ValueError (отчёт без базового анализа бессмысленен). 2. forecast-ран (`latest_run_for(..., schema_version="1.0")`) — нет → Part B (§4–§6) деградирует «нет данных», отчёт всё равно валиден (передаём {} в part_b). 3. КЭШ-ключ = (analyze_run_id, forecast_run_id). Если метадата-ран `report-pdf-1.0` с теми же id уже есть И файл на месте → cache-hit, PDF не пере-рендерим. 4. connection-capacity (`get_connection_capacity`) — best-effort, для §3-резервов. 5. концепция — server-side генерация (как POST /concepts): `ConceptInput` из geom участка (дефолт count=3 comfort) → `geometry.generate`. Best-effort: упала/нет geom → отчёт без §7-концепции (§5 деградирует в рыночный контекст). 6. HTML (PR-A/B) + карты (PR-C: `render_parcel_map_png` / `render_concept_footprint_png` → `embed_map_png`, PNG max_px=1400) → PDF (:func:`render_full_report_pdf`). 7. Запись файла + метадата-ран `report-pdf-1.0` (result = pdf_path/analyze_run_id/ forecast_run_id/generated_at/size_bytes). WeasyPrint импортируется ЛОКАЛЬНО внутри :func:`render_full_report_pdf` (тяжёлый native — ломает pytest-сбор на хостах без GTK/Pango; образец `layout_tz_pdf.render_layout_tz_pdf`). """ from __future__ import annotations import datetime as _dt import logging import os import re from pathlib import Path from typing import Any from sqlalchemy.orm import Session from app.core.config import settings from app.services.analysis_runs.repository import ( ANALYZE_SCHEMA_VERSION, latest_run_for, persist_analysis_run, ) from app.services.exporters.full_report_html import ( MAP_CONCEPT_PLACEHOLDER, MAP_PARCEL_PLACEHOLDER, build_full_report_html, build_full_report_html_part_a, build_full_report_html_part_b, ) from app.services.exporters.report_maps import ( embed_map_png, render_concept_footprint_png, render_parcel_map_png, ) logger = logging.getLogger(__name__) # schema_version метадата-строки отчёта в analysis_runs. Собственная схема (НЕ analyze / # НЕ §22-форсайт "1.0") — по ней ищем существующий отчёт для cache-hit. REPORT_SCHEMA_VERSION = "report-pdf-1.0" # schema_version §22-форсайт-рана (зеркало `_FORECAST_SCHEMA_VERSION` в parcels.py — НЕ # "forecast-1.0", а именно "1.0", это SiteFinderReport._SCHEMA_VERSION). _FORECAST_SCHEMA_VERSION = "1.0" # Верхняя граница длинной стороны карт-PNG (px) — печатный A4, 1400 достаточно для # ~150 dpi на ширину колонки, но не раздувает PDF гигабайтными растрами. _MAP_MAX_PX = 1400 # Радиус поиска ЦП для connection-capacity (м) — тот же дефолт, что у эндпоинта # GET /{cad}/connection-capacity (parcels.py) и функции get_connection_capacity. _CONNECTION_CAPACITY_RADIUS_M = 3000 def render_full_report_pdf(html: str) -> bytes: """Отрендерить готовый HTML полного отчёта в PDF-байты (WeasyPrint). Тонкая обёртка: WeasyPrint импортируется ЛОКАЛЬНО (тяжёлый native — не нужен при импорте модуля, иначе ломает pytest-сбор на macOS без GTK/Pango). Образец — `layout_tz_pdf.render_layout_tz_pdf`. Args: html: полный HTML-документ (`build_full_report_html`). Returns: PDF-байты (готовы к записи файла / FileResponse). """ from weasyprint import HTML pdf_bytes: bytes = HTML(string=html).write_pdf() logger.info("render_full_report_pdf: %d bytes", len(pdf_bytes)) return pdf_bytes def _generate_concept_result(db: Session, analyze: dict[str, Any]) -> dict[str, Any] | None: """Сгенерировать концепцию server-side как это делает POST /concepts (best-effort). Концепция НЕ персистится — генерируем на лету из геометрии участка с ДЕФОЛТНОЙ программой (count=3 comfort — три стратегии жадной раскладки). Повторяем поток `create_concept` (concepts.py): рыночная цена через `_lookup_market_price` по центроиду → `geometry.generate` → сериализуем `ConceptOutput`. Любая ошибка (нет geom / вырожденный участок / сбой БД) → None: отчёт валиден без §7-концепции, §5 деградирует в рыночный контекст. Args: db: SQLAlchemy session (для lookup рыночной цены). analyze: persist-payload analyze-рана (несёт `geom_geojson` участка). Returns: Сериализованный `ConceptOutput` (dict `{"variants": [...]}`) или None. """ geom = analyze.get("geom_geojson") if not isinstance(geom, dict): logger.info("build_full_report: analyze-payload без geom_geojson → §7-концепция пропущена") return None try: # Lazy import — тяжёлый generative-стек не нужен на module-load; concepts-хелперы # цены живут в API-слое (он знает БД), переиспользуем ИМЕННО их (single source). from app.api.v1.concepts import _lookup_market_price, _parcel_centroid_wkt from app.schemas.concept import ConceptInput, ConceptOutput from app.services.generative import geometry payload = ConceptInput(parcel_geojson=geom) # дефолт: housing_class="comfort", 3 стратегии market_price: float | None = None price_source = "class_norm" try: wkt_point = _parcel_centroid_wkt(payload) market_price, price_source = _lookup_market_price(db, wkt_point) except Exception as exc: # Рыночная цена best-effort — деградируем в норматив класса, не роняем концепцию. logger.warning("build_full_report: market-price lookup упал, class_norm: %s", exc) variants = geometry.generate( payload, market_price_per_sqm=market_price, price_source=price_source, ) result: dict[str, Any] = ConceptOutput(variants=variants).model_dump() logger.info("build_full_report: концепция сгенерирована (%d вариантов)", len(variants)) return result except Exception: # Вырожденный участок / сбой generative-стека — отчёт валиден БЕЗ §7-концепции. logger.exception("build_full_report: генерация концепции упала → §7 без концепции") return None def _get_connection_capacity(db: Session, cad: str) -> dict[str, Any] | None: """Дотянуть connection-capacity для §3-резервов (best-effort). connection-capacity НЕ в persist-payload (ленивый эндпоинт) — тянем `get_connection_capacity` как это делает GET /{cad}/connection-capacity. Ошибка (участок без geom / сбой БД) → None: §3 деградирует к тому, что есть в analyze-payload (OSM-сети + НСПД-сооружения). """ try: from app.services.site_finder.connection_capacity_lookup import get_connection_capacity return get_connection_capacity(db, cad, _CONNECTION_CAPACITY_RADIUS_M) except Exception: logger.exception("build_full_report: connection-capacity lookup упал → §3 без резервов") return None def _find_cached_report( db: Session, cad: str, analyze_run_id: int | None, forecast_run_id: int | None, ) -> dict[str, Any] | None: """Найти готовый метадата-ран `report-pdf-1.0` с тем же кэш-ключом + существующим файлом. Кэш-ключ = (analyze_run_id, forecast_run_id). Совпали ОБА id И файл `pdf_path` на месте → возвращаем result метадата-рана (cache-hit, не пере-рендерим). Иначе None. """ row = latest_run_for(db, cad, schema_version=REPORT_SCHEMA_VERSION) if row is None: return None result = row.result if isinstance(row.result, dict) else {} if ( result.get("analyze_run_id") == analyze_run_id and result.get("forecast_run_id") == forecast_run_id ): pdf_path = result.get("pdf_path") if isinstance(pdf_path, str) and os.path.exists(pdf_path): return result return None def _cad_safe(cad: str) -> str: """Кад.номер → безопасный компонент имени файла (whitelist). Кад.номер приходит из URL-path — жёстко ограничиваем алфавит `[0-9:]` (цифры + разделители кад.номера РФ), ВСЁ прочее вырезаем, затем `:` → `_`. Так в имя файла физически не попадут `/`, `..`, пробелы или спецсимволы (path-traversal / инъекция в путь), даже если path-param окажется мусорным. """ return re.sub(r"[^0-9:]", "", cad).replace(":", "_") def build_full_report(db: Session, cad: str) -> dict[str, Any]: """Собрать (или вернуть из кэша) полный PDF-отчёт участка + метадата-ран. #2259 PR-D. Оркестрация: analyze-ран (нет → ValueError) → forecast-ран (нет → Part B «нет данных») → cache-check по (analyze_run_id, forecast_run_id) → connection-capacity (§3-резервы, best-effort) → концепция server-side (best-effort) → HTML (PR-A/B) + карты (PR-C) → PDF (WeasyPrint) → запись файла на volume + метадата-ран `report-pdf-1.0`. Args: db: SQLAlchemy session. cad: кадастровый номер участка. Returns: Метадата отчёта: {pdf_path, analyze_run_id, forecast_run_id, generated_at, size_bytes} (+ `cache_hit: True` если вернули существующий файл). Raises: ValueError: нет analyze-рана для участка (нечего отчётировать). """ analyze_row = latest_run_for(db, cad, schema_version=ANALYZE_SCHEMA_VERSION) if analyze_row is None: raise ValueError(f"нет analyze-рана для {cad!r} — сначала запустите /analyze") analyze: dict[str, Any] = analyze_row.result or {} analyze_run_id = int(analyze_row.id) forecast_row = latest_run_for(db, cad, schema_version=_FORECAST_SCHEMA_VERSION) forecast: dict[str, Any] = (forecast_row.result or {}) if forecast_row is not None else {} forecast_run_id = int(forecast_row.id) if forecast_row is not None else None # Cache-hit: тот же (analyze, forecast) ран-ключ + файл на месте → не пере-рендерим. cached = _find_cached_report(db, cad, analyze_run_id, forecast_run_id) if cached is not None: logger.info( "build_full_report: cache-hit cad=%s analyze=%s forecast=%s path=%s", cad, analyze_run_id, forecast_run_id, cached.get("pdf_path"), ) return {**cached, "cache_hit": True} # connection-capacity (§3-резервы) + концепция (§7) — оба best-effort. connection_capacity = _get_connection_capacity(db, cad) concept = _generate_concept_result(db, analyze) # Адрес участка для титула (egrn.address; None → строка адреса не рисуется). egrn = analyze.get("egrn") if isinstance(analyze.get("egrn"), dict) else {} address = egrn.get("address") if isinstance(egrn, dict) else None generated_at = _dt.datetime.now(_dt.UTC) generated_at_ru = generated_at.strftime("%d.%m.%Y") # HTML (PR-A/B): §1–§3 (+ connection-capacity в §3) и §4–§7 (пустой forecast → «нет данных»). part_a = build_full_report_html_part_a( analyze, cad=cad, connection_capacity=connection_capacity ) part_b = build_full_report_html_part_b(forecast, concept, cad=cad) html = build_full_report_html( part_a, part_b, cad=cad, address=address if isinstance(address, str) else None, generated_at=generated_at_ru, ) # Карты (PR-C): участок (§1/§3) + концепция (§7, лучший вариант). Оба graceful: # None → embed_map_png ставит плашку «карта недоступна». parcel_png = render_parcel_map_png(analyze, max_px=_MAP_MAX_PX) html = embed_map_png(html, MAP_PARCEL_PLACEHOLDER, parcel_png) concept_png: bytes | None = None if isinstance(concept, dict): variants = concept.get("variants") if isinstance(variants, list) and variants and isinstance(variants[0], dict): concept_png = render_concept_footprint_png( variants[0], analyze.get("geom_geojson"), max_px=_MAP_MAX_PX ) html = embed_map_png(html, MAP_CONCEPT_PLACEHOLDER, concept_png) pdf_bytes = render_full_report_pdf(html) # Запись файла на volume + метадата-ран. Каталог создаём (parents, exist_ok). reports_dir = Path(settings.reports_dir) reports_dir.mkdir(parents=True, exist_ok=True) file_name = f"gendesign_report_{_cad_safe(cad)}_{generated_at_ru}.pdf" pdf_path = reports_dir / file_name # АТОМАРНАЯ запись: пишем в .tmp рядом и os.replace → финальный путь. Два конкурентных # POST в один день целятся в ОДИН pdf_path (имя несёт только дату) — прямой write_bytes # мог бы interleave-писать байты обоих рендеров в один файл (битый PDF). os.replace # атомарен в пределах одной FS → download всегда видит целый файл (свой или чужой). tmp_path = pdf_path.with_suffix(f".{os.getpid()}.tmp") tmp_path.write_bytes(pdf_bytes) os.replace(tmp_path, pdf_path) size_bytes = len(pdf_bytes) result: dict[str, Any] = { "pdf_path": str(pdf_path), "analyze_run_id": analyze_run_id, "forecast_run_id": forecast_run_id, "generated_at": generated_at.isoformat(), "size_bytes": size_bytes, } # Метадата-ран `report-pdf-1.0` (best-effort persist; провал не роняет отчёт — PDF # уже записан, просто следующий вызов не поймает cache-hit и пере-рендерит). persist_analysis_run( db, cad_num=cad, result=result, params={"source": "report-pdf-task"}, district=analyze_row.district, confidence=None, status="done", schema_version=REPORT_SCHEMA_VERSION, created_by=None, ) logger.info( "build_full_report: cad=%s written path=%s size=%d analyze=%s forecast=%s", cad, pdf_path, size_bytes, analyze_run_id, forecast_run_id, ) return result