diff --git a/backend/app/services/exporters/layout_tz_pdf.py b/backend/app/services/exporters/layout_tz_pdf.py
index fa4531d6..4061dabd 100644
--- a/backend/app/services/exporters/layout_tz_pdf.py
+++ b/backend/app/services/exporters/layout_tz_pdf.py
@@ -14,15 +14,20 @@ from app.schemas.parcel import BestLayoutsResponse
logger = logging.getLogger(__name__)
-def render_layout_tz_pdf(
+def build_layout_tz_html(
response: BestLayoutsResponse,
*,
cad_num: str,
parcel_address: str | None = None,
radius_km: float,
time_window: str,
-) -> bytes:
- """Render ТЗ PDF от best-layouts response.
+) -> 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
@@ -32,7 +37,7 @@ def render_layout_tz_pdf(
time_window: окно анализа (last_month/quarter/year)
Returns:
- PDF bytes (готово для StreamingResponse)
+ HTML-строка документа (готова для WeasyPrint).
"""
today = dt.date.today().strftime("%d.%m.%Y")
safe_cad = _html.escape(cad_num)
@@ -45,6 +50,18 @@ def render_layout_tz_pdf(
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(
""
@@ -70,6 +87,42 @@ def render_layout_tz_pdf(
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 = (
@@ -95,6 +148,8 @@ def render_layout_tz_pdf(
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; }}
@@ -133,7 +188,7 @@ def render_layout_tz_pdf(
{top_rows}
-
+{supply_only_section}
Качество данных
Покрытие: {dq.objects_with_velocity_data} из
@@ -154,6 +209,40 @@ def render_layout_tz_pdf(