feat(exporters): Excel exporter for SiteFinderReport §13 (#991, 955-A5)
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).
This commit is contained in:
parent
fc312fe606
commit
815bae5591
3 changed files with 709 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
0
backend/tests/services/exporters/__init__.py
Normal file
0
backend/tests/services/exporters/__init__.py
Normal file
228
backend/tests/services/exporters/test_excel.py
Normal file
228
backend/tests/services/exporters/test_excel.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue