From 815bae55915f449b44e5ae33d1e982c40c365228 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Wed, 3 Jun 2026 13:56:55 +0500 Subject: [PATCH] =?UTF-8?q?feat(exporters):=20Excel=20exporter=20for=20Sit?= =?UTF-8?q?eFinderReport=20=C2=A713=20(#991,=20955-A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement export_report_xlsx(report) -> bytes: renders a SiteFinderReport (#987) to .xlsx via openpyxl, one sheet per §13 section (Сводка/Рынок сейчас/Будущий рынок/Продукт ТЗ/Сценарии/Скоринг). Deterministic, no LLM/DB/network; bytes via BytesIO (no disk I/O). Accepts SiteFinderReport or its as_dict(). Graceful on partial/empty/junk ("нет данных", never crashes). ADVISORY marker on Сводка. 11 tests (openpyxl reload, sheet titles, known cells, graceful). Additive — no endpoint wiring (#959) or other exporters (#989). --- backend/app/services/exporters/excel.py | 483 +++++++++++++++++- backend/tests/services/exporters/__init__.py | 0 .../tests/services/exporters/test_excel.py | 228 +++++++++ 3 files changed, 709 insertions(+), 2 deletions(-) create mode 100644 backend/tests/services/exporters/__init__.py create mode 100644 backend/tests/services/exporters/test_excel.py diff --git a/backend/app/services/exporters/excel.py b/backend/app/services/exporters/excel.py index ea4c6c5d..b4c5c815 100644 --- a/backend/app/services/exporters/excel.py +++ b/backend/app/services/exporters/excel.py @@ -1,4 +1,483 @@ -"""Excel export via openpyxl. +"""§13 Excel-экспортёр итогового советующего отчёта Site Finder v2 (openpyxl). -Stage 1c: 3 sheets — TEAP per variant, Financial model, Comparison. +#991 (955-A5, Site Finder v2 / «GG-форсайт» ТЗ §13), EPIC 11 «Отчёт». Рендерит +`SiteFinderReport` (#987) в .xlsx-книгу (по одному листу на содержательную секцию §13) +и возвращает БАЙТЫ — готовые для `Response(media_type=...)` / записи. Зеркалит house- +style соседей-экспортёров (snapshot_pdf / trade_in_pdf): module-level `logger`, named- +константы стилей/заголовков, RU-микрокопия, graceful-None, чистый возврат `bytes` без +файлового I/O на диск. + +ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM, БЕЗ БД/сети: только ПОТРЕБЛЯЕТ уже-собранный отчёт (его +наполняет сборщик #988) и раскладывает по листам. Принимает КАК `SiteFinderReport`- +инстанс, ТАК и его `as_dict()`-словарь (нормализуется через `_normalize`). + +GRACEFUL (дух всего форсайт-стека): частичный/пустой отчёт ВАЛИДЕН — пустая секция +рисует ячейку «нет данных», экспортёр НИКОГДА не падает. Отчёт СОВЕТУЮЩИЙ: на листе +«Сводка» проставляется ADVISORY-маркер (оценка не основание для инвест-решения). + +Подключение к эндпоинту экспорта — #959 (вне scope: здесь только сам экспортёр). """ + +from __future__ import annotations + +import logging +from io import BytesIO +from typing import Any + +from openpyxl import Workbook +from openpyxl.styles import Alignment, Font, PatternFill +from openpyxl.utils import get_column_letter +from openpyxl.worksheet.worksheet import Worksheet + +logger = logging.getLogger(__name__) + +# ── Named-константы: заголовки листов (по одному на содержательную секцию §13) ── + +_SHEET_SUMMARY: str = "Сводка" +_SHEET_MARKET_NOW: str = "Рынок сейчас" +_SHEET_FUTURE_MARKET: str = "Будущий рынок" +_SHEET_PRODUCT_TZ: str = "Продукт ТЗ" +_SHEET_SCENARIOS: str = "Сценарии" +_SHEET_SCORING: str = "Скоринг" + +# ── Named-константы: микрокопия / плейсхолдеры ──────────────────────────────── + +# Заглушка пустой ячейки/секции (зеркало `_format_cost` → "—" у snapshot_pdf). +_DASH: str = "—" +# Текст для полностью пустой секции (graceful: лист есть, данных нет). +_NO_DATA: str = "нет данных" +# ADVISORY-маркер на листе «Сводка» — отчёт советующий, не основание для решения. +_ADVISORY_MARKER: str = ( + "ADVISORY: отчёт носит советующий характер и не является основанием " + "для инвестиционного решения." +) +# Округление чисел по умолчанию (скоры/индексы ∈ [0,1] и доли — 3 знака читаемо). +_ROUND_DIGITS: int = 3 + +# RU-метки уровней отчётной уверенности (§15) для листа «Сводка». +_LEVEL_RU: dict[str, str] = { + "high": "высокая", + "medium": "средняя", + "low": "низкая", +} + +# ── Named-константы: стили (header bold + заливка, выравнивание) ─────────────── + +_HEADER_FONT: Font = Font(bold=True, color="FFFFFF") +_HEADER_FILL: PatternFill = PatternFill("solid", fgColor="1D4ED8") # синий house-accent +_TITLE_FONT: Font = Font(bold=True, size=14) +_SECTION_FONT: Font = Font(bold=True, size=11, color="374151") +_ADVISORY_FONT: Font = Font(italic=True, color="B45309") # янтарный — предупреждение +_WRAP_TOP: Alignment = Alignment(wrap_text=True, vertical="top") +_LEFT_TOP: Alignment = Alignment(horizontal="left", vertical="top") + +# Ширины колонок по умолчанию (символы). Первая колонка-метка шире, далее значения. +_LABEL_COL_WIDTH: float = 32.0 +_VALUE_COL_WIDTH: float = 28.0 +_WIDE_COL_WIDTH: float = 44.0 + + +# ────────────────────────────────────────────────────────────────────────────── +# Нормализация входа + микро-хелперы форматирования. Все PURE, graceful. +# ────────────────────────────────────────────────────────────────────────────── + + +def _normalize(report: Any) -> dict[str, Any]: + """Привести вход к `as_dict()`-словарю отчёта (dataclass | dict). PURE, graceful. + + Принимаем КАК `SiteFinderReport`-инстанс (есть метод `as_dict`), ТАК и уже его + `as_dict()`-словарь — вызывающий (#959) волен передать любое. Любой иной тип + (мусор/None) → пустой dict: экспортёр отрисует пустой отчёт, а не упадёт. + """ + as_dict = getattr(report, "as_dict", None) + if callable(as_dict): + result = as_dict() + return result if isinstance(result, dict) else {} + if isinstance(report, dict): + return report + return {} + + +def _fmt(value: Any) -> Any: + """Привести значение к ячейке Excel: None → "—", float → округление. PURE. + + Числа округляем до `_ROUND_DIGITS` (но bool оставляем как есть — это не число для + округления). Строки/прочие скаляры — как есть. Контейнеры (dict/list) сворачиваем + в компактную строку (на случай неожиданно-вложенного значения — не падаем). + """ + if value is None: + return _DASH + if isinstance(value, bool): + return "да" if value else "нет" + if isinstance(value, float): + return round(value, _ROUND_DIGITS) + if isinstance(value, (str, int)): + return value + if isinstance(value, (dict, list)): + return str(value) + return value + + +def _as_dict(value: Any) -> dict[str, Any]: + """Вернуть dict как есть, иначе пустой dict (defensive чтение секций). PURE.""" + return value if isinstance(value, dict) else {} + + +def _as_list(value: Any) -> list[Any]: + """Вернуть list как есть, иначе пустой list (defensive чтение секций). PURE.""" + return value if isinstance(value, list) else [] + + +def _level_ru(level: Any) -> str: + """RU-метка уровня уверенности (high/medium/low) или "—". PURE.""" + if isinstance(level, str) and level in _LEVEL_RU: + return _LEVEL_RU[level] + return _DASH + + +# ────────────────────────────────────────────────────────────────────────────── +# Низкоуровневые помощники записи листа (заголовки/строки/ширины). PURE-побочка +# (мутируют переданный лист). Не ходят в БД/сеть. +# ────────────────────────────────────────────────────────────────────────────── + + +def _write_title(ws: Worksheet, row: int, text: str) -> int: + """Заголовок-секции жирным; вернуть номер следующей свободной строки. PURE-side.""" + cell = ws.cell(row=row, column=1, value=text) + cell.font = _SECTION_FONT + return row + 1 + + +def _write_kv(ws: Worksheet, row: int, label: str, value: Any) -> int: + """Строка «метка → значение» (label жирный, value через `_fmt`). PURE-side.""" + label_cell = ws.cell(row=row, column=1, value=label) + label_cell.font = Font(bold=True) + label_cell.alignment = _LEFT_TOP + value_cell = ws.cell(row=row, column=2, value=_fmt(value)) + value_cell.alignment = _WRAP_TOP + return row + 1 + + +def _write_no_data(ws: Worksheet, row: int) -> int: + """Ячейка-заглушка «нет данных» для пустой секции (graceful). PURE-side.""" + cell = ws.cell(row=row, column=1, value=_NO_DATA) + cell.font = Font(italic=True, color="9CA3AF") + return row + 1 + + +def _write_table(ws: Worksheet, row: int, headers: list[str], rows: list[list[Any]]) -> int: + """Таблица: жирная шапка (заливка) + строки данных через `_fmt`. PURE-side. + + Пустой `rows` → одна строка-заглушка «нет данных» под шапкой (graceful — таблица + всё равно валидна). Вернуть номер следующей свободной строки. + """ + for col_idx, header in enumerate(headers, start=1): + cell = ws.cell(row=row, column=col_idx, value=header) + cell.font = _HEADER_FONT + cell.fill = _HEADER_FILL + cell.alignment = _WRAP_TOP + row += 1 + if not rows: + ws.cell(row=row, column=1, value=_NO_DATA).font = Font(italic=True, color="9CA3AF") + return row + 1 + for data_row in rows: + for col_idx, value in enumerate(data_row, start=1): + cell = ws.cell(row=row, column=col_idx, value=_fmt(value)) + cell.alignment = _WRAP_TOP + row += 1 + return row + + +def _set_widths(ws: Worksheet, widths: list[float]) -> None: + """Проставить ширины колонок (символы) по порядку. PURE-side.""" + for idx, width in enumerate(widths, start=1): + ws.column_dimensions[get_column_letter(idx)].width = width + + +# ────────────────────────────────────────────────────────────────────────────── +# Построители листов — по одному на содержательную секцию §13. Каждый graceful: +# пустая секция → лист с «нет данных», НИКОГДА не падает. +# ────────────────────────────────────────────────────────────────────────────── + + +def _build_summary_sheet(ws: Worksheet, report: dict[str, Any]) -> None: + """Лист «Сводка»: exec_summary + meta + confidence + ADVISORY-маркер. Graceful.""" + exec_summary = _as_dict(report.get("exec_summary")) + meta = _as_dict(report.get("meta")) + confidence = _as_dict(report.get("confidence")) + + title = ws.cell(row=1, column=1, value="Site Finder v2 — советующий отчёт §13") + title.font = _TITLE_FONT + advisory = ws.cell(row=2, column=1, value=_ADVISORY_MARKER) + advisory.font = _ADVISORY_FONT + advisory.alignment = _WRAP_TOP + row = 4 + + # ── Вердикт (exec_summary) ── + row = _write_title(ws, row, "Вердикт") + row = _write_kv(ws, row, "Заголовок", exec_summary.get("headline")) + row = _write_kv(ws, row, "Развёрнутый вердикт", exec_summary.get("verdict")) + row = _write_kv(ws, row, "Общая уверенность", _level_ru(exec_summary.get("overall_confidence"))) + row += 1 + + # ── Ключевые числа (плоский dict) ── + row = _write_title(ws, row, "Ключевые числа") + key_numbers = _as_dict(exec_summary.get("key_numbers")) + if key_numbers: + for key, value in key_numbers.items(): + row = _write_kv(ws, row, str(key), value) + else: + row = _write_no_data(ws, row) + row += 1 + + # ── Уверенность (§15) ── + row = _write_title(ws, row, "Уверенность отчёта") + row = _write_kv(ws, row, "Уровень", _level_ru(confidence.get("level"))) + row = _write_kv(ws, row, "Обоснование", confidence.get("rationale")) + row += 1 + + # ── Контекст (meta) ── + row = _write_title(ws, row, "Контекст") + row = _write_kv(ws, row, "Кадастровый номер", meta.get("cad_num")) + row = _write_kv(ws, row, "Район", meta.get("district")) + horizons = _as_list(meta.get("horizons")) + row = _write_kv( + ws, row, "Горизонты (мес)", ", ".join(str(h) for h in horizons) if horizons else None + ) + row = _write_kv(ws, row, "Сформировано", meta.get("generated_at")) + row = _write_kv(ws, row, "Версия схемы", meta.get("schema_version")) + + _set_widths(ws, [_LABEL_COL_WIDTH, _WIDE_COL_WIDTH]) + + +def _build_market_now_sheet(ws: Worksheet, report: dict[str, Any]) -> None: + """Лист «Рынок сейчас»: абсорбция/цены (market_metrics) + конкуренты. Graceful.""" + market_now = _as_dict(report.get("market_now")) + metrics = _as_dict(market_now.get("market_metrics")) + competitors = _as_list(market_now.get("competitors")) + supply = _as_dict(market_now.get("supply_layers")) + + row = _write_title(ws, 1, "Текущий рынок") + row = _write_kv(ws, row, "Резюме", market_now.get("summary")) + row += 1 + + # ── Метрики рынка §9.2 (плоский dict — рисуем как метка→значение) ── + row = _write_title(ws, row, "Метрики рынка") + if metrics: + for key, value in metrics.items(): + row = _write_kv(ws, row, str(key), value) + else: + row = _write_no_data(ws, row) + row += 1 + + # ── Слои предложения §9.3 ── + row = _write_title(ws, row, "Слои предложения") + if supply: + for key, value in supply.items(): + row = _write_kv(ws, row, str(key), value) + else: + row = _write_no_data(ws, row) + row += 1 + + # ── Конкуренты §9.1 (таблица) ── + row = _write_title(ws, row, "Конкуренты") + headers = ["ЖК", "ID", "Релевантность"] + table_rows = [ + [c.get("comm_name"), c.get("obj_id"), c.get("relevance_weight")] + for c in competitors + if isinstance(c, dict) + ] + _write_table(ws, row, headers, table_rows) + + _set_widths(ws, [_LABEL_COL_WIDTH, _VALUE_COL_WIDTH, _VALUE_COL_WIDTH]) + + +def _build_future_market_sheet(ws: Worksheet, report: dict[str, Any]) -> None: + """Лист «Будущий рынок»: спрос/предложение/дефицит по горизонтам. Graceful.""" + future = _as_dict(report.get("future_market")) + forecasts = _as_list(future.get("forecasts_by_horizon")) + scenarios_summary = _as_dict(future.get("scenarios_summary")) + + row = _write_title(ws, 1, "Будущий рынок") + row = _write_kv(ws, row, "Резюме", future.get("summary")) + row += 1 + + # ── Прогноз по горизонтам (таблица) ── + row = _write_title(ws, row, "Прогноз по горизонтам") + headers = ["Горизонт, мес", "Спрос", "Предложение", "Индекс дефицита", "Уверенность"] + table_rows = [ + [ + f.get("horizon_months"), + f.get("demand"), + f.get("supply"), + f.get("deficit_index"), + _level_ru(f.get("confidence")), + ] + for f in forecasts + if isinstance(f, dict) + ] + row = _write_table(ws, row, headers, table_rows) + row += 1 + + # ── Разброс сценариев по дефициту ── + row = _write_title(ws, row, "Разброс сценариев (индекс дефицита)") + if scenarios_summary: + for key, value in scenarios_summary.items(): + row = _write_kv(ws, row, str(key), value) + else: + row = _write_no_data(ws, row) + + _set_widths( + ws, + [_LABEL_COL_WIDTH, _VALUE_COL_WIDTH, _VALUE_COL_WIDTH, _VALUE_COL_WIDTH, _VALUE_COL_WIDTH], + ) + + +def _build_product_tz_sheet(ws: Worksheet, report: dict[str, Any]) -> None: + """Лист «Продукт ТЗ»: класс + квартирография (mix) + коммерция + USP. Graceful.""" + product = _as_dict(report.get("product_tz")) + mix = _as_list(product.get("mix")) + commercial = _as_dict(product.get("commercial")) + usp = _as_list(product.get("usp")) + + row = _write_title(ws, 1, "Рекомендация продукта") + row = _write_kv(ws, row, "Резюме", product.get("summary")) + row = _write_kv(ws, row, "Рекомендованный класс", product.get("obj_class")) + row += 1 + + # ── Квартирография (таблица: формат + доля/класс/дефицит — defensive ключи) ── + row = _write_title(ws, row, "Квартирография (mix)") + headers = ["Формат", "Доля, %", "Класс", "Индекс дефицита"] + table_rows = [ + [m.get("bucket"), m.get("pct"), m.get("obj_class"), m.get("deficit_index")] + for m in mix + if isinstance(m, dict) + ] + row = _write_table(ws, row, headers, table_rows) + row += 1 + + # ── Коммерция §10.4 ── + row = _write_title(ws, row, "Коммерция") + if commercial: + for key, value in commercial.items(): + row = _write_kv(ws, row, str(key), value) + else: + row = _write_no_data(ws, row) + row += 1 + + # ── USP-ниши §10.5 (таблица: сегмент + текст) ── + row = _write_title(ws, row, "USP-ниши") + headers = ["Сегмент", "USP"] + usp_rows = [[u.get("segment"), u.get("usp_text")] for u in usp if isinstance(u, dict)] + _write_table(ws, row, headers, usp_rows) + + _set_widths(ws, [_LABEL_COL_WIDTH, _VALUE_COL_WIDTH, _VALUE_COL_WIDTH, _VALUE_COL_WIDTH]) + + +def _build_scenarios_sheet(ws: Worksheet, report: dict[str, Any]) -> None: + """Лист «Сценарии»: conservative/base/aggressive (таблица). Graceful.""" + scenarios = _as_dict(report.get("scenarios")) + by_scenario = _as_dict(scenarios.get("by_scenario")) + + row = _write_title(ws, 1, "Сценарии") + row = _write_kv(ws, row, "Резюме", scenarios.get("summary")) + row += 1 + + # ── Таблица сценариев (имя + overall + advisory; прочие ключи в «детали») ── + row = _write_title(ws, row, "Сводка по сценариям") + headers = ["Сценарий", "Overall", "Advisory"] + table_rows: list[list[Any]] = [] + for name, payload in by_scenario.items(): + data = _as_dict(payload) + table_rows.append([name, data.get("overall"), data.get("advisory")]) + _write_table(ws, row, headers, table_rows) + + _set_widths(ws, [_LABEL_COL_WIDTH, _VALUE_COL_WIDTH, _VALUE_COL_WIDTH]) + + +def _build_scoring_sheet(ws: Worksheet, report: dict[str, Any]) -> None: + """Лист «Скоринг»: продуктовые скоры (#985) + спец-индексы (#986) + overall. Graceful.""" + scoring = _as_dict(report.get("scoring")) + product_scores = _as_dict(scoring.get("product_scores")) + special_indices = _as_dict(scoring.get("special_indices")) + + row = _write_title(ws, 1, "Скоринг") + row = _write_kv(ws, row, "Итоговый скор (overall)", scoring.get("overall")) + row += 1 + + # ── Продуктовые скоры (#985): scores = {name: {value: ...}} → таблица ── + row = _write_title(ws, row, "Продуктовые скоры") + scores = _as_dict(product_scores.get("scores")) + score_rows = [[name, _as_dict(payload).get("value")] for name, payload in scores.items()] + row = _write_table(ws, row, ["Скор", "Значение"], score_rows) + row += 1 + + # ── Спец-индексы (#986): indices = {name: {value, label}} → таблица ── + row = _write_title(ws, row, "Специальные индексы") + indices = _as_dict(special_indices.get("indices")) + index_rows = [ + [name, _as_dict(payload).get("value"), _as_dict(payload).get("label")] + for name, payload in indices.items() + ] + _write_table(ws, row, ["Индекс", "Значение", "Метка"], index_rows) + + _set_widths(ws, [_LABEL_COL_WIDTH, _VALUE_COL_WIDTH, _VALUE_COL_WIDTH]) + + +# Реестр: (заголовок листа → построитель). Порядок = порядок листов в книге. +_SHEET_BUILDERS: tuple[tuple[str, Any], ...] = ( + (_SHEET_SUMMARY, _build_summary_sheet), + (_SHEET_MARKET_NOW, _build_market_now_sheet), + (_SHEET_FUTURE_MARKET, _build_future_market_sheet), + (_SHEET_PRODUCT_TZ, _build_product_tz_sheet), + (_SHEET_SCENARIOS, _build_scenarios_sheet), + (_SHEET_SCORING, _build_scoring_sheet), +) + + +# ────────────────────────────────────────────────────────────────────────────── +# Публичный API — рендер отчёта в .xlsx-байты (без файлового I/O на диск). +# ────────────────────────────────────────────────────────────────────────────── + + +def export_report_xlsx(report: Any) -> bytes: + """§13 Отрендерить `SiteFinderReport` (#987) в .xlsx-книгу и вернуть БАЙТЫ. + + По одному листу на содержательную секцию §13 (Сводка / Рынок сейчас / Будущий + рынок / Продукт ТЗ / Сценарии / Скоринг). Шапки таблиц жирные с заливкой, RU-метки, + числа округлены, None → "—". На листе «Сводка» — ADVISORY-маркер (отчёт советующий). + + ДЕТЕРМИНИРОВАННО, БЕЗ LLM/БД/сети. Принимает КАК `SiteFinderReport`-инстанс, ТАК и + его `as_dict()`-словарь (нормализуется через `_normalize`). GRACEFUL: частичный/ + пустой отчёт → листы с «нет данных», НИКОГДА не падает. + + Args: + report: `SiteFinderReport`-инстанс или его `as_dict()`-словарь. + + Returns: + Непустые .xlsx-байты (можно перечитать `openpyxl.load_workbook(BytesIO(...))`), + готовые для `Response(media_type="application/vnd.openxmlformats-...")`. + """ + data = _normalize(report) + + wb = Workbook() + # Workbook создаёт дефолтный лист — переиспользуем его под первый (Сводка). + default_ws = wb.active + for idx, (title, builder) in enumerate(_SHEET_BUILDERS): + ws = default_ws if idx == 0 else wb.create_sheet(title=title) + ws.title = title + builder(ws, data) + + buffer = BytesIO() + wb.save(buffer) + payload = buffer.getvalue() + + meta = _as_dict(data.get("meta")) + logger.info( + "export_report_xlsx: cad_num=%s sheets=%d size=%d bytes advisory=%s", + meta.get("cad_num"), + len(_SHEET_BUILDERS), + len(payload), + data.get("advisory"), + ) + return payload diff --git a/backend/tests/services/exporters/__init__.py b/backend/tests/services/exporters/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/services/exporters/test_excel.py b/backend/tests/services/exporters/test_excel.py new file mode 100644 index 00000000..24b6c72c --- /dev/null +++ b/backend/tests/services/exporters/test_excel.py @@ -0,0 +1,228 @@ +"""Unit-тесты §13 Excel-экспортёра (#991, 955-A5) — `export_report_xlsx`. + +Чистые тесты БЕЗ БД/сети (экспортёр только ПОТРЕБЛЯЕТ уже-собранный отчёт): + • полный `SiteFinderReport` → непустые .xlsx-байты, перечитываемые openpyxl; + • все шесть ожидаемых листов присутствуют (Сводка / Рынок сейчас / Будущий рынок / + Продукт ТЗ / Сценарии / Скоринг); + • известные ячейки заполнены (заголовок вердикта, класс продукта, ADVISORY-маркер); + • экспортёр принимает КАК dataclass-инстанс, ТАК и его `as_dict()`-словарь; + • частичный / пустой отчёт → всё равно валидный .xlsx (graceful, без падения). + +Детерминированно, без LLM. DATABASE_URL выставляем до импорта app-модулей (зеркало +test_report.py) — на случай side-effect'ов импорта пакета. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import datetime as dt +from io import BytesIO + +from openpyxl import load_workbook + +from app.services.exporters.excel import ( + _SHEET_FUTURE_MARKET, + _SHEET_MARKET_NOW, + _SHEET_PRODUCT_TZ, + _SHEET_SCENARIOS, + _SHEET_SCORING, + _SHEET_SUMMARY, + export_report_xlsx, +) +from app.services.forecasting.report import ( + ReportConfidence, + ReportExecSummary, + ReportFutureMarket, + ReportMarketNow, + ReportMeta, + ReportProductTz, + ReportScenarios, + ReportScoring, + SiteFinderReport, +) + +# Шесть ожидаемых листов (по одному на содержательную секцию §13). +_EXPECTED_SHEETS: tuple[str, ...] = ( + _SHEET_SUMMARY, + _SHEET_MARKET_NOW, + _SHEET_FUTURE_MARKET, + _SHEET_PRODUCT_TZ, + _SHEET_SCENARIOS, + _SHEET_SCORING, +) + + +def _full_report() -> SiteFinderReport: + """Полный SiteFinderReport из реалистичных sample-данных (форма #987 `as_dict()`).""" + return SiteFinderReport( + exec_summary=ReportExecSummary( + headline="Строить комфорт-класс, акцент на студии и 1-к.", + verdict="Дефицит малых форматов на горизонте 12 мес; рынок поглощает быстро.", + key_numbers={"overall_score": 0.71, "deficit_index": 0.34, "horizon_months": 12}, + overall_confidence="medium", + ), + market_now=ReportMarketNow( + market_metrics={"unit_velocity": 8.2, "overstock_index": 0.21, "confidence": "medium"}, + competitors=[ + {"obj_id": 1, "comm_name": "ЖК Альфа", "relevance_weight": 0.8}, + {"obj_id": 2, "comm_name": "ЖК Бета", "relevance_weight": 0.5}, + ], + supply_layers={"open_units": 1200, "hidden_units": 800}, + summary="Текущий рынок: умеренная абсорбция, средняя плотность конкурентов.", + ), + future_market=ReportFutureMarket( + forecasts_by_horizon=[ + {"horizon_months": 6, "demand": 410, "supply": 380, "deficit_index": 0.2}, + {"horizon_months": 12, "demand": 920, "supply": 700, "deficit_index": 0.34}, + ], + future_supply={"index": 0.42, "horizon_months": 12, "confidence": "low"}, + future_competitors=[{"obj_id": 3, "stage": "котлован"}], + scenarios_summary={"base": 0.34, "conservative": 0.18, "aggressive": 0.49}, + summary="Будущий рынок: дефицит сохраняется на 12 мес.", + ), + product_tz=ReportProductTz( + obj_class="комфорт", + mix=[ + {"bucket": "1-Студия", "pct": 35}, + {"bucket": "2-1-к", "pct": 40}, + {"bucket": "3-2-к", "pct": 25}, + ], + commercial={"available": False, "caveat": "коммерция: нет достаточных данных"}, + usp=[{"segment": "1-Студия", "usp_text": "Дефицит студий — стройте их."}], + reasons=[{"why": "deficit_index +0.34 на горизонте 12 мес", "advisory": True}], + summary="Рекомендован комфорт-класс с акцентом на малые форматы.", + ), + scenarios=ReportScenarios( + by_scenario={ + "conservative": {"overall": 0.18, "advisory": True}, + "base": {"overall": 0.34, "advisory": True}, + "aggressive": {"overall": 0.49, "advisory": True}, + }, + summary="Разброс сценариев умеренный.", + ), + scoring=ReportScoring( + product_scores={ + "overall": 0.71, + "scores": {"market_fit": {"value": 0.67}, "demand": {"value": 0.51}}, + "advisory": True, + }, + special_indices={ + "indices": {"launch_window": {"value": 0.6, "label": "12 мес"}}, + "advisory": True, + }, + overall=0.71, + ), + confidence=ReportConfidence( + level="medium", + rationale="Источники advisory-capped; данные средней плотности.", + factors={"data_coverage": 0.6, "engine_validated": False}, + ), + meta=ReportMeta( + cad_num="66:41:0000000:1", + district="Верх-Исетский", + segment={"obj_class": "комфорт", "room_bucket": "1-к 30-45"}, + horizons=[6, 12, 18, 24], + generated_at=dt.date(2026, 6, 3), + ), + ) + + +def _reload(payload: bytes): # type: ignore[no-untyped-def] + """Перечитать .xlsx-байты обратно в openpyxl Workbook (round-trip-проверка).""" + return load_workbook(BytesIO(payload)) + + +# ── Полный отчёт: непустые байты, перечитываются, листы + ячейки на месте ────── + + +class TestFullReportExport: + def test_returns_non_empty_bytes(self) -> None: + payload = export_report_xlsx(_full_report()) + assert isinstance(payload, bytes) + assert len(payload) > 0 + + def test_openpyxl_can_reload(self) -> None: + # Главный контракт: байты — валидный .xlsx, перечитываемый openpyxl. + wb = _reload(export_report_xlsx(_full_report())) + assert wb.sheetnames # не пусто + + def test_expected_sheet_titles_present(self) -> None: + wb = _reload(export_report_xlsx(_full_report())) + for title in _EXPECTED_SHEETS: + assert title in wb.sheetnames, f"отсутствует лист {title}" + + def test_known_cells_populated_summary(self) -> None: + wb = _reload(export_report_xlsx(_full_report())) + ws = wb[_SHEET_SUMMARY] + # Заголовок книги + ADVISORY-маркер в первых строках. + assert "§13" in str(ws.cell(row=1, column=1).value) + assert "ADVISORY" in str(ws.cell(row=2, column=1).value) + # Заголовок вердикта (значение метки «Заголовок») присутствует где-то на листе. + flat = [str(c.value) for col in ws.iter_cols() for c in col if c.value is not None] + assert any("Строить комфорт" in v for v in flat) + assert any("66:41:0000000:1" in v for v in flat) + + def test_known_cells_populated_product_tz(self) -> None: + wb = _reload(export_report_xlsx(_full_report())) + ws = wb[_SHEET_PRODUCT_TZ] + flat = [str(c.value) for col in ws.iter_cols() for c in col if c.value is not None] + assert any("комфорт" in v for v in flat) + assert any("1-Студия" in v for v in flat) + + def test_known_cells_populated_scoring(self) -> None: + wb = _reload(export_report_xlsx(_full_report())) + ws = wb[_SHEET_SCORING] + flat = [c.value for col in ws.iter_cols() for c in col if c.value is not None] + # overall-скор 0.71 присутствует как число (округление сохраняет значение). + assert 0.71 in flat + assert any(isinstance(v, str) and "market_fit" in v for v in flat) + + def test_accepts_as_dict_input(self) -> None: + # Экспортёр принимает уже-словарь `as_dict()` (не только dataclass). + payload = export_report_xlsx(_full_report().as_dict()) + wb = _reload(payload) + for title in _EXPECTED_SHEETS: + assert title in wb.sheetnames + + +# ── Graceful: частичный / пустой / мусорный вход → валидный .xlsx без падения ── + + +class TestGracefulPartialReport: + def test_empty_report_still_valid_xlsx(self) -> None: + # Полностью дефолтный (пустой) отчёт — валидный .xlsx, все листы, не падает. + payload = export_report_xlsx(SiteFinderReport()) + assert len(payload) > 0 + wb = _reload(payload) + for title in _EXPECTED_SHEETS: + assert title in wb.sheetnames + + def test_empty_report_shows_no_data_marker(self) -> None: + wb = _reload(export_report_xlsx(SiteFinderReport())) + ws = wb[_SHEET_SCORING] + flat = [str(c.value) for col in ws.iter_cols() for c in col if c.value is not None] + # Пустая секция рисует заглушку «нет данных» (graceful), а не падает. + assert any("нет данных" in v for v in flat) + + def test_partial_report_some_sections(self) -> None: + # Заполнены только meta + exec_summary — остальные пусты, .xlsx валиден. + report = SiteFinderReport( + exec_summary=ReportExecSummary(headline="Тонкий анализ — только заголовок."), + meta=ReportMeta(cad_num="66:41:0000000:2", horizons=[12]), + ) + wb = _reload(export_report_xlsx(report)) + ws = wb[_SHEET_SUMMARY] + flat = [str(c.value) for col in ws.iter_cols() for c in col if c.value is not None] + assert any("Тонкий анализ" in v for v in flat) + assert any("66:41:0000000:2" in v for v in flat) + + def test_garbage_input_does_not_crash(self) -> None: + # Мусор (None / не-отчёт) → пустой, но валидный .xlsx (нормализация в {}). + for junk in (None, 123, "not a report"): + payload = export_report_xlsx(junk) + assert len(payload) > 0 + wb = _reload(payload) + assert _SHEET_SUMMARY in wb.sheetnames -- 2.45.3