gendesign/backend/app/services/exporters/layout_tz_pdf.py
bot-backend a6e0d231ac
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 3m26s
CI / backend-tests (pull_request) Successful in 16m9s
feat(exporters): §4.2 «По предложению (без темпа продаж)» в ТЗ-PDF
layout_tz_pdf рендерит supply_only_layouts (#2177 шаг 3): таблица
комплекс/тип/площадь/лотов/медиана ₽/м² + честный caveat, аддитивно после
топа продаж. Graceful на старых снапшотах (пусто → секции нет). HTML-сборка
вынесена в pure build_layout_tz_html — юнит-тесты без native WeasyPrint
(до этого весь файл скипался на macOS). Экранирование скрапленных строк
html.escape — консистентно соседним блокам (ревью-verified).

Примечание: остальные блоки недели (§4.3 offer_trend, «Как участок
сходится», §3 gas) — frontend-only; полного backend-экспортёра /analyze
не существует → issue #2259.
2026-07-03 11:21:19 +05:00

252 lines
10 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 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"<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>"
def _price_m2_cell(val: float | None) -> str:
"""Ячейка цены ₽/м² (тыс-разделитель — пробел). None → «—» (graceful)."""
if val is None:
return "<td>—</td>"
return f"<td>{val:,.0f}".replace(",", " ") + " ₽/м²</td>"
def _area_cell(val: float | None) -> str:
"""Ячейка площади, м² (1 знак). None → «—» (graceful)."""
if val is None:
return "<td>—</td>"
return f"<td>{val:.1f} м²</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
)
# §4.2 «По предложению (без темпа продаж)» (#2177 шаг 3). Аддитивный fallback-блок:
# структура текущего предложения (комплекс/тип/площадь/лотов/медиана ₽/м²) по
# комплексам, сматченным по ядру имени, но БЕЗ velocity. Graceful — на старых
# снапшотах поле отсутствует / пустой список (default []), тогда секцию не рисуем.
supply_only = getattr(response, "supply_only_layouts", None) or []
supply_only_rows = "".join(
"<tr>"
f"<td>{_html.escape(s.project_name)}</td>"
f"<td>{_html.escape(s.room_bucket)}</td>"
f"<td>{_html.escape(s.area_bin)}</td>"
f"{_area_cell(s.median_area_m2)}"
f"<td>{s.lots_count}</td>"
f"{_price_m2_cell(s.median_price_m2)}"
"</tr>"
for s in supply_only
)
supply_only_section = (
f"""
<h2>По предложению (без темпа продаж)</h2>
<p class="caveat">
Комплексы сматчены по ядру имени на объявления Объектива, но по ним НЕТ сделок
(темпа продаж) — ниже только структура остатков в продаже (objective_lots),
БЕЗ ранжирования по скорости. Дополняет топ продаж выше, не заменяет его.
</p>
<table>
<thead><tr>
<th>Комплекс</th><th>Комнаты</th><th>Площадь</th>
<th>Ср. площадь</th><th>Лотов в продаже</th><th>Медиана, ₽/м²</th>
</tr></thead>
<tbody>{supply_only_rows}</tbody>
</table>
"""
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"""<!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; }}
.caveat {{ background: #fffbeb; padding: 8px 10px; border-left: 3px solid #c9a132;
margin: 0.6em 0; color: #7a5c12; font-size: 9.5pt; }}
.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>
{supply_only_section}
<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>"""
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