161 lines
5.7 KiB
Python
161 lines
5.7 KiB
Python
"""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"<p>Адрес: {safe_addr}</p>" if safe_addr else ""
|
||
|
||
def _price_cell(val: float | None) -> str:
|
||
if val is None:
|
||
return "<td>—</td>"
|
||
return f"<td>{val:,.0f}".replace(",", " ") + " ₽</td>"
|
||
|
||
# Top layouts table rows
|
||
top_rows = "".join(
|
||
"<tr>"
|
||
f"<td>{r.rank}</td>"
|
||
f"<td>{_html.escape(r.room_bucket)}</td>"
|
||
f"<td>{_html.escape(r.area_bin)}</td>"
|
||
f"<td>{r.velocity_per_month:.1f}</td>"
|
||
f"<td>{r.avg_area_m2:.1f}</td>"
|
||
f"{_price_cell(r.avg_price_per_m2_rub)}"
|
||
f"<td>{r.total_sold_in_window}</td>"
|
||
"</tr>"
|
||
for r in response.top_layouts
|
||
)
|
||
|
||
# Recommendation mix table rows
|
||
mix_rows = "".join(
|
||
"<tr>"
|
||
f"<td>{_html.escape(m.room_bucket)}</td>"
|
||
f"<td>{m.pct}%</td>"
|
||
f"<td>{m.abs_units if m.abs_units is not None else '—'}</td>"
|
||
f"<td>{f'{m.avg_target_area_m2:.1f}' if m.avg_target_area_m2 is not None else '—'}</td>"
|
||
"</tr>"
|
||
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"""<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>ТЗ на проектирование — {safe_cad}</title>
|
||
<style>
|
||
body {{ font-family: 'Helvetica', sans-serif; font-size: 11pt; color: #222; }}
|
||
h1 {{ font-size: 18pt; margin-bottom: 0.2em; }}
|
||
h2 {{ font-size: 14pt; margin-top: 1.2em; border-bottom: 1px solid #ccc; }}
|
||
.meta {{ color: #666; font-size: 10pt; margin-bottom: 1em; }}
|
||
table {{ width: 100%; border-collapse: collapse; margin: 0.5em 0; }}
|
||
th, td {{ padding: 6px 10px; border: 1px solid #ddd; text-align: left; }}
|
||
th {{ background: #f5f5f5; font-weight: bold; }}
|
||
.rationale {{ background: #f8f8f8; padding: 10px; border-left: 3px solid #4a90e2;
|
||
margin: 1em 0; }}
|
||
.footer {{ margin-top: 2em; padding-top: 1em; border-top: 1px solid #ddd;
|
||
color: #888; font-size: 9pt; }}
|
||
.confidence-high {{ color: #2a8c2a; }}
|
||
.confidence-medium {{ color: #c9a132; }}
|
||
.confidence-low {{ color: #b03434; }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>Техническое задание на проектирование (data-driven)</h1>
|
||
<div class="meta">
|
||
<p>Кадастровый номер: <strong>{safe_cad}</strong></p>
|
||
{addr_line}
|
||
<p>Радиус анализа: {radius_km} км · Окно: {safe_time_window}</p>
|
||
<p>Дата формирования: {today}</p>
|
||
</div>
|
||
|
||
<h2>Рекомендуемая структура квартирографии (unit-mix)</h2>
|
||
<div class="rationale">{safe_rationale}</div>
|
||
<table>
|
||
<thead><tr>
|
||
<th>Комнатность</th><th>Доля</th><th>Кол-во (от target)</th><th>Целевая площадь, м²</th>
|
||
</tr></thead>
|
||
<tbody>{mix_rows}</tbody>
|
||
</table>
|
||
<p>Средневзвешенная цена benchmark: <strong>{weighted_price}</strong></p>
|
||
<p>Основано на {rec.based_on_obj_count} ЖК / {rec.based_on_total_deals} сделок</p>
|
||
<p>Период данных:
|
||
{rec.data_window_start.strftime("%d.%m.%Y")} – {rec.data_window_end.strftime("%d.%m.%Y")}
|
||
</p>
|
||
|
||
<h2>Топ планировок конкурентов по продажам</h2>
|
||
<table>
|
||
<thead><tr>
|
||
<th>#</th><th>Комнаты</th><th>Площадь</th><th>Продажи/мес</th>
|
||
<th>Ср. площадь, м²</th><th>Ср. цена, ₽/м²</th><th>Продано (окно)</th>
|
||
</tr></thead>
|
||
<tbody>{top_rows}</tbody>
|
||
</table>
|
||
|
||
<h2>Качество данных</h2>
|
||
<p>
|
||
Покрытие: {dq.objects_with_velocity_data} из
|
||
{dq.objects_total_in_radius} ЖК с данными velocity
|
||
({dq.velocity_coverage_pct:.1f}%)
|
||
</p>
|
||
<p>
|
||
Уверенность:
|
||
<span class="confidence-{dq.confidence}">
|
||
{dq.confidence.upper()}
|
||
</span>
|
||
</p>
|
||
|
||
<div class="footer">
|
||
<p>GenDesign Site Finder · сгенерировано из данных DOM.РФ + Objective + Росреестр</p>
|
||
<p>Phase 2.1: без layout_type (евро/классика/панорама) и balcony_count.</p>
|
||
</div>
|
||
</body>
|
||
</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
|