'
def _kv_table(pairs: list[tuple[str, Any]]) -> str:
"""Таблица «метка → значение» из списка пар. Пустой список → «нет данных». PURE."""
if not pairs:
return _no_data()
rows = "".join(_kv_row(label, value) for label, value in pairs)
return f'
{rows}
'
def _data_table(headers: list[str], rows: list[list[Any]]) -> str:
"""Таблица: шапка (статичные RU-метки) + строки данных (через `_esc`). PURE.
Пустой `rows` → строка-заглушка «нет данных» под шапкой (graceful). Все ячейки
данных экранируются.
"""
head = "".join(f"
{html.escape(h)}
" for h in headers)
if not rows:
body = f'
{_NO_DATA}
'
else:
body = "".join(
"
" + "".join(f"
{_esc(cell)}
" for cell in row) + "
" for row in rows
)
return f'
{head}
{body}
'
# ──────────────────────────────────────────────────────────────────────────────
# §1 «Участок»: адрес / площадь / категория / ВРИ / градрегламент / ЗОУИТ / gate.
# ──────────────────────────────────────────────────────────────────────────────
def _build_parcel_facts(result: dict[str, Any]) -> str:
"""KV-таблица кадастровых фактов: адрес, площадь, категория, ВРИ, статус, стоимость."""
egrn = _as_dict(result.get("egrn"))
parcel_meta = _as_dict(result.get("parcel_meta"))
geometry = _as_dict(result.get("geometry_suitability"))
address = egrn.get("address")
area_m2 = egrn.get("area_m2") or geometry.get("area_m2")
category = egrn.get("land_category") or parcel_meta.get("land_category")
vri = egrn.get("permitted_use_text") or parcel_meta.get("permitted_use")
cad_cost = egrn.get("cadastral_value_rub") or parcel_meta.get("cad_cost")
pairs: list[tuple[str, Any]] = [
("Адрес", address),
("Площадь, м²", _fmt_int_ru(area_m2) if area_m2 is not None else None),
("Категория земель", category),
("Разрешённое использование (ВРИ)", vri),
("Подвид", parcel_meta.get("land_subtype")),
("Статус", egrn.get("parcel_status")),
("Форма собственности", egrn.get("ownership_type")),
("Право", egrn.get("right_type")),
("Кадастровая стоимость, ₽", _fmt_int_ru(cad_cost) if cad_cost is not None else None),
]
# Убираем строки без значения, чтобы не заваливать таблицу «—».
pairs = [(k, v) for k, v in pairs if v not in (None, "")]
return _kv_table(pairs)
def _build_zoning(result: dict[str, Any]) -> str:
"""Градрегламент ПЗЗ: зона + ТЭП (FAR / этажность / высота / застройка) + ВРИ-список."""
nspd_zoning = _as_dict(result.get("nspd_zoning"))
if not nspd_zoning:
# Fallback: старый per-parcel zoning (обычно data_available=false).
zoning = _as_dict(result.get("zoning"))
if not zoning or not zoning.get("zone_code"):
note = zoning.get("note")
note_html = f'
{_esc(note)}
' if note else ""
return _no_data() + note_html
zone_code = nspd_zoning.get("zone_code") or nspd_zoning.get("regulation_zone_index")
pairs: list[tuple[str, Any]] = [
("Зона ПЗЗ", zone_code),
("Наименование зоны", nspd_zoning.get("zone_name")),
("Макс. коэф. застройки (FAR)", nspd_zoning.get("max_far")),
("Макс. этажность", nspd_zoning.get("max_floors")),
("Макс. высота, м", nspd_zoning.get("max_height_m")),
("Макс. процент застройки", nspd_zoning.get("max_building_pct")),
("Мин. площадь участка, м²", nspd_zoning.get("min_parcel_area_m2")),
("Источник регламента", nspd_zoning.get("regulation_source")),
]
pairs = [(k, v) for k, v in pairs if v not in (None, "")]
zoning_table = _kv_table(pairs)
main_vri = _as_list(nspd_zoning.get("main_vri"))
vri_rows = [[v] for v in main_vri if v not in (None, "")]
vri_table = (
f"
Разрешённые ВРИ зоны
{_data_table(['Вид разрешённого использования'], vri_rows)}"
if vri_rows
else ""
)
return zoning_table + vri_table
def _build_zouit(result: dict[str, Any]) -> str:
"""ЗОУИТ-ограничения: сводка + список пересечений (тип / № границы / покрытие)."""
encumbrance = _as_dict(result.get("encumbrance"))
overlaps = [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"
Пересечения ЗОУИТ
{table}"
def _build_gate_verdict(result: dict[str, Any]) -> str:
"""Плашка gate-вердикта «можно ли строить МКД» + блокеры / предупреждения."""
gate = _as_dict(result.get("gate_verdict"))
if not gate:
return ""
can_build = gate.get("can_build_mkd")
label = gate.get("verdict_label") or ("Можно" if can_build else "Нельзя")
cls = "verdict-ok" if can_build else "verdict-no"
verdict = (
f'
Строительство МКД: {_esc(label)}
'
)
def _issues(items: Any, title: str) -> str:
rows = [
[it.get("code"), it.get("detail")] for it in _as_list(items) if isinstance(it, dict)
]
if not rows:
return ""
return f"
"""
# ──────────────────────────────────────────────────────────────────────────────
# §2 «Окружение»: транспорт / шум / воздух / геотехника / гидрология / соседи.
# ──────────────────────────────────────────────────────────────────────────────
def _build_transport(result: dict[str, Any]) -> str:
"""Транспорт: удалённость от центра ЕКБ + ближайшие станции метро."""
location = _as_dict(result.get("location"))
metro = _as_dict(result.get("metro"))
pairs: list[tuple[str, Any]] = []
dist = location.get("distance_to_center_km")
if dist is not None:
pairs.append(("До центра ЕКБ, км", dist))
metro_rows = [
[m.get("name"), _fmt_int_ru(m.get("distance_m"))]
for m in _as_list(metro.get("nearest_top3"))
if isinstance(m, dict)
]
metro_table = (
f"
Ближайшее метро
{_data_table(['Станция', 'Расстояние, м'], metro_rows)}"
if metro_rows
else ""
)
head = _kv_table(pairs) if pairs else _no_data()
return head + metro_table
def _build_noise_air(result: dict[str, Any]) -> str:
"""Шум + качество воздуха KV-таблицей."""
noise = _as_dict(result.get("noise"))
air = _as_dict(result.get("air_quality"))
pairs: list[tuple[str, Any]] = [
("Уровень шума", noise.get("level")),
("Оценка шума, дБ", noise.get("estimated_db")),
("NO₂", air.get("no2")),
("PM10", air.get("pm10")),
("PM2.5", air.get("pm2_5")),
("Источник воздуха", air.get("source")),
]
pairs = [(k, v) for k, v in pairs if v not in (None, "")]
return _kv_table(pairs)
def _build_geotech_hydro(result: dict[str, Any]) -> str:
"""Геотехника (сейсмика / промышленность) + гидрология (пойма / водные объекты)."""
geotech = _as_dict(result.get("geotech_risk"))
hydro = _as_dict(result.get("hydrology"))
pairs: list[tuple[str, Any]] = [
("Сейсмика", geotech.get("seismic_label")),
("Балльность", geotech.get("seismic_intensity_balls")),
("Многолетняя мерзлота", geotech.get("permafrost")),
("Промобъектов в 500 м", geotech.get("industrial_within_500m")),
("Риск подтопления", hydro.get("flood_risk_flag")),
]
pairs = [(k, v) for k, v in pairs if v not in (None, "")]
geotech_table = _kv_table(pairs)
water_rows = [
[w.get("name") or w.get("subtype"), _fmt_int_ru(w.get("distance_m"))]
for w in _as_list(hydro.get("nearest"))
if isinstance(w, dict)
]
water_table = (
f"
Ближайшие водные объекты
{_data_table(['Объект', 'Расстояние, м'], water_rows)}"
if water_rows
else ""
)
return geotech_table + water_table
def _build_neighbors(result: dict[str, Any]) -> str:
"""Соседняя застройка (cad_buildings в радиусе): назначение / этажность / расстояние."""
summary = _as_dict(result.get("neighbors_summary"))
neighbors = _as_list(summary.get("neighbors"))
rows: list[list[Any]] = []
for nb in neighbors:
if not isinstance(nb, dict):
continue
rows.append(
[
nb.get("building_name") or nb.get("cad_num"),
nb.get("purpose"),
nb.get("floors_parsed") or nb.get("floors"),
_fmt_int_ru(nb.get("distance_m")),
]
)
return _data_table(["Объект", "Назначение", "Этажей", "Расстояние, м"], rows)
def _build_section_2(result: dict[str, Any]) -> str:
"""§2 «Окружение»: транспорт + шум/воздух + геотехника/гидрология + соседи."""
return f"""
{html.escape(_TITLE_S2)}
Транспортная доступность
{_build_transport(result)}
Шум и качество воздуха
{_build_noise_air(result)}
Геотехника и гидрология
{_build_geotech_hydro(result)}
Соседняя застройка
{_build_neighbors(result)}
"""
# ──────────────────────────────────────────────────────────────────────────────
# §3 «Инженерные сети» + блок альтернатив программы.
# ──────────────────────────────────────────────────────────────────────────────
def _build_utilities(result: dict[str, Any]) -> str:
"""Инженерные сети рядом (OSM `utilities`): ближайшие сети + сводка по видам."""
utilities = _as_dict(result.get("utilities"))
if not utilities:
return _no_data()
pairs: list[tuple[str, Any]] = [
("Ближайшая ПС/подстанция, м", _fmt_int_ru(utilities.get("nearest_substation_m"))),
("Ближайший водовод, м", _fmt_int_ru(utilities.get("nearest_water_main_m"))),
("Ближайший газопровод, м", _fmt_int_ru(utilities.get("nearest_gas_m"))),
("Ближайшая теплотрасса, м", _fmt_int_ru(utilities.get("nearest_heat_m"))),
# ключ в persist-payload реально смешивает латиницу и кириллицу — НЕ «чинить»
("В охранной зоне ЛЭП", utilities.get("power_line_охранная_зона_flag")),
]
pairs = [(k, v) for k, v in pairs if v not in (None, _DASH)]
head = _kv_table(pairs)
summary_rows = [
[
s.get("subtype"),
_fmt_int_ru(s.get("nearest_m")),
s.get("count_within_2km"),
]
for s in _as_list(utilities.get("summary"))
if isinstance(s, dict)
]
summary_table = (
f"
Сети рядом с участком (OSM)
"
f"{_data_table(['Вид сети', 'Ближайшая, м', 'В радиусе 2 км'], summary_rows)}"
if summary_rows
else ""
)
return head + summary_table
def _build_engineering_nearby(result: dict[str, Any]) -> str:
"""Инженерные сооружения НСПД рядом (name / назначение / расстояние из raw_props)."""
items = _as_list(result.get("nspd_engineering_nearby"))
rows: list[list[Any]] = []
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"
Инженерные сооружения рядом (НСПД)
{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"""
{html.escape(title)}
NPV {html.escape(_fmt_money_signed(npv))}
ROI {html.escape(_fmt_pct(v.get("roi")))} · """
f"""IRR {html.escape(_fmt_pct(v.get("irr")))}
"""
def _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("
Электроснабжение — свободная мощность
" + _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(
"
Водоснабжение/водоотведение — резервы
"
+ _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(
"
Газоснабжение — свободная мощность ГРС
"
+ _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(
"
Теплоснабжение — резервы систем
"
+ _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(
"
Сети рядом (охранные зоны)
"
+ _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"""
{_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'
{_esc(summary)}
' if summary else ""
return f"""
{html.escape(_TITLE_S4)}
{summary_html}
Метрики рынка (темп, ликвидность, цена)
{_build_market_metrics(forecast)}
Конкуренты рядом
{_build_market_competitors(forecast)}
Покрытие данными и уверенность
{_build_market_coverage(forecast)}
"""
# ── §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'
{html.escape(c)}
' 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"
Рыночный контекст цены
{_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 = (
'
Финансовая модель (DCF, NPV/IRR/ROI, каскад затрат) '
"считается по концепции застройки — она ещё не рассчитана. Ниже — только "
"рыночный контекст цены из прогноза.
"
)
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 (
'
В радиусе 500 м участка новых разрешений на '
"строительство не найдено (по данным ГИСОГД Свердловской области).
"
)
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'
{_esc(summary)}
' if summary else ""
return f"""
{html.escape(_TITLE_S6)}
{summary_html}
Дефицит / затоварка по горизонтам
{_build_deficit_by_horizon(forecast)}
Давление будущего предложения
{_build_supply_pressure(forecast)}
Риск-индексы
{_build_risk_indices(forecast)}
Сценарии
{_build_scenarios_honesty(forecast)}
Разрешения на строительство рядом (500 м)
{_build_permits_nearby(result)}
"""
# ── §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"
Программа застройки
{_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'
Размещено {placed} из {requested} секций — участок '
"вмещает меньше, чем в заданной программе; ТЭП и финмодель — по фактически "
"размещённым корпусам.
"""
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"""
{html.escape(_TITLE_S7)}
Концепция застройки не рассчитана — раздел появится после генерации
вариантов концепции для участка.
"""
cards = "".join(_build_concept_variant(v) for v in variants)
return f"""
{html.escape(_TITLE_S7)}
{MAP_CONCEPT_PLACEHOLDER}
{cards}
"""
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 (четыре `
`), готовый как `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 (три `
` + опциональный блок альтернатив),
готовый как `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'