"""PURE HTML-агрегатор полного PDF-отчёта ПТИЦА §1–§3 + альтернативы (эпик #2259 PR-A). Foundation-слой полного PDF-экспорта отчёта `/site-finder/analysis/{cad}`. Собирает из ПЕРСИСТНУТОГО `analysis_runs.result` (schema `analyze-1.0`, loose dict ~90 ключей — `AnalyzeResponse`, `extra="allow"`) единый печатный HTML-документ: • §1 «Участок» — адрес / площадь / категория / ВРИ / градрегламент (ПЗЗ-зона, ТЭП) / ЗОУИТ-ограничения / gate-вердикт «можно ли строить МКД». • §2 «Окружение» — транспорт (центр / метро) / шум / воздух / геотехника / гидрология / соседняя застройка. • §3 «Сети» — инженерные сети рядом (`utilities`) + инж.сооружения НСПД + блок альтернатив программы (`program_alternatives`, при отрицательном фин-вердикте). Архитектура — зеркало `layout_tz_pdf.build_layout_tz_html`: PURE-сборка HTML (без WeasyPrint / БД / сети), тестируется юнит-тестами БЕЗ native GTK/Pango. Нормализация loose-словарей — по образцу `report_pdf._normalize`/`_fmt`/`_as_dict`/`_as_list`. БЕЗОПАСНОСТЬ: ВСЕ динамические строки payload проходят `html.escape` (вход трактуем как недоверенный — часть данных приходит из внешних источников: НСПД / OSM / геопортал). GRACEFUL: любой ключ читается через `.get()` с дефолтом; пустая секция рисует «нет данных» по-русски; агрегатор НИКОГДА не падает на частичном / пустом payload. КАРТА: место под карту участка — плейсхолдер `{{MAP_PARCEL}}` (реальная подстановка в PR-C). PR-A оставляет только маркер. ГРАНИЦЫ PR-A: §4–§7 (PR-B), карты (PR-C), live-дозапросы отсутствующих в persist-payload слоёв — «Ресурсные резервы» ЦП/Водоканала и `nearby_network_zones` берутся ленивым `connection-capacity`-эндпоинтом, ИХ В persist-payload НЕТ (PR-D). """ from __future__ import annotations import html import logging from typing import Any logger = logging.getLogger(__name__) # ── Плейсхолдер карты (подставится в PR-C) ───────────────────────────────────── MAP_PARCEL_PLACEHOLDER = "{{MAP_PARCEL}}" # ── Микрокопия / заглушки ────────────────────────────────────────────────────── _DASH = "—" _NO_DATA = "нет данных" # Заголовки секций (якоря — id внутри) и титул документа. _TITLE_DOC = "Отчёт по участку — Site Finder ПТИЦА" _TITLE_S1 = "§1. Участок" _TITLE_S2 = "§2. Окружение" _TITLE_S3 = "§3. Инженерные сети" _TITLE_ALT = "Как участок сходится (альтернативы программы)" # id-якоря секций (оглавление + внутренние ссылки). _ANCHOR_S1 = "section-1" _ANCHOR_S2 = "section-2" _ANCHOR_S3 = "section-3" _ANCHOR_ALT = "section-alternatives" # RU-метки источников цены варианта программы (зеркало SectionAlternatives.tsx). _PRICE_SOURCE_RU: dict[str, str] = { "objective_district_median": "рынок: медиана объявлений Objective по району", "objective_geo_radius": "рынок: медиана новостроек в радиусе 3 км", "district_reference": "рынок: справочная медиана района", "class_norm": "норматив класса (нет рыночных данных по участку)", } # RU-метки класса жилья / типа застройки (зеркало concept-api-лейблов фронта). _HOUSING_CLASS_RU: dict[str, str] = { "econom": "Эконом", "comfort": "Комфорт", "business": "Бизнес", } _DEVELOPMENT_TYPE_RU: dict[str, str] = { "spot": "точечная", "mid_rise": "среднеэтажная", "high_rise": "высотная", } # ── Печатная типографика + page-break-правила WeasyPrint ──────────────────────── _CSS = """ @page { size: A4; margin: 18mm 16mm 20mm 16mm; @bottom-center { content: "Site Finder ПТИЦА · стр. " counter(page); font-size: 7.5pt; color: #9ca3af; } } * { box-sizing: border-box; } body { font-family: 'DejaVu Sans', 'Helvetica', 'Arial', sans-serif; font-size: 10pt; color: #1a1d23; margin: 0; padding: 0; } h1 { font-size: 20pt; margin: 0 0 6pt 0; line-height: 1.2; } h2 { font-size: 14pt; margin: 0 0 8pt 0; border-bottom: 1.5px solid #e6e8ec; padding-bottom: 4pt; color: #1d4ed8; } h3 { font-size: 11pt; margin: 12pt 0 6pt 0; color: #374151; } p { margin: 4pt 0; } /* Page-break: каждая содержательная секция стартует со свежей страницы, но не рвётся внутри заголовка (WeasyPrint поддерживает break-before/inside). */ .section { margin-bottom: 16pt; break-inside: avoid-page; } .section + .section { break-before: page; } h2, h3 { break-after: avoid-page; } table { break-inside: avoid-page; } /* Титул */ .cover { padding: 6pt 0 12pt 0; border-bottom: 2px solid #1d4ed8; margin-bottom: 14pt; } .cover-meta { font-size: 9pt; color: #5b6066; margin-top: 4pt; } /* Оглавление */ .toc { margin: 0 0 16pt 0; break-after: page; } .toc ul { list-style: none; padding: 0; margin: 6pt 0 0 0; } .toc li { padding: 3pt 0; font-size: 10.5pt; } /* Карта-плейсхолдер */ .map-placeholder { margin: 8pt 0; padding: 40pt 12pt; text-align: center; background: #fafbfc; border: 1px dashed #d1d5db; border-radius: 6pt; color: #9ca3af; font-size: 9pt; } /* KV-таблица «метка → значение» */ .kv { width: 100%; border-collapse: collapse; margin-top: 4pt; } .kv td { padding: 4pt 7pt; border-bottom: 1px solid #eef0f3; vertical-align: top; } .kv td.k { font-weight: 700; color: #374151; width: 40%; } .kv td.v { color: #1a1d23; } /* Таблицы данных */ table.data { width: 100%; border-collapse: collapse; margin-top: 8pt; font-size: 9pt; } table.data thead tr { background: #1d4ed8; } table.data th { padding: 5pt 7pt; text-align: left; font-weight: 700; color: #fff; border-bottom: 1.5px solid #1e40af; } table.data td { padding: 5pt 7pt; border-bottom: 1px solid #e6e8ec; vertical-align: top; } table.data tr:nth-child(even) td { background: #f9fafb; } td.empty, .no-data { text-align: center; color: #9ca3af; font-style: italic; padding: 10pt; } .no-data { padding: 6pt 0; } /* Плашки-вердикты */ .verdict-block { margin: 8pt 0; padding: 8pt 12pt; border-radius: 0 4pt 4pt 0; font-size: 9.5pt; } .verdict-ok { background: #dcfce7; border-left: 4px solid #0a7a3a; color: #05612c; } .verdict-no { background: #fee2e2; border-left: 4px solid #b3261e; color: #8a1b15; } .caveat { margin: 8pt 0; padding: 8pt 12pt; border-radius: 0 4pt 4pt 0; background: #fef3c7; border-left: 4px solid #9a6700; color: #7a5209; font-size: 9pt; } /* Карточки альтернатив */ .alt-card { border: 1px solid #e6e8ec; border-radius: 8pt; padding: 10pt 12pt; margin: 8pt 0; } .alt-title { font-size: 11pt; font-weight: 700; color: #111; } .alt-npv { font-size: 15pt; font-weight: 700; margin: 4pt 0; } .alt-npv.pos { color: #0a7a3a; } .alt-npv.neg { color: #b3261e; } .alt-meta { font-size: 9pt; color: #5b6066; } .footer { margin-top: 16pt; padding-top: 8pt; border-top: 1px solid #e6e8ec; font-size: 8pt; color: #9ca3af; } """ # ────────────────────────────────────────────────────────────────────────────── # Нормализация входа + микро-хелперы форматирования. Все PURE, graceful. # ────────────────────────────────────────────────────────────────────────────── def _as_dict(value: Any) -> dict[str, Any]: """Вернуть dict как есть, иначе пустой dict (defensive чтение секций). PURE.""" return value if isinstance(value, dict) else {} def _as_list(value: Any) -> list[Any]: """Вернуть list как есть, иначе пустой list (defensive чтение секций). PURE.""" return value if isinstance(value, list) else [] def _fmt(value: Any) -> str: """Привести значение к тексту ячейки: None → «—», float → аккуратно, str — как есть. Float с целым значением (78414.0) печатаем без хвоста «.0». Прочие float — до 2 знаков, лишние нули убираем. Bool → «да»/«нет». Возврат — ВСЕГДА str (его экранирует `_esc`). PURE. """ if value is None: return _DASH if isinstance(value, bool): return "да" if value else "нет" if isinstance(value, float): if value.is_integer(): return str(int(value)) return f"{value:.2f}".rstrip("0").rstrip(".") if isinstance(value, str): return value if value != "" else _DASH return str(value) def _esc(value: Any) -> str: """Привести значение к ячейке (`_fmt`) и ЭКРАНИРОВАТЬ для HTML. PURE. Единая точка вставки динамических данных в разметку: всё, что попадает в HTML, проходит `html.escape` (вход недоверенный — см. модульный docstring). """ return html.escape(_fmt(value)) def _fmt_int_ru(value: Any) -> str: """Целое число с пробелами-разрядами («77 656»), иначе «—». PURE.""" if isinstance(value, bool) or value is None: return _DASH if isinstance(value, int | float): return f"{int(value):,}".replace(",", " ") return _DASH def _fmt_money_signed(value: Any) -> str: """Деньги округлением до млн со знаком: «+42 млн ₽» / «−216 млн ₽». PURE. Ниже 1 млн — точная сумма с пробелами-разрядами. Минус — типографский U+2212. None / не-число → «—». """ if isinstance(value, bool) or not isinstance(value, int | float): return _DASH num = float(value) sign = "+" if num > 0 else "−" if num < 0 else "" magnitude = abs(num) if magnitude >= 1e9: return f"{sign}{magnitude / 1e9:.1f} млрд ₽" if magnitude >= 1e6: return f"{sign}{round(magnitude / 1e6)} млн ₽" return f"{sign}{f'{round(magnitude):,}'.replace(',', ' ')} ₽" def _fmt_pct(fraction: Any) -> str: """Доля 0.184 → «18.4%». Не-число → «—». PURE.""" if isinstance(fraction, bool) or not isinstance(fraction, int | float): return _DASH value = f"{fraction * 100:.1f}" if value.startswith("-"): return "−" + value[1:] + "%" return value + "%" def _housing_class_ru(value: Any) -> str: """RU-метка класса жилья, иначе значение как есть (str). PURE.""" if isinstance(value, str) and value in _HOUSING_CLASS_RU: return _HOUSING_CLASS_RU[value] return _fmt(value) def _development_type_ru(value: Any) -> str: """RU-метка типа застройки, иначе значение как есть (str). PURE.""" if isinstance(value, str) and value in _DEVELOPMENT_TYPE_RU: return _DEVELOPMENT_TYPE_RU[value] return _fmt(value) def _price_source_ru(value: Any) -> str: """RU-фраза источника цены варианта, иначе значение как есть. PURE.""" if isinstance(value, str) and value in _PRICE_SOURCE_RU: return _PRICE_SOURCE_RU[value] return _fmt(value) # ────────────────────────────────────────────────────────────────────────────── # Низкоуровневые HTML-помощники (kv-строки / таблицы / заглушки). Все PURE. # ────────────────────────────────────────────────────────────────────────────── def _no_data() -> str: """Блок-заглушка «нет данных» для пустой секции (graceful). PURE.""" return f'
{_NO_DATA}
' def _kv_row(label: str, value: Any) -> str: """Строка «метка → значение» (оба экранированы). `label` — статичная RU-метка.""" return f'{html.escape(label)}{_esc(value)}' def _kv_table(pairs: list[tuple[str, Any]]) -> str: """Таблица «метка → значение» из списка пар. Пустой список → «нет данных». PURE.""" if not pairs: return _no_data() rows = "".join(_kv_row(label, value) for label, value in pairs) return f'{rows}
' def _data_table(headers: list[str], rows: list[list[Any]]) -> str: """Таблица: шапка (статичные RU-метки) + строки данных (через `_esc`). PURE. Пустой `rows` → строка-заглушка «нет данных» под шапкой (graceful). Все ячейки данных экранируются. """ head = "".join(f"{html.escape(h)}" for h in headers) if not rows: body = f'{_NO_DATA}' else: body = "".join( "" + "".join(f"{_esc(cell)}" for cell in row) + "" for row in rows ) return f'{head}{body}
' # ────────────────────────────────────────────────────────────────────────────── # §1 «Участок»: адрес / площадь / категория / ВРИ / градрегламент / ЗОУИТ / gate. # ────────────────────────────────────────────────────────────────────────────── def _build_parcel_facts(result: dict[str, Any]) -> str: """KV-таблица кадастровых фактов: адрес, площадь, категория, ВРИ, статус, стоимость.""" egrn = _as_dict(result.get("egrn")) parcel_meta = _as_dict(result.get("parcel_meta")) geometry = _as_dict(result.get("geometry_suitability")) address = egrn.get("address") area_m2 = egrn.get("area_m2") or geometry.get("area_m2") category = egrn.get("land_category") or parcel_meta.get("land_category") vri = egrn.get("permitted_use_text") or parcel_meta.get("permitted_use") cad_cost = egrn.get("cadastral_value_rub") or parcel_meta.get("cad_cost") pairs: list[tuple[str, Any]] = [ ("Адрес", address), ("Площадь, м²", _fmt_int_ru(area_m2) if area_m2 is not None else None), ("Категория земель", category), ("Разрешённое использование (ВРИ)", vri), ("Подвид", parcel_meta.get("land_subtype")), ("Статус", egrn.get("parcel_status")), ("Форма собственности", egrn.get("ownership_type")), ("Право", egrn.get("right_type")), ("Кадастровая стоимость, ₽", _fmt_int_ru(cad_cost) if cad_cost is not None else None), ] # Убираем строки без значения, чтобы не заваливать таблицу «—». pairs = [(k, v) for k, v in pairs if v not in (None, "")] return _kv_table(pairs) def _build_zoning(result: dict[str, Any]) -> str: """Градрегламент ПЗЗ: зона + ТЭП (FAR / этажность / высота / застройка) + ВРИ-список.""" nspd_zoning = _as_dict(result.get("nspd_zoning")) if not nspd_zoning: # Fallback: старый per-parcel zoning (обычно data_available=false). zoning = _as_dict(result.get("zoning")) if not zoning or not zoning.get("zone_code"): note = zoning.get("note") note_html = f'

