feat(exporters): Site Finder report PDF exporter (#989, 955-A3)
Add backend/app/services/exporters/report_pdf.py: export_report_pdf(report)->bytes, the additive PDF counterpart to excel.export_report_xlsx (#991). Renders a SiteFinderReport (#987) to PDF via WeasyPrint across the 6 §13 content sections (Сводка/Рынок сейчас/Будущий рынок/Продукт ТЗ/Сценарии/Скоринг). Mirrors house style: _normalize (instance or as_dict), named-const titles/CSS/ microcopy, RU labels, None→"—", graceful-never-raise, ADVISORY marker, local weasyprint import. All dynamic fields html.escape'd. STRICTLY ADDITIVE — zero edits to the 3 live PDF exporters (tradein/layout/snapshot) per tech-analyst blast-radius analysis; "unify pdf.py" deferred (would rewrite 3 working features for no gain). Endpoint wiring is #959. 16 tests (render proven via Rosetta x86_64; 6 native-render cases need Docker/CI matched-arch libgobject — pure-logic green).
This commit is contained in:
parent
299f2365fb
commit
2c19f35eff
2 changed files with 776 additions and 0 deletions
548
backend/app/services/exporters/report_pdf.py
Normal file
548
backend/app/services/exporters/report_pdf.py
Normal file
|
|
@ -0,0 +1,548 @@
|
||||||
|
"""§13 PDF-экспортёр итогового советующего отчёта Site Finder v2 (WeasyPrint).
|
||||||
|
|
||||||
|
#989 (955-A3, Site Finder v2 / «GG-форсайт» ТЗ §13), EPIC 11 «Отчёт». Рендерит
|
||||||
|
`SiteFinderReport` (#987) в PDF-документ (по одной секции §13 на блок) и возвращает
|
||||||
|
БАЙТЫ — готовые для `Response(media_type="application/pdf")` / записи. Это АДДИТИВНЫЙ
|
||||||
|
двойник `excel.export_report_xlsx` (#991): зеркалит его house-style ОДИН-В-ОДИН —
|
||||||
|
module-level `logger`, `_normalize` (приём инстанса ИЛИ `as_dict()`-словаря), named-
|
||||||
|
константы заголовков/микрокопии/CSS, RU-метки, graceful-None, чистый возврат `bytes`.
|
||||||
|
|
||||||
|
ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM, БЕЗ БД/сети: только ПОТРЕБЛЯЕТ уже-собранный отчёт (его
|
||||||
|
наполняет сборщик #988) и раскладывает по секциям. Принимает КАК `SiteFinderReport`-
|
||||||
|
инстанс, ТАК и его `as_dict()`-словарь (нормализуется через `_normalize`).
|
||||||
|
|
||||||
|
GRACEFUL (дух всего форсайт-стека): частичный/пустой отчёт ВАЛИДЕН — пустая секция
|
||||||
|
рисует «нет данных», экспортёр НИКОГДА не падает. Отчёт СОВЕТУЮЩИЙ: на блоке «Сводка»
|
||||||
|
проставляется заметный ADVISORY-маркер (оценка не основание для инвест-решения).
|
||||||
|
|
||||||
|
БЕЗОПАСНОСТЬ: ВСЕ динамические строковые поля экранируются `html.escape` (микрокопия
|
||||||
|
RU-шаблонная, но трактуем вход как недоверенный — отчёт могут наполнить из внешних
|
||||||
|
источников). WeasyPrint импортируется ЛОКАЛЬНО внутри функции (тяжёлая зависимость —
|
||||||
|
не нужна при импорте модуля; зеркало `snapshot_pdf`).
|
||||||
|
|
||||||
|
Подключение к эндпоинту экспорта — #959 (вне scope: здесь только сам экспортёр).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Named-константы: заголовки секций (по одной на содержательную секцию §13) ──
|
||||||
|
# Тот же набор из шести содержательных секций, что рисует excel.py (#991).
|
||||||
|
|
||||||
|
_TITLE_DOC: str = "Site Finder v2 — советующий отчёт §13"
|
||||||
|
_TITLE_SUMMARY: str = "Сводка"
|
||||||
|
_TITLE_MARKET_NOW: str = "Рынок сейчас"
|
||||||
|
_TITLE_FUTURE_MARKET: str = "Будущий рынок"
|
||||||
|
_TITLE_PRODUCT_TZ: str = "Продукт ТЗ"
|
||||||
|
_TITLE_SCENARIOS: str = "Сценарии"
|
||||||
|
_TITLE_SCORING: str = "Скоринг"
|
||||||
|
|
||||||
|
# ── Named-константы: микрокопия / плейсхолдеры (зеркало excel.py) ──────────────
|
||||||
|
|
||||||
|
# Заглушка пустой ячейки/поля (зеркало `_DASH` у excel.py).
|
||||||
|
_DASH: str = "—"
|
||||||
|
# Текст для полностью пустой секции (graceful: блок есть, данных нет).
|
||||||
|
_NO_DATA: str = "нет данных"
|
||||||
|
# ADVISORY-маркер на блоке «Сводка» — отчёт советующий, не основание для решения.
|
||||||
|
_ADVISORY_MARKER: str = (
|
||||||
|
"ADVISORY: отчёт носит советующий характер и не является основанием "
|
||||||
|
"для инвестиционного решения."
|
||||||
|
)
|
||||||
|
# Округление чисел по умолчанию (скоры/индексы ∈ [0,1] и доли — 3 знака читаемо).
|
||||||
|
_ROUND_DIGITS: int = 3
|
||||||
|
# Сноска-футер на каждой странице (зеркало advisory-духа форсайт-стека).
|
||||||
|
_FOOTER_NOTE: str = "GenDesign · Site Finder v2 · советующий отчёт §13 (не оферта)"
|
||||||
|
|
||||||
|
# RU-метки уровней отчётной уверенности (§15) — зеркало `_LEVEL_RU` у excel.py.
|
||||||
|
_LEVEL_RU: dict[str, str] = {
|
||||||
|
"high": "высокая",
|
||||||
|
"medium": "средняя",
|
||||||
|
"low": "низкая",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Named-константы: CSS (house-accent синий, янтарный ADVISORY — как у соседей) ──
|
||||||
|
|
||||||
|
_CSS: str = """
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 18mm 16mm 18mm 16mm;
|
||||||
|
@bottom-center {
|
||||||
|
content: "Site Finder v2 · советующий отчёт §13 · стр. " counter(page);
|
||||||
|
font-size: 7.5pt;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: 'DejaVu Sans', 'Helvetica', 'Arial', sans-serif;
|
||||||
|
font-size: 10pt;
|
||||||
|
color: #1a1d23;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
h1 { font-size: 18pt; margin: 0 0 4pt 0; line-height: 1.2; color: #fff; }
|
||||||
|
h2 {
|
||||||
|
font-size: 13pt; margin: 0 0 8pt 0;
|
||||||
|
border-bottom: 1.5px solid #e6e8ec; padding-bottom: 4pt; color: #1d4ed8;
|
||||||
|
}
|
||||||
|
h3 { font-size: 11pt; margin: 12pt 0 6pt 0; color: #374151; }
|
||||||
|
.section { margin-bottom: 16pt; }
|
||||||
|
.section:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* Cover / summary header */
|
||||||
|
.cover-header {
|
||||||
|
background: #1d4ed8; color: #fff;
|
||||||
|
padding: 16pt 18pt; border-radius: 6pt; margin-bottom: 14pt;
|
||||||
|
}
|
||||||
|
.cover-meta { font-size: 9pt; color: rgba(255,255,255,0.85); margin-top: 4pt; }
|
||||||
|
.advisory {
|
||||||
|
margin: 0 0 14pt 0; padding: 10pt 12pt;
|
||||||
|
background: #fffbeb; border-left: 4px solid #b45309;
|
||||||
|
border-radius: 0 4pt 4pt 0; font-size: 9.5pt; color: #92400e; font-weight: 700;
|
||||||
|
}
|
||||||
|
.headline { font-size: 13pt; font-weight: 700; margin: 0 0 6pt 0; color: #111827; }
|
||||||
|
.verdict { font-size: 10pt; color: #374151; margin: 0 0 8pt 0; }
|
||||||
|
|
||||||
|
/* Key-value rows */
|
||||||
|
.kv { width: 100%; border-collapse: collapse; margin-top: 4pt; }
|
||||||
|
.kv td { padding: 4pt 7pt; border-bottom: 1px solid #eef0f3; vertical-align: top; }
|
||||||
|
.kv td.k { font-weight: 700; color: #374151; width: 38%; }
|
||||||
|
.kv td.v { color: #1a1d23; }
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
table.data { width: 100%; border-collapse: collapse; margin-top: 8pt; font-size: 9pt; }
|
||||||
|
table.data thead tr { background: #1d4ed8; }
|
||||||
|
table.data th {
|
||||||
|
padding: 5pt 7pt; text-align: left; font-weight: 700;
|
||||||
|
color: #fff; border-bottom: 1.5px solid #1e40af;
|
||||||
|
}
|
||||||
|
table.data td { padding: 5pt 7pt; border-bottom: 1px solid #e6e8ec; vertical-align: top; }
|
||||||
|
table.data tr:nth-child(even) td { background: #f9fafb; }
|
||||||
|
td.empty, .no-data { text-align: center; color: #9ca3af; font-style: italic; padding: 12pt; }
|
||||||
|
.no-data { padding: 6pt 0; }
|
||||||
|
|
||||||
|
/* Confidence badge */
|
||||||
|
.badge {
|
||||||
|
display: inline-block; padding: 2pt 8pt;
|
||||||
|
border-radius: 6pt; font-size: 9pt; font-weight: 700;
|
||||||
|
background: #eff6ff; color: #1d4ed8; border: 1px solid #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
margin-top: 18pt; padding-top: 8pt;
|
||||||
|
border-top: 1px solid #e6e8ec; font-size: 8pt; color: #9ca3af;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Нормализация входа + микро-хелперы форматирования. Все PURE, graceful. Зеркало
|
||||||
|
# `_normalize`/`_fmt`/`_as_dict`/`_as_list`/`_level_ru` у excel.py (#991).
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(report: Any) -> dict[str, Any]:
|
||||||
|
"""Привести вход к `as_dict()`-словарю отчёта (dataclass | dict). PURE, graceful.
|
||||||
|
|
||||||
|
Принимаем КАК `SiteFinderReport`-инстанс (есть метод `as_dict`), ТАК и уже его
|
||||||
|
`as_dict()`-словарь — вызывающий (#959) волен передать любое. Любой иной тип
|
||||||
|
(мусор/None) → пустой dict: экспортёр отрисует пустой отчёт, а не упадёт.
|
||||||
|
"""
|
||||||
|
as_dict = getattr(report, "as_dict", None)
|
||||||
|
if callable(as_dict):
|
||||||
|
result = as_dict()
|
||||||
|
return result if isinstance(result, dict) else {}
|
||||||
|
if isinstance(report, dict):
|
||||||
|
return report
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(value: Any) -> str:
|
||||||
|
"""Привести значение к ТЕКСТУ ячейки PDF: None → "—", float → округление. PURE.
|
||||||
|
|
||||||
|
Числа округляем до `_ROUND_DIGITS` (но bool → «да»/«нет» — это не число для
|
||||||
|
округления). Строки/прочие скаляры — как есть (str). Контейнеры (dict/list)
|
||||||
|
сворачиваем в компактную строку (на случай неожиданно-вложенного значения — не
|
||||||
|
падаем). Возврат — ВСЕГДА str (его экранирует `_esc` перед вставкой в HTML).
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return _DASH
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "да" if value else "нет"
|
||||||
|
if isinstance(value, float):
|
||||||
|
return str(round(value, _ROUND_DIGITS))
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _esc(value: Any) -> str:
|
||||||
|
"""Привести значение к ячейке (`_fmt`) и ЭКРАНИРОВАТЬ для HTML. PURE.
|
||||||
|
|
||||||
|
Единая точка вставки динамических данных в разметку: всё, что попадает в HTML,
|
||||||
|
проходит `html.escape` (вход трактуем как недоверенный, см. модульный docstring).
|
||||||
|
"""
|
||||||
|
return html.escape(_fmt(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _as_dict(value: Any) -> dict[str, Any]:
|
||||||
|
"""Вернуть dict как есть, иначе пустой dict (defensive чтение секций). PURE."""
|
||||||
|
return value if isinstance(value, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _as_list(value: Any) -> list[Any]:
|
||||||
|
"""Вернуть list как есть, иначе пустой list (defensive чтение секций). PURE."""
|
||||||
|
return value if isinstance(value, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def _level_ru(level: Any) -> str:
|
||||||
|
"""RU-метка уровня уверенности (high/medium/low) или "—". PURE."""
|
||||||
|
if isinstance(level, str) and level in _LEVEL_RU:
|
||||||
|
return _LEVEL_RU[level]
|
||||||
|
return _DASH
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Низкоуровневые HTML-помощники (kv-строки / таблицы / заглушки). Все PURE: строят
|
||||||
|
# строку HTML из уже-экранированных кусочков, в БД/сеть не ходят.
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _kv_row(label: str, value: Any) -> str:
|
||||||
|
"""Строка «метка → значение» (оба экранированы). `label` — статичная RU-метка."""
|
||||||
|
return f'<tr><td class="k">{html.escape(label)}</td><td class="v">{_esc(value)}</td></tr>'
|
||||||
|
|
||||||
|
|
||||||
|
def _kv_table(pairs: list[tuple[str, Any]]) -> str:
|
||||||
|
"""Таблица «метка → значение» из списка пар. Пустой список → «нет данных»."""
|
||||||
|
if not pairs:
|
||||||
|
return _no_data()
|
||||||
|
rows = "".join(_kv_row(label, value) for label, value in pairs)
|
||||||
|
return f'<table class="kv">{rows}</table>'
|
||||||
|
|
||||||
|
|
||||||
|
def _dict_kv_table(data: dict[str, Any]) -> str:
|
||||||
|
"""Таблица «ключ → значение» из плоского dict (ключи — данные, тоже экранируем)."""
|
||||||
|
if not data:
|
||||||
|
return _no_data()
|
||||||
|
rows = "".join(_kv_row(str(key), value) for key, value in data.items())
|
||||||
|
return f'<table class="kv">{rows}</table>'
|
||||||
|
|
||||||
|
|
||||||
|
def _data_table(headers: list[str], rows: list[list[Any]]) -> str:
|
||||||
|
"""Таблица: шапка (статичные RU-метки) + строки данных (через `_esc`). PURE.
|
||||||
|
|
||||||
|
Пустой `rows` → одна строка-заглушка «нет данных» под шапкой (graceful — таблица
|
||||||
|
всё равно валидна). Все ячейки данных экранируются.
|
||||||
|
"""
|
||||||
|
head = "".join(f"<th>{html.escape(h)}</th>" for h in headers)
|
||||||
|
if not rows:
|
||||||
|
body = f'<tr><td class="empty" colspan="{len(headers)}">{_NO_DATA}</td></tr>'
|
||||||
|
else:
|
||||||
|
body = "".join(
|
||||||
|
"<tr>" + "".join(f"<td>{_esc(cell)}</td>" for cell in row) + "</tr>" for row in rows
|
||||||
|
)
|
||||||
|
return f'<table class="data"><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>'
|
||||||
|
|
||||||
|
|
||||||
|
def _no_data() -> str:
|
||||||
|
"""Блок-заглушка «нет данных» для пустой секции (graceful). PURE."""
|
||||||
|
return f'<div class="no-data">{_NO_DATA}</div>'
|
||||||
|
|
||||||
|
|
||||||
|
def _join_ints(values: list[Any]) -> Any:
|
||||||
|
"""Свернуть список горизонтов в строку «6, 12, 18» или None (для `_fmt` → "—")."""
|
||||||
|
return ", ".join(str(v) for v in values) if values else None
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Построители секций — по одной на содержательную секцию §13. Каждый graceful:
|
||||||
|
# пустая секция → «нет данных», НИКОГДА не падает. Возвращают HTML-фрагмент.
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _build_summary(report: dict[str, Any]) -> str:
|
||||||
|
"""Блок «Сводка»: cover + ADVISORY-маркер + вердикт + ключевые числа + контекст."""
|
||||||
|
exec_summary = _as_dict(report.get("exec_summary"))
|
||||||
|
meta = _as_dict(report.get("meta"))
|
||||||
|
confidence = _as_dict(report.get("confidence"))
|
||||||
|
|
||||||
|
headline = exec_summary.get("headline")
|
||||||
|
verdict = exec_summary.get("verdict")
|
||||||
|
key_numbers = _as_dict(exec_summary.get("key_numbers"))
|
||||||
|
|
||||||
|
cad = _esc(meta.get("cad_num"))
|
||||||
|
district = _esc(meta.get("district"))
|
||||||
|
horizons = _join_ints(_as_list(meta.get("horizons")))
|
||||||
|
|
||||||
|
context_pairs: list[tuple[str, Any]] = [
|
||||||
|
("Кадастровый номер", meta.get("cad_num")),
|
||||||
|
("Район", meta.get("district")),
|
||||||
|
("Горизонты (мес)", horizons),
|
||||||
|
("Сформировано", meta.get("generated_at")),
|
||||||
|
("Версия схемы", meta.get("schema_version")),
|
||||||
|
]
|
||||||
|
confidence_pairs: list[tuple[str, Any]] = [
|
||||||
|
("Уровень", _level_ru(confidence.get("level"))),
|
||||||
|
("Обоснование", confidence.get("rationale")),
|
||||||
|
]
|
||||||
|
overall_conf = _esc(_level_ru(exec_summary.get("overall_confidence")))
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="section" id="summary">
|
||||||
|
<div class="cover-header">
|
||||||
|
<h1>{html.escape(_TITLE_DOC)}</h1>
|
||||||
|
<div class="cover-meta">Кадастровый номер: {cad} · Район: {district}</div>
|
||||||
|
</div>
|
||||||
|
<div class="advisory">{html.escape(_ADVISORY_MARKER)}</div>
|
||||||
|
|
||||||
|
<h2>{html.escape(_TITLE_SUMMARY)}</h2>
|
||||||
|
<p class="headline">{_esc(headline)}</p>
|
||||||
|
<p class="verdict">{_esc(verdict)}</p>
|
||||||
|
<span class="badge">Общая уверенность: {overall_conf}</span>
|
||||||
|
|
||||||
|
<h3>Ключевые числа</h3>
|
||||||
|
{_dict_kv_table(key_numbers)}
|
||||||
|
|
||||||
|
<h3>Уверенность отчёта</h3>
|
||||||
|
{_kv_table(confidence_pairs)}
|
||||||
|
|
||||||
|
<h3>Контекст</h3>
|
||||||
|
{_kv_table(context_pairs)}
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_market_now(report: dict[str, Any]) -> str:
|
||||||
|
"""Блок «Рынок сейчас»: резюме + метрики + слои предложения + конкуренты."""
|
||||||
|
market_now = _as_dict(report.get("market_now"))
|
||||||
|
metrics = _as_dict(market_now.get("market_metrics"))
|
||||||
|
supply = _as_dict(market_now.get("supply_layers"))
|
||||||
|
competitors = _as_list(market_now.get("competitors"))
|
||||||
|
|
||||||
|
comp_rows = [
|
||||||
|
[c.get("comm_name"), c.get("obj_id"), c.get("relevance_weight")]
|
||||||
|
for c in competitors
|
||||||
|
if isinstance(c, dict)
|
||||||
|
]
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="section" id="market-now">
|
||||||
|
<h2>{html.escape(_TITLE_MARKET_NOW)}</h2>
|
||||||
|
<p class="verdict">{_esc(market_now.get("summary"))}</p>
|
||||||
|
|
||||||
|
<h3>Метрики рынка</h3>
|
||||||
|
{_dict_kv_table(metrics)}
|
||||||
|
|
||||||
|
<h3>Слои предложения</h3>
|
||||||
|
{_dict_kv_table(supply)}
|
||||||
|
|
||||||
|
<h3>Конкуренты</h3>
|
||||||
|
{_data_table(["ЖК", "ID", "Релевантность"], comp_rows)}
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_future_market(report: dict[str, Any]) -> str:
|
||||||
|
"""Блок «Будущий рынок»: резюме + прогноз по горизонтам + разброс сценариев."""
|
||||||
|
future = _as_dict(report.get("future_market"))
|
||||||
|
forecasts = _as_list(future.get("forecasts_by_horizon"))
|
||||||
|
scenarios_summary = _as_dict(future.get("scenarios_summary"))
|
||||||
|
|
||||||
|
forecast_rows = [
|
||||||
|
[
|
||||||
|
f.get("horizon_months"),
|
||||||
|
f.get("demand"),
|
||||||
|
f.get("supply"),
|
||||||
|
f.get("deficit_index"),
|
||||||
|
_level_ru(f.get("confidence")),
|
||||||
|
]
|
||||||
|
for f in forecasts
|
||||||
|
if isinstance(f, dict)
|
||||||
|
]
|
||||||
|
forecast_headers = ["Горизонт, мес", "Спрос", "Предложение", "Индекс дефицита", "Уверенность"]
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="section" id="future-market">
|
||||||
|
<h2>{html.escape(_TITLE_FUTURE_MARKET)}</h2>
|
||||||
|
<p class="verdict">{_esc(future.get("summary"))}</p>
|
||||||
|
|
||||||
|
<h3>Прогноз по горизонтам</h3>
|
||||||
|
{_data_table(forecast_headers, forecast_rows)}
|
||||||
|
|
||||||
|
<h3>Разброс сценариев (индекс дефицита)</h3>
|
||||||
|
{_dict_kv_table(scenarios_summary)}
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_product_tz(report: dict[str, Any]) -> str:
|
||||||
|
"""Блок «Продукт ТЗ»: класс + квартирография (mix) + коммерция + USP."""
|
||||||
|
product = _as_dict(report.get("product_tz"))
|
||||||
|
mix = _as_list(product.get("mix"))
|
||||||
|
commercial = _as_dict(product.get("commercial"))
|
||||||
|
usp = _as_list(product.get("usp"))
|
||||||
|
|
||||||
|
mix_rows = [
|
||||||
|
[m.get("bucket"), m.get("pct"), m.get("obj_class"), m.get("deficit_index")]
|
||||||
|
for m in mix
|
||||||
|
if isinstance(m, dict)
|
||||||
|
]
|
||||||
|
usp_rows = [[u.get("segment"), u.get("usp_text")] for u in usp if isinstance(u, dict)]
|
||||||
|
|
||||||
|
head_pairs: list[tuple[str, Any]] = [
|
||||||
|
("Резюме", product.get("summary")),
|
||||||
|
("Рекомендованный класс", product.get("obj_class")),
|
||||||
|
]
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="section" id="product-tz">
|
||||||
|
<h2>{html.escape(_TITLE_PRODUCT_TZ)}</h2>
|
||||||
|
{_kv_table(head_pairs)}
|
||||||
|
|
||||||
|
<h3>Квартирография (mix)</h3>
|
||||||
|
{_data_table(["Формат", "Доля, %", "Класс", "Индекс дефицита"], mix_rows)}
|
||||||
|
|
||||||
|
<h3>Коммерция</h3>
|
||||||
|
{_dict_kv_table(commercial)}
|
||||||
|
|
||||||
|
<h3>USP-ниши</h3>
|
||||||
|
{_data_table(["Сегмент", "USP"], usp_rows)}
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_scenarios(report: dict[str, Any]) -> str:
|
||||||
|
"""Блок «Сценарии»: conservative/base/aggressive (таблица). Graceful."""
|
||||||
|
scenarios = _as_dict(report.get("scenarios"))
|
||||||
|
by_scenario = _as_dict(scenarios.get("by_scenario"))
|
||||||
|
|
||||||
|
rows: list[list[Any]] = []
|
||||||
|
for name, payload in by_scenario.items():
|
||||||
|
data = _as_dict(payload)
|
||||||
|
rows.append([name, data.get("overall"), data.get("advisory")])
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="section" id="scenarios">
|
||||||
|
<h2>{html.escape(_TITLE_SCENARIOS)}</h2>
|
||||||
|
<p class="verdict">{_esc(scenarios.get("summary"))}</p>
|
||||||
|
|
||||||
|
<h3>Сводка по сценариям</h3>
|
||||||
|
{_data_table(["Сценарий", "Overall", "Advisory"], rows)}
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_scoring(report: dict[str, Any]) -> str:
|
||||||
|
"""Блок «Скоринг»: продуктовые скоры (#985) + спец-индексы (#986) + overall."""
|
||||||
|
scoring = _as_dict(report.get("scoring"))
|
||||||
|
product_scores = _as_dict(scoring.get("product_scores"))
|
||||||
|
special_indices = _as_dict(scoring.get("special_indices"))
|
||||||
|
|
||||||
|
scores = _as_dict(product_scores.get("scores"))
|
||||||
|
score_rows = [[name, _as_dict(payload).get("value")] for name, payload in scores.items()]
|
||||||
|
|
||||||
|
indices = _as_dict(special_indices.get("indices"))
|
||||||
|
index_rows = [
|
||||||
|
[name, _as_dict(payload).get("value"), _as_dict(payload).get("label")]
|
||||||
|
for name, payload in indices.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="section" id="scoring">
|
||||||
|
<h2>{html.escape(_TITLE_SCORING)}</h2>
|
||||||
|
<span class="badge">Итоговый скор (overall): {_esc(scoring.get("overall"))}</span>
|
||||||
|
|
||||||
|
<h3>Продуктовые скоры</h3>
|
||||||
|
{_data_table(["Скор", "Значение"], score_rows)}
|
||||||
|
|
||||||
|
<h3>Специальные индексы</h3>
|
||||||
|
{_data_table(["Индекс", "Значение", "Метка"], index_rows)}
|
||||||
|
|
||||||
|
<div class="footer">{html.escape(_FOOTER_NOTE)}</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# Реестр построителей секций. Порядок = порядок блоков в документе (зеркало
|
||||||
|
# `_SHEET_BUILDERS` у excel.py — тот же набор из шести содержательных секций §13).
|
||||||
|
_SECTION_BUILDERS: tuple[Any, ...] = (
|
||||||
|
_build_summary,
|
||||||
|
_build_market_now,
|
||||||
|
_build_future_market,
|
||||||
|
_build_product_tz,
|
||||||
|
_build_scenarios,
|
||||||
|
_build_scoring,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_html(report: dict[str, Any]) -> str:
|
||||||
|
"""Склеить HTML-документ из шести секций §13. PURE (только строки). Graceful."""
|
||||||
|
sections = "".join(builder(report) for builder in _SECTION_BUILDERS)
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{html.escape(_TITLE_DOC)}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{sections}
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Публичный API — рендер отчёта в PDF-байты (без файлового I/O на диск).
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def export_report_pdf(report: Any) -> bytes:
|
||||||
|
"""§13 Отрендерить `SiteFinderReport` (#987) в PDF-документ и вернуть БАЙТЫ.
|
||||||
|
|
||||||
|
По одному блоку на содержательную секцию §13 (Сводка / Рынок сейчас / Будущий
|
||||||
|
рынок / Продукт ТЗ / Сценарии / Скоринг — тот же набор, что и `export_report_xlsx`).
|
||||||
|
Шапки таблиц с заливкой, RU-метки, числа округлены, None → "—". На блоке «Сводка» —
|
||||||
|
заметный ADVISORY-маркер (отчёт советующий). ВСЕ динамические строки экранируются
|
||||||
|
`html.escape`.
|
||||||
|
|
||||||
|
ДЕТЕРМИНИРОВАННО, БЕЗ LLM/БД/сети. Принимает КАК `SiteFinderReport`-инстанс, ТАК и
|
||||||
|
его `as_dict()`-словарь (нормализуется через `_normalize`). GRACEFUL: частичный/
|
||||||
|
пустой отчёт → блоки с «нет данных», НИКОГДА не падает.
|
||||||
|
|
||||||
|
WeasyPrint импортируется ЛОКАЛЬНО (тяжёлая зависимость — не нужна при импорте
|
||||||
|
модуля; зеркало `snapshot_pdf`).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
report: `SiteFinderReport`-инстанс или его `as_dict()`-словарь.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Непустые PDF-байты (начинаются с `b"%PDF"`), готовые для
|
||||||
|
`Response(media_type="application/pdf")`.
|
||||||
|
"""
|
||||||
|
# WeasyPrint импортируем локально — тяжёлый; не нужен при импорте модуля.
|
||||||
|
try:
|
||||||
|
from weasyprint import CSS, HTML
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"WeasyPrint не установлен. Добавь 'weasyprint>=62.0' в pyproject.toml."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
data = _normalize(report)
|
||||||
|
html_str = _build_html(data)
|
||||||
|
pdf_bytes: bytes = HTML(string=html_str).write_pdf(stylesheets=[CSS(string=_CSS)])
|
||||||
|
|
||||||
|
meta = _as_dict(data.get("meta"))
|
||||||
|
logger.info(
|
||||||
|
"export_report_pdf: cad_num=%s sections=%d size=%d bytes advisory=%s",
|
||||||
|
meta.get("cad_num"),
|
||||||
|
len(_SECTION_BUILDERS),
|
||||||
|
len(pdf_bytes),
|
||||||
|
data.get("advisory"),
|
||||||
|
)
|
||||||
|
return pdf_bytes
|
||||||
228
backend/tests/services/exporters/test_report_pdf.py
Normal file
228
backend/tests/services/exporters/test_report_pdf.py
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
"""Unit-тесты §13 PDF-экспортёра (#989, 955-A3) — `export_report_pdf`.
|
||||||
|
|
||||||
|
Чистые тесты БЕЗ БД/сети (экспортёр только ПОТРЕБЛЯЕТ уже-собранный отчёт):
|
||||||
|
• полный `SiteFinderReport` → байты начинаются с `b"%PDF"` + нетривиальная длина;
|
||||||
|
• ожидаемый текст секций присутствует в HTML-источнике (заголовок вердикта, класс
|
||||||
|
продукта, RU-заголовки блоков) — рендерим тот же HTML, что уходит в WeasyPrint;
|
||||||
|
• экспортёр принимает КАК dataclass-инстанс, ТАК и его `as_dict()`-словарь;
|
||||||
|
• частичный / пустой / мусорный отчёт → всё равно валидный PDF (graceful, без падения);
|
||||||
|
• ADVISORY-маркер присутствует.
|
||||||
|
|
||||||
|
Детерминированно, без LLM. DATABASE_URL выставляем до импорта app-модулей (зеркало
|
||||||
|
test_excel.py / test_report.py) — на случай side-effect'ов импорта пакета.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
from app.services.exporters.report_pdf import (
|
||||||
|
_ADVISORY_MARKER,
|
||||||
|
_TITLE_FUTURE_MARKET,
|
||||||
|
_TITLE_MARKET_NOW,
|
||||||
|
_TITLE_PRODUCT_TZ,
|
||||||
|
_TITLE_SCENARIOS,
|
||||||
|
_TITLE_SCORING,
|
||||||
|
_TITLE_SUMMARY,
|
||||||
|
_build_html,
|
||||||
|
_normalize,
|
||||||
|
export_report_pdf,
|
||||||
|
)
|
||||||
|
from app.services.forecasting.report import (
|
||||||
|
ReportConfidence,
|
||||||
|
ReportExecSummary,
|
||||||
|
ReportFutureMarket,
|
||||||
|
ReportMarketNow,
|
||||||
|
ReportMeta,
|
||||||
|
ReportProductTz,
|
||||||
|
ReportScenarios,
|
||||||
|
ReportScoring,
|
||||||
|
SiteFinderReport,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Шесть ожидаемых заголовков блоков (по одному на содержательную секцию §13).
|
||||||
|
_EXPECTED_TITLES: tuple[str, ...] = (
|
||||||
|
_TITLE_SUMMARY,
|
||||||
|
_TITLE_MARKET_NOW,
|
||||||
|
_TITLE_FUTURE_MARKET,
|
||||||
|
_TITLE_PRODUCT_TZ,
|
||||||
|
_TITLE_SCENARIOS,
|
||||||
|
_TITLE_SCORING,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _full_report() -> SiteFinderReport:
|
||||||
|
"""Полный SiteFinderReport из реалистичных sample-данных (форма #987 `as_dict()`)."""
|
||||||
|
return SiteFinderReport(
|
||||||
|
exec_summary=ReportExecSummary(
|
||||||
|
headline="Строить комфорт-класс, акцент на студии и 1-к.",
|
||||||
|
verdict="Дефицит малых форматов на горизонте 12 мес; рынок поглощает быстро.",
|
||||||
|
key_numbers={"overall_score": 0.71, "deficit_index": 0.34, "horizon_months": 12},
|
||||||
|
overall_confidence="medium",
|
||||||
|
),
|
||||||
|
market_now=ReportMarketNow(
|
||||||
|
market_metrics={"unit_velocity": 8.2, "overstock_index": 0.21, "confidence": "medium"},
|
||||||
|
competitors=[
|
||||||
|
{"obj_id": 1, "comm_name": "ЖК Альфа", "relevance_weight": 0.8},
|
||||||
|
{"obj_id": 2, "comm_name": "ЖК Бета", "relevance_weight": 0.5},
|
||||||
|
],
|
||||||
|
supply_layers={"open_units": 1200, "hidden_units": 800},
|
||||||
|
summary="Текущий рынок: умеренная абсорбция, средняя плотность конкурентов.",
|
||||||
|
),
|
||||||
|
future_market=ReportFutureMarket(
|
||||||
|
forecasts_by_horizon=[
|
||||||
|
{"horizon_months": 6, "demand": 410, "supply": 380, "deficit_index": 0.2},
|
||||||
|
{"horizon_months": 12, "demand": 920, "supply": 700, "deficit_index": 0.34},
|
||||||
|
],
|
||||||
|
future_supply={"index": 0.42, "horizon_months": 12, "confidence": "low"},
|
||||||
|
future_competitors=[{"obj_id": 3, "stage": "котлован"}],
|
||||||
|
scenarios_summary={"base": 0.34, "conservative": 0.18, "aggressive": 0.49},
|
||||||
|
summary="Будущий рынок: дефицит сохраняется на 12 мес.",
|
||||||
|
),
|
||||||
|
product_tz=ReportProductTz(
|
||||||
|
obj_class="комфорт",
|
||||||
|
mix=[
|
||||||
|
{"bucket": "1-Студия", "pct": 35},
|
||||||
|
{"bucket": "2-1-к", "pct": 40},
|
||||||
|
{"bucket": "3-2-к", "pct": 25},
|
||||||
|
],
|
||||||
|
commercial={"available": False, "caveat": "коммерция: нет достаточных данных"},
|
||||||
|
usp=[{"segment": "1-Студия", "usp_text": "Дефицит студий — стройте их."}],
|
||||||
|
reasons=[{"why": "deficit_index +0.34 на горизонте 12 мес", "advisory": True}],
|
||||||
|
summary="Рекомендован комфорт-класс с акцентом на малые форматы.",
|
||||||
|
),
|
||||||
|
scenarios=ReportScenarios(
|
||||||
|
by_scenario={
|
||||||
|
"conservative": {"overall": 0.18, "advisory": True},
|
||||||
|
"base": {"overall": 0.34, "advisory": True},
|
||||||
|
"aggressive": {"overall": 0.49, "advisory": True},
|
||||||
|
},
|
||||||
|
summary="Разброс сценариев умеренный.",
|
||||||
|
),
|
||||||
|
scoring=ReportScoring(
|
||||||
|
product_scores={
|
||||||
|
"overall": 0.71,
|
||||||
|
"scores": {"market_fit": {"value": 0.67}, "demand": {"value": 0.51}},
|
||||||
|
"advisory": True,
|
||||||
|
},
|
||||||
|
special_indices={
|
||||||
|
"indices": {"launch_window": {"value": 0.6, "label": "12 мес"}},
|
||||||
|
"advisory": True,
|
||||||
|
},
|
||||||
|
overall=0.71,
|
||||||
|
),
|
||||||
|
confidence=ReportConfidence(
|
||||||
|
level="medium",
|
||||||
|
rationale="Источники advisory-capped; данные средней плотности.",
|
||||||
|
factors={"data_coverage": 0.6, "engine_validated": False},
|
||||||
|
),
|
||||||
|
meta=ReportMeta(
|
||||||
|
cad_num="66:41:0000000:1",
|
||||||
|
district="Верх-Исетский",
|
||||||
|
segment={"obj_class": "комфорт", "room_bucket": "1-к 30-45"},
|
||||||
|
horizons=[6, 12, 18, 24],
|
||||||
|
generated_at=dt.date(2026, 6, 3),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Полный отчёт: PDF-байты (магия %PDF), нетривиальная длина ───────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestFullReportExport:
|
||||||
|
def test_returns_pdf_magic_bytes(self) -> None:
|
||||||
|
payload = export_report_pdf(_full_report())
|
||||||
|
assert isinstance(payload, bytes)
|
||||||
|
assert payload.startswith(b"%PDF")
|
||||||
|
|
||||||
|
def test_returns_non_trivial_length(self) -> None:
|
||||||
|
# Полный отчёт с таблицами — заметно больше пустого «скелета».
|
||||||
|
payload = export_report_pdf(_full_report())
|
||||||
|
assert len(payload) > 2000
|
||||||
|
|
||||||
|
def test_accepts_as_dict_input(self) -> None:
|
||||||
|
# Экспортёр принимает уже-словарь `as_dict()` (не только dataclass).
|
||||||
|
payload = export_report_pdf(_full_report().as_dict())
|
||||||
|
assert payload.startswith(b"%PDF")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Содержимое HTML-источника: ожидаемый текст секций присутствует ─────────────
|
||||||
|
# Рендерим ТОТ ЖЕ HTML, что уходит в WeasyPrint (быстрее и точнее, чем парсить PDF).
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderedHtmlContent:
|
||||||
|
def test_all_section_titles_present(self) -> None:
|
||||||
|
html_str = _build_html(_normalize(_full_report()))
|
||||||
|
for title in _EXPECTED_TITLES:
|
||||||
|
assert title in html_str, f"отсутствует заголовок блока {title}"
|
||||||
|
|
||||||
|
def test_advisory_marker_present(self) -> None:
|
||||||
|
html_str = _build_html(_normalize(_full_report()))
|
||||||
|
assert _ADVISORY_MARKER in html_str
|
||||||
|
assert "ADVISORY" in html_str
|
||||||
|
|
||||||
|
def test_exec_headline_and_context_present(self) -> None:
|
||||||
|
html_str = _build_html(_normalize(_full_report()))
|
||||||
|
assert "Строить комфорт" in html_str
|
||||||
|
assert "66:41:0000000:1" in html_str
|
||||||
|
|
||||||
|
def test_product_class_and_mix_present(self) -> None:
|
||||||
|
html_str = _build_html(_normalize(_full_report()))
|
||||||
|
assert "комфорт" in html_str
|
||||||
|
assert "1-Студия" in html_str
|
||||||
|
|
||||||
|
def test_scoring_values_present(self) -> None:
|
||||||
|
html_str = _build_html(_normalize(_full_report()))
|
||||||
|
assert "market_fit" in html_str
|
||||||
|
assert "0.71" in html_str
|
||||||
|
|
||||||
|
def test_dynamic_fields_are_escaped(self) -> None:
|
||||||
|
# html.escape применён ко всем динамическим строкам: вредоносный ввод обезврежен.
|
||||||
|
report = SiteFinderReport(
|
||||||
|
exec_summary=ReportExecSummary(headline="<script>alert(1)</script> & co"),
|
||||||
|
)
|
||||||
|
html_str = _build_html(_normalize(report))
|
||||||
|
assert "<script>alert(1)</script>" not in html_str
|
||||||
|
assert "<script>alert(1)</script>" in html_str
|
||||||
|
assert "& co" in html_str
|
||||||
|
|
||||||
|
|
||||||
|
# ── Graceful: частичный / пустой / мусорный вход → валидный PDF без падения ─────
|
||||||
|
|
||||||
|
|
||||||
|
class TestGracefulPartialReport:
|
||||||
|
def test_empty_report_still_valid_pdf(self) -> None:
|
||||||
|
# Полностью дефолтный (пустой) отчёт — валидный PDF, не падает.
|
||||||
|
payload = export_report_pdf(SiteFinderReport())
|
||||||
|
assert payload.startswith(b"%PDF")
|
||||||
|
assert len(payload) > 0
|
||||||
|
|
||||||
|
def test_empty_report_shows_no_data_marker(self) -> None:
|
||||||
|
# Пустые секции рисуют заглушку «нет данных» (graceful), а не падают.
|
||||||
|
html_str = _build_html(_normalize(SiteFinderReport()))
|
||||||
|
assert "нет данных" in html_str
|
||||||
|
# ADVISORY-маркер остаётся даже у пустого отчёта.
|
||||||
|
assert "ADVISORY" in html_str
|
||||||
|
|
||||||
|
def test_partial_report_some_sections(self) -> None:
|
||||||
|
# Заполнены только meta + exec_summary — остальные пусты, PDF валиден.
|
||||||
|
report = SiteFinderReport(
|
||||||
|
exec_summary=ReportExecSummary(headline="Тонкий анализ — только заголовок."),
|
||||||
|
meta=ReportMeta(cad_num="66:41:0000000:2", horizons=[12]),
|
||||||
|
)
|
||||||
|
payload = export_report_pdf(report)
|
||||||
|
assert payload.startswith(b"%PDF")
|
||||||
|
html_str = _build_html(_normalize(report))
|
||||||
|
assert "Тонкий анализ" in html_str
|
||||||
|
assert "66:41:0000000:2" in html_str
|
||||||
|
|
||||||
|
def test_garbage_input_does_not_crash(self) -> None:
|
||||||
|
# Мусор (None / не-отчёт) → пустой, но валидный PDF (нормализация в {}).
|
||||||
|
for junk in (None, 123, "not a report"):
|
||||||
|
payload = export_report_pdf(junk)
|
||||||
|
assert payload.startswith(b"%PDF")
|
||||||
|
assert len(payload) > 0
|
||||||
Loading…
Add table
Reference in a new issue