"""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: """Процент, УЖЕ выраженный в единицах 0–100 (напр. 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'
{_NO_DATA}
' def _kv_row(label: str, value: Any) -> str: """Строка «метка → значение» (оба экранированы). `label` — статичная RU-метка.""" return f'{html.escape(label)}{_esc(value)}' def _kv_table(pairs: list[tuple[str, Any]]) -> str: """Таблица «метка → значение» из списка пар. Пустой список → «нет данных». PURE.""" if not pairs: return _no_data() rows = "".join(_kv_row(label, value) for label, value in pairs) return f'{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"

{html.escape(title)}

{_data_table(['Код', 'Детали'], rows)}" blockers = _issues(gate.get("blockers"), "Блокеры") warnings = _issues(gate.get("warnings"), "Предупреждения") return verdict + blockers + warnings def _build_section_1(result: dict[str, Any]) -> str: """§1 «Участок»: карта-плейсхолдер + факты + градрегламент + ЗОУИТ + gate-вердикт.""" return f"""

{html.escape(_TITLE_S1)}

{MAP_PARCEL_PLACEHOLDER}

Кадастровые факты

{_build_parcel_facts(result)}

Градостроительный регламент

{_build_zoning(result)}

Ограничения (ЗОУИТ)

{_build_zouit(result)} {_build_gate_verdict(result)}
""" # ────────────────────────────────────────────────────────────────────────────── # §2 «Окружение»: транспорт / шум / воздух / геотехника / гидрология / соседи. # ────────────────────────────────────────────────────────────────────────────── def _build_transport(result: dict[str, Any]) -> str: """Транспорт: удалённость от центра ЕКБ + ближайшие станции метро.""" location = _as_dict(result.get("location")) metro = _as_dict(result.get("metro")) pairs: list[tuple[str, Any]] = [] dist = location.get("distance_to_center_km") if dist is not None: pairs.append(("До центра ЕКБ, км", dist)) metro_rows = [ [m.get("name"), _fmt_int_ru(m.get("distance_m"))] for m in _as_list(metro.get("nearest_top3")) if isinstance(m, dict) ] metro_table = ( f"

Ближайшее метро

{_data_table(['Станция', 'Расстояние, м'], metro_rows)}" if metro_rows else "" ) head = _kv_table(pairs) if pairs else _no_data() return head + metro_table def _build_noise_air(result: dict[str, Any]) -> str: """Шум + качество воздуха KV-таблицей.""" noise = _as_dict(result.get("noise")) air = _as_dict(result.get("air_quality")) pairs: list[tuple[str, Any]] = [ ("Уровень шума", noise.get("level")), ("Оценка шума, дБ", noise.get("estimated_db")), ("NO₂", air.get("no2")), ("PM10", air.get("pm10")), ("PM2.5", air.get("pm2_5")), ("Источник воздуха", air.get("source")), ] pairs = [(k, v) for k, v in pairs if v not in (None, "")] return _kv_table(pairs) def _build_geotech_hydro(result: dict[str, Any]) -> str: """Геотехника (сейсмика / промышленность) + гидрология (пойма / водные объекты).""" geotech = _as_dict(result.get("geotech_risk")) hydro = _as_dict(result.get("hydrology")) pairs: list[tuple[str, Any]] = [ ("Сейсмика", geotech.get("seismic_label")), ("Балльность", geotech.get("seismic_intensity_balls")), ("Многолетняя мерзлота", geotech.get("permafrost")), ("Промобъектов в 500 м", geotech.get("industrial_within_500m")), ("Риск подтопления", hydro.get("flood_risk_flag")), ] pairs = [(k, v) for k, v in pairs if v not in (None, "")] geotech_table = _kv_table(pairs) water_rows = [ [w.get("name") or w.get("subtype"), _fmt_int_ru(w.get("distance_m"))] for w in _as_list(hydro.get("nearest")) if isinstance(w, dict) ] water_table = ( f"

Ближайшие водные объекты

{_data_table(['Объект', 'Расстояние, м'], water_rows)}" if water_rows else "" ) return geotech_table + water_table def _build_neighbors(result: dict[str, Any]) -> str: """Соседняя застройка (cad_buildings в радиусе): назначение / этажность / расстояние.""" summary = _as_dict(result.get("neighbors_summary")) neighbors = _as_list(summary.get("neighbors")) rows: list[list[Any]] = [] for nb in neighbors: if not isinstance(nb, dict): continue rows.append( [ nb.get("building_name") or nb.get("cad_num"), nb.get("purpose"), nb.get("floors_parsed") or nb.get("floors"), _fmt_int_ru(nb.get("distance_m")), ] ) return _data_table(["Объект", "Назначение", "Этажей", "Расстояние, м"], rows) def _build_section_2(result: dict[str, Any]) -> str: """§2 «Окружение»: транспорт + шум/воздух + геотехника/гидрология + соседи.""" return f"""

{html.escape(_TITLE_S2)}

Транспортная доступность

{_build_transport(result)}

Шум и качество воздуха

{_build_noise_air(result)}

Геотехника и гидрология

{_build_geotech_hydro(result)}

Соседняя застройка

{_build_neighbors(result)}
""" # ────────────────────────────────────────────────────────────────────────────── # §3 «Инженерные сети» + блок альтернатив программы. # ────────────────────────────────────────────────────────────────────────────── def _build_utilities(result: dict[str, Any]) -> str: """Инженерные сети рядом (OSM `utilities`): ближайшие сети + сводка по видам.""" utilities = _as_dict(result.get("utilities")) if not utilities: return _no_data() pairs: list[tuple[str, Any]] = [ ("Ближайшая ПС/подстанция, м", _fmt_int_ru(utilities.get("nearest_substation_m"))), ("Ближайший водовод, м", _fmt_int_ru(utilities.get("nearest_water_main_m"))), ("Ближайший газопровод, м", _fmt_int_ru(utilities.get("nearest_gas_m"))), ("Ближайшая теплотрасса, м", _fmt_int_ru(utilities.get("nearest_heat_m"))), # ключ в persist-payload реально смешивает латиницу и кириллицу — НЕ «чинить» ("В охранной зоне ЛЭП", utilities.get("power_line_охранная_зона_flag")), ] pairs = [(k, v) for k, v in pairs if v not in (None, _DASH)] head = _kv_table(pairs) summary_rows = [ [ s.get("subtype"), _fmt_int_ru(s.get("nearest_m")), s.get("count_within_2km"), ] for s in _as_list(utilities.get("summary")) if isinstance(s, dict) ] summary_table = ( f"

Сети рядом с участком (OSM)

" f"{_data_table(['Вид сети', 'Ближайшая, м', 'В радиусе 2 км'], summary_rows)}" if summary_rows else "" ) return head + summary_table def _build_engineering_nearby(result: dict[str, Any]) -> str: """Инженерные сооружения НСПД рядом (name / назначение / расстояние из raw_props).""" items = _as_list(result.get("nspd_engineering_nearby")) rows: list[list[Any]] = [] 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")))}
Жилая {html.escape(_fmt_int_ru(v.get("residential_sqm")))} м² · """ f"""{html.escape(_fmt_int_ru(v.get("apartments_count")))} квартир
Цена {html.escape(_fmt_int_ru(v.get("price_per_sqm_used")))} ₽/м² """ f"""({html.escape(_price_source_ru(v.get("price_source")))})
""" ) caveat = alt.get("caveat") caveat_html = f'
{_esc(caveat)}
' if caveat else "" return f"""

{html.escape(_TITLE_ALT)}

{html.escape(subtitle)}

{"".join(cards)} {caveat_html}
""" def _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"""

{html.escape(_TITLE_S3)}

Инженерные сети рядом

{_build_utilities(result)} {_build_engineering_nearby(result)} {_build_connection_capacity(connection_capacity)}
{_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, каскад затрат) ' "считается по концепции застройки — она ещё не рассчитана. Ниже — только " "рыночный контекст цены из прогноза.
" ) return f"""

{html.escape(_TITLE_S5)}

{note} {affordability or _no_data()}
""" strategy_label = _strategy_ru(_variant_strategy(variant)) return f"""

{html.escape(_TITLE_S5)}

Опорный вариант концепции (макс. NPV): {html.escape(strategy_label)}.

Ключевые показатели (DCF)

{_build_financial_headline(financial)}

Каскад затрат и расчёт прибыли

{_build_financial_cascade(financial)} {affordability}
""" # ── §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 ( '
Сценарная дифференциация недоступна: ' f"{html.escape(reason_text)}
" ) 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} секций — участок ' "вмещает меньше, чем в заданной программе; ТЭП и финмодель — по фактически " "размещённым корпусам.
" ) return f"""
{html.escape(strategy_label)}
{partial}

ТЭП

{_kv_table(teap_pairs)}

Финансовый итог

{_kv_table(fin_pairs)} {_build_concept_program(variant)}
""" 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'
  • {html.escape(_TITLE_S1)}
  • ', f'
  • {html.escape(_TITLE_S2)}
  • ', f'
  • {html.escape(_TITLE_S3)}
  • ', ] if has_part_b: items.extend( [ f'
  • {html.escape(_TITLE_S4)}
  • ', f'
  • {html.escape(_TITLE_S5)}
  • ', f'
  • {html.escape(_TITLE_S6)}
  • ', f'
  • {html.escape(_TITLE_S7)}
  • ', ] ) return f'' def build_full_report_html( part_a_html: str, part_b_html: str | None, *, cad: str, address: str | None, generated_at: str, ) -> str: """Собрать единый печатный HTML-документ полного отчёта (каркас + Part A [+ Part B]). Общий `

    {html.escape(_TITLE_DOC)}

    Кадастровый номер: {html.escape(cad)}
    {addr_line}
    Дата формирования: {html.escape(generated_at)}
    {_build_toc(has_part_b)} {part_a_html} {part_b_block} """