{_esc(note)}

' if note else "" return _no_data() + note_html zone_code = nspd_zoning.get("zone_code") or nspd_zoning.get("regulation_zone_index") pairs: list[tuple[str, Any]] = [ ("Зона ПЗЗ", zone_code), ("Наименование зоны", nspd_zoning.get("zone_name")), ("Макс. коэф. застройки (FAR)", nspd_zoning.get("max_far")), ("Макс. этажность", nspd_zoning.get("max_floors")), ("Макс. высота, м", nspd_zoning.get("max_height_m")), ("Макс. процент застройки", nspd_zoning.get("max_building_pct")), ("Мин. площадь участка, м²", nspd_zoning.get("min_parcel_area_m2")), ("Источник регламента", nspd_zoning.get("regulation_source")), ] pairs = [(k, v) for k, v in pairs if v not in (None, "")] zoning_table = _kv_table(pairs) main_vri = _as_list(nspd_zoning.get("main_vri")) vri_rows = [[v] for v in main_vri if v not in (None, "")] vri_table = ( f"

Разрешённые ВРИ зоны

{_data_table(['Вид разрешённого использования'], vri_rows)}" if vri_rows else "" ) return zoning_table + vri_table def _build_zouit(result: dict[str, Any]) -> str: """ЗОУИТ-ограничения: сводка + список пересечений (тип / № границы / покрытие).""" encumbrance = _as_dict(result.get("encumbrance")) overlaps = _as_list(result.get("nspd_zouit_overlaps")) summary_pairs: list[tuple[str, Any]] = [ ("Есть ЗОУИТ", encumbrance.get("has_zouit")), ("Кол-во типов ЗОУИТ", encumbrance.get("zouit_count")), ] zouit_types = _as_list(encumbrance.get("zouit_types")) if zouit_types: summary_pairs.append(("Типы", ", ".join(str(t) for t in zouit_types))) rows: list[list[Any]] = [] for ov in overlaps: if not isinstance(ov, dict): continue coverage = ov.get("coverage_pct") coverage_str = _fmt_pct(coverage) if isinstance(coverage, int | float) else _DASH rows.append( [ ov.get("type_zone") or ov.get("name"), ov.get("reg_numb_border"), coverage_str, ] ) summary = _kv_table(summary_pairs) table = _data_table(["Тип зоны", "№ границы", "Покрытие участка"], rows) return summary + f"

Пересечения ЗОУИТ

{table}" def _build_gate_verdict(result: dict[str, Any]) -> str: """Плашка gate-вердикта «можно ли строить МКД» + блокеры / предупреждения.""" gate = _as_dict(result.get("gate_verdict")) if not gate: return "" can_build = gate.get("can_build_mkd") label = gate.get("verdict_label") or ("Можно" if can_build else "Нельзя") cls = "verdict-ok" if can_build else "verdict-no" verdict = ( f'
Строительство МКД: {_esc(label)}
' ) def _issues(items: Any, title: str) -> str: rows = [ [it.get("code"), it.get("detail")] for it in _as_list(items) if isinstance(it, dict) ] if not rows: return "" return f"

{html.escape(title)}

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

{html.escape(_TITLE_S1)}

{MAP_PARCEL_PLACEHOLDER}

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

{_build_parcel_facts(result)}

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

{_build_zoning(result)}

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

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

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

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

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

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

{html.escape(_TITLE_S2)}

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

{_build_transport(result)}

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

{_build_noise_air(result)}

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

{_build_geotech_hydro(result)}

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

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

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

" f"{_data_table(['Вид сети', 'Ближайшая, м', 'В радиусе 2 км'], summary_rows)}" if summary_rows else "" ) return head + summary_table def _build_engineering_nearby(result: dict[str, Any]) -> str: """Инженерные сооружения НСПД рядом (name / назначение / расстояние из raw_props).""" items = _as_list(result.get("nspd_engineering_nearby")) rows: list[list[Any]] = [] for it in items: if not isinstance(it, dict): continue raw = _as_dict(it.get("raw_props")) name = it.get("name") or raw.get("params_name") or raw.get("cad_number") purpose = it.get("type") or raw.get("params_purpose") rows.append([name, purpose, _fmt_int_ru(it.get("distance_m"))]) if not rows: return "" return ( f"

Инженерные сооружения рядом (НСПД)

" f"{_data_table(['Сооружение', 'Назначение', 'Расстояние, м'], rows)}" ) def _build_alternatives(result: dict[str, Any]) -> str: """Блок альтернатив программы (`program_alternatives`). Присутствует ТОЛЬКО при отрицательном базовом фин-вердикте (program_optimizer.py); иначе поле null → возвращаем пустую строку (блок не рисуется вовсе). Топ-N карточек по NPV + честный caveat. Оценка по МАКС. застройке зоны, НЕ инвест-рекомендация. """ alt = _as_dict(result.get("program_alternatives")) variants = _as_list(alt.get("variants")) if not alt or not variants: return "" any_viable = bool(alt.get("any_viable")) grid_size = alt.get("grid_size") subtitle = ( "Базовая оценка отрицательна, но участок сходится при другой программе — " "топ вариантов по NPV:" if any_viable else ( f"Ни один из {_fmt(grid_size)} вариантов программы (класс × этажность) не " "выходит в плюс. Ниже — наименее убыточные:" ) ) cards: list[str] = [] for v in variants: if not isinstance(v, dict): continue npv = v.get("npv_rub") npv_cls = "pos" if isinstance(npv, int | float) and npv > 0 else "neg" title = ( f"{_housing_class_ru(v.get('housing_class'))} · " f"{_fmt(v.get('floors'))} эт · {_development_type_ru(v.get('development_type'))}" ) cards.append( f"""
{html.escape(title)}
NPV {html.escape(_fmt_money_signed(npv))}
ROI {html.escape(_fmt_pct(v.get("roi")))} · """ f"""IRR {html.escape(_fmt_pct(v.get("irr")))}
Жилая {html.escape(_fmt_int_ru(v.get("residential_sqm")))} м² · """ f"""{html.escape(_fmt_int_ru(v.get("apartments_count")))} квартир
Цена {html.escape(_fmt_int_ru(v.get("price_per_sqm_used")))} ₽/м² """ f"""({html.escape(_price_source_ru(v.get("price_source")))})
""" ) caveat = alt.get("caveat") caveat_html = f'
{_esc(caveat)}
' if caveat else "" return f"""

