"""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, FinancialModel 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 · детерминированный расчёт ТЭП, финмодели и DCF" 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"
| Показатель | {headers}
|---|
| Показатель | {headers}
|---|
{html.escape(_SUBTITLE)}
" f"{_DASH} нет вариантов для отображения
" ) disc_pct = f"{variants[0].financial.discount_rate_used * 100:.0f}%" return ( f"" f"{html.escape(_SUBTITLE)}
" f"{_teap_table(variants)}" f"{_financial_table(variants)}" "NPV / IRR / PBP рассчитаны помесячным DCF по ТИПОВОМУ графику фаз " f"(ПИР 6 мес → СМР по типу застройки → распродажа 30 мес, дисконт {disc_pct} годовых). " "График фаз и темп продаж — типовые допущения, НЕ график конкретного проекта; " "точность метрик зависит от реального графика. Где IRR помечен «оценочный» — поток " "вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. " "НДС — реализация жилья и услуги застройщика по ДДУ освобождены (ст. 149 НК РФ), " "входной НДС по СМР встроен в себестоимость; НДС начисляется на нежилые части — " "машиноместа и коммерцию/офисы 1-го этажа (встроенный НДС в добавленной стоимости " "каждой части). Налог на прибыль — 25% (с 2025). Цены и себестоимость — рыночные " "ориентиры. Коммерция/офисы 1-го этажа учтены по нормативной доле от общей площади " "и продаются с умеренной наценкой к цене жилья того же класса; себестоимость СМР " "нежилого — по той же ставке, что и жильё (отдельной строки затрат нет, повторного " "учёта в затратах нет).
" "" ) 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"]