From e095a308c9a589c2f9ca4fad70e6dd0465ac0c9a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 14:36:42 +0500 Subject: [PATCH] =?UTF-8?q?feat(exporters):=20PURE=20HTML-=D0=B0=D0=B3?= =?UTF-8?q?=D1=80=D0=B5=D0=B3=D0=B0=D1=82=D0=BE=D1=80=20=C2=A71-=C2=A73=20?= =?UTF-8?q?+=20=D0=B0=D0=BB=D1=8C=D1=82=D0=B5=D1=80=D0=BD=D0=B0=D1=82?= =?UTF-8?q?=D0=B8=D0=B2=D1=8B=20(#2259=20PR-A=20Foundation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новый full_report_html.py: build_full_report_html_part_a(analyze_result, cad) собирает §1 участок / §2 окружение / §3 сети из персистнутого analysis_runs.result (analyze-1.0 loose dict) + опциональный блок program_alternatives. Каркас build_full_report_html(part_a, part_b?, ...) — печатный A4 HTML с page-break-правилами WeasyPrint, титулом, оглавлением-якорями; part_b опционален (PR-B). Карта — плейсхолдер {{MAP_PARCEL}} (PR-C). Всё через .get() с graceful «нет данных», html.escape на всех строках payload. PURE — 13 юнит-тестов без native libs. Форматтеры (_as_dict/_as_list/_esc) намеренно продублированы с report_pdf для независимости Foundation-PR — консолидация в shared-хелперы позже. NB: резервы ЦП/Водоканала + nearby_network_zones в persist-payload ОТСУТСТВУЮТ (ленивый connection-capacity-эндпоинт) — дотянуть в PR-D. Refs #2259 --- .../services/exporters/full_report_html.py | 818 ++++++++++++++++++ .../exporters/test_full_report_html_part_a.py | 368 ++++++++ 2 files changed, 1186 insertions(+) create mode 100644 backend/app/services/exporters/full_report_html.py create mode 100644 backend/tests/services/exporters/test_full_report_html_part_a.py diff --git a/backend/app/services/exporters/full_report_html.py b/backend/app/services/exporters/full_report_html.py new file mode 100644 index 00000000..c82b3fd1 --- /dev/null +++ b/backend/app/services/exporters/full_report_html.py @@ -0,0 +1,818 @@ +"""PURE HTML-агрегатор полного PDF-отчёта ПТИЦА §1–§3 + альтернативы (эпик #2259 PR-A). + +Foundation-слой полного PDF-экспорта отчёта `/site-finder/analysis/{cad}`. Собирает +из ПЕРСИСТНУТОГО `analysis_runs.result` (schema `analyze-1.0`, loose dict ~90 ключей — +`AnalyzeResponse`, `extra="allow"`) единый печатный HTML-документ: + + • §1 «Участок» — адрес / площадь / категория / ВРИ / градрегламент (ПЗЗ-зона, ТЭП) + / ЗОУИТ-ограничения / gate-вердикт «можно ли строить МКД». + • §2 «Окружение» — транспорт (центр / метро) / шум / воздух / геотехника / гидрология + / соседняя застройка. + • §3 «Сети» — инженерные сети рядом (`utilities`) + инж.сооружения НСПД + блок + альтернатив программы (`program_alternatives`, при отрицательном фин-вердикте). + +Архитектура — зеркало `layout_tz_pdf.build_layout_tz_html`: PURE-сборка HTML (без +WeasyPrint / БД / сети), тестируется юнит-тестами БЕЗ native GTK/Pango. Нормализация +loose-словарей — по образцу `report_pdf._normalize`/`_fmt`/`_as_dict`/`_as_list`. + +БЕЗОПАСНОСТЬ: ВСЕ динамические строки payload проходят `html.escape` (вход трактуем +как недоверенный — часть данных приходит из внешних источников: НСПД / OSM / геопортал). + +GRACEFUL: любой ключ читается через `.get()` с дефолтом; пустая секция рисует «нет +данных» по-русски; агрегатор НИКОГДА не падает на частичном / пустом payload. + +КАРТА: место под карту участка — плейсхолдер `{{MAP_PARCEL}}` (реальная подстановка в +PR-C). PR-A оставляет только маркер. + +ГРАНИЦЫ PR-A: §4–§7 (PR-B), карты (PR-C), live-дозапросы отсутствующих в persist-payload +слоёв — «Ресурсные резервы» ЦП/Водоканала и `nearby_network_zones` берутся ленивым +`connection-capacity`-эндпоинтом, ИХ В persist-payload НЕТ (PR-D). +""" + +from __future__ import annotations + +import html +import logging +from typing import Any + +logger = logging.getLogger(__name__) + +# ── Плейсхолдер карты (подставится в PR-C) ───────────────────────────────────── +MAP_PARCEL_PLACEHOLDER = "{{MAP_PARCEL}}" + +# ── Микрокопия / заглушки ────────────────────────────────────────────────────── +_DASH = "—" +_NO_DATA = "нет данных" + +# Заголовки секций (якоря — id внутри) и титул документа. +_TITLE_DOC = "Отчёт по участку — Site Finder ПТИЦА" +_TITLE_S1 = "§1. Участок" +_TITLE_S2 = "§2. Окружение" +_TITLE_S3 = "§3. Инженерные сети" +_TITLE_ALT = "Как участок сходится (альтернативы программы)" + +# id-якоря секций (оглавление + внутренние ссылки). +_ANCHOR_S1 = "section-1" +_ANCHOR_S2 = "section-2" +_ANCHOR_S3 = "section-3" +_ANCHOR_ALT = "section-alternatives" + +# RU-метки источников цены варианта программы (зеркало SectionAlternatives.tsx). +_PRICE_SOURCE_RU: dict[str, str] = { + "objective_district_median": "рынок: медиана объявлений Objective по району", + "objective_geo_radius": "рынок: медиана новостроек в радиусе 3 км", + "district_reference": "рынок: справочная медиана района", + "class_norm": "норматив класса (нет рыночных данных по участку)", +} + +# RU-метки класса жилья / типа застройки (зеркало concept-api-лейблов фронта). +_HOUSING_CLASS_RU: dict[str, str] = { + "econom": "Эконом", + "comfort": "Комфорт", + "business": "Бизнес", +} +_DEVELOPMENT_TYPE_RU: dict[str, str] = { + "spot": "точечная", + "mid_rise": "среднеэтажная", + "high_rise": "высотная", +} + +# ── Печатная типографика + page-break-правила WeasyPrint ──────────────────────── +_CSS = """ +@page { + size: A4; + margin: 18mm 16mm 20mm 16mm; + @bottom-center { + content: "Site Finder ПТИЦА · стр. " counter(page); + font-size: 7.5pt; + color: #9ca3af; + } +} +* { box-sizing: border-box; } +body { + font-family: 'DejaVu Sans', 'Helvetica', 'Arial', sans-serif; + font-size: 10pt; + color: #1a1d23; + margin: 0; + padding: 0; +} +h1 { font-size: 20pt; margin: 0 0 6pt 0; line-height: 1.2; } +h2 { + font-size: 14pt; margin: 0 0 8pt 0; + border-bottom: 1.5px solid #e6e8ec; padding-bottom: 4pt; color: #1d4ed8; +} +h3 { font-size: 11pt; margin: 12pt 0 6pt 0; color: #374151; } +p { margin: 4pt 0; } + +/* Page-break: каждая содержательная секция стартует со свежей страницы, но + не рвётся внутри заголовка (WeasyPrint поддерживает break-before/inside). */ +.section { margin-bottom: 16pt; break-inside: avoid-page; } +.section + .section { break-before: page; } +h2, h3 { break-after: avoid-page; } +table { break-inside: avoid-page; } + +/* Титул */ +.cover { padding: 6pt 0 12pt 0; border-bottom: 2px solid #1d4ed8; margin-bottom: 14pt; } +.cover-meta { font-size: 9pt; color: #5b6066; margin-top: 4pt; } + +/* Оглавление */ +.toc { margin: 0 0 16pt 0; break-after: page; } +.toc ul { list-style: none; padding: 0; margin: 6pt 0 0 0; } +.toc li { padding: 3pt 0; font-size: 10.5pt; } + +/* Карта-плейсхолдер */ +.map-placeholder { + margin: 8pt 0; padding: 40pt 12pt; text-align: center; + background: #fafbfc; border: 1px dashed #d1d5db; border-radius: 6pt; + color: #9ca3af; font-size: 9pt; +} + +/* KV-таблица «метка → значение» */ +.kv { width: 100%; border-collapse: collapse; margin-top: 4pt; } +.kv td { padding: 4pt 7pt; border-bottom: 1px solid #eef0f3; vertical-align: top; } +.kv td.k { font-weight: 700; color: #374151; width: 40%; } +.kv td.v { color: #1a1d23; } + +/* Таблицы данных */ +table.data { width: 100%; border-collapse: collapse; margin-top: 8pt; font-size: 9pt; } +table.data thead tr { background: #1d4ed8; } +table.data th { + padding: 5pt 7pt; text-align: left; font-weight: 700; + color: #fff; border-bottom: 1.5px solid #1e40af; +} +table.data td { padding: 5pt 7pt; border-bottom: 1px solid #e6e8ec; vertical-align: top; } +table.data tr:nth-child(even) td { background: #f9fafb; } +td.empty, .no-data { text-align: center; color: #9ca3af; font-style: italic; padding: 10pt; } +.no-data { padding: 6pt 0; } + +/* Плашки-вердикты */ +.verdict-block { + margin: 8pt 0; padding: 8pt 12pt; border-radius: 0 4pt 4pt 0; font-size: 9.5pt; +} +.verdict-ok { background: #dcfce7; border-left: 4px solid #0a7a3a; color: #05612c; } +.verdict-no { background: #fee2e2; border-left: 4px solid #b3261e; color: #8a1b15; } +.caveat { + margin: 8pt 0; padding: 8pt 12pt; border-radius: 0 4pt 4pt 0; + background: #fef3c7; border-left: 4px solid #9a6700; color: #7a5209; font-size: 9pt; +} + +/* Карточки альтернатив */ +.alt-card { + border: 1px solid #e6e8ec; border-radius: 8pt; padding: 10pt 12pt; margin: 8pt 0; +} +.alt-title { font-size: 11pt; font-weight: 700; color: #111; } +.alt-npv { font-size: 15pt; font-weight: 700; margin: 4pt 0; } +.alt-npv.pos { color: #0a7a3a; } +.alt-npv.neg { color: #b3261e; } +.alt-meta { font-size: 9pt; color: #5b6066; } + +.footer { + margin-top: 16pt; padding-top: 8pt; + border-top: 1px solid #e6e8ec; font-size: 8pt; color: #9ca3af; +} +""" + + +# ────────────────────────────────────────────────────────────────────────────── +# Нормализация входа + микро-хелперы форматирования. Все PURE, graceful. +# ────────────────────────────────────────────────────────────────────────────── + + +def _as_dict(value: Any) -> dict[str, Any]: + """Вернуть dict как есть, иначе пустой dict (defensive чтение секций). PURE.""" + return value if isinstance(value, dict) else {} + + +def _as_list(value: Any) -> list[Any]: + """Вернуть list как есть, иначе пустой list (defensive чтение секций). PURE.""" + return value if isinstance(value, list) else [] + + +def _fmt(value: Any) -> str: + """Привести значение к тексту ячейки: None → «—», float → аккуратно, str — как есть. + + Float с целым значением (78414.0) печатаем без хвоста «.0». Прочие float — + до 2 знаков, лишние нули убираем. Bool → «да»/«нет». Возврат — ВСЕГДА str + (его экранирует `_esc`). PURE. + """ + if value is None: + return _DASH + if isinstance(value, bool): + return "да" if value else "нет" + if isinstance(value, float): + if value.is_integer(): + return str(int(value)) + return f"{value:.2f}".rstrip("0").rstrip(".") + if isinstance(value, str): + return value if value != "" else _DASH + return str(value) + + +def _esc(value: Any) -> str: + """Привести значение к ячейке (`_fmt`) и ЭКРАНИРОВАТЬ для HTML. PURE. + + Единая точка вставки динамических данных в разметку: всё, что попадает в HTML, + проходит `html.escape` (вход недоверенный — см. модульный docstring). + """ + return html.escape(_fmt(value)) + + +def _fmt_int_ru(value: Any) -> str: + """Целое число с пробелами-разрядами («77 656»), иначе «—». PURE.""" + if isinstance(value, bool) or value is None: + return _DASH + if isinstance(value, int | float): + return f"{int(value):,}".replace(",", " ") + return _DASH + + +def _fmt_money_signed(value: Any) -> str: + """Деньги округлением до млн со знаком: «+42 млн ₽» / «−216 млн ₽». PURE. + + Ниже 1 млн — точная сумма с пробелами-разрядами. Минус — типографский U+2212. + None / не-число → «—». + """ + if isinstance(value, bool) or not isinstance(value, int | float): + return _DASH + num = float(value) + sign = "+" if num > 0 else "−" if num < 0 else "" + magnitude = abs(num) + if magnitude >= 1e9: + return f"{sign}{magnitude / 1e9:.1f} млрд ₽" + if magnitude >= 1e6: + return f"{sign}{round(magnitude / 1e6)} млн ₽" + return f"{sign}{f'{round(magnitude):,}'.replace(',', ' ')} ₽" + + +def _fmt_pct(fraction: Any) -> str: + """Доля 0.184 → «18.4%». Не-число → «—». PURE.""" + if isinstance(fraction, bool) or not isinstance(fraction, int | float): + return _DASH + value = f"{fraction * 100:.1f}" + if value.startswith("-"): + return "−" + value[1:] + "%" + return value + "%" + + +def _housing_class_ru(value: Any) -> str: + """RU-метка класса жилья, иначе значение как есть (str). PURE.""" + if isinstance(value, str) and value in _HOUSING_CLASS_RU: + return _HOUSING_CLASS_RU[value] + return _fmt(value) + + +def _development_type_ru(value: Any) -> str: + """RU-метка типа застройки, иначе значение как есть (str). PURE.""" + if isinstance(value, str) and value in _DEVELOPMENT_TYPE_RU: + return _DEVELOPMENT_TYPE_RU[value] + return _fmt(value) + + +def _price_source_ru(value: Any) -> str: + """RU-фраза источника цены варианта, иначе значение как есть. PURE.""" + if isinstance(value, str) and value in _PRICE_SOURCE_RU: + return _PRICE_SOURCE_RU[value] + return _fmt(value) + + +# ────────────────────────────────────────────────────────────────────────────── +# Низкоуровневые HTML-помощники (kv-строки / таблицы / заглушки). Все PURE. +# ────────────────────────────────────────────────────────────────────────────── + + +def _no_data() -> str: + """Блок-заглушка «нет данных» для пустой секции (graceful). PURE.""" + return f'
{_NO_DATA}
' + + +def _kv_row(label: str, value: Any) -> str: + """Строка «метка → значение» (оба экранированы). `label` — статичная RU-метка.""" + return f'{html.escape(label)}{_esc(value)}' + + +def _kv_table(pairs: list[tuple[str, Any]]) -> str: + """Таблица «метка → значение» из списка пар. Пустой список → «нет данных». PURE.""" + if not pairs: + return _no_data() + rows = "".join(_kv_row(label, value) for label, value in pairs) + return f'{rows}
' + + +def _data_table(headers: list[str], rows: list[list[Any]]) -> str: + """Таблица: шапка (статичные RU-метки) + строки данных (через `_esc`). PURE. + + Пустой `rows` → строка-заглушка «нет данных» под шапкой (graceful). Все ячейки + данных экранируются. + """ + head = "".join(f"{html.escape(h)}" for h in headers) + if not rows: + body = f'{_NO_DATA}' + else: + body = "".join( + "" + "".join(f"{_esc(cell)}" for cell in row) + "" for row in rows + ) + return f'{head}{body}
' + + +# ────────────────────────────────────────────────────────────────────────────── +# §1 «Участок»: адрес / площадь / категория / ВРИ / градрегламент / ЗОУИТ / gate. +# ────────────────────────────────────────────────────────────────────────────── + + +def _build_parcel_facts(result: dict[str, Any]) -> str: + """KV-таблица кадастровых фактов: адрес, площадь, категория, ВРИ, статус, стоимость.""" + egrn = _as_dict(result.get("egrn")) + parcel_meta = _as_dict(result.get("parcel_meta")) + geometry = _as_dict(result.get("geometry_suitability")) + + address = egrn.get("address") + area_m2 = egrn.get("area_m2") or geometry.get("area_m2") + category = egrn.get("land_category") or parcel_meta.get("land_category") + vri = egrn.get("permitted_use_text") or parcel_meta.get("permitted_use") + cad_cost = egrn.get("cadastral_value_rub") or parcel_meta.get("cad_cost") + + pairs: list[tuple[str, Any]] = [ + ("Адрес", address), + ("Площадь, м²", _fmt_int_ru(area_m2) if area_m2 is not None else None), + ("Категория земель", category), + ("Разрешённое использование (ВРИ)", vri), + ("Подвид", parcel_meta.get("land_subtype")), + ("Статус", egrn.get("parcel_status")), + ("Форма собственности", egrn.get("ownership_type")), + ("Право", egrn.get("right_type")), + ("Кадастровая стоимость, ₽", _fmt_int_ru(cad_cost) if cad_cost is not None else None), + ] + # Убираем строки без значения, чтобы не заваливать таблицу «—». + pairs = [(k, v) for k, v in pairs if v not in (None, "")] + return _kv_table(pairs) + + +def _build_zoning(result: dict[str, Any]) -> str: + """Градрегламент ПЗЗ: зона + ТЭП (FAR / этажность / высота / застройка) + ВРИ-список.""" + nspd_zoning = _as_dict(result.get("nspd_zoning")) + if not nspd_zoning: + # Fallback: старый per-parcel zoning (обычно data_available=false). + zoning = _as_dict(result.get("zoning")) + if not zoning or not zoning.get("zone_code"): + note = zoning.get("note") + note_html = f'

{_esc(note)}

' if note else "" + return _no_data() + note_html + + zone_code = nspd_zoning.get("zone_code") or nspd_zoning.get("regulation_zone_index") + pairs: list[tuple[str, Any]] = [ + ("Зона ПЗЗ", zone_code), + ("Наименование зоны", nspd_zoning.get("zone_name")), + ("Макс. коэф. застройки (FAR)", nspd_zoning.get("max_far")), + ("Макс. этажность", nspd_zoning.get("max_floors")), + ("Макс. высота, м", nspd_zoning.get("max_height_m")), + ("Макс. процент застройки", nspd_zoning.get("max_building_pct")), + ("Мин. площадь участка, м²", nspd_zoning.get("min_parcel_area_m2")), + ("Источник регламента", nspd_zoning.get("regulation_source")), + ] + pairs = [(k, v) for k, v in pairs if v not in (None, "")] + zoning_table = _kv_table(pairs) + + main_vri = _as_list(nspd_zoning.get("main_vri")) + vri_rows = [[v] for v in main_vri if v not in (None, "")] + vri_table = ( + f"

Разрешённые ВРИ зоны

{_data_table(['Вид разрешённого использования'], vri_rows)}" + if vri_rows + else "" + ) + return zoning_table + vri_table + + +def _build_zouit(result: dict[str, Any]) -> str: + """ЗОУИТ-ограничения: сводка + список пересечений (тип / № границы / покрытие).""" + encumbrance = _as_dict(result.get("encumbrance")) + overlaps = _as_list(result.get("nspd_zouit_overlaps")) + + summary_pairs: list[tuple[str, Any]] = [ + ("Есть ЗОУИТ", encumbrance.get("has_zouit")), + ("Кол-во типов ЗОУИТ", encumbrance.get("zouit_count")), + ] + zouit_types = _as_list(encumbrance.get("zouit_types")) + if zouit_types: + summary_pairs.append(("Типы", ", ".join(str(t) for t in zouit_types))) + + rows: list[list[Any]] = [] + for ov in overlaps: + if not isinstance(ov, dict): + continue + coverage = ov.get("coverage_pct") + coverage_str = _fmt_pct(coverage) if isinstance(coverage, int | float) else _DASH + rows.append( + [ + ov.get("type_zone") or ov.get("name"), + ov.get("reg_numb_border"), + coverage_str, + ] + ) + + summary = _kv_table(summary_pairs) + table = _data_table(["Тип зоны", "№ границы", "Покрытие участка"], rows) + return summary + f"

Пересечения ЗОУИТ

{table}" + + +def _build_gate_verdict(result: dict[str, Any]) -> str: + """Плашка gate-вердикта «можно ли строить МКД» + блокеры / предупреждения.""" + gate = _as_dict(result.get("gate_verdict")) + if not gate: + return "" + can_build = gate.get("can_build_mkd") + label = gate.get("verdict_label") or ("Можно" if can_build else "Нельзя") + cls = "verdict-ok" if can_build else "verdict-no" + verdict = ( + f'
Строительство МКД: {_esc(label)}
' + ) + + def _issues(items: Any, title: str) -> str: + rows = [ + [it.get("code"), it.get("detail")] for it in _as_list(items) if isinstance(it, dict) + ] + if not rows: + return "" + return f"

{html.escape(title)}

{_data_table(['Код', 'Детали'], rows)}" + + blockers = _issues(gate.get("blockers"), "Блокеры") + warnings = _issues(gate.get("warnings"), "Предупреждения") + return verdict + blockers + warnings + + +def _build_section_1(result: dict[str, Any]) -> str: + """§1 «Участок»: карта-плейсхолдер + факты + градрегламент + ЗОУИТ + gate-вердикт.""" + return f""" +
+

{html.escape(_TITLE_S1)}

+
{MAP_PARCEL_PLACEHOLDER}
+ +

Кадастровые факты

+ {_build_parcel_facts(result)} + +

Градостроительный регламент

+ {_build_zoning(result)} + +

Ограничения (ЗОУИТ)

+ {_build_zouit(result)} + + {_build_gate_verdict(result)} +
+""" + + +# ────────────────────────────────────────────────────────────────────────────── +# §2 «Окружение»: транспорт / шум / воздух / геотехника / гидрология / соседи. +# ────────────────────────────────────────────────────────────────────────────── + + +def _build_transport(result: dict[str, Any]) -> str: + """Транспорт: удалённость от центра ЕКБ + ближайшие станции метро.""" + location = _as_dict(result.get("location")) + metro = _as_dict(result.get("metro")) + + pairs: list[tuple[str, Any]] = [] + dist = location.get("distance_to_center_km") + if dist is not None: + pairs.append(("До центра ЕКБ, км", dist)) + + metro_rows = [ + [m.get("name"), _fmt_int_ru(m.get("distance_m"))] + for m in _as_list(metro.get("nearest_top3")) + if isinstance(m, dict) + ] + metro_table = ( + f"

Ближайшее метро

{_data_table(['Станция', 'Расстояние, м'], metro_rows)}" + if metro_rows + else "" + ) + head = _kv_table(pairs) if pairs else _no_data() + return head + metro_table + + +def _build_noise_air(result: dict[str, Any]) -> str: + """Шум + качество воздуха KV-таблицей.""" + noise = _as_dict(result.get("noise")) + air = _as_dict(result.get("air_quality")) + + pairs: list[tuple[str, Any]] = [ + ("Уровень шума", noise.get("level")), + ("Оценка шума, дБ", noise.get("estimated_db")), + ("NO₂", air.get("no2")), + ("PM10", air.get("pm10")), + ("PM2.5", air.get("pm2_5")), + ("Источник воздуха", air.get("source")), + ] + pairs = [(k, v) for k, v in pairs if v not in (None, "")] + return _kv_table(pairs) + + +def _build_geotech_hydro(result: dict[str, Any]) -> str: + """Геотехника (сейсмика / промышленность) + гидрология (пойма / водные объекты).""" + geotech = _as_dict(result.get("geotech_risk")) + hydro = _as_dict(result.get("hydrology")) + + pairs: list[tuple[str, Any]] = [ + ("Сейсмика", geotech.get("seismic_label")), + ("Балльность", geotech.get("seismic_intensity_balls")), + ("Многолетняя мерзлота", geotech.get("permafrost")), + ("Промобъектов в 500 м", geotech.get("industrial_within_500m")), + ("Риск подтопления", hydro.get("flood_risk_flag")), + ] + pairs = [(k, v) for k, v in pairs if v not in (None, "")] + geotech_table = _kv_table(pairs) + + water_rows = [ + [w.get("name") or w.get("subtype"), _fmt_int_ru(w.get("distance_m"))] + for w in _as_list(hydro.get("nearest")) + if isinstance(w, dict) + ] + water_table = ( + f"

Ближайшие водные объекты

{_data_table(['Объект', 'Расстояние, м'], water_rows)}" + if water_rows + else "" + ) + return geotech_table + water_table + + +def _build_neighbors(result: dict[str, Any]) -> str: + """Соседняя застройка (cad_buildings в радиусе): назначение / этажность / расстояние.""" + summary = _as_dict(result.get("neighbors_summary")) + neighbors = _as_list(summary.get("neighbors")) + + rows: list[list[Any]] = [] + for nb in neighbors: + if not isinstance(nb, dict): + continue + rows.append( + [ + nb.get("building_name") or nb.get("cad_num"), + nb.get("purpose"), + nb.get("floors_parsed") or nb.get("floors"), + _fmt_int_ru(nb.get("distance_m")), + ] + ) + return _data_table(["Объект", "Назначение", "Этажей", "Расстояние, м"], rows) + + +def _build_section_2(result: dict[str, Any]) -> str: + """§2 «Окружение»: транспорт + шум/воздух + геотехника/гидрология + соседи.""" + return f""" +
+

{html.escape(_TITLE_S2)}

+ +

Транспортная доступность

+ {_build_transport(result)} + +

Шум и качество воздуха

+ {_build_noise_air(result)} + +

Геотехника и гидрология

+ {_build_geotech_hydro(result)} + +

Соседняя застройка

+ {_build_neighbors(result)} +
+""" + + +# ────────────────────────────────────────────────────────────────────────────── +# §3 «Инженерные сети» + блок альтернатив программы. +# ────────────────────────────────────────────────────────────────────────────── + + +def _build_utilities(result: dict[str, Any]) -> str: + """Инженерные сети рядом (OSM `utilities`): ближайшие сети + сводка по видам.""" + utilities = _as_dict(result.get("utilities")) + if not utilities: + return _no_data() + + pairs: list[tuple[str, Any]] = [ + ("Ближайшая ПС/подстанция, м", _fmt_int_ru(utilities.get("nearest_substation_m"))), + ("Ближайший водовод, м", _fmt_int_ru(utilities.get("nearest_water_main_m"))), + ("Ближайший газопровод, м", _fmt_int_ru(utilities.get("nearest_gas_m"))), + ("Ближайшая теплотрасса, м", _fmt_int_ru(utilities.get("nearest_heat_m"))), + # ключ в persist-payload реально смешивает латиницу и кириллицу — НЕ «чинить» + ("В охранной зоне ЛЭП", utilities.get("power_line_охранная_зона_flag")), + ] + pairs = [(k, v) for k, v in pairs if v not in (None, _DASH)] + head = _kv_table(pairs) + + summary_rows = [ + [ + s.get("subtype"), + _fmt_int_ru(s.get("nearest_m")), + s.get("count_within_2km"), + ] + for s in _as_list(utilities.get("summary")) + if isinstance(s, dict) + ] + summary_table = ( + f"

Сети рядом с участком (OSM)

" + f"{_data_table(['Вид сети', 'Ближайшая, м', 'В радиусе 2 км'], summary_rows)}" + if summary_rows + else "" + ) + return head + summary_table + + +def _build_engineering_nearby(result: dict[str, Any]) -> str: + """Инженерные сооружения НСПД рядом (name / назначение / расстояние из raw_props).""" + items = _as_list(result.get("nspd_engineering_nearby")) + rows: list[list[Any]] = [] + for it in items: + if not isinstance(it, dict): + continue + raw = _as_dict(it.get("raw_props")) + name = it.get("name") or raw.get("params_name") or raw.get("cad_number") + purpose = it.get("type") or raw.get("params_purpose") + rows.append([name, purpose, _fmt_int_ru(it.get("distance_m"))]) + if not rows: + return "" + return ( + f"

Инженерные сооружения рядом (НСПД)

" + f"{_data_table(['Сооружение', 'Назначение', 'Расстояние, м'], rows)}" + ) + + +def _build_alternatives(result: dict[str, Any]) -> str: + """Блок альтернатив программы (`program_alternatives`). + + Присутствует ТОЛЬКО при отрицательном базовом фин-вердикте (program_optimizer.py); + иначе поле null → возвращаем пустую строку (блок не рисуется вовсе). Топ-N карточек + по NPV + честный caveat. Оценка по МАКС. застройке зоны, НЕ инвест-рекомендация. + """ + alt = _as_dict(result.get("program_alternatives")) + variants = _as_list(alt.get("variants")) + if not alt or not variants: + return "" + + any_viable = bool(alt.get("any_viable")) + grid_size = alt.get("grid_size") + subtitle = ( + "Базовая оценка отрицательна, но участок сходится при другой программе — " + "топ вариантов по NPV:" + if any_viable + else ( + f"Ни один из {_fmt(grid_size)} вариантов программы (класс × этажность) не " + "выходит в плюс. Ниже — наименее убыточные:" + ) + ) + + cards: list[str] = [] + for v in variants: + if not isinstance(v, dict): + continue + npv = v.get("npv_rub") + npv_cls = "pos" if isinstance(npv, int | float) and npv > 0 else "neg" + title = ( + f"{_housing_class_ru(v.get('housing_class'))} · " + f"{_fmt(v.get('floors'))} эт · {_development_type_ru(v.get('development_type'))}" + ) + cards.append( + f""" +
+
{html.escape(title)}
+
NPV {html.escape(_fmt_money_signed(npv))}
+
ROI {html.escape(_fmt_pct(v.get("roi")))} · """ + f"""IRR {html.escape(_fmt_pct(v.get("irr")))}
+
Жилая {html.escape(_fmt_int_ru(v.get("residential_sqm")))} м² · """ + f"""{html.escape(_fmt_int_ru(v.get("apartments_count")))} квартир
+
Цена {html.escape(_fmt_int_ru(v.get("price_per_sqm_used")))} ₽/м² """ + f"""({html.escape(_price_source_ru(v.get("price_source")))})
+
""" + ) + + caveat = alt.get("caveat") + caveat_html = f'
{_esc(caveat)}
' if caveat else "" + return f""" +
+

{html.escape(_TITLE_ALT)}

+

{html.escape(subtitle)}

+ {"".join(cards)} + {caveat_html} +
+""" + + +def _build_section_3(result: dict[str, Any]) -> str: + """§3 «Инженерные сети»: OSM-сети рядом + НСПД-сооружения + альтернативы программы. + + NB: «Ресурсные резервы» (ЦП Россетей / Водоканал) и `nearby_network_zones` в + persist-payload ОТСУТСТВУЮТ (ленивый connection-capacity-эндпоинт) — их подтянет + PR-D. Здесь только то, что реально есть в `analyze_result`. + """ + return f""" +
+

{html.escape(_TITLE_S3)}

+ +

Инженерные сети рядом

+ {_build_utilities(result)} + + {_build_engineering_nearby(result)} +
+{_build_alternatives(result)} +""" + + +# ────────────────────────────────────────────────────────────────────────────── +# Публичный API PR-A: сборка Part A (§1–§3) + общий каркас документа. +# ────────────────────────────────────────────────────────────────────────────── + + +def build_full_report_html_part_a(analyze_result: dict[str, Any], *, cad: str) -> str: + """Собрать HTML Part A полного отчёта: §1 «Участок» + §2 «Окружение» + §3 «Сети». + + PURE (без WeasyPrint / БД / сети). Читает ПЕРСИСТНУТЫЙ `analysis_runs.result` + (`analyze-1.0`, loose dict). Каждый ключ — через `.get()` с дефолтом; отсутствующая + секция → «нет данных»; ВСЕ строки payload проходят `html.escape`. Карта участка — + плейсхолдер `{{MAP_PARCEL}}` (PR-C). Блок альтернатив (`program_alternatives`) + рисуется только при его наличии в payload. + + Args: + analyze_result: `analysis_runs.result` (schema `analyze-1.0`) или любой dict. + Не-dict / None → трактуется как пустой payload (все секции → «нет данных»). + cad: кадастровый номер участка (для логов; в HTML не встраивается напрямую — + он приходит в каркас через `build_full_report_html`). + + Returns: + HTML-фрагмент Part A (три `
` + опциональный блок альтернатив), + готовый как `part_a_html` для `build_full_report_html`. + """ + result = _as_dict(analyze_result) + part_a = _build_section_1(result) + _build_section_2(result) + _build_section_3(result) + logger.info( + "build_full_report_html_part_a: cad=%s keys=%d has_alternatives=%s", + cad, + len(result), + bool(_as_dict(result.get("program_alternatives")).get("variants")), + ) + return part_a + + +def _build_toc(has_part_b: bool) -> str: + """Оглавление-якоря. Part B опционален (PR-B) → его пункт скрываем, если None. PURE.""" + items = [ + f'
  • {html.escape(_TITLE_S1)}
  • ', + f'
  • {html.escape(_TITLE_S2)}
  • ', + f'
  • {html.escape(_TITLE_S3)}
  • ', + ] + if has_part_b: + items.append('
  • §4–§7. Рынок, продукт, концепция
  • ') + return f'' + + +def build_full_report_html( + part_a_html: str, + part_b_html: str | None, + *, + cad: str, + address: str | None, + generated_at: str, +) -> str: + """Собрать единый печатный HTML-документ полного отчёта (каркас + Part A [+ Part B]). + + Общий ` + + +
    +

    {html.escape(_TITLE_DOC)}

    +
    Кадастровый номер: {html.escape(cad)}
    + {addr_line} +
    Дата формирования: {html.escape(generated_at)}
    +
    + + {_build_toc(has_part_b)} + + {part_a_html} + {part_b_block} + + + +""" diff --git a/backend/tests/services/exporters/test_full_report_html_part_a.py b/backend/tests/services/exporters/test_full_report_html_part_a.py new file mode 100644 index 00000000..d56575de --- /dev/null +++ b/backend/tests/services/exporters/test_full_report_html_part_a.py @@ -0,0 +1,368 @@ +"""Unit-тесты PURE HTML-агрегатора §1–§3 + альтернативы (эпик #2259 PR-A). + +Чистые тесты БЕЗ native libs (WeasyPrint не импортируется) и БЕЗ БД/сети: агрегатор +только ПОТРЕБЛЯЕТ уже-персистнутый `analysis_runs.result` (loose dict `analyze-1.0`). +Фикстур — СИНТЕТИЧЕСКИЙ (структура зеркалит прод-payload, но без реальных строк). + +Покрываем: + • реалистичный полный payload → все секции §1–§3 + якоря + факты; + • отсутствующие секции / None-поля → «нет данных», без падения (graceful); + • пустой / не-dict payload → валидный HTML с «нет данных»; + • HTML-escape динамических строк payload (XSS-вектор через внешние источники); + • плейсхолдер карты `{{MAP_PARCEL}}` присутствует; + • блок альтернатив рисуется только при наличии `program_alternatives`; + • каркас `build_full_report_html`: титул / оглавление / опциональный Part B. + +DATABASE_URL выставляем до импорта app-модулей (зеркало test_report_md.py) — на случай +side-effect'ов импорта пакета app. +""" + +from __future__ import annotations + +import os +from typing import Any + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services.exporters.full_report_html import ( + _ANCHOR_ALT, + _ANCHOR_S1, + _ANCHOR_S2, + _ANCHOR_S3, + _NO_DATA, + MAP_PARCEL_PLACEHOLDER, + build_full_report_html, + build_full_report_html_part_a, +) + +# ── Синтетический фикстур (структура прод-payload, без реальных строк) ────────── + + +def _synthetic_result() -> dict[str, Any]: + """Реалистичный полный analyze-payload: §1 участок + §2 окружение + §3 сети.""" + return { + "cad_num": "00:00:0000000:0000", + # §1 — кадастр / регламент / ЗОУИТ / gate + "egrn": { + "address": "Тестовый регион, тестовый город, улица Примерная, 1", + "area_m2": 12345.0, + "land_category": "Категория земель тестовая", + "permitted_use_text": "тестовое разрешённое использование", + "parcel_status": "Учтенный", + "ownership_type": "Частная", + "right_type": "Собственность", + "cadastral_value_rub": 9876543.0, + }, + "parcel_meta": { + "land_subtype": "Землепользование", + "land_category": "Категория земель тестовая", + "permitted_use": "тестовое разрешённое использование", + "cad_cost": 9876543.0, + }, + "geometry_suitability": {"area_m2": 12345, "label": "подходящий"}, + "nspd_zoning": { + "zone_code": "ТЗ-1", + "zone_name": None, + "max_far": 2.0, + "max_floors": 12, + "max_height_m": 40, + "max_building_pct": 60, + "min_parcel_area_m2": None, + "regulation_source": "test-geoportal", + "regulation_zone_index": "ТЗ-1", + "main_vri": ["2.6 Тестовая многоэтажная застройка", "3.1 Тестовое обслуживание"], + }, + "encumbrance": { + "has_zouit": True, + "zouit_count": 2, + "zouit_types": ["Тестовая охранная зона", "Тестовая санитарная зона"], + }, + "nspd_zouit_overlaps": [ + { + "type_zone": "Тестовая охранная зона сетей", + "name": "Тестовая охранная зона сетей", + "reg_numb_border": "00:00-0.0000", + "coverage_pct": 0.5, + } + ], + "gate_verdict": { + "source": "nspd_dump", + "can_build_mkd": False, + "verdict_label": "Нельзя", + "blockers": [{"code": "TEST_BLOCK", "detail": "Тестовый блокер застройки"}], + "warnings": [{"code": "TEST_WARN", "detail": "Тестовое предупреждение"}], + }, + # §2 — окружение + "location": {"distance_to_center_km": 7.5}, + "metro": {"nearest_top3": [{"name": "Тестовая станция", "distance_m": 4200}]}, + "noise": {"level": "тихо", "estimated_db": 43.2}, + "air_quality": {"no2": 1.6, "pm10": 4.9, "pm2_5": 4.1, "source": "test-source"}, + "geotech_risk": { + "seismic_label": "минимальная сейсмика", + "seismic_intensity_balls": 5, + "permafrost": False, + "industrial_within_500m": 3, + }, + "hydrology": { + "flood_risk_flag": False, + "nearest": [{"name": "Тестовая река", "subtype": "river", "distance_m": 500}], + }, + "neighbors_summary": { + "neighbors": [ + { + "building_name": "Тестовое здание", + "cad_num": "00:00:0000000:0001", + "purpose": "Нежилое", + "floors_parsed": 2, + "distance_m": 0, + } + ] + }, + # §3 — сети + "utilities": { + "nearest_substation_m": 120, + "nearest_water_main_m": None, + "nearest_gas_m": None, + "nearest_heat_m": None, + "power_line_охранная_зона_flag": False, + "summary": [ + {"subtype": "substation", "nearest_m": 120, "count_within_2km": 2}, + {"subtype": "pipeline", "nearest_m": 175, "count_within_2km": 4}, + ], + }, + "nspd_engineering_nearby": [ + { + "name": None, + "type": None, + "distance_m": 50, + "raw_props": { + "params_name": "Тестовый трубопровод", + "params_purpose": "тестовое коммунальное сооружение", + "cad_number": "00:00:0000000:0002", + }, + } + ], + } + + +def _synthetic_alternatives() -> dict[str, Any]: + """Блок `program_alternatives` (отрицательный фин-вердикт → топ вариантов).""" + return { + "variants": [ + { + "housing_class": "comfort", + "floors": 12, + "development_type": "high_rise", + "npv_rub": -216_000_000.0, + "irr": -0.05, + "roi": -0.12, + "residential_sqm": 20000.0, + "apartments_count": 350, + "price_per_sqm_used": 142500.0, + "price_source": "objective_district_median", + "schedule_is_default": True, + }, + { + "housing_class": "econom", + "floors": 9, + "development_type": "mid_rise", + "npv_rub": -300_000_000.0, + "irr": -0.09, + "roi": -0.2, + "residential_sqm": 15000.0, + "apartments_count": 280, + "price_per_sqm_used": 110000.0, + "price_source": "class_norm", + "schedule_is_default": False, + }, + ], + "any_viable": False, + "grid_size": 9, + "caveat": "Оценка по максимальной застройке зоны, не инвест-рекомендация.", + } + + +# ── 1. Реалистичный полный payload → все секции + якоря ───────────────────────── + + +def test_full_payload_renders_all_sections_and_anchors() -> None: + html = build_full_report_html_part_a(_synthetic_result(), cad="00:00:0000000:0000") + for anchor in (_ANCHOR_S1, _ANCHOR_S2, _ANCHOR_S3): + assert f'id="{anchor}"' in html + # Ключевые факты §1 + assert "улица Примерная, 1" in html + assert "ТЗ-1" in html # зона ПЗЗ + assert "12 345" in html # площадь с пробелом-разрядом + # §2 окружение + assert "Тестовая станция" in html + assert "минимальная сейсмика" in html + # §3 сети + assert "substation" in html + assert "Тестовый трубопровод" in html + + +# ── 2. Плейсхолдер карты присутствует ─────────────────────────────────────────── + + +def test_map_placeholder_present() -> None: + html = build_full_report_html_part_a(_synthetic_result(), cad="X") + assert MAP_PARCEL_PLACEHOLDER in html + assert "{{MAP_PARCEL}}" in html + + +# ── 3. gate-вердикт: «Нельзя» + блокеры/предупреждения ────────────────────────── + + +def test_gate_verdict_renders_blockers_and_warnings() -> None: + html = build_full_report_html_part_a(_synthetic_result(), cad="X") + assert "Нельзя" in html + assert "Тестовый блокер застройки" in html + assert "Тестовое предупреждение" in html + + +# ── 4. Отсутствующие секции → «нет данных», без падения ───────────────────────── + + +def test_missing_sections_render_no_data() -> None: + html = build_full_report_html_part_a({}, cad="X") + # Секции и якоря всё равно на месте (каркас граничный). + for anchor in (_ANCHOR_S1, _ANCHOR_S2, _ANCHOR_S3): + assert f'id="{anchor}"' in html + assert _NO_DATA in html + # Плейсхолдер карты рисуется даже на пустом payload. + assert MAP_PARCEL_PLACEHOLDER in html + + +# ── 5. Не-dict / None payload → валидный HTML с «нет данных» ───────────────────── + + +def test_non_dict_payload_is_graceful() -> None: + for junk in (None, [], "строка", 42): + html = build_full_report_html_part_a(junk, cad="X") # type: ignore[arg-type] + assert f'id="{_ANCHOR_S1}"' in html + assert _NO_DATA in html + + +# ── 6. None-поля внутри существующих секций не ломают рендер ───────────────────── + + +def test_none_fields_within_sections() -> None: + result: dict[str, Any] = { + "egrn": {"address": None, "area_m2": None, "land_category": None}, + "nspd_zoning": {"zone_code": None, "max_far": None, "main_vri": None}, + "utilities": {"summary": None, "nearest_substation_m": None}, + "metro": {"nearest_top3": None}, + "hydrology": {"nearest": None}, + "neighbors_summary": {"neighbors": None}, + } + html = build_full_report_html_part_a(result, cad="X") + # Не должно быть исключений; секции присутствуют, «нет данных» где пусто. + assert f'id="{_ANCHOR_S2}"' in html + assert _NO_DATA in html + + +# ── 7. HTML-escape динамических строк payload (XSS-вектор) ─────────────────────── + + +def test_dynamic_strings_are_escaped() -> None: + result: dict[str, Any] = { + "egrn": {"address": ""}, + "nspd_zoning": { + "zone_code": "zone", + "main_vri": [""], + }, + "gate_verdict": { + "can_build_mkd": True, + "verdict_label": "ok", + "blockers": [{"code": "", "detail": ""}], + }, + } + html = build_full_report_html_part_a(result, cad="X") + # Сырые теги payload не должны попасть в разметку неэкранированными. + assert "", + address="addr", + generated_at="03.07.2026", + ) + assert "" not in doc + assert "<script>x</script>" in doc + assert "<b>addr</b>" in doc -- 2.45.3