All checks were successful
CI / changes (pull_request) Successful in 7s
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Successful in 1m1s
CI / frontend-tests (pull_request) Successful in 1m17s
CI / openapi-codegen-check (push) Successful in 2m19s
CI / backend-tests (pull_request) Successful in 9m11s
CI / backend-tests (push) Successful in 9m15s
CI / openapi-codegen-check (pull_request) Successful in 1m43s
report_docx и report_pptx рендерили bare _scenario_deficit_index под захардкоженной шапкой «(12 мес)» — при fallback на другой горизонт подпись лгала. Переключил оба на _scenario_deficit_cell (как в report_md): per-cell аннотация «(гор. N мес)» при N != 12. Закрывает code-review major по #1590.
354 lines
19 KiB
Python
354 lines
19 KiB
Python
"""§22 PPTX-экспортёр итогового советующего отчёта Site Finder v2 (python-pptx).
|
||
|
||
#959 (EPIC export) — ПОСЛЕДНИЙ формат набора выгрузки §22-форсайта (md/json/tg/docx
|
||
уже есть, этот завершает EPIC): рендерит `SiteFinderReport` (#987) в презентацию
|
||
(.pptx) и возвращает БАЙТЫ — готовые для
|
||
`Response(media_type="application/vnd.openxmlformats-officedocument.presentationml.presentation")`.
|
||
Аддитивный двойник DOCX/PDF/Markdown-экспортёров (`report_docx` / `report_pdf` /
|
||
`report_md`): то же содержание, НО presentation-форма — это колода слайдов, а не
|
||
документ, поэтому секции СЖАТЫ до ключевого (титул + 6 содержательных слайдов), без
|
||
длинных таблиц на каждый под-блок.
|
||
|
||
DRY: PURE-хелперы нормализации/форматирования НЕ дублируем — импортируем из
|
||
`report_pdf` (там они уже есть: `_normalize` — приём инстанса ИЛИ `as_dict()`-словаря,
|
||
`_fmt` — строковое форматирование значения/None→"—", `_level_ru`, `_as_dict`/`_as_list`,
|
||
`_scenario_deficit_cell`) + переиспользуем его named-константы
|
||
(`_DASH`/`_NO_DATA`/`_ADVISORY_MARKER`). Здесь добавлены ТОЛЬКО pptx-специфичные
|
||
микро-билдеры (слайды/буллеты/таблица python-pptx).
|
||
|
||
ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM, БЕЗ БД/сети: только ПОТРЕБЛЯЕТ уже-собранный отчёт (его
|
||
наполняет сборщик #988) и раскладывает по слайдам. Принимает КАК `SiteFinderReport`-
|
||
инстанс, ТАК и его `as_dict()`-словарь (нормализуется через `_normalize`).
|
||
|
||
GRACEFUL (дух всего форсайт-стека): частичный/пустой/мусорный отчёт ВАЛИДЕН — пустой
|
||
блок рисует «нет данных», экспортёр НИКОГДА не падает (нет KeyError на тонком отчёте)
|
||
и возвращает минимальную валидную .pptx. Отчёт СОВЕТУЮЩИЙ: на титульном слайде —
|
||
заметный advisory-дисклеймер (оценка не основание для инвест-решения).
|
||
|
||
`python-pptx` (PyPI `python-pptx`, import `pptx`) — чистый Python (lxml/Pillow), без
|
||
нативных системных библиотек: Dockerfile не трогаем. Импортируется ЛОКАЛЬНО внутри
|
||
функции (зеркало python-docx в `report_docx` / WeasyPrint в `report_pdf` — не нужен при
|
||
импорте модуля).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
import logging
|
||
from typing import TYPE_CHECKING, Any
|
||
|
||
from app.services.exporters.report_pdf import (
|
||
_ADVISORY_MARKER,
|
||
_DASH,
|
||
_NO_DATA,
|
||
_as_dict,
|
||
_as_list,
|
||
_fmt,
|
||
_level_ru,
|
||
_normalize,
|
||
_scenario_deficit_cell,
|
||
)
|
||
|
||
if TYPE_CHECKING:
|
||
# Только для аннотаций: тяжёлый python-pptx импортируем ЛОКАЛЬНО в render_report_pptx
|
||
# (как python-docx в report_docx), чтобы импорт модуля был дешёвым и не требовал dep.
|
||
from pptx.presentation import Presentation as _PptxPresentation
|
||
from pptx.slide import Slide as _PptxSlide
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ── Named-константы: заголовки слайдов (зеркало report_docx — те же секции §22) ──
|
||
|
||
_TITLE_DECK: str = "Site Finder v2 — прогноз"
|
||
_TITLE_SUMMARY: str = "Сводка"
|
||
_TITLE_FUTURE_MARKET: str = "Будущий рынок"
|
||
_TITLE_SCENARIOS: str = "Сценарии"
|
||
_TITLE_PRODUCT_TZ: str = "Продукт ТЗ"
|
||
_TITLE_CONFIDENCE: str = "Уверенность"
|
||
|
||
# Краткий RU-дисклеймер (формулировка из ТЗ #959) — в подзаголовке титульного слайда.
|
||
_ADVISORY_DISCLAIMER: str = "Оценка advisory — не основание для инвест-решения."
|
||
|
||
# Индексы дефолтных layout'ов встроенного шаблона python-pptx (стабильны):
|
||
# 0 — «Title Slide» (placeholder idx 0=title, 1=subtitle)
|
||
# 1 — «Title and Content» (placeholder idx 0=title, 1=body — буллеты)
|
||
# 5 — «Title Only» (placeholder idx 0=title — под кастомную таблицу/текст)
|
||
_LAYOUT_TITLE: int = 0
|
||
_LAYOUT_TITLE_CONTENT: int = 1
|
||
_LAYOUT_TITLE_ONLY: int = 5
|
||
|
||
# Основной продуктовый горизонт (мес) — подпись сводного дефицита сценария
|
||
# (зеркало report_docx._PRIMARY_HORIZON_MONTHS; значение тянет _scenario_deficit_cell —
|
||
# при fallback на чужой горизонт буллет несёт «(гор. N мес)», #1590).
|
||
_PRIMARY_HORIZON_MONTHS: int = 12
|
||
|
||
# Сколько USP-ниш выводить буллетами на слайд «Продукт ТЗ» (колода — держим компактно).
|
||
_USP_TOP_N: int = 4
|
||
# Сколько факторов уверенности выводить буллетами на слайд «Уверенность».
|
||
_FACTORS_TOP_N: int = 6
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# pptx-специфичные микро-билдеры. Принимают `prs`/`slide` python-pptx и пишут в них
|
||
# (мутируют презентацию). Хелперы нормализации/форматирования — импортированы из
|
||
# report_pdf, не дублируются (DRY). Числа/None уже причёсаны `_fmt` → str.
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _add_title_slide(prs: _PptxPresentation, title: str, subtitle: str) -> _PptxSlide:
|
||
"""Титульный слайд (layout «Title Slide»): заголовок + подзаголовок. Graceful."""
|
||
slide = prs.slides.add_slide(prs.slide_layouts[_LAYOUT_TITLE])
|
||
slide.shapes.title.text = title
|
||
# Подзаголовок (placeholder idx 1) есть в дефолтном layout; defensive на кастомный шаблон.
|
||
if len(slide.placeholders) > 1:
|
||
slide.placeholders[1].text = subtitle
|
||
return slide
|
||
|
||
|
||
def _add_bullets_slide(
|
||
prs: _PptxPresentation, title: str, bullets: list[tuple[str, Any]]
|
||
) -> _PptxSlide:
|
||
"""Слайд «Title and Content»: заголовок + буллеты «метка: значение». Graceful.
|
||
|
||
Метка — статичная RU-строка, значение — через `_fmt` (None → "—"). Пустой список →
|
||
один буллет «нет данных» (слайд всё равно валиден). Зеркало `report_docx._add_kv_lines`
|
||
(там docx-абзацы; здесь — параграфы text-frame'а content-плейсхолдера).
|
||
"""
|
||
slide = prs.slides.add_slide(prs.slide_layouts[_LAYOUT_TITLE_CONTENT])
|
||
slide.shapes.title.text = title
|
||
body = slide.placeholders[1].text_frame
|
||
body.clear()
|
||
|
||
if not bullets:
|
||
body.paragraphs[0].text = _NO_DATA
|
||
return slide
|
||
|
||
for idx, (label, value) in enumerate(bullets):
|
||
# Первый параграф уже существует (после clear) — переиспользуем, дальше add_paragraph.
|
||
para = body.paragraphs[0] if idx == 0 else body.add_paragraph()
|
||
para.text = f"{label}: {_fmt(value)}" if label else _fmt(value)
|
||
para.level = 0
|
||
return slide
|
||
|
||
|
||
def _add_table_slide(
|
||
prs: _PptxPresentation, title: str, headers: list[str], rows: list[list[Any]]
|
||
) -> _PptxSlide:
|
||
"""Слайд «Title Only» + кастомная таблица: шапка (RU-метки) + строки (через `_fmt`).
|
||
|
||
Пустой `rows` → одна строка-заглушка «нет данных» под шапкой (таблица валидна).
|
||
Все ячейки данных проходят `_fmt`. Зеркало `report_docx._add_table` (там docx Table;
|
||
здесь — python-pptx GraphicFrame table на отдельном слайде). Размеры — в EMU через
|
||
`pptx.util.Inches` (импортируем локально, как и сам Presentation).
|
||
"""
|
||
from pptx.util import Inches
|
||
|
||
slide = prs.slides.add_slide(prs.slide_layouts[_LAYOUT_TITLE_ONLY])
|
||
slide.shapes.title.text = title
|
||
|
||
body_rows = rows if rows else [[_NO_DATA] + [_DASH] * (len(headers) - 1)]
|
||
n_rows = len(body_rows) + 1 # +1 на шапку
|
||
n_cols = len(headers)
|
||
|
||
left, top, width, height = Inches(0.5), Inches(1.6), Inches(9.0), Inches(0.4)
|
||
table = slide.shapes.add_table(n_rows, n_cols, left, top, width, height).table
|
||
|
||
for col_idx, header in enumerate(headers):
|
||
table.cell(0, col_idx).text = header
|
||
|
||
for row_idx, row in enumerate(body_rows, start=1):
|
||
for col_idx in range(n_cols):
|
||
# Строка может оказаться короче шапки (defensive) — недостающее → "—".
|
||
value = row[col_idx] if col_idx < len(row) else None
|
||
table.cell(row_idx, col_idx).text = _fmt(value)
|
||
return slide
|
||
|
||
|
||
def _join_horizons(values: list[Any]) -> Any:
|
||
"""Свернуть список горизонтов в «6, 12, 18» или None (→ `_fmt` отдаст "—"). PURE."""
|
||
return ", ".join(str(v) for v in values) if values else None
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Построители слайдов — по одному на содержательный слайд §22 (сжато: колода).
|
||
# Каждый graceful (пустой блок → «нет данных», не падает) и ПИШЕТ в `prs`.
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _build_title(prs: _PptxPresentation, report: dict[str, Any]) -> None:
|
||
"""Титульный слайд: «Site Finder v2 — прогноз» + cad/район/горизонты + дисклеймер."""
|
||
meta = _as_dict(report.get("meta"))
|
||
cad = _fmt(meta.get("cad_num"))
|
||
district = _fmt(meta.get("district"))
|
||
horizons = _fmt(_join_horizons(_as_list(meta.get("horizons"))))
|
||
|
||
subtitle = (
|
||
f"Кадастровый номер: {cad}\n"
|
||
f"Район: {district}\n"
|
||
f"Горизонты (мес): {horizons}\n"
|
||
f"{_ADVISORY_DISCLAIMER} {_ADVISORY_MARKER}"
|
||
)
|
||
_add_title_slide(prs, _TITLE_DECK, subtitle)
|
||
|
||
|
||
def _build_summary(prs: _PptxPresentation, report: dict[str, Any]) -> None:
|
||
"""Слайд «Сводка»: headline + ключевые числа (deficit/MOI/overall/confidence)."""
|
||
exec_summary = _as_dict(report.get("exec_summary"))
|
||
headline = exec_summary.get("headline")
|
||
key_numbers = _as_dict(exec_summary.get("key_numbers"))
|
||
overall_conf = _level_ru(exec_summary.get("overall_confidence"))
|
||
|
||
bullets: list[tuple[str, Any]] = [("Вывод", headline)]
|
||
bullets.extend((str(key), value) for key, value in key_numbers.items())
|
||
bullets.append(("Общая уверенность", overall_conf))
|
||
_add_bullets_slide(prs, _TITLE_SUMMARY, bullets)
|
||
|
||
|
||
def _build_future_market(prs: _PptxPresentation, report: dict[str, Any]) -> None:
|
||
"""Слайд «Будущий рынок»: компактная таблица forecasts_by_horizon + давление."""
|
||
future = _as_dict(report.get("future_market"))
|
||
forecasts = _as_list(future.get("forecasts_by_horizon"))
|
||
|
||
headers = ["Горизонт, мес", "Дефицит", "Мес. запаса", "Ставка", "Уверенность"]
|
||
rows = [
|
||
[
|
||
f.get("horizon_months"),
|
||
f.get("deficit_index"),
|
||
f.get("months_of_inventory"),
|
||
f.get("rate_future"),
|
||
_level_ru(f.get("confidence")),
|
||
]
|
||
for f in forecasts
|
||
if isinstance(f, dict)
|
||
]
|
||
_add_table_slide(prs, _TITLE_FUTURE_MARKET, headers, rows)
|
||
|
||
|
||
def _build_scenarios(prs: _PptxPresentation, report: dict[str, Any]) -> None:
|
||
"""Слайд «Сценарии»: base/aggressive/conservative — дефицит (12 мес) + rate_path."""
|
||
scenarios = _as_dict(report.get("scenarios"))
|
||
by_scenario = _as_dict(scenarios.get("by_scenario"))
|
||
|
||
bullets: list[tuple[str, Any]] = []
|
||
for name, payload in by_scenario.items():
|
||
data = _as_dict(payload)
|
||
rate_path = _as_dict(data.get("rate_path"))
|
||
rate_str = (
|
||
", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else _DASH
|
||
)
|
||
deficit = _fmt(_scenario_deficit_cell(data))
|
||
bullets.append(
|
||
(str(name), f"дефицит ({_PRIMARY_HORIZON_MONTHS} мес) {deficit}; ставка {rate_str}")
|
||
)
|
||
_add_bullets_slide(prs, _TITLE_SCENARIOS, bullets)
|
||
|
||
|
||
def _build_product_tz(prs: _PptxPresentation, report: dict[str, Any]) -> None:
|
||
"""Слайд «Продукт ТЗ»: класс + квартирография (mix) + USP-ниши (буллеты)."""
|
||
product = _as_dict(report.get("product_tz"))
|
||
mix = _as_list(product.get("mix"))
|
||
usp = _as_list(product.get("usp"))
|
||
|
||
bullets: list[tuple[str, Any]] = [
|
||
("Рекомендованный класс", product.get("obj_class")),
|
||
]
|
||
mix_str = (
|
||
"; ".join(
|
||
f"{_fmt(m.get('bucket'))} — {_fmt(m.get('pct'))}%" for m in mix if isinstance(m, dict)
|
||
)
|
||
or None
|
||
)
|
||
bullets.append(("Квартирография", mix_str))
|
||
for item in usp[:_USP_TOP_N]:
|
||
if isinstance(item, dict):
|
||
bullets.append((f"USP · {_fmt(item.get('segment'))}", item.get("usp_text")))
|
||
_add_bullets_slide(prs, _TITLE_PRODUCT_TZ, bullets)
|
||
|
||
|
||
def _build_confidence(prs: _PptxPresentation, report: dict[str, Any]) -> None:
|
||
"""Слайд «Уверенность»: уровень + топ-факторы (буллеты). Graceful."""
|
||
confidence = _as_dict(report.get("confidence"))
|
||
level = _level_ru(confidence.get("level"))
|
||
factors = _as_dict(confidence.get("factors"))
|
||
|
||
bullets: list[tuple[str, Any]] = [("Уровень", level)]
|
||
for name, payload in list(factors.items())[:_FACTORS_TOP_N]:
|
||
# Факторы #990: {name: {value, level, note}} ИЛИ плоское {name: value}. Defensive.
|
||
if isinstance(payload, dict):
|
||
detail = payload.get("note") or _level_ru(payload.get("level"))
|
||
bullets.append((str(name), detail))
|
||
else:
|
||
bullets.append((str(name), payload))
|
||
_add_bullets_slide(prs, _TITLE_CONFIDENCE, bullets)
|
||
|
||
|
||
# Реестр построителей. Порядок = порядок слайдов после титула (зеркало report_docx:
|
||
# Сводка → Будущий рынок → Сценарии → Продукт ТЗ → Уверенность; title идёт отдельно).
|
||
_SLIDE_BUILDERS: tuple[Any, ...] = (
|
||
_build_summary,
|
||
_build_future_market,
|
||
_build_scenarios,
|
||
_build_product_tz,
|
||
_build_confidence,
|
||
)
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Публичный API — рендер отчёта в PPTX-байты (без файлового I/O на диск).
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def render_report_pptx(report: Any) -> bytes:
|
||
"""§22 Отрендерить `SiteFinderReport` (#987) в презентацию (.pptx) и вернуть БАЙТЫ.
|
||
|
||
Титульный слайд («Site Finder v2 — прогноз» + cad/район/горизонты + advisory-
|
||
дисклеймер в подзаголовке), далее пять содержательных слайдов §22 (Сводка / Будущий
|
||
рынок / Сценарии / Продукт ТЗ / Уверенность) — содержание ЗЕРКАЛИТ
|
||
`report_docx`/`report_pdf`, но СЖАТО под формат колоды (буллеты + одна компактная
|
||
таблица прогноза). Числа форматируются через импортированный `_fmt` (None → "—"),
|
||
уровни уверенности — RU-метками.
|
||
|
||
ДЕТЕРМИНИРОВАННО, БЕЗ LLM/БД/сети. Принимает КАК `SiteFinderReport`-инстанс, ТАК и
|
||
его `as_dict()`-словарь (нормализуется через импортированный `_normalize`). GRACEFUL:
|
||
частичный/пустой/мусорный отчёт → слайды с «нет данных», НИКОГДА не падает (нет
|
||
KeyError на тонком отчёте) — возвращает минимальную валидную .pptx.
|
||
|
||
`python-pptx` импортируется ЛОКАЛЬНО (как python-docx в `report_docx`): не нужен при
|
||
импорте модуля. Презентация собирается в памяти (`io.BytesIO`) — без файлового I/O.
|
||
|
||
Args:
|
||
report: `SiteFinderReport`-инстанс или его `as_dict()`-словарь (или мусор → {}).
|
||
|
||
Returns:
|
||
Непустые PPTX-байты (OOXML — это zip, начинаются с `b"PK"`), готовые для
|
||
`Response(media_type=
|
||
"application/vnd.openxmlformats-officedocument.presentationml.presentation")`.
|
||
"""
|
||
# python-pptx импортируем локально — тяжёлый (lxml/Pillow); не нужен при импорте модуля.
|
||
try:
|
||
from pptx import Presentation
|
||
except ImportError as exc:
|
||
raise RuntimeError(
|
||
"python-pptx не установлен. Добавь 'python-pptx>=1.0.0' в pyproject.toml."
|
||
) from exc
|
||
|
||
data = _normalize(report)
|
||
prs = Presentation()
|
||
|
||
_build_title(prs, data)
|
||
for builder in _SLIDE_BUILDERS:
|
||
builder(prs, data)
|
||
|
||
buffer = io.BytesIO()
|
||
prs.save(buffer)
|
||
pptx_bytes = buffer.getvalue()
|
||
|
||
meta = _as_dict(data.get("meta"))
|
||
logger.info(
|
||
"render_report_pptx: cad_num=%s slides=%d size=%d bytes advisory=%s",
|
||
meta.get("cad_num"),
|
||
len(_SLIDE_BUILDERS) + 1, # +1 титульный
|
||
len(pptx_bytes),
|
||
data.get("advisory"),
|
||
)
|
||
return pptx_bytes
|