gendesign/backend/app/services/exporters/full_report_pdf.py
bot-backend c129515ab8
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 3m34s
Deploy / build-worker (push) Successful in 4m25s
Deploy / build-frontend (push) Successful in 5m25s
Deploy / deploy (push) Successful in 1m33s
feat(exporters): DOCX-вариант полного отчёта ПТИЦА (#2259 R2) (#2301)
2026-07-03 20:21:19 +00:00

402 lines
20 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.

"""Оркестратор полного 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`).
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"
# Верхняя граница длинной стороны карт-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 _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`: `.<pid>.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)
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)
# 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