"""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 "—" return f"{val:,.0f}".replace(",", " ") + " ₽" def _price_m2_cell(val: float | None) -> str: """Ячейка цены ₽/м² (тыс-разделитель — пробел). None → «—» (graceful).""" if val is None: return "—" return f"{val:,.0f}".replace(",", " ") + " ₽/м²" def _area_cell(val: float | None) -> str: """Ячейка площади, м² (1 знак). None → «—» (graceful).""" if val is None: return "—" return f"{val:.1f} м²" # Top layouts table rows top_rows = "".join( "" f"{r.rank}" f"{_html.escape(r.room_bucket)}" f"{_html.escape(r.area_bin)}" f"{r.velocity_per_month:.1f}" f"{r.avg_area_m2:.1f}" f"{_price_cell(r.avg_price_per_m2_rub)}" f"{r.total_sold_in_window}" "" for r in response.top_layouts ) # Recommendation mix table rows mix_rows = "".join( "" f"{_html.escape(m.room_bucket)}" f"{m.pct}%" f"{m.abs_units if m.abs_units is not None else '—'}" f"{f'{m.avg_target_area_m2:.1f}' if m.avg_target_area_m2 is not None else '—'}" "" for m in response.recommendation_for_tz.mix ) # §4.2 «По предложению (без темпа продаж)» (#2177 шаг 3). Аддитивный fallback-блок: # структура текущего предложения (комплекс/тип/площадь/лотов/медиана ₽/м²) по # комплексам, сматченным по ядру имени, но БЕЗ velocity. Graceful — на старых # снапшотах поле отсутствует / пустой список (default []), тогда секцию не рисуем. supply_only = getattr(response, "supply_only_layouts", None) or [] supply_only_rows = "".join( "" f"{_html.escape(s.project_name)}" f"{_html.escape(s.room_bucket)}" f"{_html.escape(s.area_bin)}" f"{_area_cell(s.median_area_m2)}" f"{s.lots_count}" f"{_price_m2_cell(s.median_price_m2)}" "" for s in supply_only ) supply_only_section = ( f"""

По предложению (без темпа продаж)

Комплексы сматчены по ядру имени на объявления Объектива, но по ним НЕТ сделок (темпа продаж) — ниже только структура остатков в продаже (objective_lots), БЕЗ ранжирования по скорости. Дополняет топ продаж выше, не заменяет его.

{supply_only_rows}
КомплексКомнатыПлощадь Ср. площадьЛотов в продажеМедиана, ₽/м²
""" if supply_only else "" ) rec = response.recommendation_for_tz safe_rationale = _html.escape(rec.rationale_text) weighted_price = ( f"{rec.weighted_avg_price_per_m2_rub:,.0f}".replace(",", " ") + " ₽/м²" if rec.weighted_avg_price_per_m2_rub is not None else "нет данных" ) dq = response.data_quality html = f""" ТЗ на проектирование — {safe_cad}

Техническое задание на проектирование (data-driven)

Кадастровый номер: {safe_cad}

{addr_line}

Радиус анализа: {radius_km} км · Окно: {safe_time_window}

Дата формирования: {today}

Рекомендуемая структура квартирографии (unit-mix)

{safe_rationale}
{mix_rows}
КомнатностьДоляКол-во (от 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")}

Топ планировок конкурентов по продажам

{top_rows}
#КомнатыПлощадьПродажи/мес Ср. площадь, м²Ср. цена, ₽/м²Продано (окно)
{supply_only_section}

Качество данных

Покрытие: {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