gendesign/backend/app/services/exporters/layout_tz_pdf.py
bot-backend b028377584
All checks were successful
Deploy / changes (push) Successful in 10s
Deploy / build-backend (push) Successful in 2m51s
Deploy / build-worker (push) Successful in 4m5s
Deploy / build-frontend (push) Successful in 5m8s
Deploy / deploy (push) Successful in 1m37s
fix(best-layouts): покрытие §4.2 в комплексах, не в сырых obj_id (#2177, шаг 1/3) (#2198)
2026-07-02 19:34:11 +00:00

163 lines
6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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.

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