"""Generative Design — Stage 1c PDF export via WeasyPrint. Renders a one-page summary of the three concept variants — a ТЭП table and a financial table — into a PDF. This is the *concept* summary, distinct from Site Finder's ``app.services.exporters.report_pdf`` (advisory site report). WeasyPrint is imported *lazily inside the function* (mirrors the repo's ``report_pdf`` / ``snapshot_pdf`` house-style): it is a heavy native dependency, so importing this module must never fail even on a dev box without the system libs. All dynamic strings are passed through ``html.escape`` (defence-in-depth: variant strategy names are from a fixed Literal, but treat rendered text as untrusted). Returns ``bytes`` ready for an HTTP response / file write. Deterministic, no DB / no network. """ from __future__ import annotations import html import logging from collections.abc import Sequence from app.schemas.concept import ConceptVariant logger = logging.getLogger(__name__) # RU-подписи стратегий (ключ — Literal из контракта). _STRATEGY_LABELS: dict[str, str] = { "max_area": "Максимум площади", "max_insolation": "Максимум инсоляции", "balanced": "Баланс", } _DASH = "—" # Минимальный CSS для печати (А4, читаемые таблицы). Inline — без внешних ресурсов. _CSS = """ @page { size: A4 landscape; margin: 18mm; } body { font-family: "DejaVu Sans", Arial, sans-serif; font-size: 11px; color: #1a1a1a; } h1 { font-size: 18px; margin: 0 0 4px; } .sub { color: #666; font-size: 10px; margin: 0 0 14px; } table { border-collapse: collapse; width: 100%; margin-bottom: 18px; } th, td { border: 1px solid #ccc; padding: 6px 8px; text-align: right; } th.row, td.row { text-align: left; font-weight: 600; background: #f5f5f5; } caption { text-align: left; font-weight: 700; font-size: 13px; margin-bottom: 6px; } thead th { background: #ececec; } """ _TITLE = "Концепции застройки — сводка вариантов" _SUBTITLE = "Generative Design · Stage 1c · детерминированный расчёт ТЭП и финмодели" def _fmt_int(value: float | int) -> str: """Целое с разделителями тысяч (узкий пробел) для читаемости.""" return f"{round(value):,}".replace(",", " ") def _fmt_money(value: float) -> str: """Деньги в млн руб (1 знак) — итоговые таблицы читаются в млн.""" return f"{value / 1_000_000:,.1f}".replace(",", " ") def _strategy_label(strategy: str) -> str: return _STRATEGY_LABELS.get(strategy, strategy) def _teap_table(variants: Sequence[ConceptVariant]) -> str: """HTML-таблица ТЭП по всем вариантам (строки — показатели, колонки — стратегии).""" headers = "".join( f"{html.escape(_strategy_label(v.strategy))}" for v in variants ) rows: list[tuple[str, list[str]]] = [ ("Пятно застройки, кв.м", [_fmt_int(v.teap.built_area_sqm) for v in variants]), ("Общая площадь (GFA), кв.м", [_fmt_int(v.teap.total_floor_area_sqm) for v in variants]), ("Жилая площадь, кв.м", [_fmt_int(v.teap.residential_area_sqm) for v in variants]), ("Квартир, шт", [_fmt_int(v.teap.apartments_count) for v in variants]), ("Плотность (FAR)", [f"{v.teap.density:.2f}" for v in variants]), ("Машиномест", [_fmt_int(v.teap.parking_spaces) for v in variants]), ] body = "".join( "" + html.escape(label) + "" + "".join(f"{html.escape(cell)}" for cell in cells) + "" for label, cells in rows ) return ( "" f"{headers}" f"{body}
Технико-экономические показатели
Показатель
" ) def _financial_table(variants: Sequence[ConceptVariant]) -> str: """HTML-таблица финмодели (деньги в млн руб; IRR — proxy, помечен).""" headers = "".join( f"{html.escape(_strategy_label(v.strategy))}" for v in variants ) rows: list[tuple[str, list[str]]] = [ ("Выручка, млн руб", [_fmt_money(v.financial.revenue_rub) for v in variants]), ("Затраты, млн руб", [_fmt_money(v.financial.cost_rub) for v in variants]), ("Валовая маржа, млн руб", [_fmt_money(v.financial.gross_margin_rub) for v in variants]), ("IRR-proxy", [f"{v.financial.irr * 100:.1f}%" for v in variants]), ] body = "".join( "" + html.escape(label) + "" + "".join(f"{html.escape(cell)}" for cell in cells) + "" for label, cells in rows ) return ( "" f"{headers}" f"{body}
Финансовая модель (упрощённая)
Показатель
" ) def _build_html(variants: Sequence[ConceptVariant]) -> str: if not variants: return ( f"" f"

{html.escape(_TITLE)}

" f"

{html.escape(_SUBTITLE)}

" f"

{_DASH} нет вариантов для отображения

" ) return ( f"" f"

{html.escape(_TITLE)}

" f"

{html.escape(_SUBTITLE)}

" f"{_teap_table(variants)}" f"{_financial_table(variants)}" "

IRR-proxy — аннуализированная маржа-на-затраты без " "дисконтирования (не настоящий IRR). Цены и себестоимость — рыночные " "ориентиры, не калиброванная модель ценообразования.

" "" ) def export_concept_pdf(variants: Sequence[ConceptVariant]) -> bytes: """Свести варианты в PDF-сводку (ТЭП + финмодель). Возвращает bytes (PDF). Graceful: пустой список вариантов рендерит страницу-заглушку, экспорт не падает. WeasyPrint импортируется лениво (тяжёлая нативная зависимость). """ # Лениво: импорт WeasyPrint не должен падать при импорте модуля # (тяжёлая нативная зависимость; зеркало report_pdf/snapshot_pdf). from weasyprint import HTML document = _build_html(variants) # write_pdf(target=None) возвращает bytes; weasyprint без stubs -> явная коэрция. rendered = HTML(string=document).write_pdf() pdf_bytes: bytes = bytes(rendered) if rendered is not None else b"" logger.info("PDF export: variants=%d bytes=%d", len(variants), len(pdf_bytes)) return pdf_bytes __all__ = ["export_concept_pdf"]