Объединяет наше (площади из реальной геометрии teap) + полноту девелоперской Excel-модели (каскад затрат + БДР с налогами). Расширяет financial.py из статического GDV−COST в полный статический расчёт. БЕЗ DCF (PR-3), БЕЗ новых вводных от пользователя — только нормативы + существующий TEAP. Каскад (financial.py): - Выручка: жильё (residential×price) + ПАРКИНГ (spaces×1.9М) - Затраты: СМР(резид+паркинг) + ПИР 2.5% + сети/ТУ 6000₽/м² + услуги заказчика 1.5% + резерв 3% + маркетинг/риэлтор 7% выручки + земля - БДР: валовая маржа → НДС (упрощённо) → прибыль до налога → налог 25% (ФЗ 176-ФЗ) → ЧИСТАЯ прибыль → ROI + чистая маржа% - Нормативы документированы (источник: Excel-эталон «Авангардная 13» + RU-рынок) FinancialModel (concept.py): +16 additive-полей (revenue breakdown, cost cascade, БДР/налоги, roi, margin_pct, irr_is_proxy). Старые 4 поля сохранены (backward-compat). IRR честно помечен irr_is_proxy=True (annualized net ROI, не DCF) — настоящий IRR/NPV/PBP в PR-3 (помесячный cashflow). НДС — документированное упрощение, не точный НК РФ. Офисы пропущены (нет площади в TEAP). PDF + concept UI: полный БДР-каскад + чистая прибыль/ROI + честные сноски. api-types.ts регенерён авторитетно (openapi-typescript + prettier, как CI). mypy strict clean (generative.*), +тесты (каскад/паркинг/налоги/backward-compat/ edge cost=0), 36 passed. Refs #1881
190 lines
9.6 KiB
Python
190 lines
9.6 KiB
Python
"""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"<th>{html.escape(_strategy_label(v.strategy))}</th>" 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(
|
||
"<tr><td class='row'>"
|
||
+ html.escape(label)
|
||
+ "</td>"
|
||
+ "".join(f"<td>{html.escape(cell)}</td>" for cell in cells)
|
||
+ "</tr>"
|
||
for label, cells in rows
|
||
)
|
||
return (
|
||
"<table><caption>Технико-экономические показатели</caption>"
|
||
f"<thead><tr><th class='row'>Показатель</th>{headers}</tr></thead>"
|
||
f"<tbody>{body}</tbody></table>"
|
||
)
|
||
|
||
|
||
def _financial_table(variants: Sequence[ConceptVariant]) -> str:
|
||
"""HTML-таблица финмодели (деньги в млн руб; полный каскад + БДР; IRR proxy)."""
|
||
headers = "".join(
|
||
f"<th>{html.escape(_strategy_label(v.strategy))}</th>" 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],
|
||
),
|
||
("Выручка (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]),
|
||
("IRR-proxy", [f"{v.financial.irr * 100:.1f}%" for v in variants]),
|
||
]
|
||
body = "".join(
|
||
"<tr><td class='row'>"
|
||
+ html.escape(label)
|
||
+ "</td>"
|
||
+ "".join(f"<td>{html.escape(cell)}</td>" for cell in cells)
|
||
+ "</tr>"
|
||
for label, cells in rows
|
||
)
|
||
return (
|
||
"<table><caption>Финансовая модель (упрощённая)</caption>"
|
||
f"<thead><tr><th class='row'>Показатель</th>{headers}</tr></thead>"
|
||
f"<tbody>{body}</tbody></table>"
|
||
)
|
||
|
||
|
||
def _build_html(variants: Sequence[ConceptVariant]) -> str:
|
||
if not variants:
|
||
return (
|
||
f"<html><head><meta charset='utf-8'><style>{_CSS}</style></head>"
|
||
f"<body><h1>{html.escape(_TITLE)}</h1>"
|
||
f"<p class='sub'>{html.escape(_SUBTITLE)}</p>"
|
||
f"<p>{_DASH} нет вариантов для отображения</p></body></html>"
|
||
)
|
||
return (
|
||
f"<html><head><meta charset='utf-8'><style>{_CSS}</style></head><body>"
|
||
f"<h1>{html.escape(_TITLE)}</h1>"
|
||
f"<p class='sub'>{html.escape(_SUBTITLE)}</p>"
|
||
f"{_teap_table(variants)}"
|
||
f"{_financial_table(variants)}"
|
||
"<p class='sub'>Статический девелоперский расчёт (без дисконтирования / DCF). "
|
||
"НДС — упрощённая оценка (встроенный НДС в положительной марже, не полная "
|
||
"механика входного/выходного НДС НК РФ). Налог на прибыль — 25% (с 2025). "
|
||
"IRR-proxy — аннуализированный чистый ROI без дисконтирования (не настоящий "
|
||
"IRR). Цены и себестоимость — рыночные ориентиры, не калиброванная модель. "
|
||
"Коммерческие/офисные площади не учитываются (нет в ТЭП).</p>"
|
||
"</body></html>"
|
||
)
|
||
|
||
|
||
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"]
|