"""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 _sales_phrase(financial: FinancialModel) -> str:
"""Фраза о сроке распродажи для методической сноски. PURE.
#2464: срок был зашит числом «30 мес» — при том, что ставка дисконта в той же
строке берётся из расчёта. 30 — это ФОЛБЭК (`financial._SALES_DURATION_MONTHS`),
применяемый только когда рыночная скорость абсорбции не передана. Иначе окно
считается как площадь/скорость и клампится в [6, 120] мес, то есть сноска обещала
читателю не тот срок, по которому посчитан NPV.
Оба нужных поля уже есть в схеме: `sales_duration_months` (реализованное окно) и
`schedule_is_default` (честный флаг «норматив, а не рынок»). Отчёт Site Finder флаг
уже читает — full_report_html.py:1335 и full_report_docx.py:855; игнорировал его
только этот экспортёр.
getattr с дефолтом — тот же оборонительный приём, что у соседних полей: старый
сериализованный вариант без новых ключей не должен ронять экспорт.
"""
months = getattr(financial, "sales_duration_months", None)
if getattr(financial, "schedule_is_default", True) or months is None:
return "распродажа 30 мес (нормативный темп)"
return f"распродажа {months:.0f} мес (по рыночной абсорбции)"
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}%"
sales_phrase = _sales_phrase(variants[0].financial)
return (
f""
f"{html.escape(_TITLE)}
"
f"{html.escape(_SUBTITLE)}
"
f"{_teap_table(variants)}"
f"{_financial_table(variants)}"
"NPV / IRR / PBP рассчитаны помесячным DCF по ТИПОВОМУ графику фаз "
f"(ПИР 6 мес → СМР по типу застройки → {sales_phrase}, дисконт {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"]