"""Оркестратор полного 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")`) — нет → best-effort СИНХРОННО считаем его тут же (`_ensure_forecast_run`, зеркало Celery-таски форсайта, ~20–30с) и перечитываем; всё ещё нет (сбой/тонкие данные) → 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`). 6b. DOCX-вариант (PR-F, `build_full_report_docx`) из ТЕХ ЖЕ исходных словарей + ТЕХ ЖЕ карт-PNG (НЕ рендерим карты дважды) — рядом `.docx`-файл. 7. Запись файлов (PDF + DOCX, атомарно tmp+os.replace) + метадата-ран `report-pdf-1.0` (result = pdf_path/docx_path/analyze_run_id/forecast_run_id/generated_at/size_bytes/ docx_size_bytes). Старые раны без docx_path → download?format=docx отдаёт 404. 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_docx import build_full_report_docx 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" # Горизонты best-effort синхронного форсайта (мес). Зеркало Celery-таски # `forecast_site_finder_report(horizon=12)`: `_horizons_for(12)` = sorted({6,12,18,24}|{12}) # = [6,12,18,24] (forecast.py) = orchestrator._DEFAULT_HORIZONS. Держим тот же набор, # чтобы «холодный» участок получил §4–§6 идентичные ленивому GET /forecast-пути. _FORECAST_HORIZONS: tuple[int, ...] = (6, 12, 18, 24) # Верхняя граница длинной стороны карт-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 _largest_polygon_geojson(geom: dict[str, Any]) -> dict[str, Any]: """MultiPolygon → GeoJSON крупнейшего полигона-контура; Polygon и прочее — как есть. Многоконтурный участок приходит как MultiPolygon, а concept-стек (`parse_parcel` + `_parcel_centroid_wkt`) принимает только Polygon → `ParcelGeometryError`. Берём контур с максимальной площадью (тот же приём, что generative-геометрия применяет к buildable-мультиполигону после буфера, geometry.py:263-265). Любой сбой парсинга / не-MultiPolygon → возвращаем geom без изменений (best-effort, не роняем концепцию). Args: geom: GeoJSON-геометрия участка (Polygon / MultiPolygon / Feature-обёртка). Returns: GeoJSON Polygon крупнейшего контура (если вход был MultiPolygon), иначе `geom`. """ geom_dict = geom.get("geometry") if geom.get("type") == "Feature" else geom if not isinstance(geom_dict, dict) or geom_dict.get("type") != "MultiPolygon": return geom try: from shapely.geometry import mapping, shape multi = shape(geom_dict) if multi.is_empty or not hasattr(multi, "geoms"): return geom largest = max(multi.geoms, key=lambda g: g.area) logger.info( "build_full_report: multi-contour участок — взят крупнейший контур из %d", len(list(multi.geoms)), ) return dict(mapping(largest)) except Exception: # Вырожденная/битая геометрия — отдаём как есть, concept-стек сам решит (best-effort). logger.exception("build_full_report: не удалось выделить крупнейший контур MultiPolygon") return geom 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 # Многоконтурный участок → geom = MultiPolygon, а concept-стек (parse_parcel + # _parcel_centroid_wkt через _parse_polygon) принимает ТОЛЬКО Polygon и роняет # ParcelGeometryError("expected Polygon, got MultiPolygon"). Берём крупнейший контур — # ровно как generative-геометрия после буфера (geometry.py:263-265). Иначе §7 и # market-price молча деградируют на любом мультиконтуре. geom = _largest_polygon_geojson(geom) 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 _ensure_forecast_run( db: Session, cad: str, analyze_row: Any, analyze: dict[str, Any], ) -> None: """Best-effort синхронно посчитать §22-форсайт для «холодного» участка (#2259 gap). §22-форсайт-ран ("1.0") существует ТОЛЬКО если пользователь открывал страницу участка (§22 строится лениво GET /forecast-поллингом Celery-таской `forecast_site_finder_report`). На холодном участке полный отчёт выходил БЕЗ §4–§6. Здесь — тот же compute+persist, что делает Celery-таска, но СИНХРОННО и ПРЯМЫМ вызовом: мы УЖЕ внутри worker'а (build_full_report_task), поэтому НЕ .delay — считаем inline (~20–30с) и персистим тем же контрактом, чтобы последующий `latest_run_for("1.0")` в build_full_report поймал свежий ран (его новый id корректно войдёт в кэш-ключ отчёта → cache-miss → §4–§6 попадут в PDF). Зеркало `forecast_site_finder_report(horizon=12)` (workers/tasks/forecast.py): • horizons = `_FORECAST_HORIZONS` (= `_horizons_for(12)` = [6,12,18,24]); • district = денорм-колонка рана → fallback analyze["district"]["district_name"]; • `build_site_finder_report(...)` → `report.as_dict()` → `persist_analysis_run(..., schema_version=d["schema_version"], status="done", ...)`. Best-effort: ЛЮБОЙ сбой/долгий compute → logger.warning + return (НЕ exception — форсайт может честно не собраться на тонких данных, GlitchTip-шум не нужен). Тогда отчёт, как и раньше, выйдет без §4–§6 (part_b деградирует «нет данных»). Args: db: SQLAlchemy session (та же, что у build_full_report — свою НЕ открываем). cad: кадастровый номер участка. analyze_row: Row analyze-рана (несёт денорм `district`). analyze: persist-payload analyze-рана (district-fallback + competitors для сегмента). """ try: # Lazy import — тяжёлый forecasting-стек не нужен на module-load (как concept-стек). from app.services.forecasting.orchestrator import build_site_finder_report district = analyze_row.district or (analyze.get("district") or {}).get("district_name") logger.info( "build_full_report: холодный участок cad=%s — best-effort синхронный §22-форсайт " "(district=%s horizons=%s)", cad, district, _FORECAST_HORIZONS, ) report = build_site_finder_report( db, analyze=analyze, cad_num=cad, district=district, horizons=_FORECAST_HORIZONS, ) d = report.as_dict() new_id = persist_analysis_run( db, cad_num=cad, result=d, params={"horizon": 12, "source": "full-report-inline-forecast"}, district=district, confidence=(d.get("confidence") or {}).get("level"), status="done", schema_version=d["schema_version"], # "1.0" (SiteFinderReport._SCHEMA_VERSION) created_by=None, segment=(d.get("meta") or {}).get("segment"), ) logger.info("build_full_report: §22-форсайт посчитан inline cad=%s run_id=%s", cad, new_id) except Exception: # Форсайт может честно не собраться (тонкие данные / сбой §9.x-шва) или занять # слишком долго — деградируем в отчёт без §4–§6 (warning, НЕ exception: держим # GlitchTip-шум в узде, ведёт себя как ленивая Celery-таска, которая тоже best-effort). logger.warning( "build_full_report: inline §22-форсайт не собрался cad=%s → отчёт без §4–§6", cad, exc_info=True, ) 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 _atomic_write(path: Path, data: bytes) -> None: """Атомарно записать байты в `path`: `..tmp` рядом → `os.replace`. Два конкурентных POST в один день целятся в ОДИН путь (имя несёт только дату) — прямой `write_bytes` мог бы interleave-писать байты обоих рендеров в один файл (битый вывод). `os.replace` атомарен в пределах одной FS → download всегда видит целый файл (свой или чужой). Общий для PDF и DOCX (тот же приём). """ tmp_path = path.with_suffix(f".{os.getpid()}.tmp") tmp_path.write_bytes(data) os.replace(tmp_path, path) 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) if forecast_row is None: # Холодный участок: §22-форсайт-ран ("1.0") строится лениво GET /forecast-поллингом # и на не-открытом участке отсутствует → отчёт выходил без §4–§6. Best-effort # считаем его СИНХРОННО прямо тут (мы в worker'е) и перечитываем — самодостаточность # отчёта важнее +20–30с (как §7-концепция генерится в оркестраторе). Сбой/долго → # forecast_row остаётся None, part_b деградирует «нет данных» (как раньше). _ensure_forecast_run(db, cad, analyze_row, analyze) 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, analyze_result=analyze) 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) # DOCX-вариант (PR-F): из ТЕХ ЖЕ исходных словарей + ТЕХ ЖЕ карт-PNG (parcel_png / # concept_png уже отрендерены выше — НЕ рендерим карты дважды). Зеркалит §1–§7 PDF. docx_bytes = build_full_report_docx( analyze, forecast, concept, connection_capacity, cad=cad, address=address if isinstance(address, str) else None, generated_at=generated_at_ru, parcel_map_png=parcel_png, concept_map_png=concept_png, ) # Запись файлов на volume + метадата-ран. Каталог создаём (parents, exist_ok). reports_dir = Path(settings.reports_dir) reports_dir.mkdir(parents=True, exist_ok=True) base_name = f"gendesign_report_{_cad_safe(cad)}_{generated_at_ru}" pdf_path = reports_dir / f"{base_name}.pdf" docx_path = reports_dir / f"{base_name}.docx" # Атомарная запись обоих файлов (tmp+os.replace — см. `_atomic_write`). _atomic_write(pdf_path, pdf_bytes) _atomic_write(docx_path, docx_bytes) size_bytes = len(pdf_bytes) docx_size_bytes = len(docx_bytes) result: dict[str, Any] = { "pdf_path": str(pdf_path), "docx_path": str(docx_path), "analyze_run_id": analyze_run_id, "forecast_run_id": forecast_run_id, "generated_at": generated_at.isoformat(), "size_bytes": size_bytes, "docx_size_bytes": docx_size_bytes, } # Метадата-ран `report-pdf-1.0` (best-effort persist; провал не роняет отчёт — файлы # уже записаны, просто следующий вызов не поймает 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 pdf=%s (%d B) docx=%s (%d B) analyze=%s forecast=%s", cad, pdf_path, size_bytes, docx_path, docx_size_bytes, analyze_run_id, forecast_run_id, ) return result