"""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"{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 _fmt_pbp(value: float | None) -> str: """Срок окупаемости — мес (1 знак), либо «не окупается».""" if value is None: return "не окупается" return f"{value:.1f} мес" def _fmt_irr(financial: FinancialModel) -> str: """IRR в % с пометкой «оценочный», если это proxy (вырожденный поток).""" pct = f"{financial.irr * 100:.1f}%" return f"{pct} (оценочный)" if financial.irr_is_proxy else pct def _financial_table(variants: Sequence[ConceptVariant]) -> str: """HTML-таблица финмодели (деньги в млн руб; полный каскад + БДР + DCF).""" headers = "".join( f"{html.escape(_strategy_label(v.strategy))}" for v in variants ) rows: list[tuple[str, list[str]]] = [ ( "Выручка — жильё, млн руб", [_fmt_money(v.financial.revenue_residential_rub) for v in variants], ), ( "Выручка — паркинг, млн руб", [_fmt_money(v.financial.revenue_parking_rub) for v in variants], ), ( "Выручка — нежилое (1-й этаж), млн руб", [_fmt_money(v.financial.revenue_office_rub) for v in variants], ), ("Выручка (GDV), млн руб", [_fmt_money(v.financial.revenue_rub) for v in variants]), ("СМР, млн руб", [_fmt_money(v.financial.construction_rub) for v in variants]), ("ПИР, млн руб", [_fmt_money(v.financial.pir_rub) for v in variants]), ("Сети (ТУ), млн руб", [_fmt_money(v.financial.networks_rub) for v in variants]), ( "Услуги заказчика, млн руб", [_fmt_money(v.financial.developer_services_rub) for v in variants], ), ("Резерв, млн руб", [_fmt_money(v.financial.contingency_rub) for v in variants]), ("Маркетинг + риэлтор, млн руб", [_fmt_money(v.financial.marketing_rub) for v in variants]), ("Земля, млн руб", [_fmt_money(v.financial.land_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]), ( "НДС (нежилое: паркинг + коммерция), млн руб", [_fmt_money(v.financial.vat_rub) for v in variants], ), ( "Прибыль до налога, млн руб", [_fmt_money(v.financial.profit_before_tax_rub) for v in variants], ), ("Налог на прибыль, млн руб", [_fmt_money(v.financial.profit_tax_rub) for v in variants]), ("Чистая прибыль, млн руб", [_fmt_money(v.financial.net_profit_rub) for v in variants]), ("ROI (на затраты)", [f"{v.financial.roi * 100:.1f}%" for v in variants]), ("Чистая маржа (на выручку)", [f"{v.financial.margin_pct * 100:.1f}%" for v in variants]), ("NPV (DCF), млн руб", [_fmt_money(v.financial.npv_rub) for v in variants]), ("IRR (DCF, годовой)", [_fmt_irr(v.financial) for v in variants]), ("Окупаемость (PBP)", [_fmt_pbp(v.financial.payback_months) 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} нет вариантов для отображения

" ) disc_pct = f"{variants[0].financial.discount_rate_used * 100:.0f}%" return ( f"" f"

{html.escape(_TITLE)}

" 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 НК РФ), " "входной НДС по СМР встроен в себестоимость; НДС начисляется только на нежилую часть " "(машиноместа). Налог на прибыль — 25% (с 2025). Цены и себестоимость " "— рыночные ориентиры. Коммерческие/офисные площади не учитываются (нет в ТЭП).

" "" ) 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"]