gendesign/backend/app/services/exporters/report_pptx.py
Light1YT 764db61734
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
fix(exporters): annotate scenario deficit horizon in docx/pptx (#1590 follow-up)
report_docx и report_pptx рендерили bare _scenario_deficit_index под
захардкоженной шапкой «(12 мес)» — при fallback на другой горизонт подпись
лгала. Переключил оба на _scenario_deficit_cell (как в report_md): per-cell
аннотация «(гор. N мес)» при N != 12. Закрывает code-review major по #1590.
2026-06-17 14:35:48 +05:00

354 lines
19 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.

"""§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