'
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"
"""
# ──────────────────────────────────────────────────────────────────────────────
# §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")))}
"""
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"""
{_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'