gendesign/backend/app/services/generative/exporters/pdf.py
Light1YT 8242e51b61
Some checks failed
CI / frontend-tests (push) Has been skipped
CI / changes (push) Successful in 10s
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 7m39s
CI / backend-tests (pull_request) Successful in 7m39s
Deploy / deploy (push) Has been cancelled
Deploy / build-frontend (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
Deploy / changes (push) Successful in 6s
feat(generative): exporters (dxf/pdf) + generative test suite (#54 #55 #56)
Дополняет движок: exporters/{dxf,pdf}.py (ezdxf + WeasyPrint lazy) + 5 тест-модулей
(geometry/placement/teap_financial/exporters/api). Не вошли в предыдущий commit
(untracked-директории).
2026-06-13 21:33:20 +05:00

160 lines
7.3 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
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_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]),
("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'>IRR-proxy — аннуализированная маржа-на-затраты без "
"дисконтирования (не настоящий 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"]