{html.escape(_TITLE_ALT)}

{html.escape(subtitle)}

{"".join(cards)} {caveat_html}
""" def _build_section_3(result: dict[str, Any]) -> str: """§3 «Инженерные сети»: OSM-сети рядом + НСПД-сооружения + альтернативы программы. NB: «Ресурсные резервы» (ЦП Россетей / Водоканал) и `nearby_network_zones` в persist-payload ОТСУТСТВУЮТ (ленивый connection-capacity-эндпоинт) — их подтянет PR-D. Здесь только то, что реально есть в `analyze_result`. """ return f"""

{html.escape(_TITLE_S3)}

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

{_build_utilities(result)} {_build_engineering_nearby(result)}
{_build_alternatives(result)} """ # ────────────────────────────────────────────────────────────────────────────── # Публичный API PR-A: сборка Part A (§1–§3) + общий каркас документа. # ────────────────────────────────────────────────────────────────────────────── def build_full_report_html_part_a(analyze_result: dict[str, Any], *, cad: str) -> str: """Собрать HTML Part A полного отчёта: §1 «Участок» + §2 «Окружение» + §3 «Сети». PURE (без WeasyPrint / БД / сети). Читает ПЕРСИСТНУТЫЙ `analysis_runs.result` (`analyze-1.0`, loose dict). Каждый ключ — через `.get()` с дефолтом; отсутствующая секция → «нет данных»; ВСЕ строки payload проходят `html.escape`. Карта участка — плейсхолдер `{{MAP_PARCEL}}` (PR-C). Блок альтернатив (`program_alternatives`) рисуется только при его наличии в payload. Args: analyze_result: `analysis_runs.result` (schema `analyze-1.0`) или любой dict. Не-dict / None → трактуется как пустой payload (все секции → «нет данных»). cad: кадастровый номер участка (для логов; в HTML не встраивается напрямую — он приходит в каркас через `build_full_report_html`). Returns: HTML-фрагмент Part A (три `
` + опциональный блок альтернатив), готовый как `part_a_html` для `build_full_report_html`. """ result = _as_dict(analyze_result) part_a = _build_section_1(result) + _build_section_2(result) + _build_section_3(result) logger.info( "build_full_report_html_part_a: cad=%s keys=%d has_alternatives=%s", cad, len(result), bool(_as_dict(result.get("program_alternatives")).get("variants")), ) return part_a def _build_toc(has_part_b: bool) -> str: """Оглавление-якоря. Part B опционален (PR-B) → его пункт скрываем, если None. PURE.""" items = [ f'
  • {html.escape(_TITLE_S1)}
  • ', f'
  • {html.escape(_TITLE_S2)}
  • ', f'
  • {html.escape(_TITLE_S3)}
  • ', ] if has_part_b: items.append('
  • §4–§7. Рынок, продукт, концепция
  • ') return f'' def build_full_report_html( part_a_html: str, part_b_html: str | None, *, cad: str, address: str | None, generated_at: str, ) -> str: """Собрать единый печатный HTML-документ полного отчёта (каркас + Part A [+ Part B]). Общий `

    {html.escape(_TITLE_DOC)}

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