"""§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 # Основной продуктовый горизонт (мес) — из него тянем сводный deficit_index сценария # (зеркало report_assembler._PRIMARY_HORIZON_MONTHS). ScenarioForecast.as_dict() несёт # список forecasts по горизонтам, скалярного «overall» у сценария НЕТ — берём дефицит # основного горизонта как сводный показатель сценария. _PRIMARY_HORIZON_MONTHS: int = 12 # Сноска-футер на каждой странице (зеркало 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 def _future_supply_pairs(future_supply: Any) -> dict[str, Any]: """Плоская карта «метка → значение» по FutureSupplyPressure.as_dict() (§9.3). PURE. `future_supply` = `FutureSupplyPressure.as_dict()`: верхнеуровневый `index`/контекст + вложенный `breakdown` (объёмы по слоям, поглощение, месяцы давления). Разворачиваем breakdown на верхний уровень с RU-метками. Пустой/None → {} (graceful → «нет данных»). """ data = _as_dict(future_supply) if not data: return {} breakdown = _as_dict(data.get("breakdown")) return { "Индекс давления": data.get("index"), "Уверенность": _level_ru(data.get("confidence")), "Открытый сток (ед.)": breakdown.get("open_units"), "Скрытый запас (ед.)": breakdown.get("hidden_units"), "Будущий слой в горизонте (ед.)": breakdown.get("future_units_by_horizon"), "Поглощение (ед./мес)": breakdown.get("monthly_absorption_units"), "Месяцев давления": breakdown.get("months_of_pressure"), } # ────────────────────────────────────────────────────────────────────────────── # Построители секций — по одной на содержательную секцию §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")) factors = _as_dict(confidence.get("factors")) 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)}

Факторы уверенности

{_dict_kv_table(factors)}

Контекст

{_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("projected_demand_units"), f.get("projected_supply_units"), 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(_future_supply_pairs(future.get("future_supply")))}

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

{_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 _scenario_deficit_index(payload: dict[str, Any]) -> Any: """Сводный deficit_index сценария = дефицит основного горизонта (12 мес). PURE. `payload` = `ScenarioForecast.as_dict()`: у сценария НЕТ скалярного «overall» — есть список `forecasts` по горизонтам, каждый с `deficit_index`. Берём горизонт `_PRIMARY_HORIZON_MONTHS`, иначе первый с не-None дефицитом. Нет → None (→ "—"). """ forecasts = _as_list(payload.get("forecasts")) primary = next( ( f for f in forecasts if isinstance(f, dict) and f.get("horizon_months") == _PRIMARY_HORIZON_MONTHS ), None, ) if primary is not None and primary.get("deficit_index") is not None: return primary.get("deficit_index") for f in forecasts: if isinstance(f, dict) and f.get("deficit_index") is not None: return f.get("deficit_index") return None 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, _scenario_deficit_index(data), data.get("advisory")]) headers = ["Сценарий", "Индекс дефицита (12 мес)", "Advisory"] return f"""

{html.escape(_TITLE_SCENARIOS)}

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

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

{_data_table(headers, 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