This commit is contained in:
parent
85435fbf1b
commit
c0c18f4ecc
2 changed files with 1186 additions and 0 deletions
818
backend/app/services/exporters/full_report_html.py
Normal file
818
backend/app/services/exporters/full_report_html.py
Normal file
|
|
@ -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'<div class="no-data">{_NO_DATA}</div>'
|
||||
|
||||
|
||||
def _kv_row(label: str, value: Any) -> str:
|
||||
"""Строка «метка → значение» (оба экранированы). `label` — статичная RU-метка."""
|
||||
return f'<tr><td class="k">{html.escape(label)}</td><td class="v">{_esc(value)}</td></tr>'
|
||||
|
||||
|
||||
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'<table class="kv">{rows}</table>'
|
||||
|
||||
|
||||
def _data_table(headers: list[str], rows: list[list[Any]]) -> str:
|
||||
"""Таблица: шапка (статичные RU-метки) + строки данных (через `_esc`). PURE.
|
||||
|
||||
Пустой `rows` → строка-заглушка «нет данных» под шапкой (graceful). Все ячейки
|
||||
данных экранируются.
|
||||
"""
|
||||
head = "".join(f"<th>{html.escape(h)}</th>" for h in headers)
|
||||
if not rows:
|
||||
body = f'<tr><td class="empty" colspan="{len(headers)}">{_NO_DATA}</td></tr>'
|
||||
else:
|
||||
body = "".join(
|
||||
"<tr>" + "".join(f"<td>{_esc(cell)}</td>" for cell in row) + "</tr>" for row in rows
|
||||
)
|
||||
return f'<table class="data"><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>'
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# §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'<p class="alt-meta">{_esc(note)}</p>' 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"<h3>Разрешённые ВРИ зоны</h3>{_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"<h3>Пересечения ЗОУИТ</h3>{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'<div class="verdict-block {cls}">Строительство МКД: <strong>{_esc(label)}</strong></div>'
|
||||
)
|
||||
|
||||
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"<h3>{html.escape(title)}</h3>{_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"""
|
||||
<div class="section" id="{_ANCHOR_S1}">
|
||||
<h2>{html.escape(_TITLE_S1)}</h2>
|
||||
<div class="map-placeholder">{MAP_PARCEL_PLACEHOLDER}</div>
|
||||
|
||||
<h3>Кадастровые факты</h3>
|
||||
{_build_parcel_facts(result)}
|
||||
|
||||
<h3>Градостроительный регламент</h3>
|
||||
{_build_zoning(result)}
|
||||
|
||||
<h3>Ограничения (ЗОУИТ)</h3>
|
||||
{_build_zouit(result)}
|
||||
|
||||
{_build_gate_verdict(result)}
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# §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"<h3>Ближайшее метро</h3>{_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"<h3>Ближайшие водные объекты</h3>{_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"""
|
||||
<div class="section" id="{_ANCHOR_S2}">
|
||||
<h2>{html.escape(_TITLE_S2)}</h2>
|
||||
|
||||
<h3>Транспортная доступность</h3>
|
||||
{_build_transport(result)}
|
||||
|
||||
<h3>Шум и качество воздуха</h3>
|
||||
{_build_noise_air(result)}
|
||||
|
||||
<h3>Геотехника и гидрология</h3>
|
||||
{_build_geotech_hydro(result)}
|
||||
|
||||
<h3>Соседняя застройка</h3>
|
||||
{_build_neighbors(result)}
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# §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"<h3>Сети рядом с участком (OSM)</h3>"
|
||||
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"<h3>Инженерные сооружения рядом (НСПД)</h3>"
|
||||
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"""
|
||||
<div class="alt-card">
|
||||
<div class="alt-title">{html.escape(title)}</div>
|
||||
<div class="alt-npv {npv_cls}">NPV {html.escape(_fmt_money_signed(npv))}</div>
|
||||
<div class="alt-meta">ROI {html.escape(_fmt_pct(v.get("roi")))} · """
|
||||
f"""IRR {html.escape(_fmt_pct(v.get("irr")))}</div>
|
||||
<div class="alt-meta">Жилая {html.escape(_fmt_int_ru(v.get("residential_sqm")))} м² · """
|
||||
f"""{html.escape(_fmt_int_ru(v.get("apartments_count")))} квартир</div>
|
||||
<div class="alt-meta">Цена {html.escape(_fmt_int_ru(v.get("price_per_sqm_used")))} ₽/м² """
|
||||
f"""({html.escape(_price_source_ru(v.get("price_source")))})</div>
|
||||
</div>"""
|
||||
)
|
||||
|
||||
caveat = alt.get("caveat")
|
||||
caveat_html = f'<div class="caveat">{_esc(caveat)}</div>' if caveat else ""
|
||||
return f"""
|
||||
<div class="section" id="{_ANCHOR_ALT}">
|
||||
<h2>{html.escape(_TITLE_ALT)}</h2>
|
||||
<p class="alt-meta">{html.escape(subtitle)}</p>
|
||||
{"".join(cards)}
|
||||
{caveat_html}
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
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"""
|
||||
<div class="section" id="{_ANCHOR_S3}">
|
||||
<h2>{html.escape(_TITLE_S3)}</h2>
|
||||
|
||||
<h3>Инженерные сети рядом</h3>
|
||||
{_build_utilities(result)}
|
||||
|
||||
{_build_engineering_nearby(result)}
|
||||
</div>
|
||||
{_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 (три `<div class="section">` + опциональный блок альтернатив),
|
||||
готовый как `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'<li><a href="#{_ANCHOR_S1}">{html.escape(_TITLE_S1)}</a></li>',
|
||||
f'<li><a href="#{_ANCHOR_S2}">{html.escape(_TITLE_S2)}</a></li>',
|
||||
f'<li><a href="#{_ANCHOR_S3}">{html.escape(_TITLE_S3)}</a></li>',
|
||||
]
|
||||
if has_part_b:
|
||||
items.append('<li><a href="#section-4">§4–§7. Рынок, продукт, концепция</a></li>')
|
||||
return f'<nav class="toc"><h3>Содержание</h3><ul>{"".join(items)}</ul></nav>'
|
||||
|
||||
|
||||
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]).
|
||||
|
||||
Общий `<style>` (печатная типографика A4, page-break-правила WeasyPrint), титул с
|
||||
кад.номером / адресом / датой, оглавление-якоря. `part_b_html=None` → блок §4–§7
|
||||
(PR-B) пропускается, пункт оглавления скрывается. PURE (только строки); WeasyPrint
|
||||
здесь НЕ вызывается — render-обёртка (PR-C) отдельно.
|
||||
|
||||
Args:
|
||||
part_a_html: HTML §1–§3 от `build_full_report_html_part_a`.
|
||||
part_b_html: HTML §4–§7 (PR-B) или None (пропуск).
|
||||
cad: кадастровый номер участка (титул).
|
||||
address: человекочитаемый адрес участка или None (титул).
|
||||
generated_at: строка даты формирования (уже отформатированная вызывающим).
|
||||
|
||||
Returns:
|
||||
Полный HTML-документ (готов для WeasyPrint в PR-C).
|
||||
"""
|
||||
has_part_b = bool(part_b_html)
|
||||
addr_line = f'<div class="cover-meta">Адрес: {html.escape(address)}</div>' if address else ""
|
||||
part_b_block = part_b_html or ""
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{html.escape(_TITLE_DOC)} — {html.escape(cad)}</title>
|
||||
<style>{_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="cover">
|
||||
<h1>{html.escape(_TITLE_DOC)}</h1>
|
||||
<div class="cover-meta">Кадастровый номер: {html.escape(cad)}</div>
|
||||
{addr_line}
|
||||
<div class="cover-meta">Дата формирования: {html.escape(generated_at)}</div>
|
||||
</header>
|
||||
|
||||
{_build_toc(has_part_b)}
|
||||
|
||||
{part_a_html}
|
||||
{part_b_block}
|
||||
|
||||
<div class="footer">
|
||||
GenDesign · Site Finder ПТИЦА · отчёт носит советующий характер и не является
|
||||
основанием для инвестиционного решения.
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
368
backend/tests/services/exporters/test_full_report_html_part_a.py
Normal file
368
backend/tests/services/exporters/test_full_report_html_part_a.py
Normal file
|
|
@ -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": "<script>alert('xss')</script>"},
|
||||
"nspd_zoning": {
|
||||
"zone_code": "<b>zone</b>",
|
||||
"main_vri": ["<img src=x onerror=alert(1)>"],
|
||||
},
|
||||
"gate_verdict": {
|
||||
"can_build_mkd": True,
|
||||
"verdict_label": "<i>ok</i>",
|
||||
"blockers": [{"code": "<code>", "detail": "<detail>"}],
|
||||
},
|
||||
}
|
||||
html = build_full_report_html_part_a(result, cad="X")
|
||||
# Сырые теги payload не должны попасть в разметку неэкранированными.
|
||||
assert "<script>alert" not in html
|
||||
assert "<script>alert" in html
|
||||
assert "<img src=x onerror" not in html
|
||||
assert "<img src=x onerror" in html
|
||||
assert "<b>zone</b>" in html
|
||||
|
||||
|
||||
# ── 8. Блок альтернатив: рисуется только при наличии program_alternatives ────────
|
||||
|
||||
|
||||
def test_alternatives_block_present_when_data() -> None:
|
||||
result = _synthetic_result()
|
||||
result["program_alternatives"] = _synthetic_alternatives()
|
||||
html = build_full_report_html_part_a(result, cad="X")
|
||||
assert f'id="{_ANCHOR_ALT}"' in html
|
||||
assert "Комфорт" in html # RU-метка housing_class
|
||||
assert "высотная" in html # RU-метка development_type
|
||||
assert "216 млн ₽" in html # NPV округлён до млн
|
||||
assert "наименее убыточные" in html # any_viable=False → эта формулировка
|
||||
assert "не инвест-рекомендация" in html # caveat
|
||||
|
||||
|
||||
def test_alternatives_block_absent_when_missing() -> None:
|
||||
# Нет ключа вовсе.
|
||||
html = build_full_report_html_part_a(_synthetic_result(), cad="X")
|
||||
assert f'id="{_ANCHOR_ALT}"' not in html
|
||||
# None / пустые variants → тоже пропуск.
|
||||
for empty in (None, {}, {"variants": []}):
|
||||
result = _synthetic_result()
|
||||
result["program_alternatives"] = empty
|
||||
html = build_full_report_html_part_a(result, cad="X")
|
||||
assert f'id="{_ANCHOR_ALT}"' not in html
|
||||
|
||||
|
||||
def test_alternatives_any_viable_true_subtitle() -> None:
|
||||
alt = _synthetic_alternatives()
|
||||
alt["any_viable"] = True
|
||||
alt["variants"][0]["npv_rub"] = 42_000_000.0 # положительный → «+42 млн ₽»
|
||||
result = _synthetic_result()
|
||||
result["program_alternatives"] = alt
|
||||
html = build_full_report_html_part_a(result, cad="X")
|
||||
assert "сходится при другой программе" in html
|
||||
assert "+42 млн ₽" in html
|
||||
|
||||
|
||||
# ── 9. Каркас документа: титул / оглавление / опциональный Part B ────────────────
|
||||
|
||||
|
||||
def test_document_frame_with_part_a_only() -> None:
|
||||
part_a = build_full_report_html_part_a(_synthetic_result(), cad="00:00:0000000:0000")
|
||||
doc = build_full_report_html(
|
||||
part_a,
|
||||
None,
|
||||
cad="00:00:0000000:0000",
|
||||
address="улица Примерная, 1",
|
||||
generated_at="03.07.2026",
|
||||
)
|
||||
assert doc.startswith("<!DOCTYPE html>")
|
||||
assert "00:00:0000000:0000" in doc
|
||||
assert "улица Примерная, 1" in doc
|
||||
assert "03.07.2026" in doc
|
||||
# Оглавление §1–§3 есть, пункт Part B (§4–§7) скрыт.
|
||||
assert f'href="#{_ANCHOR_S1}"' in doc
|
||||
assert "section-4" not in doc
|
||||
# Part A встроен.
|
||||
assert f'id="{_ANCHOR_S1}"' in doc
|
||||
|
||||
|
||||
def test_document_frame_with_part_b_adds_toc_entry() -> None:
|
||||
part_a = build_full_report_html_part_a(_synthetic_result(), cad="X")
|
||||
part_b = '<div class="section" id="section-4"><h2>§4 test</h2></div>'
|
||||
doc = build_full_report_html(part_a, part_b, cad="X", address=None, generated_at="03.07.2026")
|
||||
assert "section-4" in doc
|
||||
assert part_b in doc
|
||||
|
||||
|
||||
def test_document_frame_escapes_cad_and_address() -> None:
|
||||
doc = build_full_report_html(
|
||||
"<div></div>",
|
||||
None,
|
||||
cad="<script>x</script>",
|
||||
address="<b>addr</b>",
|
||||
generated_at="03.07.2026",
|
||||
)
|
||||
assert "<script>x</script>" not in doc
|
||||
assert "<script>x</script>" in doc
|
||||
assert "<b>addr</b>" in doc
|
||||
Loading…
Add table
Reference in a new issue