feat(exporters): §4.2 supply-only блок в ТЗ-PDF (#2177) #2260
2 changed files with 224 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
130
backend/tests/test_layout_tz_html.py
Normal file
130
backend/tests/test_layout_tz_html.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue