325 lines
16 KiB
Python
325 lines
16 KiB
Python
"""Оркестратор полного 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
|