All checks were successful
CI Trade-In / changes (pull_request) Successful in 6s
CI / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m4s
CI / backend-tests (pull_request) Successful in 14m34s
Полный PDF-отчёт не-открытого участка выходил без §4-§6: форсайт-ран (schema "1.0") строится лениво GET /forecast-поллингом и на холодном участке отсутствует (стресс-свип: 66:41:0001001:30). build_full_report теперь при отсутствии рана считает форсайт синхронно прямым вызовом build_site_finder_report (мы уже в worker'е, ~20-30с) с теми же дефолтами, что Celery-таск (horizons=[6,12,18,24] = _horizons_for(12), district-fallback, persist schema "1.0" — контракт сверен построчно, потребители по params.source не фильтруют), и перечитывает ран. Best-effort: сбой → warning (не exception — GlitchTip), отчёт без §4-§6 как раньше. Отчёт самодостаточен (как §7-концепция). Таймлимит 600с не тронут — worst-case ~250с < 540с soft. Refs #2259
494 lines
26 KiB
Python
494 lines
26 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")`) — нет → 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`: `.<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)
|
||
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)
|
||
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
|