gendesign/backend/app/services/generative/exporters/pdf.py
bot-backend b82963d12e
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m50s
Deploy / build-worker (push) Successful in 3m0s
Deploy / build-frontend (push) Successful in 3m18s
Deploy / deploy (push) Successful in 1m22s
feat(financial): ground-floor нежилое (office/commercial) tranche
Carve a documented office share (comfort/business 5%, econom 0) out of GFA before residential — fixes prior overstatement (all non-parking GFA = residential). total_floor_area unchanged → no double-count; office is additive revenue (+15% premium), нежилое → VAT-able (vat base = parking_va + office_va). Σ invariant holds. Additive schema + api-types regen + PDF/concept display. Deep-review . Refs #1881
2026-06-25 11:40:01 +00:00

216 lines
11 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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