From c129515ab8dd4f2b6a219c6290793b1d718e2dca Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 20:21:19 +0000 Subject: [PATCH] =?UTF-8?q?feat(exporters):=20DOCX-=D0=B2=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D0=B0=D0=BD=D1=82=20=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=B0=20=D0=9F=D0=A2?= =?UTF-8?q?=D0=98=D0=A6=D0=90=20(#2259=20R2)=20(#2301)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/parcels.py | 46 +- .../services/exporters/full_report_docx.py | 1215 +++++++++++++++++ .../app/services/exporters/full_report_pdf.py | 63 +- backend/tests/api/v1/test_parcel_report.py | 149 ++ .../exporters/test_full_report_docx.py | 404 ++++++ frontend/src/lib/api-types.ts | 16 +- 6 files changed, 1866 insertions(+), 27 deletions(-) create mode 100644 backend/app/services/exporters/full_report_docx.py create mode 100644 backend/tests/services/exporters/test_full_report_docx.py diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 18eb1f27..1d8fedf5 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -1695,31 +1695,61 @@ def parcel_report_status( ) +# Медиа-типы + расширения по формату скачивания полного отчёта (PR-D pdf + PR-F docx). +_REPORT_DOCX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + + @router.get( "/{cad_num}/report/download", - summary="Скачать готовый полный PDF-отчёт участка (#2259 PR-D)", + summary="Скачать готовый полный отчёт участка — PDF или DOCX (#2259 PR-D/PR-F)", ) def download_parcel_report( cad_num: str, db: Annotated[Session, Depends(get_db)], + format: Annotated[ + Literal["pdf", "docx"], + Query(description="Формат файла: pdf (default) | docx (Word-документ, #2259 PR-F)"), + ] = "pdf", ) -> FileResponse: - """Отдать готовый PDF-файл полного отчёта (application/pdf). + """Отдать готовый файл полного отчёта в выбранном формате (PDF / DOCX). Готовый отчёт (метадата-ран `report-pdf-1.0` + файл на диске) → FileResponse с - Content-Disposition attachment (`gendesign_report__.pdf`). Отчёт не готов / + Content-Disposition attachment (`gendesign_report__.`). Отчёт не готов / файл не найден → 404 (сначала POST /report, дождаться status=ready). + `format=docx`: старые раны (собранные до PR-F) не несут `docx_path` в метадате → + честный 404 с подсказкой «пересоберите отчёт» (POST /report пере-соберёт с DOCX, т.к. + ключ кэша не изменился, но файла docx нет — пере-рендер запишет оба). + Args: cad_num: кадастровый номер участка. + format: "pdf" (default) | "docx". """ cached = _cached_report_result(db, cad_num) if cached is None: raise HTTPException( status_code=404, detail="отчёт ещё не готов — запустите POST /report и дождитесь ready" ) - pdf_path = cached["pdf_path"] + + if format == "docx": + file_path = cached.get("docx_path") + media_type = _REPORT_DOCX_MEDIA_TYPE + ext = "docx" + # Старый ран без docx_path (собран до PR-F) → пере-соберите POST /report. + if not isinstance(file_path, str) or not Path(file_path).exists(): + raise HTTPException( + status_code=404, + detail=( + "DOCX-версия недоступна для этого отчёта — пересоберите отчёт (POST /report)" + ), + ) + else: + file_path = cached["pdf_path"] + media_type = "application/pdf" + ext = "pdf" + cad_safe = cad_num.replace(":", "_") - # Дата в имени файла — из МЕТАДАТЫ отчёта (когда PDF реально собран), НЕ today: на + # Дата в имени файла — из МЕТАДАТЫ отчёта (когда файл реально собран), НЕ today: на # cache-hit со вчерашнего отчёта today соврал бы. generated_at — ISO-строка из # build_full_report; битую/отсутствующую парсим best-effort → fallback на today. date_str = _dt.date.today().strftime("%Y-%m-%d") @@ -1729,10 +1759,10 @@ def download_parcel_report( date_str = _dt.datetime.fromisoformat(generated_at).strftime("%Y-%m-%d") except ValueError: logger.warning("report download: битый generated_at %r для %s", generated_at, cad_num) - filename = f"gendesign_report_{cad_safe}_{date_str}.pdf" + filename = f"gendesign_report_{cad_safe}_{date_str}.{ext}" return FileResponse( - pdf_path, - media_type="application/pdf", + file_path, + media_type=media_type, filename=filename, ) diff --git a/backend/app/services/exporters/full_report_docx.py b/backend/app/services/exporters/full_report_docx.py new file mode 100644 index 00000000..3106a658 --- /dev/null +++ b/backend/app/services/exporters/full_report_docx.py @@ -0,0 +1,1215 @@ +"""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_section_6(doc: _DocxDocument, forecast: dict[str, Any]) -> None: + """§6 «Риски и дефицит»: дефицит по горизонтам + давление предложения + риск-индексы.""" + _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) + + +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) + _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 diff --git a/backend/app/services/exporters/full_report_pdf.py b/backend/app/services/exporters/full_report_pdf.py index 4742b9b0..c2491202 100644 --- a/backend/app/services/exporters/full_report_pdf.py +++ b/backend/app/services/exporters/full_report_pdf.py @@ -17,8 +17,11 @@ volume `/app/reports/` с метадата-строкой в `analysis_runs` (sc → отчёт без §7-концепции (§5 деградирует в рыночный контекст). 6. HTML (PR-A/B) + карты (PR-C: `render_parcel_map_png` / `render_concept_footprint_png` → `embed_map_png`, PNG max_px=1400) → PDF (:func:`render_full_report_pdf`). - 7. Запись файла + метадата-ран `report-pdf-1.0` (result = pdf_path/analyze_run_id/ - forecast_run_id/generated_at/size_bytes). + 6b. DOCX-вариант (PR-F, `build_full_report_docx`) из ТЕХ ЖЕ исходных словарей + ТЕХ ЖЕ + карт-PNG (НЕ рендерим карты дважды) — рядом `.docx`-файл. + 7. Запись файлов (PDF + DOCX, атомарно tmp+os.replace) + метадата-ран `report-pdf-1.0` + (result = pdf_path/docx_path/analyze_run_id/forecast_run_id/generated_at/size_bytes/ + docx_size_bytes). Старые раны без docx_path → download?format=docx отдаёт 404. WeasyPrint импортируется ЛОКАЛЬНО внутри :func:`render_full_report_pdf` (тяжёлый native — ломает pytest-сбор на хостах без GTK/Pango; образец `layout_tz_pdf.render_layout_tz_pdf`). @@ -41,6 +44,7 @@ from app.services.analysis_runs.repository import ( latest_run_for, persist_analysis_run, ) +from app.services.exporters.full_report_docx import build_full_report_docx from app.services.exporters.full_report_html import ( MAP_CONCEPT_PLACEHOLDER, MAP_PARCEL_PLACEHOLDER, @@ -243,6 +247,19 @@ def _cad_safe(cad: str) -> str: return re.sub(r"[^0-9:]", "", cad).replace(":", "_") +def _atomic_write(path: Path, data: bytes) -> None: + """Атомарно записать байты в `path`: `..tmp` рядом → `os.replace`. + + Два конкурентных POST в один день целятся в ОДИН путь (имя несёт только дату) — + прямой `write_bytes` мог бы interleave-писать байты обоих рендеров в один файл + (битый вывод). `os.replace` атомарен в пределах одной FS → download всегда видит + целый файл (свой или чужой). Общий для PDF и DOCX (тот же приём). + """ + tmp_path = path.with_suffix(f".{os.getpid()}.tmp") + tmp_path.write_bytes(data) + os.replace(tmp_path, path) + + def build_full_report(db: Session, cad: str) -> dict[str, Any]: """Собрать (или вернуть из кэша) полный PDF-отчёт участка + метадата-ран. #2259 PR-D. @@ -323,30 +340,44 @@ def build_full_report(db: Session, cad: str) -> dict[str, Any]: pdf_bytes = render_full_report_pdf(html) - # Запись файла на volume + метадата-ран. Каталог создаём (parents, exist_ok). + # DOCX-вариант (PR-F): из ТЕХ ЖЕ исходных словарей + ТЕХ ЖЕ карт-PNG (parcel_png / + # concept_png уже отрендерены выше — НЕ рендерим карты дважды). Зеркалит §1–§7 PDF. + docx_bytes = build_full_report_docx( + analyze, + forecast, + concept, + connection_capacity, + cad=cad, + address=address if isinstance(address, str) else None, + generated_at=generated_at_ru, + parcel_map_png=parcel_png, + concept_map_png=concept_png, + ) + + # Запись файлов на volume + метадата-ран. Каталог создаём (parents, exist_ok). reports_dir = Path(settings.reports_dir) reports_dir.mkdir(parents=True, exist_ok=True) - file_name = f"gendesign_report_{_cad_safe(cad)}_{generated_at_ru}.pdf" - pdf_path = reports_dir / file_name - # АТОМАРНАЯ запись: пишем в .tmp рядом и os.replace → финальный путь. Два конкурентных - # POST в один день целятся в ОДИН pdf_path (имя несёт только дату) — прямой write_bytes - # мог бы interleave-писать байты обоих рендеров в один файл (битый PDF). os.replace - # атомарен в пределах одной FS → download всегда видит целый файл (свой или чужой). - tmp_path = pdf_path.with_suffix(f".{os.getpid()}.tmp") - tmp_path.write_bytes(pdf_bytes) - os.replace(tmp_path, pdf_path) + base_name = f"gendesign_report_{_cad_safe(cad)}_{generated_at_ru}" + pdf_path = reports_dir / f"{base_name}.pdf" + docx_path = reports_dir / f"{base_name}.docx" + # Атомарная запись обоих файлов (tmp+os.replace — см. `_atomic_write`). + _atomic_write(pdf_path, pdf_bytes) + _atomic_write(docx_path, docx_bytes) size_bytes = len(pdf_bytes) + docx_size_bytes = len(docx_bytes) result: dict[str, Any] = { "pdf_path": str(pdf_path), + "docx_path": str(docx_path), "analyze_run_id": analyze_run_id, "forecast_run_id": forecast_run_id, "generated_at": generated_at.isoformat(), "size_bytes": size_bytes, + "docx_size_bytes": docx_size_bytes, } - # Метадата-ран `report-pdf-1.0` (best-effort persist; провал не роняет отчёт — PDF - # уже записан, просто следующий вызов не поймает cache-hit и пере-рендерит). + # Метадата-ран `report-pdf-1.0` (best-effort persist; провал не роняет отчёт — файлы + # уже записаны, просто следующий вызов не поймает cache-hit и пере-рендерит). persist_analysis_run( db, cad_num=cad, @@ -359,10 +390,12 @@ def build_full_report(db: Session, cad: str) -> dict[str, Any]: created_by=None, ) logger.info( - "build_full_report: cad=%s written path=%s size=%d analyze=%s forecast=%s", + "build_full_report: cad=%s pdf=%s (%d B) docx=%s (%d B) analyze=%s forecast=%s", cad, pdf_path, size_bytes, + docx_path, + docx_size_bytes, analyze_run_id, forecast_run_id, ) diff --git a/backend/tests/api/v1/test_parcel_report.py b/backend/tests/api/v1/test_parcel_report.py index 61598f4a..ea51004d 100644 --- a/backend/tests/api/v1/test_parcel_report.py +++ b/backend/tests/api/v1/test_parcel_report.py @@ -273,3 +273,152 @@ def test_download_404_when_not_ready() -> None: assert "готов" in resp.json()["detail"] finally: app.dependency_overrides.clear() + + +# ── GET /{cad}/report/download?format=docx (#2259 PR-F) ───────────────────────── + + +def test_download_docx_returns_word_when_ready(tmp_path: Path) -> None: + """format=docx + docx_path на диске → 200 + Word media-type + .docx filename.""" + from app.core.db import get_db + + pdf = tmp_path / "report.pdf" + pdf.write_bytes(b"%PDF-1.7 payload") + docx = tmp_path / "report.docx" + docx.write_bytes(b"PK\x03\x04 fake-docx") + report_row = _run( + 500, + { + "pdf_path": str(pdf), + "docx_path": str(docx), + "analyze_run_id": 101, + "forecast_run_id": 202, + "generated_at": "2026-07-01T09:00:00+00:00", + "size_bytes": 16, + "docx_size_bytes": 12, + }, + ) + rows = {REPORT_SCHEMA_VERSION: report_row} + db = MagicMock() + app.dependency_overrides[get_db] = _override_db(db) + try: + with patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)): + client = TestClient(app) + resp = client.get(f"/api/v1/parcels/{_CAD}/report/download?format=docx") + assert resp.status_code == 200, resp.text + assert resp.headers["content-type"] == ( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) + cd = resp.headers["content-disposition"] + assert "attachment;" in cd + assert cd.endswith('.docx"') + assert "2026-07-01" in cd + assert resp.content == b"PK\x03\x04 fake-docx" + finally: + app.dependency_overrides.clear() + + +def test_download_default_format_is_pdf(tmp_path: Path) -> None: + """format не задан → default pdf (даже если docx_path тоже есть).""" + from app.core.db import get_db + + pdf = tmp_path / "report.pdf" + pdf.write_bytes(b"%PDF-1.7 payload") + docx = tmp_path / "report.docx" + docx.write_bytes(b"PK\x03\x04 fake-docx") + report_row = _run( + 500, + { + "pdf_path": str(pdf), + "docx_path": str(docx), + "analyze_run_id": 101, + "forecast_run_id": 202, + "generated_at": "2026-07-01T09:00:00+00:00", + "size_bytes": 16, + }, + ) + rows = {REPORT_SCHEMA_VERSION: report_row} + db = MagicMock() + app.dependency_overrides[get_db] = _override_db(db) + try: + with patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)): + client = TestClient(app) + resp = client.get(f"/api/v1/parcels/{_CAD}/report/download") + assert resp.status_code == 200, resp.text + assert resp.headers["content-type"] == "application/pdf" + assert resp.content == b"%PDF-1.7 payload" + finally: + app.dependency_overrides.clear() + + +def test_download_docx_404_when_legacy_run_has_no_docx(tmp_path: Path) -> None: + """Старый ран (собран до PR-F) без docx_path → format=docx 404 «пересоберите».""" + from app.core.db import get_db + + pdf = tmp_path / "report.pdf" + pdf.write_bytes(b"%PDF-1.7 payload") + # Метадата без docx_path — legacy отчёт, собранный до PR-F. + report_row = _run( + 500, + { + "pdf_path": str(pdf), + "analyze_run_id": 101, + "forecast_run_id": 202, + "generated_at": "2026-07-01T09:00:00+00:00", + "size_bytes": 16, + }, + ) + rows = {REPORT_SCHEMA_VERSION: report_row} + db = MagicMock() + app.dependency_overrides[get_db] = _override_db(db) + try: + with patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)): + client = TestClient(app) + resp = client.get(f"/api/v1/parcels/{_CAD}/report/download?format=docx") + assert resp.status_code == 404, resp.text + assert "пересоберите" in resp.json()["detail"] + finally: + app.dependency_overrides.clear() + + +def test_download_docx_404_when_file_missing(tmp_path: Path) -> None: + """docx_path в метадате есть, но файл на диске исчез → format=docx 404.""" + from app.core.db import get_db + + pdf = tmp_path / "report.pdf" + pdf.write_bytes(b"%PDF") + report_row = _run( + 500, + { + "pdf_path": str(pdf), + "docx_path": str(tmp_path / "gone.docx"), # файла нет + "analyze_run_id": 101, + "forecast_run_id": 202, + "generated_at": "2026-07-01T09:00:00+00:00", + "size_bytes": 4, + }, + ) + rows = {REPORT_SCHEMA_VERSION: report_row} + db = MagicMock() + app.dependency_overrides[get_db] = _override_db(db) + try: + with patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)): + client = TestClient(app) + resp = client.get(f"/api/v1/parcels/{_CAD}/report/download?format=docx") + assert resp.status_code == 404, resp.text + finally: + app.dependency_overrides.clear() + + +def test_download_invalid_format_returns_422() -> None: + """Неизвестный format (не pdf/docx) → 422 (Literal-валидация FastAPI).""" + from app.core.db import get_db + + db = MagicMock() + app.dependency_overrides[get_db] = _override_db(db) + try: + client = TestClient(app) + resp = client.get(f"/api/v1/parcels/{_CAD}/report/download?format=xlsx") + assert resp.status_code == 422, resp.text + finally: + app.dependency_overrides.clear() diff --git a/backend/tests/services/exporters/test_full_report_docx.py b/backend/tests/services/exporters/test_full_report_docx.py new file mode 100644 index 00000000..d8d0ae9e --- /dev/null +++ b/backend/tests/services/exporters/test_full_report_docx.py @@ -0,0 +1,404 @@ +"""Unit-тесты DOCX-варианта полного отчёта ПТИЦА (эпик #2259 PR-F) — `build_full_report_docx`. + +Чистые тесты БЕЗ БД / сети / native-libs (python-docx — чистый Python на lxml): билдер +только ПОТРЕБЛЯЕТ уже-собранные словари (analyze / forecast / concept / connection- +capacity) + карт-PNG. Проверяем: + • полный payload → НЕПУСТЫЕ байты, начинаются с `b"PK"` (OOXML/.docx — zip); + • документ открывается python-docx и несёт заголовки §1–§7 + альтернативы + титул; + • деградации: нет forecast → §4–§6 «нет данных»; нет концепции → §7 плашка + §5 контекст; + нет connection-capacity → §3 без резервов; пустой payload → валидный минимальный .docx; + • карты вставлены (inline_shapes) при наличии PNG; None → плашка «Карта недоступна». + +DATABASE_URL до импорта app-модулей (зеркало test_full_report_pdf.py) — на случай +side-effect'ов импорта пакета. +""" + +from __future__ import annotations + +import io +import os +import struct +import zlib +from typing import Any + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from docx import Document + +from app.services.exporters.full_report_docx import build_full_report_docx + +_CAD = "66:41:0204016:10" + + +def _png_1x1() -> bytes: + """Минимальный валидный 1×1 PNG (белый пиксель) для проверки вставки карт. + + Собираем вручную (chunk = len + type + data + CRC32) — не тянем PIL в фикстуру. + python-docx декодирует размеры прямо из IHDR, реальный растр ему не важен. + """ + + def _chunk(tag: bytes, data: bytes) -> bytes: + body = tag + data + crc = struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF) + return struct.pack(">I", len(data)) + body + crc + + sig = b"\x89PNG\r\n\x1a\n" + ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0) # 1×1, 8-bit, truecolor + idat = zlib.compress(b"\x00\xff\xff\xff") # one filtered scanline, white + return sig + _chunk(b"IHDR", ihdr) + _chunk(b"IDAT", idat) + _chunk(b"IEND", b"") + + +@pytest.fixture +def png() -> bytes: + return _png_1x1() + + +def _analyze() -> dict[str, Any]: + return { + "cad_num": _CAD, + "geom_geojson": {"type": "Polygon", "coordinates": [[[60.6, 56.8], [60.61, 56.8]]]}, + "egrn": { + "address": "ЕКБ, ул. Примерная, 1", + "area_m2": 7800, + "land_category": "Земли населённых пунктов", + }, + "nspd_zoning": { + "zone_code": "Ж-4", + "max_far": 2.5, + "max_floors": 16, + "main_vri": ["МКД", "Детсад"], + }, + "encumbrance": {"has_zouit": True, "zouit_count": 1, "zouit_types": ["Приаэродромная"]}, + "gate_verdict": {"can_build_mkd": True, "verdict_label": "Можно"}, + "location": {"distance_to_center_km": 4.2}, + "metro": {"nearest_top3": [{"name": "Пл.1905", "distance_m": 1200}]}, + "utilities": { + "nearest_substation_m": 300, + "summary": [{"subtype": "power", "nearest_m": 300, "count_within_2km": 5}], + }, + "neighbors_summary": { + "neighbors": [ + {"building_name": "ЖК А", "purpose": "жилое", "floors": 12, "distance_m": 80} + ] + }, + "program_alternatives": { + "any_viable": True, + "grid_size": 9, + "variants": [ + { + "housing_class": "comfort", + "floors": 16, + "development_type": "high_rise", + "npv_rub": 42_000_000, + "roi": 0.18, + "irr": 0.22, + "residential_sqm": 12_000, + "apartments_count": 240, + "price_per_sqm_used": 120_000, + "price_source": "objective_geo_radius", + } + ], + "caveat": "Оценка по макс. застройке зоны.", + }, + } + + +def _forecast() -> dict[str, Any]: + return { + "schema_version": "1.0", + "market_now": { + "summary": "Рынок активен.", + "market_metrics": {"district": "Кировский", "n_lots": 540, "unit_velocity": 12.3}, + "competitors": [ + { + "comm_name": "ЖК Б", + "dev_name": "Дев1", + "obj_class": "comfort", + "distance_m": 500, + "flat_count": 120, + } + ], + }, + "confidence": { + "level": "high", + "rationale": "много данных", + "factors": {"deals": {"label": "Сделки", "level": "high", "note": "2000 сделок"}}, + }, + "future_market": { + "summary": "Дефицит нарастает.", + "forecasts_by_horizon": [ + { + "horizon_months": 12, + "projected_demand_units": 300, + "projected_supply_units": 200, + "deficit_index": 1.5, + "months_of_inventory": 8, + "confidence": "medium", + } + ], + }, + "scoring": { + "special_indices": { + "indices": { + "cost_of_error": { + "key": "coe", + "label": "Цена ошибки", + "value": 0.3, + "detail": {"price_per_m2": 125_000, "avg_ticket_rub": 7_500_000}, + } + } + } + }, + "scenarios": {"scenarios_summary": {"base": 1.4, "optimistic": 1.8}}, + } + + +def _concept() -> dict[str, Any]: + return { + "variants": [ + { + "strategy": "balanced", + "teap": { + "built_area_sqm": 2000, + "total_floor_area_sqm": 24_000, + "residential_area_sqm": 18_000, + "apartments_count": 300, + "parking_spaces": 150, + "density": 2.4, + }, + "financial": { + "revenue_rub": 3_600_000_000, + "cost_rub": 2_800_000_000, + "net_profit_rub": 800_000_000, + "npv_rub": 420_000_000, + "roi": 0.28, + "irr": 0.19, + "price_per_sqm_used": 125_000, + "price_source": "objective_geo_radius", + "payback_months": 36, + "construction_rub": 2_000_000_000, + }, + "buildings_geojson": { + "features": [ + {"properties": {"section_type": "tower", "floors": 16}}, + {"properties": {"section_type": "tower", "floors": 16}}, + ] + }, + } + ] + } + + +def _capacity() -> dict[str, Any]: + return { + "power_summary": { + "total_power_points": 3, + "nearest_with_reserve": { + "reserve_mva": 12.5, + "voltage_class": "10кВ", + "distance_m": 400, + "reserve_unit": "МВА", + }, + }, + "gas": { + "city_grs": [ + {"grs_name": "ГРС-1", "free_capacity_th_m3_h": 50, "free_capacity_pct": 18.4} + ] + }, + "heat": { + "systems": [ + { + "org": "ЕКБ ТЭЦ", + "system_name": "Система 1", + "reserve_gcal_h": 120, + "period": "2024", + } + ] + }, + "water": [{"system_name": "ЦСВ ЕКБ", "reserve_thousand_m3_day": 30, "period": "2024"}], + "nearby_network_zones": [{"human_label": "ЛЭП 10кВ", "distance_m": 50}], + } + + +def _all_text(doc: Document) -> str: + """Весь текст документа: абзацы + ячейки всех таблиц (для проверки содержимого).""" + parts = [p.text for p in doc.paragraphs] + for table in doc.tables: + for row in table.rows: + parts.extend(cell.text for cell in row.cells) + return "\n".join(parts) + + +def _render(**overrides: Any) -> bytes: + kwargs: dict[str, Any] = { + "analyze_result": _analyze(), + "forecast_result": _forecast(), + "concept_result": _concept(), + "connection_capacity": _capacity(), + "cad": _CAD, + "address": "ЕКБ, ул. Примерная, 1", + "generated_at": "03.07.2026", + "parcel_map_png": None, + "concept_map_png": None, + } + kwargs.update(overrides) + analyze = kwargs.pop("analyze_result") + forecast = kwargs.pop("forecast_result") + concept = kwargs.pop("concept_result") + cap = kwargs.pop("connection_capacity") + return build_full_report_docx(analyze, forecast, concept, cap, **kwargs) + + +# ── Happy path ────────────────────────────────────────────────────────────────── + + +def test_full_payload_returns_docx_bytes(png: bytes) -> None: + """Полный payload → непустые байты, начинающиеся с b"PK" (OOXML zip).""" + out = _render(parcel_map_png=png, concept_map_png=png) + assert isinstance(out, bytes) + assert len(out) > 0 + assert out[:2] == b"PK" # zip-контейнер + + +def test_all_sections_present(png: bytes) -> None: + """Документ несёт заголовки §1–§7 + альтернативы + титул (зеркало PDF-структуры).""" + out = _render(parcel_map_png=png, concept_map_png=png) + doc = Document(io.BytesIO(out)) + headings = {p.text for p in doc.paragraphs} + for section in ( + "§1. Участок", + "§2. Окружение", + "§3. Инженерные сети", + "§4. Рынок", + "§5. Финансовая модель", + "§6. Риски и дефицит", + "§7. Концепция застройки", + ): + assert section in headings, f"нет секции {section}" + assert "Как участок сходится (альтернативы программы)" in headings + assert "Отчёт по участку — Site Finder ПТИЦА" in headings + + +def test_title_carries_cad_and_address(png: bytes) -> None: + """Титул несёт кад.номер, адрес и дату формирования.""" + out = _render(parcel_map_png=png, concept_map_png=png) + text = _all_text(Document(io.BytesIO(out))) + assert _CAD in text + assert "ЕКБ, ул. Примерная, 1" in text + assert "03.07.2026" in text + + +def test_key_data_rendered(png: bytes) -> None: + """Данные из исходных словарей попадают в таблицы (площадь / зона / NPV / метро).""" + out = _render(parcel_map_png=png, concept_map_png=png) + text = _all_text(Document(io.BytesIO(out))) + assert "7 800" in text # площадь через _fmt_int_ru + assert "Ж-4" in text # зона ПЗЗ + assert "Пл.1905" in text # станция метро + assert "ЖК Б" in text # конкурент рынка + + +# ── Карты ─────────────────────────────────────────────────────────────────────── + + +def test_maps_inserted_when_png_present(png: bytes) -> None: + """Обе карты (участок §1 + концепция §7) вставлены как inline-изображения.""" + out = _render(parcel_map_png=png, concept_map_png=png) + doc = Document(io.BytesIO(out)) + assert len(doc.inline_shapes) == 2 + + +def test_map_placeholder_when_png_none() -> None: + """Карты None → плашка «Карта недоступна» вместо изображения (graceful).""" + out = _render(parcel_map_png=None, concept_map_png=None) + doc = Document(io.BytesIO(out)) + assert len(doc.inline_shapes) == 0 + assert "Карта недоступна" in _all_text(doc) + + +def test_broken_png_falls_back_to_placeholder() -> None: + """Битый PNG → плашка «недоступна», отчёт не падает (best-effort вставка).""" + out = _render(parcel_map_png=b"not-a-png", concept_map_png=b"also-broken") + doc = Document(io.BytesIO(out)) + assert len(doc.inline_shapes) == 0 + assert "Карта недоступна" in _all_text(doc) + + +# ── Деградации ────────────────────────────────────────────────────────────────── + + +def test_no_forecast_degrades_part_b() -> None: + """Нет forecast → §4–§6 «нет данных», документ валиден (не падает).""" + out = _render(forecast_result=None) + doc = Document(io.BytesIO(out)) + headings = {p.text for p in doc.paragraphs} + assert "§4. Рынок" in headings + assert "§6. Риски и дефицит" in headings + assert "нет данных" in _all_text(doc) + + +def test_no_concept_shows_plashka_and_market_context() -> None: + """Нет концепции → §7 честная плашка + §5 только рыночный контекст цены.""" + out = _render(concept_result=None) + text = _all_text(Document(io.BytesIO(out))) + assert "Концепция застройки не рассчитана" in text + # §5 деградирует в рыночный контекст (cost_of_error detail в forecast). + assert "Рыночный контекст цены" in text + + +def test_no_connection_capacity_omits_reserves() -> None: + """Нет connection-capacity → §3 без блоков резервов (только OSM-сети).""" + out = _render(connection_capacity=None) + text = _all_text(Document(io.BytesIO(out))) + # OSM-сети остаются, а connection-capacity-заголовки не появляются. + assert "Инженерные сети рядом" in text + assert "Электроснабжение — свободная мощность" not in text + assert "Газоснабжение — свободная мощность ГРС" not in text + + +def test_no_alternatives_when_field_absent() -> None: + """program_alternatives отсутствует → блок альтернатив не рисуется вовсе.""" + analyze = _analyze() + analyze.pop("program_alternatives") + out = _render(analyze_result=analyze) + headings = {p.text for p in Document(io.BytesIO(out)).paragraphs} + assert "Как участок сходится (альтернативы программы)" not in headings + + +def test_empty_payload_produces_valid_docx() -> None: + """Пустой payload (всё {}) → валидный минимальный .docx БЕЗ падения (graceful).""" + out = build_full_report_docx( + {}, + {}, + {}, + {}, + cad="x", + address=None, + generated_at="03.07.2026", + parcel_map_png=None, + concept_map_png=None, + ) + assert out[:2] == b"PK" + doc = Document(io.BytesIO(out)) + headings = {p.text for p in doc.paragraphs} + # Все секции присутствуют даже на пустом payload. + assert "§1. Участок" in headings + assert "§7. Концепция застройки" in headings + assert "нет данных" in _all_text(doc) + + +def test_garbage_payload_does_not_crash() -> None: + """Мусорный payload (не-dict) нормализуется к пустому → валидный .docx.""" + out = build_full_report_docx( + "не словарь", # type: ignore[arg-type] + "тоже не словарь", # type: ignore[arg-type] + None, + None, + cad=_CAD, + address=None, + generated_at="03.07.2026", + parcel_map_png=None, + concept_map_png=None, + ) + assert out[:2] == b"PK" diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index a324e6e5..e1d545ab 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -410,15 +410,20 @@ export interface paths { cookie?: never; }; /** - * Скачать готовый полный PDF-отчёт участка (#2259 PR-D) - * @description Отдать готовый PDF-файл полного отчёта (application/pdf). + * Скачать готовый полный отчёт участка — PDF или DOCX (#2259 PR-D/PR-F) + * @description Отдать готовый файл полного отчёта в выбранном формате (PDF / DOCX). * * Готовый отчёт (метадата-ран `report-pdf-1.0` + файл на диске) → FileResponse с - * Content-Disposition attachment (`gendesign_report__.pdf`). Отчёт не готов / + * Content-Disposition attachment (`gendesign_report__.`). Отчёт не готов / * файл не найден → 404 (сначала POST /report, дождаться status=ready). * + * `format=docx`: старые раны (собранные до PR-F) не несут `docx_path` в метадате → + * честный 404 с подсказкой «пересоберите отчёт» (POST /report пере-соберёт с DOCX, т.к. + * ключ кэша не изменился, но файла docx нет — пере-рендер запишет оба). + * * Args: * cad_num: кадастровый номер участка. + * format: "pdf" (default) | "docx". */ get: operations["download_parcel_report_api_v1_parcels__cad_num__report_download_get"]; put?: never; @@ -6118,7 +6123,10 @@ export interface operations { }; download_parcel_report_api_v1_parcels__cad_num__report_download_get: { parameters: { - query?: never; + query?: { + /** @description Формат файла: pdf (default) | docx (Word-документ, #2259 PR-F) */ + format?: "pdf" | "docx"; + }; header?: never; path: { cad_num: string;