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( """ + 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 diff --git a/backend/tests/test_layout_tz_html.py b/backend/tests/test_layout_tz_html.py new file mode 100644 index 00000000..35888569 --- /dev/null +++ b/backend/tests/test_layout_tz_html.py @@ -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 "—" in html + + +def test_top_layouts_block_still_present() -> None: + """§4.2-блок аддитивен: топ продаж конкурентов не потеснён.""" + html = _render(_base_response([])) + assert "Топ планировок конкурентов по продажам" in html + assert "Рекомендуемая структура квартирографии" in html