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''
+
+
+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''
+
+
+# ──────────────────────────────────────────────────────────────────────────────
+# §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]).
+
+ Общий `
+
+
+
+
+ {_build_toc(has_part_b)}
+
+ {part_a_html}
+ {part_b_block}
+
+
+
+