gendesign/backend/app/services/exporters/full_report_html.py
bot-backend 47b3fffc99
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m5s
CI / backend-tests (pull_request) Successful in 15m17s
fix(honesty): true totals for competitors/neighbors + permits truncation disclosure (#2464 cluster B)
market_pulse.competitors_total and neighbors_summary.count_buildings_100m
were silently capped by their SQL LIMITs (20/30) and reported as true totals;
add unbounded COUNT(*) for both (coverage_pct now uses the true denominator),
matching the features_truncated pattern from epic #2445 A2. VelocityResult
gains competitors_truncated for its LIMIT-200 query. The permits-nearby table
in both the HTML and DOCX full-report exporters now discloses "… и ещё N
записей" instead of silently hard-slicing to 10. neighbors_truncated compares
against the actually-returned (20) list, catching a second Python-side [:20]
slice beyond the SQL LIMIT 30.

Refs #2464 (cluster B silent-caps).
2026-07-08 13:03:39 +05:00

1873 lines
93 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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
# §4§7 нормализуют forecast-словарь (schema §22-форсайта "1.0" = SiteFinderReport.
# as_dict()) теми же PURE-хелперами, что уже гоняет PDF-рендерер форсайта — импортируем,
# НЕ копируем (ревью PR-A уже отметило дублирование). WeasyPrint внутри report_pdf
# импортируется ЛОКАЛЬНО (в export_report_pdf), поэтому импорт модуля тут дешёвый и
# native-libs не тянет. Алиасим под _fc_ («forecast»), чтобы не спутать с локальными
# каркас-хелперами §1§3 (у них своя семантика _fmt: округление ₽ до млн против 3 знаков).
from app.services.exporters.report_pdf import _as_dict as _fc_as_dict
from app.services.exporters.report_pdf import _future_supply_pairs as _fc_future_supply_pairs
from app.services.exporters.report_pdf import _level_ru as _fc_level_ru
from app.services.exporters.report_pdf import _normalize as _fc_normalize
logger = logging.getLogger(__name__)
# ── Плейсхолдер карты (подставится в PR-C) ─────────────────────────────────────
MAP_PARCEL_PLACEHOLDER = "{{MAP_PARCEL}}"
# Плейсхолдер footprint-плана концепции §7 (реальная подстановка карты — PR-C).
MAP_CONCEPT_PLACEHOLDER = "{{MAP_CONCEPT}}"
# ── Микрокопия / заглушки ──────────────────────────────────────────────────────
_DASH = ""
_NO_DATA = "нет данных"
# ── §3 connection-capacity: кап-строки + фильтр «шума» области ───────────────────
# Печатный отчёт по участку ЕКБ, а heat_system_reserves несёт ВСЕ ~56 систем области
# (Ирбит/Тавда/Красноуфимск/Первоуральск/Лесной/Нижняя Тура…). Не хардкодим список
# городов — отсекаем по ЯВНЫМ не-ЕКБ маркерам в имени организации/системы; остальное
# капим топ-N по резерву. Дефицит (отрицательный резерв) под кап НЕ прячем — честность.
_NON_EKB_MARKERS: tuple[str, ...] = (
"ирбит",
"тавда",
"красноуфимск",
"первоуральск",
"лесной",
"нижняя тура",
"нижний тагил",
"каменск",
"серов",
"асбест",
"ревда",
"полевск",
"березовск", # покрывает «Березовский» / «Берёзовский» (ё нормализуем ниже)
"верхняя пышма",
"среднеуральск",
"заречный",
"новоуральск",
"качканар",
"краснотурьинск",
)
_EKB_MARKER = "екатеринбург"
# Generic-маркеры областных админ-единиц: «СТ: Нижнетуринский муниципальный округ» и т.п.
# проходят мимо городского списка выше. Упоминание Екатеринбурга ПЕРЕВЕШИВАЕТ (см. _is_non_ekb).
_NON_EKB_GENERIC: tuple[str, ...] = (
"муниципальный округ",
"городской округ",
"муниципальный район",
"городское поселение",
"муниципальное образование",
)
# Кап видимых строк тепло/вода-таблиц (сверх — «и ещё K систем …»).
_HEAT_ROW_CAP = 15
_WATER_ROW_CAP = 25
# Кап видимых строк таблицы разрешений §6 (сверх — «и ещё K записей …»).
_PERMITS_ROW_CAP = 10
# Усечение длинных бюрократических имён систем.
_SYSTEM_NAME_MAX = 120
# Заголовки секций (якоря — 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"
# ── §4§7 (Part B): заголовки секций + якоря ────────────────────────────────────
_TITLE_S4 = "§4. Рынок"
_TITLE_S5 = "§5. Финансовая модель"
_TITLE_S6 = "§6. Риски и дефицит"
_TITLE_S7 = "§7. Концепция застройки"
_ANCHOR_S4 = "section-4"
_ANCHOR_S5 = "section-5"
_ANCHOR_S6 = "section-6"
_ANCHOR_S7 = "section-7"
# RU-метка стратегии генерации концепции (зеркало STRATEGY_LABELS в concept-api.ts).
# «program»-вариант каркас распознаёт по флагу и подписывает «Ваша программа».
_STRATEGY_RU: dict[str, str] = {
"max_area": "Макс. площадь",
"max_insolation": "Макс. инсоляция",
"balanced": "Баланс",
"program": "Ваша программа",
}
# RU-фраза источника цены продажи финмодели (зеркало priceSourceCaption в concept-api.ts).
_PRICE_SOURCE_FIN_RU: dict[str, str] = {
"objective_district_median": "рынок: медиана объявлений Objective по району",
"objective_geo_radius": "рынок: медиана новостроек в радиусе 3 км",
"district_reference": "рынок: справочная медиана района (нет свежей выборки Objective)",
"class_norm": "норматив класса (нет рыночных данных по участку)",
}
# 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; }
/* Заголовок НЕ должен осиротеть в конце страницы, оторвавшись от своей таблицы:
и логический break-after (WeasyPrint 60+), и легаси page-break-after (fallback). */
h2, h3 { page-break-after: avoid; 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_money(value: Any) -> str:
"""Деньги округлением до млн БЕЗ знака: «8 млн ₽» / «216 млн ₽». PURE.
Для абсолютных величин-ЦЕН (средний чек, справочная цена), где «+» неуместен — это
не дельта. Отрицательные (маловероятны для цены) — с типографским минусом. Тонкая
обёртка над `_fmt_money_signed`: срезаем ведущий «+».
"""
signed = _fmt_money_signed(value)
return signed[1:] if signed.startswith("+") else signed
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 _fmt_pct_raw(value: Any) -> str:
"""Процент, УЖЕ выраженный в единицах 0100 (напр. free_capacity_pct газовой ГРС).
В отличие от `_fmt_pct` (принимает долю 0..1) — здесь значение уже проценты, НЕ
домножаем на 100. 18.4 → «18.4%». Не-число → «—». PURE.
"""
if isinstance(value, bool) or not isinstance(value, int | float):
return _DASH
text_val = f"{float(value):.1f}"
if text_val.startswith("-"):
return "" + text_val[1:] + "%"
return text_val + "%"
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 = [ov for ov in _as_list(result.get("nspd_zouit_overlaps")) if isinstance(ov, dict)]
has_zouit = encumbrance.get("has_zouit")
zouit_count = encumbrance.get("zouit_count")
zouit_types = _as_list(encumbrance.get("zouit_types"))
# Противоречие: encumbrance говорит «нет ЗОУИТ», но НСПД-пересечения ЕСТЬ. Доверяем
# фактическим пересечениям (более свежий геослой) — иначе сводка «нет / 0» врёт под
# таблицей с реальными строками. Типы/кол-во достаём из самих overlaps.
if not has_zouit and overlaps:
has_zouit = "да (по данным НСПД)"
if zouit_count in (None, 0):
zouit_count = len(overlaps)
if not zouit_types:
zouit_types = [
str(t)
for t in dict.fromkeys(ov.get("type_zone") or ov.get("name") for ov in overlaps)
if t not in (None, "")
]
summary_pairs: list[tuple[str, Any]] = [
("Есть ЗОУИТ", has_zouit),
("Кол-во типов ЗОУИТ", zouit_count),
]
if zouit_types:
summary_pairs.append(("Типы", ", ".join(str(t) for t in zouit_types)))
rows: list[list[Any]] = []
for ov in overlaps: # already filtered to dicts above
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]] = []
any_item = False
for it in items:
if not isinstance(it, dict):
continue
any_item = True
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")
# И имя, И назначение пустые → строка-призрак («— — 7»), скипаем.
if name in (None, "") and purpose in (None, ""):
continue
rows.append([name, purpose, _fmt_int_ru(it.get("distance_m"))])
# Секции вообще нет в payload → не рисуем заголовок. Есть записи, но все пустые →
# честное «нет данных» под заголовком (а не пустой невидимый блок).
if not any_item:
return ""
table = _data_table(["Сооружение", "Назначение", "Расстояние, м"], rows)
return f"<h3>Инженерные сооружения рядом (НСПД)</h3>{table}"
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 _truncate_name(value: Any, limit: int = _SYSTEM_NAME_MAX) -> Any:
"""Усечь длинное бюрократическое имя системы до `limit` символов с «…». PURE.
Не-строка / короткая строка → как есть (форматтер `_fmt`/`_esc` доработает). Режем по
границе символов, добавляя одноточечное многоточие (U+2026), чтобы длинные имена вроде
«Централизованная система теплоснабжения муниципального образования …» не разваливали
вёрстку печатной таблицы.
"""
if not isinstance(value, str) or len(value) <= limit:
return value
return value[: limit - 1].rstrip() + ""
def _is_non_ekb(*fields: Any) -> bool:
"""True, если В ЛЮБОМ из полей есть ЯВНЫЙ не-ЕКБ городской маркер (Ирбит/Тавда/…). PURE.
Организация/система из другого города области — «шум» для отчёта по участку ЕКБ.
Нормализуем ё→е и регистр; НЕ хардкодим полный справочник городов (только явные
крупные маркеры) — прочее схлопывается капом, а не этим фильтром.
"""
haystack = " ".join(str(f) for f in fields if f).lower().replace("ё", "е")
if not haystack:
return False
# Явное упоминание Екатеринбурга перевешивает любые маркеры — один предикат
# и для видимости, и для агрегата «Суммарно (ЕКБ)» (иначе они расходятся).
if _EKB_MARKER in haystack:
return False
if any(marker in haystack for marker in _NON_EKB_MARKERS):
return True
return any(marker in haystack for marker in _NON_EKB_GENERIC)
def _reserve_num(value: Any) -> float:
"""Резерв как float для сортировки/суммы; не-число → 0.0 (нейтрально). PURE."""
if isinstance(value, bool) or not isinstance(value, int | float):
return 0.0
return float(value)
def _capacity_rows_capped(
items: list[Any],
*,
reserve_key: str,
name_fields: tuple[str, ...],
row_cap: int,
row_builder: Any,
) -> tuple[list[list[Any]], int]:
"""Отфильтровать областной шум + капнуть строки резерв-таблицы (тепло/вода). PURE.
Порядок честности:
1. Дефицитные (резерв < 0) ЕКБ-системы — ВСЕГДА видимы, под кап не попадают.
2. Остальные ЕКБ (или неопределённые по городу) сортируются по резерву ↓, берётся
топ до заполнения `row_cap` (с учётом уже показанных дефицитных).
3. Явно не-ЕКБ (Ирбит/Тавда/…) и хвост сверх капа схлопываются в счётчик `hidden`.
Args:
items: сырые dict-строки (`heat.systems` / `cap.water`).
reserve_key: ключ резерва в dict (`reserve_gcal_h` / `reserve_thousand_m3_day`).
name_fields: ключи, по которым определяем город (org / system_name).
row_cap: максимум видимых строк.
row_builder: `dict -> list[cell]` — построитель ячеек строки данных.
Returns:
`(visible_rows, hidden_count)` — строки для таблицы + сколько схлопнуто.
"""
dicts = [it for it in items if isinstance(it, dict)]
deficit: list[dict[str, Any]] = []
surplus_ekb: list[dict[str, Any]] = []
hidden = 0
for it in dicts:
names = tuple(it.get(f) for f in name_fields)
reserve = _reserve_num(it.get(reserve_key))
# Дефицит ЕКБ/неопределённых — всегда показываем (даже если город не-ЕКБ:
# отрицательный резерв рядом — сигнал, честнее показать, чем спрятать).
if reserve < 0 and not _is_non_ekb(*names):
deficit.append(it)
continue
if _is_non_ekb(*names):
hidden += 1
continue
surplus_ekb.append(it)
surplus_ekb.sort(key=lambda d: _reserve_num(d.get(reserve_key)), reverse=True)
remaining = max(row_cap - len(deficit), 0)
shown_surplus = surplus_ekb[:remaining]
hidden += len(surplus_ekb) - len(shown_surplus)
visible = [row_builder(it) for it in (*deficit, *shown_surplus)]
return visible, hidden
def _build_connection_capacity(cap: dict[str, Any] | None) -> str:
"""Ресурсные резервы (ЦП/вода/газ/тепло) + сети рядом из connection-capacity (#2259 PR-D).
connection-capacity НЕ персистится в analyze-payload (ленивый эндпоинт), поэтому его
результат дотягивает оркестратор PR-D (`get_connection_capacity`) и передаёт сюда
отдельным аргументом. Ключи — из `ConnectionCapacityResponse` (schemas/parcel.py):
`power_summary`/`power_points`, `water`, `gas`, `heat`, `nearby_network_zones`.
None / пустой → блок не рисуется вовсе (пустая строка) — §3 деградирует к тому, что
есть в analyze-payload. PURE, graceful: каждый ключ через `.get()`, всё через `_esc`.
"""
cap = _as_dict(cap)
if not cap:
return ""
blocks: list[str] = []
# Электрика: сводка + ближайшие ЦП с резервом.
power_summary = _as_dict(cap.get("power_summary"))
nearest = _as_dict(power_summary.get("nearest_with_reserve"))
if power_summary or _as_list(cap.get("power_points")):
pairs: list[tuple[str, Any]] = [
("Центров питания в радиусе", power_summary.get("total_power_points")),
]
if nearest:
unit = nearest.get("reserve_unit") or "МВА"
pairs.append((f"Ближайший ЦП с резервом, {unit}", nearest.get("reserve_mva")))
pairs.append((" — класс напряжения", nearest.get("voltage_class")))
pairs.append((" — расстояние, м", _fmt_int_ru(nearest.get("distance_m"))))
pairs = [(k, v) for k, v in pairs if v not in (None, _DASH)]
if pairs:
blocks.append("<h3>Электроснабжение — свободная мощность</h3>" + _kv_table(pairs))
# Вода: строки резервов ЦСВ/ЦСК за последний период (ЕКБ-системы + кап ~25).
water_rows, water_hidden = _capacity_rows_capped(
_as_list(cap.get("water")),
reserve_key="reserve_thousand_m3_day",
name_fields=("system_name", "org"),
row_cap=_WATER_ROW_CAP,
row_builder=lambda w: [
_truncate_name(w.get("system_name")),
_fmt(w.get("reserve_thousand_m3_day")),
w.get("period"),
],
)
if water_hidden:
water_rows.append(
[f"… и ещё {water_hidden} систем (полный список в веб-версии §3)", _DASH, _DASH]
)
if water_rows:
blocks.append(
"<h3>Водоснабжение/водоотведение — резервы</h3>"
+ _data_table(["Система", "Резерв, тыс. м³/сут", "Период"], water_rows)
)
# Газ: городские ГРС со свободной мощностью.
gas = _as_dict(cap.get("gas"))
gas_rows = [
[
g.get("grs_name"),
_fmt(g.get("free_capacity_th_m3_h")),
_fmt_pct_raw(g.get("free_capacity_pct")),
]
for g in _as_list(gas.get("city_grs"))
if isinstance(g, dict)
]
if gas_rows:
blocks.append(
"<h3>Газоснабжение — свободная мощность ГРС</h3>"
+ _data_table(["ГРС", "Свободно, тыс. м³/ч", "Свободно, %"], gas_rows)
)
# Тепло: резервы систем теплоснабжения. heat_system_reserves несёт ВСЕ ~56 систем
# области — сначала агрегат по ЕКБ (сумма резервов ЕКБ-систем), затем топ-15 + кап.
heat = _as_dict(cap.get("heat"))
heat_systems = [h for h in _as_list(heat.get("systems")) if isinstance(h, dict)]
if heat_systems:
ekb_systems = [
h for h in heat_systems if not _is_non_ekb(h.get("org"), h.get("system_name"))
]
ekb_total = sum(_reserve_num(h.get("reserve_gcal_h")) for h in ekb_systems)
heat_rows, heat_hidden = _capacity_rows_capped(
heat_systems,
reserve_key="reserve_gcal_h",
name_fields=("org", "system_name"),
row_cap=_HEAT_ROW_CAP,
row_builder=lambda h: [
_truncate_name(h.get("org")),
_truncate_name(h.get("system_name")),
_fmt(h.get("reserve_gcal_h")),
h.get("period"),
],
)
# Агрегатная строка «Суммарно (ЕКБ)» сверху.
agg_row = [
"Суммарно (ЕКБ)",
f"{len(ekb_systems)} систем",
_fmt(round(ekb_total, 1)),
_DASH,
]
rows: list[list[Any]] = [agg_row, *heat_rows]
if heat_hidden:
rows.append(
[
f"… и ещё {heat_hidden} систем (полный список в веб-версии §3)",
_DASH,
_DASH,
_DASH,
]
)
blocks.append(
"<h3>Теплоснабжение — резервы систем</h3>"
+ _data_table(["Организация", "Система", "Резерв, Гкал/ч", "Период"], rows)
)
# Позитив-разрез: сетевые охранные зоны рядом (где физически проходит сеть).
zone_rows = [
[z.get("human_label"), _fmt_int_ru(z.get("distance_m"))]
for z in _as_list(cap.get("nearby_network_zones"))
if isinstance(z, dict)
]
if zone_rows:
blocks.append(
"<h3>Сети рядом (охранные зоны)</h3>"
+ _data_table(["Сеть", "Расстояние, м"], zone_rows)
)
return "".join(blocks)
def _build_section_3(result: dict[str, Any], connection_capacity: dict[str, Any] | None) -> str:
"""§3 «Инженерные сети»: OSM-сети рядом + НСПД-сооружения + ресурсные резервы + альтернативы.
Ресурсные резервы (ЦП Россетей/ЕЭСК, вода, газ, тепло) и `nearby_network_zones` в
persist-payload ОТСУТСТВУЮТ (ленивый connection-capacity-эндпоинт) — их дотягивает
оркестратор PR-D и передаёт через `connection_capacity`. None → блок не рисуется.
"""
return f"""
<div class="section" id="{_ANCHOR_S3}">
<h2>{html.escape(_TITLE_S3)}</h2>
<h3>Инженерные сети рядом</h3>
{_build_utilities(result)}
{_build_engineering_nearby(result)}
{_build_connection_capacity(connection_capacity)}
</div>
{_build_alternatives(result)}
"""
# ──────────────────────────────────────────────────────────────────────────────
# §4§7 (Part B): forecast-ран (schema "1.0" §22-форсайта = SiteFinderReport.as_dict())
# + концепция (ConceptOutput.variants). PURE, graceful — как §1§3. Нормализация
# forecast-словаря — импортированными _fc_* хелперами report_pdf (НЕ дублируем).
# ──────────────────────────────────────────────────────────────────────────────
def _price_source_fin_ru(value: Any) -> str:
"""RU-фраза источника цены финмодели, иначе значение как есть. PURE."""
if isinstance(value, str) and value in _PRICE_SOURCE_FIN_RU:
return _PRICE_SOURCE_FIN_RU[value]
return _fmt(value)
def _strategy_ru(value: Any) -> str:
"""RU-метка стратегии генерации концепции, иначе значение как есть. PURE."""
if isinstance(value, str) and value in _STRATEGY_RU:
return _STRATEGY_RU[value]
return _fmt(value)
def _variant_strategy(variant: dict[str, Any]) -> Any:
"""Стратегия варианта: program-вариант помечен на первом footprint'е `strategy`.
Движок кладёт `properties.strategy == "program"` на пятна program-режима, но
сам вариант рапортует под слотом `balanced` — распознаём program по первой фиче,
чтобы не подписать пользовательскую программу «Балансом» (зеркало isProgramVariant
во фронте). Иначе — верхнеуровневый `strategy` варианта. PURE.
"""
features = _as_list(_as_dict(variant.get("buildings_geojson")).get("features"))
first = features[0] if features else None
if isinstance(first, dict) and _as_dict(first.get("properties")).get("strategy") == "program":
return "program"
return variant.get("strategy")
# ── §4 «Рынок» ──────────────────────────────────────────────────────────────────
def _build_market_metrics(forecast: dict[str, Any]) -> str:
"""Метрики рынка сейчас: темп/абсорбция, ликвидность/затоварка, цена, покрытие."""
market_now = _fc_as_dict(forecast.get("market_now"))
metrics = _fc_as_dict(market_now.get("market_metrics"))
pairs: list[tuple[str, Any]] = [
("Район", metrics.get("district")),
("Тип помещений", metrics.get("premise_kind")),
("Объектов-аналогов", metrics.get("obj_count")),
("Лотов в выборке", _fmt_int_ru(metrics.get("n_lots"))),
("Продано за период", _fmt_int_ru(metrics.get("n_sold"))),
("В продаже (доступно)", _fmt_int_ru(metrics.get("n_available"))),
("Темп продаж (velocity), ед./мес", metrics.get("unit_velocity")),
("Темп продаж (площадь), м²/мес", metrics.get("area_velocity")),
("Окно расчёта, мес", metrics.get("window_months")),
# absorption_rate — доля стока, продаваемая в месяц (0.0112). Как «0.01» это
# нечитаемо → проценты («1.1%»). Темп в штуках уже есть в unit_velocity выше.
("Ставка абсорбции (в мес.)", _fmt_pct(metrics.get("absorption_rate"))),
("Месяцев запаса (months of supply)", metrics.get("months_of_supply")),
("Индекс затоварки (overstock)", metrics.get("overstock_index")),
("Sell-through, %", metrics.get("sell_through_pct")),
]
pairs = [(k, v) for k, v in pairs if v not in (None, "", _DASH)]
return _kv_table(pairs)
def _competitor_lots(c: dict[str, Any]) -> int:
"""Число лотов конкурента как int; не-число / None → 0. PURE."""
n = c.get("flat_count")
if isinstance(n, bool) or not isinstance(n, int | float):
return 0
return int(n)
def _build_market_competitors(forecast: dict[str, Any]) -> str:
"""Конкуренты рынка сейчас: ЖК / девелопер / класс / расстояние / лотов.
Прод-payload несёт по строке НА КОРПУС: один ЖК с 6 корпусами → 6 строк, а безымянные
записи с 0 лотов — мусор. Схлопываем: (а) строки без имени И с 0 лотов скипаем;
(б) группируем по (имя, девелопер) → ближайшая дистанция, лоты суммой, класс/девелопер
от первого, «(K корпусов)» в имени при K>1. Порядок групп — по первому появлению.
"""
market_now = _fc_as_dict(forecast.get("market_now"))
competitors = [c for c in _as_list(market_now.get("competitors")) if isinstance(c, dict)]
groups: dict[tuple[str, str], dict[str, Any]] = {}
order: list[tuple[str, str]] = []
for c in competitors:
name = c.get("comm_name")
lots = _competitor_lots(c)
# Безымянная запись без лотов — мусор (пустые «—» корпуса-призраки).
if name in (None, "") and lots == 0:
continue
key = (str(name or ""), str(c.get("dev_name") or ""))
dist = c.get("distance_m")
dist_val = (
float(dist) if isinstance(dist, int | float) and not isinstance(dist, bool) else None
)
if key not in groups:
order.append(key)
groups[key] = {
"name": name,
"dev_name": c.get("dev_name"),
"obj_class": c.get("obj_class"),
"distance_m": dist_val,
"lots": lots,
"corpus": 1,
}
else:
g = groups[key]
g["lots"] += lots
g["corpus"] += 1
if dist_val is not None and (g["distance_m"] is None or dist_val < g["distance_m"]):
g["distance_m"] = dist_val
if g["obj_class"] in (None, "") and c.get("obj_class"):
g["obj_class"] = c.get("obj_class")
rows: list[list[Any]] = []
for key in order:
g = groups[key]
name = g["name"]
if g["corpus"] > 1:
name = f"{_fmt(name)} ({g['corpus']} корпусов)"
rows.append(
[
name,
g["dev_name"],
g["obj_class"],
_fmt_int_ru(g["distance_m"]),
_fmt_int_ru(g["lots"]),
]
)
return _data_table(["ЖК", "Девелопер", "Класс", "Расстояние, м", "Лотов"], rows)
def _build_market_coverage(forecast: dict[str, Any]) -> str:
"""Покрытие данными: факторы уверенности (сделки / история / ДОМ.РФ-покрытие)."""
confidence = _fc_as_dict(forecast.get("confidence"))
factors = _fc_as_dict(confidence.get("factors"))
# «Комментарий» почти всегда дублирует «Фактор» (label==note, либо note ⊃ label) —
# тогда две колонки = визуальный шум. Показываем 3-ю колонку ТОЛЬКО если хоть у одного
# фактора комментарий несёт что-то сверх метки; иначе схлопываем в «Фактор/уровень».
parsed: list[tuple[Any, Any, Any]] = []
for _key, payload in factors.items():
data = _fc_as_dict(payload)
if not data:
continue
label = data.get("label") or data.get("note")
note = data.get("note")
parsed.append((label, _fc_level_ru(data.get("level")), note))
def _note_adds_info(label: Any, note: Any) -> bool:
if not isinstance(note, str) or note == "":
return False
if not isinstance(label, str):
return True
return note.strip() != label.strip() and label.strip() not in note
show_note = any(_note_adds_info(label, note) for label, _level, note in parsed)
if show_note:
rows = [[label, level, note] for label, level, note in parsed]
coverage_table = _data_table(["Фактор", "Уровень", "Комментарий"], rows)
else:
# Комментарий ничего не добавляет → две колонки «Фактор» + «Уровень».
rows = [[label, level] for label, level, _note in parsed]
coverage_table = _data_table(["Фактор", "Уровень"], rows)
level_pairs: list[tuple[str, Any]] = [
("Итоговая уверенность отчёта", _fc_level_ru(confidence.get("level"))),
("Обоснование", confidence.get("rationale")),
]
level_pairs = [(k, v) for k, v in level_pairs if v not in (None, "", _DASH)]
return _kv_table(level_pairs) + coverage_table
def _build_section_4(forecast: dict[str, Any]) -> str:
"""§4 «Рынок»: резюме + метрики (velocity/тренд) + конкуренты + покрытие данными."""
market_now = _fc_as_dict(forecast.get("market_now"))
summary = market_now.get("summary")
summary_html = f'<p class="verdict">{_esc(summary)}</p>' if summary else ""
return f"""
<div class="section" id="{_ANCHOR_S4}">
<h2>{html.escape(_TITLE_S4)}</h2>
{summary_html}
<h3>Метрики рынка (темп, ликвидность, цена)</h3>
{_build_market_metrics(forecast)}
<h3>Конкуренты рядом</h3>
{_build_market_competitors(forecast)}
<h3>Покрытие данными и уверенность</h3>
{_build_market_coverage(forecast)}
</div>
"""
# ── §5 «Финансовая модель» ──────────────────────────────────────────────────────
def _best_variant(concept: dict[str, Any]) -> dict[str, Any]:
"""Опорный вариант концепции для §5-финмодели: с максимальным NPV, иначе первый.
§5 показывает ОДНУ финмодель — берём вариант с наибольшим NPV (лучший экономически),
при равенстве / отсутствии NPV — первый валидный. Пустой concept → {}. PURE.
"""
variants = [v for v in _as_list(concept.get("variants")) if isinstance(v, dict)]
if not variants:
return {}
def _npv(v: dict[str, Any]) -> float:
npv = _as_dict(v.get("financial")).get("npv_rub")
if isinstance(npv, bool) or not isinstance(npv, int | float):
return float("-inf")
return float(npv)
return max(variants, key=_npv)
def _build_financial_headline(financial: dict[str, Any]) -> str:
"""Ключевые числа финмодели: DCF (NPV / IRR / окупаемость) + ROI / прибыль."""
payback = financial.get("payback_months")
payback_str = f"{_fmt(payback)} мес" if payback is not None else "не окупается"
pairs: list[tuple[str, Any]] = [
("Выручка (GDV), ₽", _fmt_money_signed(financial.get("revenue_rub"))),
("Итого затраты, ₽", _fmt_money_signed(financial.get("cost_rub"))),
("Чистая прибыль, ₽", _fmt_money_signed(financial.get("net_profit_rub"))),
("ROI на затраты", _fmt_pct(financial.get("roi"))),
("Чистая маржа на выручку", _fmt_pct(financial.get("margin_pct"))),
("NPV (DCF), ₽", _fmt_money_signed(financial.get("npv_rub"))),
("IRR (DCF, годовой)", _fmt_pct(financial.get("irr"))),
("Ставка дисконтирования", _fmt_pct(financial.get("discount_rate_used"))),
("Окупаемость (PBP)", payback_str),
(
"Цена продажи жилья, ₽/м²",
_fmt_int_ru(financial.get("price_per_sqm_used")),
),
("Источник цены", _price_source_fin_ru(financial.get("price_source"))),
]
pairs = [(k, v) for k, v in pairs if v not in (None, "", _DASH)]
caveats: list[str] = []
if financial.get("irr_is_proxy"):
caveats.append(
"IRR помечен как оценочный: денежный поток вырожденный (нет смены знака), "
"показан аннуализированный ROI вместо DCF-IRR."
)
if financial.get("schedule_is_default"):
caveats.append(
"График фаз и темп продаж — типовые нормативные допущения (ПИР → СМР"
"распродажа); точность метрик зависит от графика конкретного проекта."
)
caveat_html = "".join(f'<div class="caveat">{html.escape(c)}</div>' for c in caveats)
return _kv_table(pairs) + caveat_html
def _build_financial_cascade(financial: dict[str, Any]) -> str:
"""Каскад затрат и БДР: выручка по статьям → затраты → НДС/налог → чистая прибыль."""
rows: list[list[Any]] = [
["Выручка — жильё", _fmt_money_signed(financial.get("revenue_residential_rub"))],
["Выручка — паркинг", _fmt_money_signed(financial.get("revenue_parking_rub"))],
["Выручка — нежилое (1-й этаж)", _fmt_money_signed(financial.get("revenue_office_rub"))],
["Выручка (GDV)", _fmt_money_signed(financial.get("revenue_rub"))],
["СМР", _fmt_money_signed(financial.get("construction_rub"))],
["ПИР (проектирование)", _fmt_money_signed(financial.get("pir_rub"))],
["Сети (ТУ)", _fmt_money_signed(financial.get("networks_rub"))],
["Услуги заказчика", _fmt_money_signed(financial.get("developer_services_rub"))],
["Резерв", _fmt_money_signed(financial.get("contingency_rub"))],
["Маркетинг и риэлтор", _fmt_money_signed(financial.get("marketing_rub"))],
["Земля", _fmt_money_signed(financial.get("land_rub"))],
["Итого затраты", _fmt_money_signed(financial.get("cost_rub"))],
["Валовая маржа", _fmt_money_signed(financial.get("gross_margin_rub"))],
["НДС (паркинг)", _fmt_money_signed(financial.get("vat_rub"))],
["Прибыль до налога", _fmt_money_signed(financial.get("profit_before_tax_rub"))],
["Налог на прибыль", _fmt_money_signed(financial.get("profit_tax_rub"))],
["Чистая прибыль", _fmt_money_signed(financial.get("net_profit_rub"))],
]
if financial.get("financing_enabled"):
rows.extend(
[
["Пиковый долг", _fmt_money_signed(financial.get("peak_debt_rub"))],
["Проценты по кредиту", _fmt_money_signed(financial.get("total_interest_rub"))],
[
"Чистая прибыль после финансирования",
_fmt_money_signed(financial.get("net_profit_after_financing_rub")),
],
["IRR на собственные средства (LTC)", _fmt_pct(financial.get("levered_irr"))],
]
)
return _data_table(["Статья", "Значение"], rows)
def _build_market_affordability(forecast: dict[str, Any]) -> str:
"""Рыночный контекст цены/платёжеспособности §5: цена/ticket из cost_of_error-индекса."""
indices = _fc_as_dict(
_fc_as_dict(_fc_as_dict(forecast.get("scoring")).get("special_indices")).get("indices")
)
cost_of_error = _fc_as_dict(indices.get("cost_of_error"))
detail = _fc_as_dict(cost_of_error.get("detail"))
if not detail:
return ""
pairs: list[tuple[str, Any]] = [
("Рыночная цена, ₽/м²", _fmt_int_ru(detail.get("price_per_m2"))),
("Средний чек лота, ₽", _fmt_money(detail.get("avg_ticket_rub"))),
("Референс-площадь, м²", detail.get("ref_area_m2")),
("Индекс избытка предложения", detail.get("oversupply_risk")),
]
pairs = [(k, v) for k, v in pairs if v not in (None, "", _DASH)]
if not pairs:
return ""
return f"<h3>Рыночный контекст цены</h3>{_kv_table(pairs)}"
def _build_section_5(forecast: dict[str, Any], concept: dict[str, Any]) -> str:
"""§5 «Финмодель»: DCF/NPV/IRR/ROI + каскад затрат (из концепции) + рыночный контекст.
Финмодель (DCF) живёт в КОНЦЕПЦИИ (ConceptVariant.financial) — берём опорный вариант
(макс. NPV). Концепция не рассчитана → показываем только рыночный контекст цены из
forecast (cost_of_error) + честную заметку, без падения.
"""
variant = _best_variant(concept)
financial = _as_dict(variant.get("financial"))
affordability = _build_market_affordability(forecast)
if not financial:
note = (
'<div class="caveat">Финансовая модель (DCF, NPV/IRR/ROI, каскад затрат) '
"считается по концепции застройки — она ещё не рассчитана. Ниже — только "
"рыночный контекст цены из прогноза.</div>"
)
return f"""
<div class="section" id="{_ANCHOR_S5}">
<h2>{html.escape(_TITLE_S5)}</h2>
{note}
{affordability or _no_data()}
</div>
"""
strategy_label = _strategy_ru(_variant_strategy(variant))
return f"""
<div class="section" id="{_ANCHOR_S5}">
<h2>{html.escape(_TITLE_S5)}</h2>
<p class="alt-meta">Опорный вариант концепции (макс. NPV): {html.escape(strategy_label)}.</p>
<h3>Ключевые показатели (DCF)</h3>
{_build_financial_headline(financial)}
<h3>Каскад затрат и расчёт прибыли</h3>
{_build_financial_cascade(financial)}
{affordability}
</div>
"""
# ── §6 «Риски и дефицит» ────────────────────────────────────────────────────────
def _build_deficit_by_horizon(forecast: dict[str, Any]) -> str:
"""Прогноз дефицита/затоварки по горизонтам: спрос / предложение / индекс / months."""
future = _fc_as_dict(forecast.get("future_market"))
forecasts = _as_list(future.get("forecasts_by_horizon"))
rows: list[list[Any]] = []
for f in forecasts:
if not isinstance(f, dict):
continue
rows.append(
[
f.get("horizon_months"),
_fmt_int_ru(f.get("projected_demand_units")),
_fmt_int_ru(f.get("projected_supply_units")),
f.get("deficit_index"),
f.get("months_of_inventory"),
_fc_level_ru(f.get("confidence")),
]
)
headers = ["Горизонт, мес", "Спрос", "Предложение", "Индекс дефицита", "Мес. запаса", "Увер."]
return _data_table(headers, rows)
def _build_supply_pressure(forecast: dict[str, Any]) -> str:
"""Давление будущего предложения (future_supply): открытый/скрытый сток, поглощение."""
future = _fc_as_dict(forecast.get("future_market"))
pairs = _fc_future_supply_pairs(future.get("future_supply"))
kv = [(k, v) for k, v in pairs.items() if v not in (None, "", _DASH)]
return _kv_table(kv)
def _build_risk_indices(forecast: dict[str, Any]) -> str:
"""Риск-индексы дефицита/ошибки: белые пятна, цена ошибки, окно запуска, каннибализация."""
special = _fc_as_dict(_fc_as_dict(forecast.get("scoring")).get("special_indices"))
indices = _fc_as_dict(special.get("indices"))
rows: list[list[Any]] = []
for _key, payload in indices.items():
data = _fc_as_dict(payload)
if not data:
continue
rows.append([data.get("key"), data.get("label"), data.get("value")])
return _data_table(["Индекс", "Метка", "Значение"], rows)
def _build_scenarios_honesty(forecast: dict[str, Any]) -> str:
"""Честный блок сценариев: разброс дефицита ИЛИ плашка-объяснение при коллапсе."""
scenarios = _fc_as_dict(forecast.get("scenarios"))
if scenarios.get("scenarios_collapsed"):
reason = scenarios.get("scenarios_collapse_reason")
reason_text = (
reason
if isinstance(reason, str) and reason
else "Сценарная дифференциация недоступна на текущих данных."
)
return (
'<div class="caveat">Сценарная дифференциация недоступна: '
f"{html.escape(reason_text)}</div>"
)
summary = _fc_as_dict(scenarios.get("scenarios_summary"))
if not summary:
summary = _fc_as_dict(_fc_as_dict(forecast.get("future_market")).get("scenarios_summary"))
pairs = [(str(name), value) for name, value in summary.items()]
return _kv_table(pairs)
def _build_permits_nearby(result: dict[str, Any]) -> str:
"""РНС/РВЭ в радиусе 500 м участка (ГИСОГД-66) — короткая сводка + список до 10.
`result` — analyze-payload (НЕ forecast): читает `permits_nearby` (см.
`permits_nearby.get_permits_nearby`). Пусто / total_count=0 → честная плашка-фраза
(полное предложение, не аббревиатура). Все динамические строки через `html.escape`.
#2464 cluster B: `total_count` — честный total апстрима (get_permits_nearby
считает его COUNT'ом БЕЗ SQL LIMIT), `items` уже капнут апстримом на 30
(`items_truncated`). Здесь список дополнительно режется до `_PERMITS_ROW_CAP`
(10) для компактности PDF — раньше это резалось МОЛЧА. Дисклоузим разницу
`total_count - показано` строкой «и ещё N …», как тепло/вода-таблицы выше
(`_build_connection_capacity`).
"""
nearby = _as_dict(result.get("permits_nearby"))
total = nearby.get("total_count")
if not isinstance(total, int) or total <= 0:
return (
'<div class="caveat">В радиусе 500 м участка новых разрешений на '
"строительство не найдено (по данным ГИСОГД Свердловской области).</div>"
)
rs_count = nearby.get("rs_count") or 0
rv_count = nearby.get("rv_count") or 0
nearest = nearby.get("nearest_distance_m")
kv = [
("Разрешений на строительство (РНС)", _fmt_int_ru(rs_count)),
("Разрешений на ввод (РВЭ)", _fmt_int_ru(rv_count)),
("Ближайшее, м", _fmt_int_ru(nearest) if nearest is not None else _DASH),
]
rows: list[list[Any]] = []
for item in _as_list(nearby.get("items"))[:_PERMITS_ROW_CAP]:
data = _as_dict(item)
rows.append(
[
data.get("doc_name"),
data.get("date_doc"),
data.get("approved_organization"),
data.get("distance_m"),
]
)
hidden = total - len(rows)
if hidden > 0:
rows.append(
[
f"… и ещё {hidden} записей (полный список в веб-версии §6)",
_DASH,
_DASH,
_DASH,
]
)
headers = ["Документ", "Дата", "Согласующий орган", "Дистанция, м"]
return _kv_table(kv) + _data_table(headers, rows)
def _build_section_6(forecast: dict[str, Any], result: dict[str, Any]) -> str:
"""§6 «Риски и дефицит»: дефицит по горизонтам + давление предложения + риск-индексы.
`result` — analyze-payload (для блока разрешений рядом, `permits_nearby`); `forecast` —
форсайт-ран (дефицит/сценарии). Разные источники — §6 читает оба.
"""
future = _fc_as_dict(forecast.get("future_market"))
summary = future.get("summary")
summary_html = f'<p class="verdict">{_esc(summary)}</p>' if summary else ""
return f"""
<div class="section" id="{_ANCHOR_S6}">
<h2>{html.escape(_TITLE_S6)}</h2>
{summary_html}
<h3>Дефицит / затоварка по горизонтам</h3>
{_build_deficit_by_horizon(forecast)}
<h3>Давление будущего предложения</h3>
{_build_supply_pressure(forecast)}
<h3>Риск-индексы</h3>
{_build_risk_indices(forecast)}
<h3>Сценарии</h3>
{_build_scenarios_honesty(forecast)}
<h3>Разрешения на строительство рядом (500 м)</h3>
{_build_permits_nearby(result)}
</div>
"""
# ── §7 «Концепция застройки» ────────────────────────────────────────────────────
def _build_concept_program(variant: dict[str, Any]) -> str:
"""Программа застройки варианта из размещённых фич (тип дома × этажность → секций).
ВЫХОДНОЙ `ConceptVariant` (schemas/concept.py) НЕ несёт `building_program` — это поле
только на входном `ConceptInput`. Реальный состав программы восстанавливаем из
размещённых footprint'ов: в program-режиме `buildings_geojson.features[].properties`
несут `section_type` + `floors` (см. `_program_footprints_to_geojson` в placement.py).
1b-стратегии (max_area / max_insolation / balanced) кладут только `floors` без
`section_type` → таблица программы не рисуется (у них нет каталожного состава).
Группируем по (тип, этажность) → count; порядок групп — по первому появлению
(стабилен). PURE.
"""
features = _as_list(_as_dict(variant.get("buildings_geojson")).get("features"))
groups: dict[tuple[Any, Any], int] = {}
order: list[tuple[Any, Any]] = []
for feat in features:
props = _as_dict(_as_dict(feat).get("properties"))
section_type = props.get("section_type")
if section_type in (None, ""):
continue
key = (section_type, props.get("floors"))
if key not in groups:
order.append(key)
groups[key] = groups.get(key, 0) + 1
if not order:
return ""
rows = [[stype, floors, groups[(stype, floors)]] for stype, floors in order]
return f"<h3>Программа застройки</h3>{_data_table(['Тип дома', 'Этажность', 'Секций'], rows)}"
def _build_concept_variant(variant: dict[str, Any]) -> str:
"""Карточка одного варианта концепции: ТЭП + финитог + программа."""
teap = _as_dict(variant.get("teap"))
financial = _as_dict(variant.get("financial"))
strategy_label = _strategy_ru(_variant_strategy(variant))
features = _as_list(_as_dict(variant.get("buildings_geojson")).get("features"))
corpus_count = len(features)
teap_pairs: list[tuple[str, Any]] = [
("Корпусов", corpus_count if corpus_count else None),
("Площадь застройки, м²", _fmt_int_ru(teap.get("built_area_sqm"))),
("Общая площадь (GFA), м²", _fmt_int_ru(teap.get("total_floor_area_sqm"))),
("Жилая площадь, м²", _fmt_int_ru(teap.get("residential_area_sqm"))),
("Нежилое (1-й этаж), м²", _fmt_int_ru(teap.get("office_area_sqm"))),
("Квартир", _fmt_int_ru(teap.get("apartments_count"))),
("Плотность (FAR)", teap.get("density")),
("Машино-мест", _fmt_int_ru(teap.get("parking_spaces"))),
]
teap_pairs = [(k, v) for k, v in teap_pairs if v not in (None, "", _DASH)]
fin_pairs: list[tuple[str, Any]] = [
("Выручка (GDV), ₽", _fmt_money_signed(financial.get("revenue_rub"))),
("Итого затраты, ₽", _fmt_money_signed(financial.get("cost_rub"))),
("Чистая прибыль, ₽", _fmt_money_signed(financial.get("net_profit_rub"))),
("NPV (DCF), ₽", _fmt_money_signed(financial.get("npv_rub"))),
("ROI", _fmt_pct(financial.get("roi"))),
("IRR", _fmt_pct(financial.get("irr"))),
]
fin_pairs = [(k, v) for k, v in fin_pairs if v not in (None, "", _DASH)]
partial = ""
placed = variant.get("placed_count")
requested = variant.get("requested_count")
if (
isinstance(placed, int)
and isinstance(requested, int)
and not isinstance(placed, bool)
and not isinstance(requested, bool)
and placed < requested
):
partial = (
f'<div class="caveat">Размещено {placed} из {requested} секций — участок '
"вмещает меньше, чем в заданной программе; ТЭП и финмодель — по фактически "
"размещённым корпусам.</div>"
)
return f"""
<div class="alt-card">
<div class="alt-title">{html.escape(strategy_label)}</div>
{partial}
<h3>ТЭП</h3>
{_kv_table(teap_pairs)}
<h3>Финансовый итог</h3>
{_kv_table(fin_pairs)}
{_build_concept_program(variant)}
</div>
"""
def _build_section_7(concept: dict[str, Any] | None) -> str:
"""§7 «Концепция»: footprint-план (плейсхолдер PR-C) + ТЭП/финитог/программа вариантов.
`concept` = сериализованный `ConceptOutput` (POST /concepts) или None. None / пустые
варианты → честная заметка «концепция не рассчитана» (без падения).
"""
concept_dict = _as_dict(concept)
variants = [v for v in _as_list(concept_dict.get("variants")) if isinstance(v, dict)]
if not variants:
return f"""
<div class="section" id="{_ANCHOR_S7}">
<h2>{html.escape(_TITLE_S7)}</h2>
<div class="caveat">Концепция застройки не рассчитана — раздел появится после генерации
вариантов концепции для участка.</div>
</div>
"""
cards = "".join(_build_concept_variant(v) for v in variants)
return f"""
<div class="section" id="{_ANCHOR_S7}">
<h2>{html.escape(_TITLE_S7)}</h2>
<div class="map-placeholder">{MAP_CONCEPT_PLACEHOLDER}</div>
{cards}
</div>
"""
def build_full_report_html_part_b(
forecast_result: dict[str, Any],
concept_result: dict[str, Any] | None,
*,
cad: str,
analyze_result: dict[str, Any] | None = None,
) -> str:
"""Собрать HTML Part B полного отчёта: §4 «Рынок» + §5 «Финмодель» + §6 «Риски» + §7.
PURE (без WeasyPrint / БД / сети). §4§6 читают ПЕРСИСТНУТЫЙ форсайт-ран
(`analysis_runs.result`, schema §22-форсайта "1.0" = `SiteFinderReport.as_dict()`) —
нормализуется теми же `_fc_*`-хелперами, что и PDF-рендерер форсайта. §7 читает
сериализованный `ConceptOutput` (POST /concepts). Каждый ключ — через `.get()` с
дефолтом; отсутствующая секция → «нет данных»; ВСЕ строки payload проходят
`html.escape`. Карта footprint-плана §7 — плейсхолдер `{{MAP_CONCEPT}}` (PR-C).
Args:
forecast_result: `analysis_runs.result` (schema §22-форсайта "1.0") или любой
dict/инстанс с `as_dict()`. Не-dict / None → пустой форсайт (секции → «нет
данных»).
concept_result: сериализованный `ConceptOutput` или None. None / без вариантов →
§7 рисует честную заметку «концепция не рассчитана» (§5 — только рыночный
контекст цены).
cad: кадастровый номер участка (для логов; в HTML приходит через каркас).
analyze_result: analyze-payload (`analysis_runs.result` analyze-рана) — источник
блока «разрешения рядом» §6 (`permits_nearby`). None / не-dict → блок рисует
честную плашку «в радиусе 500 м разрешений не найдено».
Returns:
HTML-фрагмент Part B (четыре `<div class="section">`), готовый как `part_b_html`
для `build_full_report_html`.
"""
forecast = _fc_normalize(forecast_result)
part_b = (
_build_section_4(forecast)
+ _build_section_5(forecast, _as_dict(concept_result))
+ _build_section_6(forecast, _as_dict(analyze_result))
+ _build_section_7(concept_result)
)
logger.info(
"build_full_report_html_part_b: cad=%s forecast_keys=%d concept_variants=%d",
cad,
len(forecast),
len(_as_list(_as_dict(concept_result).get("variants"))),
)
return part_b
# ──────────────────────────────────────────────────────────────────────────────
# Публичный API PR-A: сборка Part A (§1§3) + общий каркас документа.
# ──────────────────────────────────────────────────────────────────────────────
def build_full_report_html_part_a(
analyze_result: dict[str, Any],
*,
cad: str,
connection_capacity: dict[str, Any] | None = None,
) -> 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`).
connection_capacity: результат `get_connection_capacity` (ЦП/вода/газ/тепло/сети
рядом) — НЕ персистится в analyze-payload, дотягивается оркестратором PR-D
и рендерится блоком «Ресурсные резервы» в §3. None → блок пропускается.
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, connection_capacity)
)
logger.info(
"build_full_report_html_part_a: cad=%s keys=%d has_alternatives=%s has_capacity=%s",
cad,
len(result),
bool(_as_dict(result.get("program_alternatives")).get("variants")),
bool(_as_dict(connection_capacity)),
)
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.extend(
[
f'<li><a href="#{_ANCHOR_S4}">{html.escape(_TITLE_S4)}</a></li>',
f'<li><a href="#{_ANCHOR_S5}">{html.escape(_TITLE_S5)}</a></li>',
f'<li><a href="#{_ANCHOR_S6}">{html.escape(_TITLE_S6)}</a></li>',
f'<li><a href="#{_ANCHOR_S7}">{html.escape(_TITLE_S7)}</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>"""