"""§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_index`) + переиспользуем его 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_index, ) 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_index). _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_index(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