"""PDF render для ТЗ (Layout Analysis #113 PR D). Pattern reference: backend/app/services/exporters/pdf.py (existing WeasyPrint). """ from __future__ import annotations import datetime as dt import html as _html import logging from app.schemas.parcel import BestLayoutsResponse logger = logging.getLogger(__name__) def build_layout_tz_html( response: BestLayoutsResponse, *, cad_num: str, parcel_address: str | None = None, radius_km: float, time_window: str, ) -> str: """Собрать HTML ТЗ из best-layouts response (PURE — без WeasyPrint/БД/сети). Вынесено из :func:`render_layout_tz_pdf`, чтобы HTML-разметку (наличие блоков, RU-микрокопию, форматирование ₽/м²) можно было проверять юнит-тестами БЕЗ native GTK/Pango (WeasyPrint на macOS/Windows отсутствует). PDF-обёртка ниже просто рендерит этот HTML. Args: response: BestLayoutsResponse от /best-layouts endpoint cad_num: кадастровый номер участка parcel_address: optional human address (если known через geocoder) radius_km: радиус анализа конкурентов time_window: окно анализа (last_month/quarter/year) Returns: HTML-строка документа (готова для WeasyPrint). """ today = dt.date.today().strftime("%d.%m.%Y") safe_cad = _html.escape(cad_num) safe_addr = _html.escape(parcel_address) if parcel_address else None safe_time_window = _html.escape(time_window) addr_line = f"
Адрес: {safe_addr}
" if safe_addr else "" def _price_cell(val: float | None) -> str: if val is None: return "Комплексы сматчены по ядру имени на объявления Объектива, но по ним НЕТ сделок (темпа продаж) — ниже только структура остатков в продаже (objective_lots), БЕЗ ранжирования по скорости. Дополняет топ продаж выше, не заменяет его.
| Комплекс | Комнаты | Площадь | Ср. площадь | Лотов в продаже | Медиана, ₽/м² |
|---|
| Комнатность | Доля | Кол-во (от target) | Целевая площадь, м² |
|---|
Средневзвешенная цена benchmark: {weighted_price}
Основано на {rec.based_on_obj_count} ЖК / {rec.based_on_total_deals} сделок
Период данных: {rec.data_window_start.strftime("%d.%m.%Y")} – {rec.data_window_end.strftime("%d.%m.%Y")}
| # | Комнаты | Площадь | Продажи/мес | Ср. площадь, м² | Ср. цена, ₽/м² | Продано (окно) |
|---|
Покрытие: {dq.objects_with_velocity_data} из {dq.objects_total_in_radius} комплексов с данными velocity ({dq.velocity_coverage_pct:.1f}%)
Уверенность: {dq.confidence.upper()}
""" return html def render_layout_tz_pdf( response: BestLayoutsResponse, *, cad_num: str, parcel_address: str | None = None, radius_km: float, time_window: str, ) -> bytes: """Render ТЗ PDF от best-layouts response. Тонкая обёртка над :func:`build_layout_tz_html` — собирает HTML (PURE) и рендерит его WeasyPrint'ом в PDF-байты. Args: response: BestLayoutsResponse от /best-layouts endpoint cad_num: кадастровый номер участка parcel_address: optional human address (если known через geocoder) radius_km: радиус анализа конкурентов time_window: окно анализа (last_month/quarter/year) Returns: PDF bytes (готово для StreamingResponse) """ html = build_layout_tz_html( response, cad_num=cad_num, parcel_address=parcel_address, radius_km=radius_km, time_window=time_window, ) # WeasyPrint импортируем локально — тяжёлый; не нужен при импорте модуля # (иначе ломает сбор pytest на хостах без native-libs, напр. macOS). from weasyprint import HTML pdf_bytes = HTML(string=html).write_pdf() logger.info("Generated layout TZ PDF for cad %s: %d bytes", cad_num, len(pdf_bytes)) return pdf_bytes