"""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 weasyprint import HTML from app.schemas.parcel import BestLayoutsResponse logger = logging.getLogger(__name__) 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. 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) """ 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(",", " ") + " ₽" # 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 ) 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}
#КомнатыПлощадьПродажи/мес Ср. площадь, м²Ср. цена, ₽/м²Продано (окно)

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

Покрытие: {dq.objects_with_velocity_data} из {dq.objects_total_in_radius} ЖК с данными velocity ({dq.velocity_coverage_pct:.1f}%)

Уверенность: {dq.confidence.upper()}

""" 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