From 25f70520847705f7b1d24d84bffe9ad6f27d7e6b Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 6 Jun 2026 23:39:17 +0500 Subject: [PATCH] =?UTF-8?q?feat(export):=20Markdown=20+=20JSON=20download?= =?UTF-8?q?=20of=20=C2=A722=20forecast=20report=20(#959)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit render_report_markdown (pure, no new runtime deps) reuses report_pdf's str helpers (DRY), + GET /{cad_num}/forecast/export?format=md|json. No forecast run → 404; graceful on thin/empty reports; GFM-safe table escaping. PDF/XLSX already existed; this adds the cheapest no-dep formats. Part of EPIC #959. --- backend/app/api/v1/parcels.py | 56 ++- backend/app/services/exporters/report_md.py | 403 ++++++++++++++++++ backend/tests/api/v1/test_parcels_forecast.py | 130 ++++++ .../services/exporters/test_report_md.py | 195 +++++++++ 4 files changed, 783 insertions(+), 1 deletion(-) create mode 100644 backend/app/services/exporters/report_md.py create mode 100644 backend/tests/services/exporters/test_report_md.py diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 85faa5ea..6278cd61 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -3,7 +3,7 @@ import json import logging import math import time -from typing import Annotated, Any +from typing import Annotated, Any, Literal import httpx from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Response @@ -44,6 +44,7 @@ from app.services.analysis_runs.repository import ( persist_analysis_run, ) from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf +from app.services.exporters.report_md import render_report_markdown from app.services.exporters.snapshot_pdf import generate_snapshot_pdf from app.services.site_finder.best_layouts import get_best_layouts from app.services.site_finder.cadastre_fetch import ( @@ -1313,6 +1314,59 @@ def get_parcel_forecast( return {"status": "pending"} +@router.get("/{cad_num}/forecast/export") +def export_parcel_forecast( + cad_num: str, + db: Annotated[Session, Depends(get_db)], + format: Annotated[ + Literal["md", "json"], + Query(description="Формат выгрузки: md (Markdown) | json (сырой отчёт)"), + ] = "md", +) -> Response: + """Скачать §22-форсайт участка файлом — Markdown (default) или JSON (#959, EPIC export). + + Самый дешёвый, БЕЗ-runtime-dep срез экспорта: читает ПОСЛЕДНИЙ §22-ран schema_version + "1.0" (тот же блоб, что отдаёт GET /{cad}/forecast inline) и отдаёт его как attachment. + • format=md → `render_report_markdown(run.result)` (text/markdown, чистый Markdown). + • format=json → сырой `run.result` (application/json) — download-вариант inline-чтения. + + В отличие от read-only поллинга GET /{cad}/forecast (нет рана → 202 pending), это + download-эндпоинт: нет рана → 404 (graceful HTTPException «прогноз ещё не посчитан», + НЕ 500). 3-сегментный путь (`/{cad}/forecast/export`) не конфликтует с 2-сегментными + `/{cad}/forecast`, `/{cad}/runs`, `/runs/{run_id}` и 1-сегментным `/{parcel_id}`. + + Args: + cad_num: кадастровый номер участка (в имени файла `:` → `_`). + format: "md" (default) | "json". + + Returns: + Response-attachment с Content-Disposition (имя + `gendesign_forecast__.`). + """ + run = latest_run_for(db, cad_num, schema_version=_FORECAST_SCHEMA_VERSION) + if run is None: + raise HTTPException(status_code=404, detail="прогноз ещё не посчитан") + + cad_safe = cad_num.replace(":", "_") + today = _dt.date.today().strftime("%Y-%m-%d") + base_name = f"gendesign_forecast_{cad_safe}_{today}" + + if format == "json": + payload = json.dumps(run.result, ensure_ascii=False, default=str) + media_type = "application/json" + filename = f"{base_name}.json" + else: + payload = render_report_markdown(run.result) + media_type = "text/markdown; charset=utf-8" + filename = f"{base_name}.md" + + return Response( + content=payload, + media_type=media_type, + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + @router.post("/{cad_num}/analyze", response_model=AnalyzeResponse) def analyze_parcel( cad_num: str, diff --git a/backend/app/services/exporters/report_md.py b/backend/app/services/exporters/report_md.py new file mode 100644 index 00000000..14b01177 --- /dev/null +++ b/backend/app/services/exporters/report_md.py @@ -0,0 +1,403 @@ +"""§13/§22 Markdown-экспортёр итогового советующего отчёта Site Finder v2. + +#959 (EPIC export). Самый дешёвый, БЕЗ-runtime-dep двойник PDF/Excel-экспортёров +(`report_pdf` / `excel`): рендерит `SiteFinderReport` (#987) в ЧИТАЕМЫЙ Markdown-текст +и возвращает СТРОКУ — готовую для `Response(media_type="text/markdown")` / записи. +Чистый str-билдинг (НИКАКИХ новых зависимостей: ни python-docx, ни pptx, ни шаблонизатора). + +DRY: PURE-хелперы нормализации/форматирования НЕ дублируем — импортируем из +`report_pdf` (там они уже есть: `_normalize` — приём инстанса ИЛИ `as_dict()`-словаря, +`_fmt` — строковое форматирование ячейки/None→"—", `_level_ru`, `_as_dict`/`_as_list`, +`_future_supply_pairs`, `_scenario_deficit_index`) + переиспользуем его named-константы +(`_DASH`/`_NO_DATA`/`_ADVISORY_MARKER`). `report_pdf._fmt` возвращает `str` (в отличие +от `excel._fmt`, отдающего `Any` под ячейку openpyxl) — для Markdown нужен именно str, +поэтому источник хелперов — `report_pdf`. Здесь добавлены ТОЛЬКО Markdown-специфичные +микро-билдеры (kv-строки / таблицы / экранирование pipe). + +ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM, БЕЗ БД/сети: только ПОТРЕБЛЯЕТ уже-собранный отчёт (его +наполняет сборщик #988). Принимает КАК `SiteFinderReport`-инстанс, ТАК и его +`as_dict()`-словарь (нормализуется через `_normalize`). + +GRACEFUL (дух всего форсайт-стека): частичный/пустой/мусорный отчёт ВАЛИДЕН — пустая +секция рисует «нет данных», экспортёр НИКОГДА не падает (нет KeyError на тонком отчёте). +Отчёт СОВЕТУЮЩИЙ: после H1 — заметный advisory-дисклеймер (оценка не основание для +инвест-решения). Содержание и ПОРЯДОК секций ЗЕРКАЛЯТ `report_pdf`. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from app.services.exporters.report_pdf import ( + _ADVISORY_MARKER, + _DASH, + _NO_DATA, + _as_dict, + _as_list, + _fmt, + _future_supply_pairs, + _level_ru, + _normalize, + _scenario_deficit_index, +) + +logger = logging.getLogger(__name__) + +# ── Named-константы: заголовки (зеркало report_pdf — те же восемь секций §13) ── + +_TITLE_DOC: str = "Site Finder v2 — советующий отчёт" +_TITLE_SUMMARY: str = "Сводка" +_TITLE_MARKET_NOW: str = "Рынок сейчас" +_TITLE_FUTURE_MARKET: str = "Будущий рынок" +_TITLE_SCENARIOS: str = "Сценарии" +_TITLE_PRODUCT_TZ: str = "Продукт ТЗ" +_TITLE_SCORING: str = "Скоринг" +_TITLE_CONFIDENCE: str = "Уверенность" + +# Краткий RU-дисклеймер (формулировка из ТЗ #959) — после H1, виден сразу. +_ADVISORY_DISCLAIMER: str = "Оценка advisory — не основание для инвест-решения." + +# Сколько конкурентов выводить в таблицу (top-N, чтобы не раздувать MD). Зеркало +# competitors_limit=5 у snapshot-экспорта. +_COMPETITORS_TOP_N: int = 5 + +# Основной продуктовый горизонт (мес) для подписи сводного дефицита сценария +# (зеркало report_pdf._PRIMARY_HORIZON_MONTHS — там private, держим локальный дубль +# ТОЛЬКО как подпись столбца, само значение тянет импортируемый _scenario_deficit_index). +_PRIMARY_HORIZON_MONTHS: int = 12 + + +# ────────────────────────────────────────────────────────────────────────────── +# Markdown-специфичные микро-билдеры. Все PURE: строят строку из уже-форматированных +# через `_fmt` кусочков. (Хелперы нормализации/форматирования — импортированы из +# report_pdf, не дублируются — DRY.) +# ────────────────────────────────────────────────────────────────────────────── + + +def _md_cell(value: Any) -> str: + """Значение → текст ячейки Markdown-таблицы: `_fmt` + экранирование `|`. PURE. + + Pipe внутри значения сломал бы разметку таблицы — заменяем на escaped `\\|`. + Переводы строк сворачиваем в пробел (ячейка таблицы однострочна). + """ + text = _fmt(value) + return text.replace("|", "\\|").replace("\n", " ").replace("\r", " ") + + +def _md_table(headers: list[str], rows: list[list[Any]]) -> str: + """GFM-таблица: шапка + разделитель + строки (через `_md_cell`). PURE. + + Пустой `rows` → строка-заглушка «нет данных» (graceful — таблица валидна). + Возвращает блок без хвостовых пустых строк (склейку делает вызывающий). + """ + head = "| " + " | ".join(headers) + " |" + sep = "| " + " | ".join("---" for _ in headers) + " |" + if not rows: + empty = "| " + " | ".join([_NO_DATA] + [""] * (len(headers) - 1)) + " |" + return "\n".join([head, sep, empty]) + body = [ + "| " + " | ".join(_md_cell(cell) for cell in row) + " |" + for row in rows + ] + return "\n".join([head, sep, *body]) + + +def _md_kv_table(data: dict[str, Any]) -> str: + """Таблица «Показатель → Значение» из плоского dict. Пустой → «нет данных». PURE.""" + rows = [[str(key), value] for key, value in data.items()] + return _md_table(["Показатель", "Значение"], rows) + + +def _md_kv_lines(pairs: list[tuple[str, Any]]) -> str: + """Список «**метка:** значение» построчно (для коротких карточек meta). PURE.""" + if not pairs: + return _NO_DATA + return "\n".join(f"- **{label}:** {_fmt(value)}" for label, value in pairs) + + +def _join_horizons(values: list[Any]) -> Any: + """Свернуть список горизонтов в «6, 12, 18» или None (→ `_fmt` отдаст "—"). PURE.""" + return ", ".join(str(v) for v in values) if values else None + + +# ────────────────────────────────────────────────────────────────────────────── +# Построители секций — по одной на содержательную секцию §13, тот же порядок/набор, +# что и в report_pdf. Каждый graceful (пустая секция → «нет данных», не падает) и +# возвращает Markdown-блок (строку) без ведущего/хвостового перевода строки. +# ────────────────────────────────────────────────────────────────────────────── + + +def _build_header(report: dict[str, Any]) -> str: + """H1 + advisory-дисклеймер + карточка meta (cad/район/горизонты/дата/схема). PURE.""" + meta = _as_dict(report.get("meta")) + schema_version = report.get("schema_version") or meta.get("schema_version") + meta_pairs: list[tuple[str, Any]] = [ + ("Кадастровый номер", meta.get("cad_num")), + ("Район", meta.get("district")), + ("Горизонты (мес)", _join_horizons(_as_list(meta.get("horizons")))), + ("Сформировано", meta.get("generated_at")), + ("Версия схемы", schema_version), + ] + return ( + f"# {_TITLE_DOC}\n\n" + f"> **{_ADVISORY_DISCLAIMER}** {_ADVISORY_MARKER}\n\n" + f"{_md_kv_lines(meta_pairs)}" + ) + + +def _build_summary(report: dict[str, Any]) -> str: + """§13.1 «Сводка»: headline (bold) + вердикт + общая уверенность + ключевые числа.""" + exec_summary = _as_dict(report.get("exec_summary")) + headline = exec_summary.get("headline") + verdict = exec_summary.get("verdict") + key_numbers = _as_dict(exec_summary.get("key_numbers")) + overall_conf = _level_ru(exec_summary.get("overall_confidence")) + + return ( + f"## {_TITLE_SUMMARY}\n\n" + f"**{_fmt(headline)}**\n\n" + f"{_fmt(verdict)}\n\n" + f"_Общая уверенность: {overall_conf}_\n\n" + f"### Ключевые числа\n\n" + f"{_md_kv_table(key_numbers)}" + ) + + +def _build_market_now(report: dict[str, Any]) -> str: + """§13.2 «Рынок сейчас»: резюме + метрики + слои предложения + конкуренты (top-N).""" + market_now = _as_dict(report.get("market_now")) + metrics = _as_dict(market_now.get("market_metrics")) + supply = _as_dict(market_now.get("supply_layers")) + competitors = _as_list(market_now.get("competitors")) + + comp_rows = [ + [c.get("comm_name"), c.get("obj_id"), c.get("relevance_weight")] + for c in competitors[:_COMPETITORS_TOP_N] + if isinstance(c, dict) + ] + + return ( + f"## {_TITLE_MARKET_NOW}\n\n" + f"{_fmt(market_now.get('summary'))}\n\n" + f"### Метрики рынка\n\n" + f"{_md_kv_table(metrics)}\n\n" + f"### Слои предложения\n\n" + f"{_md_kv_table(supply)}\n\n" + f"### Конкуренты\n\n" + f"{_md_table(['ЖК', 'ID', 'Релевантность'], comp_rows)}" + ) + + +def _build_future_market(report: dict[str, Any]) -> str: + """§13.3 «Будущий рынок»: таблица по горизонтам + давление предложения + сценарии.""" + future = _as_dict(report.get("future_market")) + forecasts = _as_list(future.get("forecasts_by_horizon")) + scenarios_summary = _as_dict(future.get("scenarios_summary")) + + forecast_headers = [ + "Горизонт, мес", + "Индекс дефицита", + "Месяцев запаса", + "Спрос", + "Предложение", + "Ставка", + "Уверенность", + ] + forecast_rows = [ + [ + f.get("horizon_months"), + f.get("deficit_index"), + f.get("months_of_inventory"), + f.get("projected_demand_units"), + f.get("projected_supply_units"), + f.get("rate_future"), + _level_ru(f.get("confidence")), + ] + for f in forecasts + if isinstance(f, dict) + ] + + return ( + f"## {_TITLE_FUTURE_MARKET}\n\n" + f"{_fmt(future.get('summary'))}\n\n" + f"### Прогноз по горизонтам\n\n" + f"{_md_table(forecast_headers, forecast_rows)}\n\n" + f"### Давление будущего предложения\n\n" + f"{_md_kv_table(_future_supply_pairs(future.get('future_supply')))}\n\n" + f"### Разброс сценариев (индекс дефицита)\n\n" + f"{_md_kv_table(scenarios_summary)}" + ) + + +def _build_scenarios(report: dict[str, Any]) -> str: + """§13.5 «Сценарии»: base/aggressive/conservative — дефицит (12 мес) + rate_path.""" + scenarios = _as_dict(report.get("scenarios")) + by_scenario = _as_dict(scenarios.get("by_scenario")) + + rows: list[list[Any]] = [] + for name, payload in by_scenario.items(): + data = _as_dict(payload) + rate_path = _as_dict(data.get("rate_path")) + rate_str = ( + ", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None + ) + rows.append( + [name, _scenario_deficit_index(data), rate_str, data.get("advisory")] + ) + + headers = [ + "Сценарий", + f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)", + "Траектория ставки", + "Advisory", + ] + return ( + f"## {_TITLE_SCENARIOS}\n\n" + f"{_fmt(scenarios.get('summary'))}\n\n" + f"{_md_table(headers, rows)}" + ) + + +def _build_product_tz(report: dict[str, Any]) -> str: + """§13.4 «Продукт ТЗ»: класс + квартирография (mix) + коммерция + USP + причины.""" + 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")) + reasons = _as_list(product.get("reasons")) + + head_pairs: list[tuple[str, Any]] = [ + ("Резюме", product.get("summary")), + ("Рекомендованный класс", product.get("obj_class")), + ] + mix_rows = [ + [m.get("bucket"), m.get("pct"), m.get("obj_class"), m.get("deficit_index")] + for m in mix + if isinstance(m, dict) + ] + usp_rows = [[u.get("segment"), u.get("usp_text")] for u in usp if isinstance(u, dict)] + reason_rows = [ + [r.get("why"), r.get("advisory")] for r in reasons if isinstance(r, dict) + ] + + return ( + f"## {_TITLE_PRODUCT_TZ}\n\n" + f"{_md_kv_lines(head_pairs)}\n\n" + f"### Квартирография (mix)\n\n" + f"{_md_table(['Формат', 'Доля, %', 'Класс', 'Индекс дефицита'], mix_rows)}\n\n" + f"### Коммерция\n\n" + f"{_md_kv_table(commercial)}\n\n" + f"### USP-ниши\n\n" + f"{_md_table(['Сегмент', 'USP'], usp_rows)}\n\n" + f"### Причины (§16)\n\n" + f"{_md_table(['Почему', 'Advisory'], reason_rows)}" + ) + + +def _build_scoring(report: dict[str, Any]) -> str: + """§13.6 «Скоринг»: продуктовые скоры (#985) + спец-индексы (#986) + overall.""" + scoring = _as_dict(report.get("scoring")) + product_scores = _as_dict(scoring.get("product_scores")) + special_indices = _as_dict(scoring.get("special_indices")) + + scores = _as_dict(product_scores.get("scores")) + score_rows = [[name, _as_dict(payload).get("value")] for name, payload in scores.items()] + + 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() + ] + + return ( + f"## {_TITLE_SCORING}\n\n" + f"_Итоговый скор (overall): {_fmt(scoring.get('overall'))}_\n\n" + f"### Продуктовые скоры\n\n" + f"{_md_table(['Скор', 'Значение'], score_rows)}\n\n" + f"### Специальные индексы\n\n" + f"{_md_table(['Индекс', 'Значение', 'Метка'], index_rows)}" + ) + + +def _build_confidence(report: dict[str, Any]) -> str: + """§13.7 «Уверенность»: уровень + обоснование + факторы-драйверы (таблица).""" + confidence = _as_dict(report.get("confidence")) + level = _level_ru(confidence.get("level")) + rationale = confidence.get("rationale") + factors = _as_dict(confidence.get("factors")) + + # Факторы #990: {name: {value, level, note}} ИЛИ плоское {name: value}. Defensive: + # если значение — dict, раскладываем на value/level/note; иначе кладём как есть. + factor_rows: list[list[Any]] = [] + for name, payload in factors.items(): + if isinstance(payload, dict): + factor_rows.append( + [name, payload.get("value"), _level_ru(payload.get("level")), payload.get("note")] + ) + else: + factor_rows.append([name, payload, _DASH, _DASH]) + + return ( + f"## {_TITLE_CONFIDENCE}\n\n" + f"_Уровень: {level}_\n\n" + f"{_fmt(rationale)}\n\n" + f"### Факторы уверенности\n\n" + f"{_md_table(['Фактор', 'Значение', 'Уровень', 'Комментарий'], factor_rows)}" + ) + + +# Реестр построителей. Порядок = порядок блоков в документе (зеркало report_pdf: +# Сводка → Рынок сейчас → Будущий рынок → Сценарии → Продукт ТЗ → Скоринг → +# Уверенность; header идёт отдельно первым). +_SECTION_BUILDERS: tuple[Any, ...] = ( + _build_summary, + _build_market_now, + _build_future_market, + _build_scenarios, + _build_product_tz, + _build_scoring, + _build_confidence, +) + + +# ────────────────────────────────────────────────────────────────────────────── +# Публичный API — рендер отчёта в Markdown-строку (без файлового I/O, без зависимостей). +# ────────────────────────────────────────────────────────────────────────────── + + +def render_report_markdown(report: Any) -> str: + """§13/§22 Отрендерить `SiteFinderReport` (#987) в читаемый Markdown и вернуть str. + + H1 + advisory-дисклеймер + карточка meta, далее восемь содержательных секций §13 + (Сводка / Рынок сейчас / Будущий рынок / Сценарии / Продукт ТЗ / Скоринг / + Уверенность) — содержание и порядок ЗЕРКАЛЯТ `report_pdf`. Числа форматируются + через импортированный `_fmt` (None → "—"), уровни уверенности — RU-метками. + Чистый Markdown (GFM-таблицы), без HTML. + + ДЕТЕРМИНИРОВАННО, БЕЗ LLM/БД/сети, БЕЗ новых зависимостей. Принимает КАК + `SiteFinderReport`-инстанс, ТАК и его `as_dict()`-словарь (нормализуется через + импортированный `_normalize`). GRACEFUL: частичный/пустой/мусорный отчёт → секции + с «нет данных», НИКОГДА не падает (нет KeyError на тонком отчёте). + + Args: + report: `SiteFinderReport`-инстанс или его `as_dict()`-словарь (или мусор → ""). + + Returns: + Непустую Markdown-строку, готовую для + `Response(media_type="text/markdown; charset=utf-8")`. + """ + data = _normalize(report) + blocks: list[str] = [_build_header(data)] + blocks.extend(builder(data) for builder in _SECTION_BUILDERS) + markdown = "\n\n".join(blocks) + "\n" + + meta = _as_dict(data.get("meta")) + logger.info( + "render_report_markdown: cad_num=%s sections=%d len=%d advisory=%s", + meta.get("cad_num"), + len(_SECTION_BUILDERS), + len(markdown), + data.get("advisory"), + ) + return markdown diff --git a/backend/tests/api/v1/test_parcels_forecast.py b/backend/tests/api/v1/test_parcels_forecast.py index 227116fb..70f792f8 100644 --- a/backend/tests/api/v1/test_parcels_forecast.py +++ b/backend/tests/api/v1/test_parcels_forecast.py @@ -327,3 +327,133 @@ def test_get_forecast_db_error_returns_pending_not_500() -> None: assert resp.json() == {"status": "pending"} finally: app.dependency_overrides.clear() + + +# ── GET /{cad}/forecast/export (#959, EPIC export — Markdown + JSON download) ──── + +_REPORT_FIXTURE = { + "schema_version": "1.0", + "advisory": True, + "exec_summary": { + "headline": "Строить комфорт-класс.", + "verdict": "Дефицит малых форматов на горизонте 12 мес.", + "key_numbers": {"deficit_index": -1.0, "overall_score": 0.42}, + "overall_confidence": "low", + }, + "future_market": { + "forecasts_by_horizon": [ + { + "horizon_months": 12, + "deficit_index": -1.0, + "projected_demand_units": 2330.8, + "projected_supply_units": 42953.2, + "confidence": "low", + } + ], + }, + "meta": {"cad_num": _CAD, "district": "Кировский", "horizons": [6, 12, 18]}, +} + + +def test_export_forecast_md_returns_markdown() -> None: + """format=md → 200 text/markdown + Content-Disposition .md, тело — Markdown отчёт.""" + from app.core.db import get_db + + fake_run = MagicMock() + fake_run.result = _REPORT_FIXTURE + + db = MagicMock() + app.dependency_overrides[get_db] = _override_db(db) + try: + with patch("app.api.v1.parcels.latest_run_for", return_value=fake_run) as lrf: + client = TestClient(app) + resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=md") + assert resp.status_code == 200, resp.text + assert resp.headers["content-type"].startswith("text/markdown") + cd = resp.headers["content-disposition"] + assert "attachment;" in cd + assert cd.endswith('.md"') + # cad-двоеточия санитизированы в имени файла. + assert f"gendesign_forecast_{_CAD.replace(':', '_')}_" in cd + body = resp.text + assert body.lstrip().startswith("# ") + assert "Оценка advisory" in body + # Читаем именно §22-схему "1.0". + _, kwargs = lrf.call_args + assert kwargs.get("schema_version") == "1.0" + finally: + app.dependency_overrides.clear() + + +def test_export_forecast_md_is_default_format() -> None: + """Без format → default md (text/markdown).""" + from app.core.db import get_db + + fake_run = MagicMock() + fake_run.result = _REPORT_FIXTURE + + db = MagicMock() + app.dependency_overrides[get_db] = _override_db(db) + try: + with patch("app.api.v1.parcels.latest_run_for", return_value=fake_run): + client = TestClient(app) + resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export") + assert resp.status_code == 200, resp.text + assert resp.headers["content-type"].startswith("text/markdown") + finally: + app.dependency_overrides.clear() + + +def test_export_forecast_json_returns_dict() -> None: + """format=json → 200 application/json + attachment .json, тело = run.result dict.""" + from app.core.db import get_db + + fake_run = MagicMock() + fake_run.result = _REPORT_FIXTURE + + db = MagicMock() + app.dependency_overrides[get_db] = _override_db(db) + try: + with patch("app.api.v1.parcels.latest_run_for", return_value=fake_run): + client = TestClient(app) + resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=json") + assert resp.status_code == 200, resp.text + assert resp.headers["content-type"] == "application/json" + cd = resp.headers["content-disposition"] + assert "attachment;" in cd + assert cd.endswith('.json"') + assert resp.json() == _REPORT_FIXTURE + finally: + app.dependency_overrides.clear() + + +def test_export_forecast_no_run_returns_404() -> None: + """Рана нет (latest_run_for → None) → 404 (download-вариант, НЕ 202/500).""" + from app.core.db import get_db + + db = MagicMock() + app.dependency_overrides[get_db] = _override_db(db) + try: + with patch("app.api.v1.parcels.latest_run_for", return_value=None): + client = TestClient(app) + resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export") + assert resp.status_code == 404, resp.text + assert "посчитан" in resp.json()["detail"] + finally: + app.dependency_overrides.clear() + + +def test_export_forecast_invalid_format_returns_422() -> None: + """format вне {md,json} → 422 (Literal-валидация ДО работы с БД).""" + from app.core.db import get_db + + db = MagicMock() + app.dependency_overrides[get_db] = _override_db(db) + try: + with patch("app.api.v1.parcels.latest_run_for", return_value=None) as lrf: + client = TestClient(app) + resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=pdf") + assert resp.status_code == 422, resp.text + lrf.assert_not_called() + finally: + app.dependency_overrides.clear() diff --git a/backend/tests/services/exporters/test_report_md.py b/backend/tests/services/exporters/test_report_md.py new file mode 100644 index 00000000..3d7957b9 --- /dev/null +++ b/backend/tests/services/exporters/test_report_md.py @@ -0,0 +1,195 @@ +"""Unit-тесты §13/§22 Markdown-экспортёра (#959, EPIC export) — `render_report_markdown`. + +Чистые тесты БЕЗ БД/сети (экспортёр только ПОТРЕБЛЯЕТ уже-собранный отчёт): + • реальный fixture-отчёт (frontend mock `parcel-forecast.json` → `report`) рендерится + в Markdown с заголовками всех секций, advisory-дисклеймером и значением дефицита; + • экспортёр принимает КАК `SiteFinderReport`-инстанс, ТАК и его `as_dict()`-словарь; + • пустой / частичный / мусорный отчёт → валидный Markdown БЕЗ падения (graceful); + • pipe в данных экранируется (не ломает GFM-таблицу). + +Детерминированно, без LLM. DATABASE_URL выставляем до импорта app-модулей (зеркало +test_report_pdf.py) — на случай side-effect'ов импорта пакета. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services.exporters.report_md import ( + _ADVISORY_DISCLAIMER, + _TITLE_CONFIDENCE, + _TITLE_FUTURE_MARKET, + _TITLE_MARKET_NOW, + _TITLE_PRODUCT_TZ, + _TITLE_SCENARIOS, + _TITLE_SCORING, + _TITLE_SUMMARY, + render_report_markdown, +) +from app.services.forecasting.report import ( + ReportExecSummary, + ReportMeta, + SiteFinderReport, +) + +# Реальный fixture: frontend mock §22-форсайта — берём вложенный `report` (форма #987 +# `as_dict()`). Так тест не разойдётся с реальной формой отчёта. +_FIXTURE_PATH = ( + Path(__file__).resolve().parents[4] + / "frontend" + / "src" + / "lib" + / "mocks" + / "parcel-forecast.json" +) + + +def _fixture_report() -> dict[str, Any]: + """`report`-словарь из frontend-мока §22 (форма SiteFinderReport.as_dict()).""" + data = json.loads(_FIXTURE_PATH.read_text(encoding="utf-8")) + report = data["report"] + assert isinstance(report, dict) + return report + + +_EXPECTED_TITLES: tuple[str, ...] = ( + _TITLE_SUMMARY, + _TITLE_MARKET_NOW, + _TITLE_FUTURE_MARKET, + _TITLE_SCENARIOS, + _TITLE_PRODUCT_TZ, + _TITLE_SCORING, + _TITLE_CONFIDENCE, +) + + +# ── Реальный fixture-отчёт: структура + ключевые значения ─────────────────────── + + +class TestFixtureReportRender: + def test_returns_non_empty_str(self) -> None: + md = render_report_markdown(_fixture_report()) + assert isinstance(md, str) + assert len(md) > 200 + + def test_h1_and_advisory_disclaimer_present(self) -> None: + md = render_report_markdown(_fixture_report()) + # H1 заголовок документа. + assert md.lstrip().startswith("# ") + # Advisory-дисклеймер обязателен (отчёт советующий). + assert _ADVISORY_DISCLAIMER in md + + def test_all_section_headers_present(self) -> None: + md = render_report_markdown(_fixture_report()) + for title in _EXPECTED_TITLES: + assert f"## {title}" in md, f"отсутствует заголовок секции «{title}»" + + def test_meta_cad_num_present(self) -> None: + md = render_report_markdown(_fixture_report()) + # Кадастр из meta фикстуры попадает в карточку. + assert "66:41:0702048:27" in md + + def test_deficit_value_present(self) -> None: + # Сводный индекс дефицита (−1.0) из exec_summary/forecasts должен быть в MD. + report = _fixture_report() + deficit = report["exec_summary"]["key_numbers"]["deficit_index"] + assert deficit is not None + # _fmt округляет float до 3 знаков → "-1.0". + assert str(round(float(deficit), 3)) in md_value(report) + + def test_headline_present(self) -> None: + report = _fixture_report() + headline = report["exec_summary"]["headline"] + md = render_report_markdown(report) + # Заголовок-вердикт присутствует (bold). + assert headline.split(".")[0] in md + + def test_gfm_table_separator_present(self) -> None: + # Хотя бы одна GFM-таблица (разделитель шапки) есть в выводе. + md = render_report_markdown(_fixture_report()) + assert "| --- |" in md + + +def md_value(report: dict[str, Any]) -> str: + """Helper: отрендерить отчёт (сахар для inline-ассертов).""" + return render_report_markdown(report) + + +# ── Вход: dataclass-инстанс ИЛИ as_dict()-словарь ─────────────────────────────── + + +class TestInputForms: + def test_accepts_dataclass_instance(self) -> None: + report = SiteFinderReport( + exec_summary=ReportExecSummary(headline="Строить комфорт-класс."), + meta=ReportMeta(cad_num="66:41:0000000:1", horizons=[12]), + ) + md = render_report_markdown(report) + assert "Строить комфорт" in md + assert "66:41:0000000:1" in md + + def test_dataclass_and_dict_equivalent(self) -> None: + report = SiteFinderReport( + exec_summary=ReportExecSummary(headline="Один заголовок."), + meta=ReportMeta(cad_num="66:41:0000000:9"), + ) + assert render_report_markdown(report) == render_report_markdown(report.as_dict()) + + +# ── Graceful: пустой / частичный / мусорный вход → валидный MD без падения ─────── + + +class TestGraceful: + def test_empty_dict_does_not_crash(self) -> None: + # Пустой отчёт {} → валидный MD (no KeyError), с дисклеймером и «нет данных». + md = render_report_markdown({}) + assert isinstance(md, str) + assert _ADVISORY_DISCLAIMER in md + assert "нет данных" in md + # Все секции всё равно отрисованы (стабильный контракт). + for title in _EXPECTED_TITLES: + assert f"## {title}" in md + + def test_default_report_does_not_crash(self) -> None: + # Полностью дефолтный (пустой) SiteFinderReport — валидный MD. + md = render_report_markdown(SiteFinderReport()) + assert _ADVISORY_DISCLAIMER in md + assert "нет данных" in md + + def test_garbage_input_returns_valid_md(self) -> None: + # Мусор (None / int / строка) → нормализуется в {} → валидный MD, не падает. + for junk in (None, 123, "not a report", []): + md = render_report_markdown(junk) + assert isinstance(md, str) + assert _ADVISORY_DISCLAIMER in md + + def test_partial_report_some_sections(self) -> None: + report = SiteFinderReport( + exec_summary=ReportExecSummary(headline="Тонкий анализ — только заголовок."), + meta=ReportMeta(cad_num="66:41:0000000:2", horizons=[12]), + ) + md = render_report_markdown(report) + assert "Тонкий анализ" in md + assert "66:41:0000000:2" in md + # Незаполненные секции рисуют «нет данных», но MD валиден. + assert "нет данных" in md + + +# ── Безопасность таблиц: pipe в данных не ломает GFM-разметку ─────────────────── + + +class TestPipeEscaping: + def test_pipe_in_value_escaped(self) -> None: + report = SiteFinderReport( + exec_summary=ReportExecSummary( + key_numbers={"weird|key": "value|with|pipes"}, + ), + ) + md = render_report_markdown(report) + # Сырой неэкранированный pipe из значения не должен утечь — он экранирован. + assert "value\\|with\\|pipes" in md