feat(exporters): §4.2 «По предложению (без темпа продаж)» в ТЗ-PDF
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
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
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.
This commit is contained in:
parent
e440209d91
commit
a6e0d231ac
2 changed files with 224 additions and 5 deletions
|
|
@ -14,15 +14,20 @@ from app.schemas.parcel import BestLayoutsResponse
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def render_layout_tz_pdf(
|
def build_layout_tz_html(
|
||||||
response: BestLayoutsResponse,
|
response: BestLayoutsResponse,
|
||||||
*,
|
*,
|
||||||
cad_num: str,
|
cad_num: str,
|
||||||
parcel_address: str | None = None,
|
parcel_address: str | None = None,
|
||||||
radius_km: float,
|
radius_km: float,
|
||||||
time_window: str,
|
time_window: str,
|
||||||
) -> bytes:
|
) -> str:
|
||||||
"""Render ТЗ PDF от best-layouts response.
|
"""Собрать HTML ТЗ из best-layouts response (PURE — без WeasyPrint/БД/сети).
|
||||||
|
|
||||||
|
Вынесено из :func:`render_layout_tz_pdf`, чтобы HTML-разметку (наличие блоков,
|
||||||
|
RU-микрокопию, форматирование ₽/м²) можно было проверять юнит-тестами БЕЗ
|
||||||
|
native GTK/Pango (WeasyPrint на macOS/Windows отсутствует). PDF-обёртка ниже
|
||||||
|
просто рендерит этот HTML.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
response: BestLayoutsResponse от /best-layouts endpoint
|
response: BestLayoutsResponse от /best-layouts endpoint
|
||||||
|
|
@ -32,7 +37,7 @@ def render_layout_tz_pdf(
|
||||||
time_window: окно анализа (last_month/quarter/year)
|
time_window: окно анализа (last_month/quarter/year)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
PDF bytes (готово для StreamingResponse)
|
HTML-строка документа (готова для WeasyPrint).
|
||||||
"""
|
"""
|
||||||
today = dt.date.today().strftime("%d.%m.%Y")
|
today = dt.date.today().strftime("%d.%m.%Y")
|
||||||
safe_cad = _html.escape(cad_num)
|
safe_cad = _html.escape(cad_num)
|
||||||
|
|
@ -45,6 +50,18 @@ def render_layout_tz_pdf(
|
||||||
return "<td>—</td>"
|
return "<td>—</td>"
|
||||||
return f"<td>{val:,.0f}".replace(",", " ") + " ₽</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 layouts table rows
|
||||||
top_rows = "".join(
|
top_rows = "".join(
|
||||||
"<tr>"
|
"<tr>"
|
||||||
|
|
@ -70,6 +87,42 @@ def render_layout_tz_pdf(
|
||||||
for m in response.recommendation_for_tz.mix
|
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
|
rec = response.recommendation_for_tz
|
||||||
safe_rationale = _html.escape(rec.rationale_text)
|
safe_rationale = _html.escape(rec.rationale_text)
|
||||||
weighted_price = (
|
weighted_price = (
|
||||||
|
|
@ -95,6 +148,8 @@ def render_layout_tz_pdf(
|
||||||
th {{ background: #f5f5f5; font-weight: bold; }}
|
th {{ background: #f5f5f5; font-weight: bold; }}
|
||||||
.rationale {{ background: #f8f8f8; padding: 10px; border-left: 3px solid #4a90e2;
|
.rationale {{ background: #f8f8f8; padding: 10px; border-left: 3px solid #4a90e2;
|
||||||
margin: 1em 0; }}
|
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;
|
.footer {{ margin-top: 2em; padding-top: 1em; border-top: 1px solid #ddd;
|
||||||
color: #888; font-size: 9pt; }}
|
color: #888; font-size: 9pt; }}
|
||||||
.confidence-high {{ color: #2a8c2a; }}
|
.confidence-high {{ color: #2a8c2a; }}
|
||||||
|
|
@ -133,7 +188,7 @@ def render_layout_tz_pdf(
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>{top_rows}</tbody>
|
<tbody>{top_rows}</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{supply_only_section}
|
||||||
<h2>Качество данных</h2>
|
<h2>Качество данных</h2>
|
||||||
<p>
|
<p>
|
||||||
Покрытие: {dq.objects_with_velocity_data} из
|
Покрытие: {dq.objects_with_velocity_data} из
|
||||||
|
|
@ -154,6 +209,40 @@ def render_layout_tz_pdf(
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</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 импортируем локально — тяжёлый; не нужен при импорте модуля
|
# WeasyPrint импортируем локально — тяжёлый; не нужен при импорте модуля
|
||||||
# (иначе ломает сбор pytest на хостах без native-libs, напр. macOS).
|
# (иначе ломает сбор pytest на хостах без native-libs, напр. macOS).
|
||||||
from weasyprint import HTML
|
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