From 2c19f35eff1637462ba27e3076d06e21c4a26fec Mon Sep 17 00:00:00 2001 From: Light1YT Date: Wed, 3 Jun 2026 14:08:26 +0500 Subject: [PATCH] feat(exporters): Site Finder report PDF exporter (#989, 955-A3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- backend/app/services/exporters/report_pdf.py | 548 ++++++++++++++++++ .../services/exporters/test_report_pdf.py | 228 ++++++++ 2 files changed, 776 insertions(+) create mode 100644 backend/app/services/exporters/report_pdf.py create mode 100644 backend/tests/services/exporters/test_report_pdf.py diff --git a/backend/app/services/exporters/report_pdf.py b/backend/app/services/exporters/report_pdf.py new file mode 100644 index 00000000..4fcd4494 --- /dev/null +++ b/backend/app/services/exporters/report_pdf.py @@ -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'{html.escape(label)}{_esc(value)}' + + +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'{rows}
' + + +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'{rows}
' + + +def _data_table(headers: list[str], rows: list[list[Any]]) -> str: + """Таблица: шапка (статичные RU-метки) + строки данных (через `_esc`). PURE. + + Пустой `rows` → одна строка-заглушка «нет данных» под шапкой (graceful — таблица + всё равно валидна). Все ячейки данных экранируются. + """ + head = "".join(f"{html.escape(h)}" for h in headers) + if not rows: + body = f'{_NO_DATA}' + else: + body = "".join( + "" + "".join(f"{_esc(cell)}" for cell in row) + "" for row in rows + ) + return f'{head}{body}
' + + +def _no_data() -> str: + """Блок-заглушка «нет данных» для пустой секции (graceful). PURE.""" + return f'
{_NO_DATA}
' + + +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""" +
+
+

{html.escape(_TITLE_DOC)}

+
Кадастровый номер: {cad} · Район: {district}
+
+
{html.escape(_ADVISORY_MARKER)}
+ +

{html.escape(_TITLE_SUMMARY)}

+

{_esc(headline)}

+

{_esc(verdict)}

+ Общая уверенность: {overall_conf} + +

Ключевые числа

+ {_dict_kv_table(key_numbers)} + +

Уверенность отчёта

+ {_kv_table(confidence_pairs)} + +

Контекст

+ {_kv_table(context_pairs)} +
+""" + + +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""" +
+

{html.escape(_TITLE_MARKET_NOW)}

+

{_esc(market_now.get("summary"))}

+ +

Метрики рынка

+ {_dict_kv_table(metrics)} + +

Слои предложения

+ {_dict_kv_table(supply)} + +

Конкуренты

+ {_data_table(["ЖК", "ID", "Релевантность"], comp_rows)} +
+""" + + +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""" +
+

{html.escape(_TITLE_FUTURE_MARKET)}

+

{_esc(future.get("summary"))}

+ +

Прогноз по горизонтам

+ {_data_table(forecast_headers, forecast_rows)} + +

Разброс сценариев (индекс дефицита)

+ {_dict_kv_table(scenarios_summary)} +
+""" + + +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""" +
+

{html.escape(_TITLE_PRODUCT_TZ)}

+ {_kv_table(head_pairs)} + +

Квартирография (mix)

+ {_data_table(["Формат", "Доля, %", "Класс", "Индекс дефицита"], mix_rows)} + +

Коммерция

+ {_dict_kv_table(commercial)} + +

USP-ниши

+ {_data_table(["Сегмент", "USP"], usp_rows)} +
+""" + + +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""" +
+

{html.escape(_TITLE_SCENARIOS)}

+

{_esc(scenarios.get("summary"))}

+ +

Сводка по сценариям

+ {_data_table(["Сценарий", "Overall", "Advisory"], rows)} +
+""" + + +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""" +
+

{html.escape(_TITLE_SCORING)}

+ Итоговый скор (overall): {_esc(scoring.get("overall"))} + +

Продуктовые скоры

+ {_data_table(["Скор", "Значение"], score_rows)} + +

Специальные индексы

+ {_data_table(["Индекс", "Значение", "Метка"], index_rows)} + + +
+""" + + +# Реестр построителей секций. Порядок = порядок блоков в документе (зеркало +# `_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""" + + + +{html.escape(_TITLE_DOC)} + + +{sections} + +""" + + +# ────────────────────────────────────────────────────────────────────────────── +# Публичный 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 diff --git a/backend/tests/services/exporters/test_report_pdf.py b/backend/tests/services/exporters/test_report_pdf.py new file mode 100644 index 00000000..2c170e44 --- /dev/null +++ b/backend/tests/services/exporters/test_report_pdf.py @@ -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=" & co"), + ) + html_str = _build_html(_normalize(report)) + assert "" 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