feat(exporters): §4.2 supply-only блок в ТЗ-PDF (#2177) #2260

Merged
bot-backend merged 1 commit from feat/pdf-new-blocks into main 2026-07-03 06:38:24 +00:00
2 changed files with 224 additions and 5 deletions

View file

@ -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 "<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>"
@ -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(
"<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 = (
@ -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(
</tr></thead>
<tbody>{top_rows}</tbody>
</table>
{supply_only_section}
<h2>Качество данных</h2>
<p>
Покрытие: {dq.objects_with_velocity_data} из
@ -154,6 +209,40 @@ def render_layout_tz_pdf(
</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

View file

@ -0,0 +1,130 @@
"""Tests для build_layout_tz_html — §4.2 «По предложению (без темпа продаж)».
Проверяют HTML-разметку ТЗ (наличие блоков, RU-микрокопию, форматирование /м²)
БЕЗ WeasyPrint: `build_layout_tz_html` PURE-функция (строит строку HTML), поэтому
эти тесты гоняются и на macOS/Windows, где native GTK/Pango отсутствуют (в отличие
от test_layout_tz_pdf.py, который зависит от write_pdf native libs).
Ключевой сценарий: §4.2 блок рендерится, когда `supply_only_layouts` есть, и
graceful ОТСУТСТВУЕТ (без ошибок), когда список пуст (старые снапшоты / нет матча).
"""
import datetime as dt
from app.schemas.parcel import (
BestLayoutsResponse,
LayoutDataQuality,
LayoutTzMixRow,
LayoutTzRecommendation,
SupplyOnlyLayoutRow,
TopLayoutRow,
)
from app.services.exporters.layout_tz_pdf import build_layout_tz_html
def _base_response(supply_only: list[SupplyOnlyLayoutRow] | None = None) -> BestLayoutsResponse:
return BestLayoutsResponse(
top_layouts=[
TopLayoutRow(
rank=1,
room_bucket="1",
area_bin="25-40",
signature="1__25-40",
competitor_obj_ids=[1234],
competitor_count=1,
total_sold_in_window=67,
velocity_per_month=8.4,
avg_price_per_m2_rub=148000.0,
avg_area_m2=38.5,
supply_units_in_radius=312,
sold_pct_of_supply=21.5,
is_oversold=False,
),
],
recommendation_for_tz=LayoutTzRecommendation(
rationale_text="Test rationale текст",
mix=[LayoutTzMixRow(room_bucket="1", pct=100, abs_units=100, avg_target_area_m2=38.5)],
weighted_avg_price_per_m2_rub=152000.0,
based_on_obj_count=5,
based_on_total_deals=107,
data_window_start=dt.date(2026, 2, 1),
data_window_end=dt.date(2026, 5, 1),
),
data_quality=LayoutDataQuality(
objects_with_velocity_data=5,
objects_total_in_radius=8,
raw_objects_total=14,
velocity_coverage_pct=62.5,
confidence="medium",
),
supply_only_layouts=supply_only or [],
)
def _render(response: BestLayoutsResponse) -> str:
return build_layout_tz_html(
response,
cad_num="66:41:0204016:10",
radius_km=1.0,
time_window="last_quarter",
)
def test_supply_only_block_renders_when_present() -> None:
"""§4.2: блок «По предложению (без темпа продаж)» + строки supply_only_layouts."""
resp = _base_response(
[
SupplyOnlyLayoutRow(
project_name="ЖК Тестовый",
room_bucket="2",
area_bin="50-65",
lots_count=17,
median_price_m2=132500.0,
median_area_m2=58.3,
),
]
)
html = _render(resp)
# Заголовок + caveat про «без темпа продаж» / objective_lots.
assert "По предложению (без темпа продаж)" in html
assert "objective_lots" in html or "Объектив" in html
# Данные строки: комплекс, тип, лоты, медиана ₽/м², ср. площадь.
assert "ЖК Тестовый" in html
assert "17" in html
assert "132 500 ₽/м²" in html # тыс-разделитель — неразрывный/обычный пробел
assert "58.3 м²" in html
def test_supply_only_block_absent_when_empty() -> None:
"""Graceful: пустой supply_only_layouts (старый снапшот) → секции НЕТ, не падаем."""
html = _render(_base_response([]))
assert "По предложению (без темпа продаж)" not in html
# Основной документ всё равно собран.
assert "Техническое задание на проектирование" in html
def test_supply_only_null_price_renders_dash() -> None:
"""median_price_m2=None (цены не прошли price-sanity) → «—», без ошибок."""
resp = _base_response(
[
SupplyOnlyLayoutRow(
project_name="ЖК Без цен",
room_bucket="studio",
area_bin="<25",
lots_count=4,
median_price_m2=None,
median_area_m2=None,
),
]
)
html = _render(resp)
assert "По предложению (без темпа продаж)" in html
assert "ЖК Без цен" in html
assert "<td>—</td>" in html
def test_top_layouts_block_still_present() -> None:
"""§4.2-блок аддитивен: топ продаж конкурентов не потеснён."""
html = _render(_base_response([]))
assert "Топ планировок конкурентов по продажам" in html
assert "Рекомендуемая структура квартирографии" in html