"""DOCX-вариант полного отчёта ПТИЦА — зеркало PDF-конвейера (эпик #2259 PR-F). Второй формат выгрузки полного отчёта `/site-finder/analysis/{cad}` (рядом с PDF- конвейером `full_report_pdf`): рендерит те же §1–§7 + альтернативы в редактируемый Word-документ (.docx) и возвращает БАЙТЫ, готовые для FileResponse / записи файла. DRY / SINGLE SOURCE: НЕ парсим HTML и НЕ дублируем нормализацию — переиспользуем ИСХОДНЫЕ словари (analyze / forecast / concept / connection-capacity) и PURE-хелперы форматирования из `full_report_html` (`_fmt`/`_fmt_int_ru`/`_fmt_money_signed`/…). Все эти хелперы — приватные (`_`-префикс), но пакет внутренний, импорт по имени легитимен. Отличие от HTML-ветки: здесь значения кладём В ЯЧЕЙКИ python-docx как есть (`_fmt`), БЕЗ `html.escape` (docx — не HTML, XML-экранирование делает сам lxml внутри python-docx). СТРУКТУРА (зеркало PDF, `build_full_report_pdf` → `build_full_report_html`): титул (cad / адрес / дата) → §1 «Участок» (карта + факты + регламент + ЗОУИТ + gate) → §2 «Окружение» → §3 «Сети» (+ connection-capacity) → альтернативы (если есть) → §4 «Рынок» → §5 «Финмодель» → §6 «Риски» → §7 «Концепция» (карта + варианты). КАРТЫ: PNG-байты участка (§1) и footprint-плана концепции (§7) прокидываются аргументами (те же рендеры `report_maps`, что и у PDF — НЕ рендерим дважды). None → плашка-абзац «карта недоступна» (graceful, как `embed_map_png` в PDF-ветке). ДЕГРАДАЦИИ (те же, что у PDF/HTML): нет концепции → §7 честная плашка + §5 только рыночный контекст; нет forecast → §4–§6 «нет данных»; нет connection-capacity → §3 без резервов; пустая секция → «нет данных». НИКОГДА не падает на частичном payload. `python-docx` (PyPI `python-docx`, import `docx`) — чистый Python (lxml), без нативных системных библиотек: Dockerfile не трогаем, тесты без skip. Импортируется ЛОКАЛЬНО внутри `build_full_report_docx` (зеркало WeasyPrint / `report_docx.render_report_docx`). """ from __future__ import annotations import io import logging from typing import TYPE_CHECKING, Any # Единый источник форматтеров/нормализации — HTML-билдеры полного отчёта. Импортируем # приватные PURE-хелперы по имени (внутренний пакет): числа/деньги/проценты/RU-метки # причёсываются ТАК ЖЕ, как в PDF-ветке → цифры в DOCX и PDF совпадают байт-в-байт. # `_fc_*`-хелперы (нормализация forecast-словаря) — реэкспорт из report_pdf через # full_report_html, тянем оттуда же (одна точка импорта). from app.services.exporters.full_report_html import ( _as_dict, _as_list, _development_type_ru, _fc_as_dict, _fc_future_supply_pairs, _fc_level_ru, _fc_normalize, _fmt, _fmt_int_ru, _fmt_money, _fmt_money_signed, _fmt_pct, _fmt_pct_raw, _housing_class_ru, _price_source_fin_ru, _price_source_ru, _reserve_num, _strategy_ru, _truncate_name, _variant_strategy, ) from app.services.exporters.full_report_html import ( _best_variant as _best_concept_variant, ) from app.services.exporters.full_report_html import ( _capacity_rows_capped as _cap_rows, ) from app.services.exporters.full_report_html import ( _competitor_lots as _competitor_lots, ) from app.services.exporters.full_report_html import ( _is_non_ekb as _is_non_ekb, ) if TYPE_CHECKING: # Только для аннотаций — тяжёлый python-docx импортируем ЛОКАЛЬНО в # build_full_report_docx (как WeasyPrint в report_pdf / Document в report_docx). from docx.document import Document as _DocxDocument logger = logging.getLogger(__name__) # ── Микрокопия / заглушки (зеркало full_report_html) ──────────────────────────── _DASH = "—" _NO_DATA = "нет данных" # Кап видимых строк тепло/вода-таблиц (зеркало _HEAT_ROW_CAP / _WATER_ROW_CAP в HTML). _HEAT_ROW_CAP = 15 _WATER_ROW_CAP = 25 # Титул документа + заголовки секций (зеркало _TITLE_* в full_report_html). _TITLE_DOC = "Отчёт по участку — Site Finder ПТИЦА" _TITLE_S1 = "§1. Участок" _TITLE_S2 = "§2. Окружение" _TITLE_S3 = "§3. Инженерные сети" _TITLE_ALT = "Как участок сходится (альтернативы программы)" _TITLE_S4 = "§4. Рынок" _TITLE_S5 = "§5. Финансовая модель" _TITLE_S6 = "§6. Риски и дефицит" _TITLE_S7 = "§7. Концепция застройки" # Встроенный стиль таблиц python-docx (рисует видимые границы; есть в любом дефолтном # Document, не требует шаблона). Зеркало _TABLE_STYLE в report_docx. _TABLE_STYLE = "Table Grid" # Ширина карт-картинок (дюймы) — под ширину печатной A4-колонки при полях документа. # python-docx масштабирует пропорционально при задании только ширины. _MAP_WIDTH_INCHES = 6.3 # Плашка недоступной карты (зеркало embed_map_png-плейсхолдера PDF-ветки). _MAP_UNAVAILABLE = "Карта недоступна." # Дисклеймер-футер (зеркало .footer в build_full_report_html). _FOOTER = ( "GenDesign · Site Finder ПТИЦА · отчёт носит советующий характер и не является " "основанием для инвестиционного решения." ) # ────────────────────────────────────────────────────────────────────────────── # docx-специфичные микро-билдеры. Все мутируют переданный `doc`/контейнер. Значения # причёсываются импортированными PURE-хелперами и кладутся как есть (без html.escape — # XML-экранирование делает python-docx/lxml). Пустая секция → «нет данных» (graceful). # ────────────────────────────────────────────────────────────────────────────── def _add_heading(doc: _DocxDocument, text: str, level: int) -> None: """Заголовок секции (python-docx Heading level). Тонкая обёртка для единообразия.""" doc.add_heading(text, level=level) def _add_no_data(doc: _DocxDocument) -> None: """Абзац-заглушка «нет данных» (graceful для пустой секции).""" doc.add_paragraph(_NO_DATA) def _add_kv_table(doc: _DocxDocument, pairs: list[tuple[str, Any]]) -> None: """Таблица «метка → значение» из списка пар. Пустой список → «нет данных». `label` — статичная RU-метка (bold-run), значение — через `_fmt` (None → «—»). Зеркало `_kv_table` в full_report_html (там HTML-таблица). Значения ПРЕД- форматированные (`_fmt_int_ru`/`_fmt_money_signed`/…) билдер оставляет строкой. """ if not pairs: _add_no_data(doc) return table = doc.add_table(rows=0, cols=2) table.style = _TABLE_STYLE for label, value in pairs: cells = table.add_row().cells # Метка — bold-run (первый абзац ячейки); значение — plain через _fmt. cells[0].text = "" cells[0].paragraphs[0].add_run(str(label)).bold = True cells[1].text = _fmt(value) def _add_data_table(doc: _DocxDocument, headers: list[str], rows: list[list[Any]]) -> None: """Таблица: шапка (bold RU-метки) + строки данных (через `_fmt`). Graceful. Пустой `rows` → одна строка-заглушка «нет данных» под шапкой (таблица всё равно валидна). Зеркало `_data_table` в full_report_html (там HTML ``). """ table = doc.add_table(rows=1, cols=len(headers)) table.style = _TABLE_STYLE header_cells = table.rows[0].cells for idx, header in enumerate(headers): header_cells[idx].text = "" header_cells[idx].paragraphs[0].add_run(str(header)).bold = True if not rows: empty_cells = table.add_row().cells empty_cells[0].text = _NO_DATA return for row in rows: cells = table.add_row().cells for idx in range(len(headers)): # Строка короче шапки (defensive) → недостающее «—». value = row[idx] if idx < len(row) else None cells[idx].text = _fmt(value) def _add_caveat(doc: _DocxDocument, text: str) -> None: """Честная заметка/оговорка курсивом (зеркало `.caveat` HTML-ветки).""" para = doc.add_paragraph() para.add_run(str(text)).italic = True def _add_map(doc: _DocxDocument, png: bytes | None) -> None: """Вставить карту-PNG (`add_picture` из BytesIO) или плашку «карта недоступна». None / пустые байты / сбой декодирования → абзац-плашка (graceful, как embed_map_png в PDF-ветке ставит SVG-плашку). Ширина фиксирована (`_MAP_WIDTH_INCHES`) — высота масштабируется пропорционально самим python-docx. """ if not png: _add_caveat(doc, _MAP_UNAVAILABLE) return try: from docx.shared import Inches doc.add_picture(io.BytesIO(png), width=Inches(_MAP_WIDTH_INCHES)) except Exception: # Битый PNG / несовместимый формат — не роняем отчёт, ставим плашку. logger.exception("full_report_docx: вставка карты упала → плашка «недоступна»") _add_caveat(doc, _MAP_UNAVAILABLE) # ────────────────────────────────────────────────────────────────────────────── # §1 «Участок» — зеркало _build_section_1 (HTML). Источник: analyze-payload. # ────────────────────────────────────────────────────────────────────────────── def _build_parcel_facts(doc: _DocxDocument, result: dict[str, Any]) -> None: """Кадастровые факты: адрес / площадь / категория / ВРИ / статус / стоимость.""" egrn = _as_dict(result.get("egrn")) parcel_meta = _as_dict(result.get("parcel_meta")) geometry = _as_dict(result.get("geometry_suitability")) 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]] = [ ("Адрес", egrn.get("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, "")] _add_kv_table(doc, pairs) def _build_zoning(doc: _DocxDocument, result: dict[str, Any]) -> None: """Градрегламент ПЗЗ: зона + ТЭП (FAR / этажность / высота) + список ВРИ зоны.""" nspd_zoning = _as_dict(result.get("nspd_zoning")) if not nspd_zoning: zoning = _as_dict(result.get("zoning")) if not zoning or not zoning.get("zone_code"): _add_no_data(doc) note = zoning.get("note") if note: doc.add_paragraph(_fmt(note)) return nspd_zoning = zoning 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, "")] _add_kv_table(doc, pairs) vri_rows = [[v] for v in _as_list(nspd_zoning.get("main_vri")) if v not in (None, "")] if vri_rows: _add_heading(doc, "Разрешённые ВРИ зоны", level=3) _add_data_table(doc, ["Вид разрешённого использования"], vri_rows) def _build_zouit(doc: _DocxDocument, result: dict[str, Any]) -> None: """ЗОУИТ-ограничения: сводка + список пересечений (тип / № границы / покрытие).""" 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")) # То же разрешение противоречия «нет ЗОУИТ / но НСПД-пересечения есть», что в HTML. 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))) _add_kv_table(doc, summary_pairs) rows: list[list[Any]] = [] for ov in overlaps: 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] ) _add_heading(doc, "Пересечения ЗОУИТ", level=3) _add_data_table(doc, ["Тип зоны", "№ границы", "Покрытие участка"], rows) def _build_gate_verdict(doc: _DocxDocument, result: dict[str, Any]) -> None: """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 "Нельзя") para = doc.add_paragraph() para.add_run("Строительство МКД: ").bold = True para.add_run(_fmt(label)).bold = True def _issues(items: Any, title: str) -> None: rows = [ [it.get("code"), it.get("detail")] for it in _as_list(items) if isinstance(it, dict) ] if not rows: return _add_heading(doc, title, level=3) _add_data_table(doc, ["Код", "Детали"], rows) _issues(gate.get("blockers"), "Блокеры") _issues(gate.get("warnings"), "Предупреждения") def _build_section_1( doc: _DocxDocument, result: dict[str, Any], parcel_map_png: bytes | None ) -> None: """§1 «Участок»: карта + кадастровые факты + регламент + ЗОУИТ + gate-вердикт.""" _add_heading(doc, _TITLE_S1, level=1) _add_map(doc, parcel_map_png) _add_heading(doc, "Кадастровые факты", level=2) _build_parcel_facts(doc, result) _add_heading(doc, "Градостроительный регламент", level=2) _build_zoning(doc, result) _add_heading(doc, "Ограничения (ЗОУИТ)", level=2) _build_zouit(doc, result) _build_gate_verdict(doc, result) # ────────────────────────────────────────────────────────────────────────────── # §2 «Окружение» — зеркало _build_section_2 (HTML). # ────────────────────────────────────────────────────────────────────────────── def _build_transport(doc: _DocxDocument, result: dict[str, Any]) -> None: """Транспорт: удалённость от центра ЕКБ + ближайшие станции метро.""" 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)) _add_kv_table(doc, pairs) 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) ] if metro_rows: _add_heading(doc, "Ближайшее метро", level=3) _add_data_table(doc, ["Станция", "Расстояние, м"], metro_rows) def _build_noise_air(doc: _DocxDocument, result: dict[str, Any]) -> None: """Шум + качество воздуха 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, "")] _add_kv_table(doc, pairs) def _build_geotech_hydro(doc: _DocxDocument, result: dict[str, Any]) -> None: """Геотехника (сейсмика / промышленность) + гидрология (пойма / водные объекты).""" 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, "")] _add_kv_table(doc, 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) ] if water_rows: _add_heading(doc, "Ближайшие водные объекты", level=3) _add_data_table(doc, ["Объект", "Расстояние, м"], water_rows) def _build_neighbors(doc: _DocxDocument, result: dict[str, Any]) -> None: """Соседняя застройка (cad_buildings в радиусе): назначение / этажность / расстояние.""" summary = _as_dict(result.get("neighbors_summary")) rows: list[list[Any]] = [] for nb in _as_list(summary.get("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")), ] ) _add_data_table(doc, ["Объект", "Назначение", "Этажей", "Расстояние, м"], rows) def _build_section_2(doc: _DocxDocument, result: dict[str, Any]) -> None: """§2 «Окружение»: транспорт + шум/воздух + геотехника/гидрология + соседи.""" _add_heading(doc, _TITLE_S2, level=1) _add_heading(doc, "Транспортная доступность", level=2) _build_transport(doc, result) _add_heading(doc, "Шум и качество воздуха", level=2) _build_noise_air(doc, result) _add_heading(doc, "Геотехника и гидрология", level=2) _build_geotech_hydro(doc, result) _add_heading(doc, "Соседняя застройка", level=2) _build_neighbors(doc, result) # ────────────────────────────────────────────────────────────────────────────── # §3 «Инженерные сети» + connection-capacity + альтернативы. # ────────────────────────────────────────────────────────────────────────────── def _build_utilities(doc: _DocxDocument, result: dict[str, Any]) -> None: """Инженерные сети рядом (OSM `utilities`): ближайшие сети + сводка по видам.""" utilities = _as_dict(result.get("utilities")) if not utilities: _add_no_data(doc) return 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)] _add_kv_table(doc, 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) ] if summary_rows: _add_heading(doc, "Сети рядом с участком (OSM)", level=3) _add_data_table(doc, ["Вид сети", "Ближайшая, м", "В радиусе 2 км"], summary_rows) def _build_engineering_nearby(doc: _DocxDocument, result: dict[str, Any]) -> None: """Инженерные сооружения НСПД рядом (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") if name in (None, "") and purpose in (None, ""): continue rows.append([name, purpose, _fmt_int_ru(it.get("distance_m"))]) if not any_item: return _add_heading(doc, "Инженерные сооружения рядом (НСПД)", level=3) _add_data_table(doc, ["Сооружение", "Назначение", "Расстояние, м"], rows) def _build_connection_capacity(doc: _DocxDocument, cap: dict[str, Any] | None) -> None: """Ресурсные резервы (ЦП/вода/газ/тепло) + сети рядом. Зеркало HTML-версии. Переиспользует общий `_capacity_rows_capped` (фильтр областного шума + кап строк) из HTML-модуля — та же логика честности (дефицит всегда виден, не-ЕКБ схлопывается). None / пустой → блок не рисуется (§3 деградирует к OSM-payload). """ cap = _as_dict(cap) if not cap: return # Электрика: сводка + ближайший ЦП с резервом. 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: _add_heading(doc, "Электроснабжение — свободная мощность", level=3) _add_kv_table(doc, pairs) # Вода: резервы ЦСВ/ЦСК (ЕКБ-системы + кап ~25). water_rows, water_hidden = _cap_rows( _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: _add_heading(doc, "Водоснабжение/водоотведение — резервы", level=3) _add_data_table(doc, ["Система", "Резерв, тыс. м³/сут", "Период"], 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: _add_heading(doc, "Газоснабжение — свободная мощность ГРС", level=3) _add_data_table(doc, ["ГРС", "Свободно, тыс. м³/ч", "Свободно, %"], gas_rows) # Тепло: агрегат ЕКБ + топ-15 + кап (зеркало HTML). 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 = _cap_rows( 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, ] ) _add_heading(doc, "Теплоснабжение — резервы систем", level=3) _add_data_table(doc, ["Организация", "Система", "Резерв, Гкал/ч", "Период"], 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: _add_heading(doc, "Сети рядом (охранные зоны)", level=3) _add_data_table(doc, ["Сеть", "Расстояние, м"], zone_rows) def _build_section_3( doc: _DocxDocument, result: dict[str, Any], connection_capacity: dict[str, Any] | None ) -> None: """§3 «Инженерные сети»: OSM-сети + НСПД-сооружения + ресурсные резервы.""" _add_heading(doc, _TITLE_S3, level=1) _add_heading(doc, "Инженерные сети рядом", level=2) _build_utilities(doc, result) _build_engineering_nearby(doc, result) _build_connection_capacity(doc, connection_capacity) def _build_alternatives(doc: _DocxDocument, result: dict[str, Any]) -> None: """Блок альтернатив программы (`program_alternatives`) — топ-N по NPV + caveat. Присутствует ТОЛЬКО при отрицательном базовом фин-вердикте; иначе поле null → блок не рисуется вовсе (зеркало HTML). PDF рисует карточки — здесь эквивалентная таблица. """ 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)} вариантов программы (класс × этажность) не " "выходит в плюс. Ниже — наименее убыточные:" ) ) _add_heading(doc, _TITLE_ALT, level=1) doc.add_paragraph(subtitle) rows: list[list[Any]] = [] for v in variants: if not isinstance(v, dict): continue program = ( f"{_housing_class_ru(v.get('housing_class'))} · " f"{_fmt(v.get('floors'))} эт · {_development_type_ru(v.get('development_type'))}" ) price = ( f"{_fmt_int_ru(v.get('price_per_sqm_used'))} ₽/м² " f"({_price_source_ru(v.get('price_source'))})" ) rows.append( [ program, _fmt_money_signed(v.get("npv_rub")), _fmt_pct(v.get("roi")), _fmt_pct(v.get("irr")), _fmt_int_ru(v.get("residential_sqm")), _fmt_int_ru(v.get("apartments_count")), price, ] ) _add_data_table( doc, ["Программа", "NPV", "ROI", "IRR", "Жилая, м²", "Квартир", "Цена"], rows, ) caveat = alt.get("caveat") if caveat: _add_caveat(doc, caveat) # ────────────────────────────────────────────────────────────────────────────── # §4–§7 (Part B): forecast-ран (нормализован _fc_normalize) + концепция. # ────────────────────────────────────────────────────────────────────────────── def _build_market_metrics(doc: _DocxDocument, forecast: dict[str, Any]) -> None: """Метрики рынка сейчас: темп/абсорбция, ликвидность/затоварка, цена, покрытие.""" 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")), ("Ставка абсорбции (в мес.)", _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)] _add_kv_table(doc, pairs) def _build_market_competitors(doc: _DocxDocument, forecast: dict[str, Any]) -> None: """Конкуренты рынка сейчас: группировка по (ЖК, девелопер), лоты суммой, ближайшая дист. Тот же схлопывающий алгоритм, что в HTML `_build_market_competitors` (по строке НА КОРПУС в прод-payload — группируем в один ЖК с «(K корпусов)»). """ 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"]), ] ) _add_data_table(doc, ["ЖК", "Девелопер", "Класс", "Расстояние, м", "Лотов"], rows) def _build_market_coverage(doc: _DocxDocument, forecast: dict[str, Any]) -> None: """Покрытие данными: итоговая уверенность + факторы (сделки / история / ДОМ.РФ).""" confidence = _fc_as_dict(forecast.get("confidence")) factors = _fc_as_dict(confidence.get("factors")) 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)] _add_kv_table(doc, level_pairs) 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") parsed.append((label, _fc_level_ru(data.get("level")), data.get("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] _add_data_table(doc, ["Фактор", "Уровень", "Комментарий"], rows) else: rows = [[label, level] for label, level, _note in parsed] _add_data_table(doc, ["Фактор", "Уровень"], rows) def _build_section_4(doc: _DocxDocument, forecast: dict[str, Any]) -> None: """§4 «Рынок»: резюме + метрики + конкуренты + покрытие данными.""" _add_heading(doc, _TITLE_S4, level=1) market_now = _fc_as_dict(forecast.get("market_now")) summary = market_now.get("summary") if summary: doc.add_paragraph(_fmt(summary)) _add_heading(doc, "Метрики рынка (темп, ликвидность, цена)", level=2) _build_market_metrics(doc, forecast) _add_heading(doc, "Конкуренты рядом", level=2) _build_market_competitors(doc, forecast) _add_heading(doc, "Покрытие данными и уверенность", level=2) _build_market_coverage(doc, forecast) def _build_financial_headline(doc: _DocxDocument, financial: dict[str, Any]) -> None: """Ключевые числа финмодели: DCF (NPV / IRR / окупаемость) + ROI / прибыль + caveats.""" 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)] _add_kv_table(doc, pairs) if financial.get("irr_is_proxy"): _add_caveat( doc, "IRR помечен как оценочный: денежный поток вырожденный (нет смены знака), " "показан аннуализированный ROI вместо DCF-IRR.", ) if financial.get("schedule_is_default"): _add_caveat( doc, "График фаз и темп продаж — типовые нормативные допущения (ПИР → СМР → " "распродажа); точность метрик зависит от графика конкретного проекта.", ) def _build_financial_cascade(doc: _DocxDocument, financial: dict[str, Any]) -> None: """Каскад затрат и БДР: выручка по статьям → затраты → НДС/налог → чистая прибыль.""" 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"))], ] ) _add_data_table(doc, ["Статья", "Значение"], rows) def _build_market_affordability(doc: _DocxDocument, forecast: dict[str, Any]) -> bool: """Рыночный контекст цены §5 (cost_of_error-индекс). Возвращает: нарисовано ли что-то.""" indices = _fc_as_dict( _fc_as_dict(_fc_as_dict(forecast.get("scoring")).get("special_indices")).get("indices") ) detail = _fc_as_dict(_fc_as_dict(indices.get("cost_of_error")).get("detail")) if not detail: return False 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 False _add_heading(doc, "Рыночный контекст цены", level=2) _add_kv_table(doc, pairs) return True def _build_section_5(doc: _DocxDocument, forecast: dict[str, Any], concept: dict[str, Any]) -> None: """§5 «Финмодель»: DCF/NPV/IRR/ROI + каскад затрат (из концепции) + рыночный контекст. Финмодель живёт в опорном варианте концепции (макс. NPV). Концепция не рассчитана → только рыночный контекст цены + честная заметка (зеркало HTML). Без падения. """ _add_heading(doc, _TITLE_S5, level=1) variant = _best_concept_variant(concept) financial = _as_dict(variant.get("financial")) if not financial: _add_caveat( doc, "Финансовая модель (DCF, NPV/IRR/ROI, каскад затрат) считается по концепции " "застройки — она ещё не рассчитана. Ниже — только рыночный контекст цены из " "прогноза.", ) if not _build_market_affordability(doc, forecast): _add_no_data(doc) return strategy_label = _strategy_ru(_variant_strategy(variant)) doc.add_paragraph(f"Опорный вариант концепции (макс. NPV): {strategy_label}.") _add_heading(doc, "Ключевые показатели (DCF)", level=2) _build_financial_headline(doc, financial) _add_heading(doc, "Каскад затрат и расчёт прибыли", level=2) _build_financial_cascade(doc, financial) _build_market_affordability(doc, forecast) def _build_deficit_by_horizon(doc: _DocxDocument, forecast: dict[str, Any]) -> None: """Прогноз дефицита/затоварки по горизонтам: спрос / предложение / индекс / months.""" future = _fc_as_dict(forecast.get("future_market")) rows: list[list[Any]] = [] for f in _as_list(future.get("forecasts_by_horizon")): 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 = ["Горизонт, мес", "Спрос", "Предложение", "Индекс дефицита", "Мес. запаса", "Увер."] _add_data_table(doc, headers, rows) def _build_supply_pressure(doc: _DocxDocument, forecast: dict[str, Any]) -> None: """Давление будущего предложения (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)] _add_kv_table(doc, kv) def _build_risk_indices(doc: _DocxDocument, forecast: dict[str, Any]) -> None: """Риск-индексы дефицита/ошибки: белые пятна, цена ошибки, окно запуска, каннибализация.""" 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")]) _add_data_table(doc, ["Индекс", "Метка", "Значение"], rows) def _build_scenarios_honesty(doc: _DocxDocument, forecast: dict[str, Any]) -> None: """Честный блок сценариев: разброс дефицита ИЛИ плашка-объяснение при коллапсе.""" 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 "Сценарная дифференциация недоступна на текущих данных." ) _add_caveat(doc, f"Сценарная дифференциация недоступна: {reason_text}") return 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()] _add_kv_table(doc, pairs) def _build_permits_nearby(doc: _DocxDocument, result: dict[str, Any]) -> None: """РНС/РВЭ в радиусе 500 м участка (ГИСОГД-66) — короткая сводка + список до 10. `result` — analyze-payload (НЕ forecast): читает `permits_nearby` (см. `permits_nearby.get_permits_nearby`). Пусто / total_count=0 → честная плашка-фраза (полное предложение, не аббревиатура). Зеркало `_build_permits_nearby` в full_report_html. """ nearby = _as_dict(result.get("permits_nearby")) total = nearby.get("total_count") if not isinstance(total, int) or total <= 0: _add_caveat( doc, "В радиусе 500 м участка новых разрешений на строительство не найдено " "(по данным ГИСОГД Свердловской области).", ) return rs_count = nearby.get("rs_count") or 0 rv_count = nearby.get("rv_count") or 0 nearest = nearby.get("nearest_distance_m") kv: list[tuple[str, Any]] = [ ("Разрешений на строительство (РНС)", _fmt_int_ru(rs_count)), ("Разрешений на ввод (РВЭ)", _fmt_int_ru(rv_count)), ("Ближайшее, м", _fmt_int_ru(nearest) if nearest is not None else _DASH), ] _add_kv_table(doc, kv) rows: list[list[Any]] = [] for item in _as_list(nearby.get("items"))[:10]: data = _as_dict(item) rows.append( [ data.get("doc_name"), data.get("date_doc"), data.get("approved_organization"), data.get("distance_m"), ] ) headers = ["Документ", "Дата", "Согласующий орган", "Дистанция, м"] _add_data_table(doc, headers, rows) def _build_section_6(doc: _DocxDocument, forecast: dict[str, Any], result: dict[str, Any]) -> None: """§6 «Риски и дефицит»: дефицит по горизонтам + давление предложения + риск-индексы. `result` — analyze-payload (для блока разрешений рядом, `permits_nearby`); `forecast` — форсайт-ран (дефицит/сценарии). Разные источники — §6 читает оба (зеркало HTML-версии). """ _add_heading(doc, _TITLE_S6, level=1) future = _fc_as_dict(forecast.get("future_market")) summary = future.get("summary") if summary: doc.add_paragraph(_fmt(summary)) _add_heading(doc, "Дефицит / затоварка по горизонтам", level=2) _build_deficit_by_horizon(doc, forecast) _add_heading(doc, "Давление будущего предложения", level=2) _build_supply_pressure(doc, forecast) _add_heading(doc, "Риск-индексы", level=2) _build_risk_indices(doc, forecast) _add_heading(doc, "Сценарии", level=2) _build_scenarios_honesty(doc, forecast) _add_heading(doc, "Разрешения на строительство рядом (500 м)", level=2) _build_permits_nearby(doc, result) def _build_concept_program(doc: _DocxDocument, variant: dict[str, Any]) -> None: """Программа застройки варианта из размещённых фич (тип дома × этажность → секций).""" 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] _add_heading(doc, "Программа застройки", level=3) _add_data_table(doc, ["Тип дома", "Этажность", "Секций"], rows) def _build_concept_variant(doc: _DocxDocument, variant: dict[str, Any]) -> None: """Карточка одного варианта концепции: ТЭП + финитог + программа.""" 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) _add_heading(doc, strategy_label, level=2) 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 ): _add_caveat( doc, f"Размещено {placed} из {requested} секций — участок вмещает меньше, чем в " "заданной программе; ТЭП и финмодель — по фактически размещённым корпусам.", ) 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)] _add_heading(doc, "ТЭП", level=3) _add_kv_table(doc, teap_pairs) 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)] _add_heading(doc, "Финансовый итог", level=3) _add_kv_table(doc, fin_pairs) _build_concept_program(doc, variant) def _build_section_7( doc: _DocxDocument, concept: dict[str, Any] | None, concept_map_png: bytes | None ) -> None: """§7 «Концепция»: footprint-план (карта) + ТЭП/финитог/программа вариантов. `concept` = сериализованный `ConceptOutput` или None. None / пустые варианты → честная плашка «концепция не рассчитана» (зеркало HTML, без падения). """ _add_heading(doc, _TITLE_S7, level=1) concept_dict = _as_dict(concept) variants = [v for v in _as_list(concept_dict.get("variants")) if isinstance(v, dict)] if not variants: _add_caveat( doc, "Концепция застройки не рассчитана — раздел появится после генерации вариантов " "концепции для участка.", ) return _add_map(doc, concept_map_png) for v in variants: _build_concept_variant(doc, v) # ────────────────────────────────────────────────────────────────────────────── # Публичный API PR-F. # ────────────────────────────────────────────────────────────────────────────── def build_full_report_docx( analyze_result: dict[str, Any], forecast_result: dict[str, Any] | None, concept_result: dict[str, Any] | None, connection_capacity: dict[str, Any] | None, *, cad: str, address: str | None, generated_at: str, parcel_map_png: bytes | None, concept_map_png: bytes | None, ) -> bytes: """Собрать DOCX-вариант полного отчёта ПТИЦА (зеркало PDF) → байты. #2259 PR-F. Структура зеркалит PDF-конвейер (`build_full_report_pdf` → `build_full_report_html`): титул → §1–§3 (+ connection-capacity в §3, + альтернативы) → §4–§7. Источники — те же ИСХОДНЫЕ словари, что кормят HTML-билдеры (НЕ парсим HTML). Карты — PNG-байты тех же рендеров `report_maps` (прокинуты аргументами, НЕ рендерим дважды). GRACEFUL: forecast=None → §4–§6 «нет данных»; concept=None → §7 плашка + §5 только рыночный контекст; connection_capacity=None → §3 без резервов; карта None → плашка «недоступна». НИКОГДА не падает на частичном payload. Args: analyze_result: `analysis_runs.result` (schema `analyze-1.0`) — §1–§3. forecast_result: forecast-ран (schema §22-форсайта "1.0") или None — §4–§6. concept_result: сериализованный `ConceptOutput` или None — §5/§7. connection_capacity: результат `get_connection_capacity` или None — §3-резервы. cad: кадастровый номер участка (титул). address: человекочитаемый адрес или None (титул). generated_at: строка даты формирования (уже отформатирована вызывающим). parcel_map_png: PNG-байты карты участка (§1) или None (плашка). concept_map_png: PNG-байты footprint-плана концепции (§7) или None (плашка). Returns: Непустые DOCX-байты (OOXML zip, начинаются с `b"PK"`), готовые для FileResponse / записи файла. """ # python-docx импортируем локально — тяжёлый (lxml); не нужен при импорте модуля # (зеркало WeasyPrint в full_report_pdf / Document в report_docx). try: from docx import Document except ImportError as exc: raise RuntimeError( "python-docx не установлен. Добавь 'python-docx>=1.1.0' в pyproject.toml." ) from exc result = _as_dict(analyze_result) forecast = _fc_normalize(forecast_result) concept = _as_dict(concept_result) doc = Document() # ── Титул (зеркало cover в build_full_report_html) ────────────────────────── doc.add_heading(_TITLE_DOC, level=0) doc.add_paragraph(f"Кадастровый номер: {cad}") if address: doc.add_paragraph(f"Адрес: {address}") doc.add_paragraph(f"Дата формирования: {generated_at}") # ── §1–§3 (Part A) ────────────────────────────────────────────────────────── _build_section_1(doc, result, parcel_map_png) _build_section_2(doc, result) _build_section_3(doc, result, connection_capacity) _build_alternatives(doc, result) # ── §4–§7 (Part B) ────────────────────────────────────────────────────────── _build_section_4(doc, forecast) _build_section_5(doc, forecast, concept) _build_section_6(doc, forecast, result) _build_section_7(doc, concept_result, concept_map_png) # ── Футер-дисклеймер ──────────────────────────────────────────────────────── _add_caveat(doc, _FOOTER) buffer = io.BytesIO() doc.save(buffer) docx_bytes = buffer.getvalue() logger.info( "build_full_report_docx: cad=%s size=%d bytes forecast_keys=%d concept_variants=%d " "parcel_map=%s concept_map=%s", cad, len(docx_bytes), len(forecast), len(_as_list(concept.get("variants"))), bool(parcel_map_png), bool(concept_map_png), ) return docx_bytes