diff --git a/backend/app/services/forecasting/__init__.py b/backend/app/services/forecasting/__init__.py index dc0b48c0..02fe86d2 100644 --- a/backend/app/services/forecasting/__init__.py +++ b/backend/app/services/forecasting/__init__.py @@ -77,6 +77,17 @@ from app.services.forecasting.rate_sensitivity import ( ols_slope_r2, shrink, ) +from app.services.forecasting.report import ( + ReportConfidence, + ReportExecSummary, + ReportFutureMarket, + ReportMarketNow, + ReportMeta, + ReportProductTz, + ReportScenarios, + ReportScoring, + SiteFinderReport, +) from app.services.forecasting.sales_series import ( SalesSeries, SegmentSpec, @@ -112,9 +123,18 @@ __all__ = [ "ProductScoreCard", "RankedSegment", "RateSensitivity", + "ReportConfidence", + "ReportExecSummary", + "ReportFutureMarket", + "ReportMarketNow", + "ReportMeta", + "ReportProductTz", + "ReportScenarios", + "ReportScoring", "SalesSeries", "ScenarioForecast", "SegmentSpec", + "SiteFinderReport", "SpecialIndex", "SpecialIndices", "WhatToBuildRanking", diff --git a/backend/app/services/forecasting/report.py b/backend/app/services/forecasting/report.py new file mode 100644 index 00000000..5daca21b --- /dev/null +++ b/backend/app/services/forecasting/report.py @@ -0,0 +1,332 @@ +"""§13 SiteFinderReport — структурный контейнер итогового советующего отчёта. + +#987 (955-A1, Site Finder v2 / «GG-форсайт» ТЗ §13), EPIC 11 «Отчёт». Это ЧИСТЫЙ +СТРУКТУРНЫЙ объект — frozen, сериализуемый dataclass, фиксирующий ВОСЕМЬ секций §13 +отчёта. Он НИЧЕГО не считает и не ходит в БД: его НАПОЛНЯЕТ сборщик #988 (assembler) +и ПОТРЕБЛЯЮТ экспортёры #989 (PDF/Excel/JSON) + чат #957. Здесь — ТОЛЬКО объект + +сериализация (НЕ сборщик, НЕ движок confidence #990, НЕ изменения экспортёров). + +ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM, СОВЕТУЮЩИЙ. Никакого SQL/сети/вычислений/print — это +зеркало advisory-духа всего форсайт-стека (#980…#986): сам отчёт лишь СОБИРАЕТ уже +посчитанные advisory-выводы, поэтому `advisory` ВСЕГДА True. + +СТРУКТУРА (решение — вложенные frozen-dataclass'ы, по одному на секцию §13): +зеркалит house-style (каждый сосед — frozen dataclass + `as_dict()`), даёт типизированные +слоты (частичный отчёт всё равно типобезопасен) и явный JSON-safe `as_dict()`. КАЖДАЯ +секция МОЖЕТ ДЕРЖАТЬ выводы под-сервисов как ПЛОСКИЕ dict'ы (их собственные `as_dict()` +уже JSON-safe — `_round_or_none`/`.isoformat()` сделаны выше по стеку): отчёт НЕ +пере-деривирует внутренности под-объектов, а оборачивает их в типизированные конверты. + +ВОСЕМЬ СЕКЦИЙ §13 (каждая — отдельный nested frozen dataclass): + 1. exec_summary (ReportExecSummary) — вердикт-заголовок + ключевые числа + общая + уверенность («что строить здесь» одним абзацем). + 2. market_now (ReportMarketNow) — текущий рынок: абсорбция/цены/конкуренты/ + слои предложения (выводы #949/#950 — market_metrics/competitors/supply_layers). + 3. future_market (ReportFutureMarket) — прогноз: спрос/предложение по горизонтам, + дефицит/затоварка, будущие конкуренты, сводка сценариев (#952/#954). + 4. product_tz (ReportProductTz) — рекомендация продукта: класс, квартирография + (mix), коммерция, USP + §16-причины (#953 recommendation). + 5. scenarios (ReportScenarios) — conservative/base/aggressive сводка (#954/#984). + 6. scoring (ReportScoring) — 10 продуктовых скоров + 6 спец-индексов + + overall (#954/#985/#986 — product_scoring/special_indices). + 7. confidence (ReportConfidence) — отчётная уверенность (High/Med/Low) + rationale. + ПЛЕЙСХОЛДЕР: настоящий §15-движок — #990; здесь ТИПИЗИРОВАННЫЙ слот (дефолт пуст). + 8. meta (ReportMeta) — cad_num/district/segment/horizons, + generated_advisory=True, schema_version (named const _SCHEMA_VERSION). + +GRACEFUL (дух всего форсайт-стека): КАЖДОЕ поле Optional/с дефолтом — частично +собранный (или вовсе пустой) отчёт ВАЛИДЕН: тонкий анализ всё равно даёт отчёт. +`as_dict()` всегда отдаёт ВОСЕМЬ секций (стабильный контракт для экспортёров/чата), +полностью JSON-сериализуемый (даты→iso, без утечки dataclass'ов/raw-объектов). +""" + +from __future__ import annotations + +import datetime as dt +from dataclasses import dataclass, field +from typing import Any, Literal + +# Версия схемы отчёта — named const (бамп при несовместимом изменении формы `as_dict`). +# Экспортёры/чат/JSON-экспорт сверяются с ней. SemVer-строка ("major.minor"). +_SCHEMA_VERSION: str = "1.0" + +# Весь отчёт советующий (зеркало advisory-cap форсайт-стека #980…#986): он лишь +# СОБИРАЕТ уже-advisory выводы — не основание для инвест-решения. +_ADVISORY: bool = True + +# Допустимые отчётные уровни уверенности (§15). Реальный движок — #990; здесь только +# типизированный слот, дефолт None (ещё не оценено). +ReportConfidenceLevel = Literal["high", "medium", "low"] + + +def _round_or_none(value: float | None, digits: int) -> float | None: + """Округлить до digits знаков или вернуть None (зеркало соседей). PURE.""" + return round(value, digits) if value is not None else None + + +def _iso_or_none(value: dt.date | dt.datetime | str | None) -> str | None: + """Привести дату/время к ISO-строке для JSON (None passthrough). PURE. + + `date`/`datetime` → `.isoformat()`; уже-строка → как есть (сборщик мог положить + готовую ISO-строку); None → None. Так `as_dict()` гарантированно JSON-safe без + утечки объектов `date` в выдачу. + """ + if value is None: + return None + if isinstance(value, (dt.date, dt.datetime)): + return value.isoformat() + return value + + +# ────────────────────────────────────────────────────────────────────────────── +# Секции §13 — вложенные frozen-dataclass'ы. Каждая держит выводы под-сервисов как +# ПЛОСКИЕ JSON-safe dict'ы (их as_dict() уже сериализуемы) + лёгкие скаляры-заголовки. +# Все поля Optional/с дефолтом → частичная/пустая секция валидна (graceful). +# ────────────────────────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class ReportExecSummary: + """§13.1 exec_summary — вердикт-заголовок + ключевые числа + общая уверенность. + + «Что строить здесь» одним абзацем (`headline`/`verdict`), опорные числа в плоском + `key_numbers` (JSON-safe), `overall_confidence` — отчётный уровень (High/Med/Low). + Всё опционально: тонкий анализ → пустой exec_summary (graceful). + """ + + headline: str | None = None # 1-фразовый вердикт «что строить здесь» + verdict: str | None = None # развёрнутый абзац-резюме + key_numbers: dict[str, Any] = field(default_factory=dict) # плоские опорные числа + overall_confidence: ReportConfidenceLevel | None = None # отчётная уверенность + + def as_dict(self) -> dict[str, Any]: + return { + "headline": self.headline, + "verdict": self.verdict, + "key_numbers": dict(self.key_numbers), + "overall_confidence": self.overall_confidence, + } + + +@dataclass(frozen=True) +class ReportMarketNow: + """§13.2 market_now — текущий рынок (выводы #949/#950). + + Держит уже-посчитанные `as_dict()` под-сервисов как плоские JSON-safe dict'ы: + `market_metrics` (§9.2 абсорбция/цены), `competitors` (§9.1), `supply_layers` + (§9.3 слои предложения). Отчёт их НЕ пере-деривирует. Всё опционально (graceful). + """ + + market_metrics: dict[str, Any] | None = None # MarketMetrics.as_dict() (#950) + competitors: list[dict[str, Any]] = field(default_factory=list) # Competitor.as_dict() (#949) + supply_layers: dict[str, Any] | None = None # supply_layers вывод (#950, §9.3) + summary: str | None = None # короткий RU-текст про текущий рынок + + def as_dict(self) -> dict[str, Any]: + return { + "market_metrics": self.market_metrics, + "competitors": [dict(c) for c in self.competitors], + "supply_layers": self.supply_layers, + "summary": self.summary, + } + + +@dataclass(frozen=True) +class ReportFutureMarket: + """§13.3 future_market — прогноз (выводы #952/#954). + + `forecasts_by_horizon` — список DemandSupplyForecast.as_dict() (по горизонтам: + спрос/предложение, дефицит/затоварка). `future_competitors` — будущие конкуренты, + `future_supply` — давление будущего предложения (FutureSupplyPressure.as_dict()), + `scenarios_summary` — короткая сводка сценариев. Всё опционально (graceful). + """ + + forecasts_by_horizon: list[dict[str, Any]] = field(default_factory=list) # #952 по горизонтам + future_supply: dict[str, Any] | None = None # FutureSupplyPressure.as_dict() (§9.3) + future_competitors: list[dict[str, Any]] = field(default_factory=list) # будущие конкуренты + scenarios_summary: dict[str, Any] | None = None # краткая сводка сценариев (#954) + summary: str | None = None # короткий RU-текст про будущий рынок + + def as_dict(self) -> dict[str, Any]: + return { + "forecasts_by_horizon": [dict(f) for f in self.forecasts_by_horizon], + "future_supply": self.future_supply, + "future_competitors": [dict(c) for c in self.future_competitors], + "scenarios_summary": self.scenarios_summary, + "summary": self.summary, + } + + +@dataclass(frozen=True) +class ReportProductTz: + """§13.4 product_tz — рекомендация продукта (выводы #953). + + `obj_class` — рекомендованный класс, `mix` — квартирография (доли форматов), + `commercial` — коммерческий сигнал §10.4, `usp` — USP-из-дефицитов §10.5, + `reasons` — §16-причины (отвергнутые альтернативы / what-would-change). Держит + под-выводы #953 как плоские JSON-safe dict'ы. Всё опционально (graceful). + """ + + obj_class: str | None = None # рекомендованный класс (§10.2) + mix: list[dict[str, Any]] = field(default_factory=list) # квартирография (доли форматов) + commercial: dict[str, Any] | None = None # коммерческий сигнал (§10.4) + usp: list[dict[str, Any]] = field(default_factory=list) # USP-из-дефицитов (§10.5) + reasons: list[dict[str, Any]] = field(default_factory=list) # §16-причины + summary: str | None = None # короткий RU-текст про рекомендованный продукт + + def as_dict(self) -> dict[str, Any]: + return { + "obj_class": self.obj_class, + "mix": [dict(m) for m in self.mix], + "commercial": self.commercial, + "usp": [dict(u) for u in self.usp], + "reasons": [dict(r) for r in self.reasons], + "summary": self.summary, + } + + +@dataclass(frozen=True) +class ReportScenarios: + """§13.5 scenarios — сводка conservative/base/aggressive (выводы #954/#984). + + `by_scenario` — карта {scenario: ScenarioForecast.as_dict()} (или иной сводный + dict на сценарий). Держит advisory-сценарии #984 как плоские JSON-safe dict'ы. + Пустая карта → секция без сценариев (graceful). advisory наследуется (cap medium). + """ + + by_scenario: dict[str, Any] = field(default_factory=dict) # {scenario: сводка/as_dict} + summary: str | None = None # короткий RU-текст про разброс сценариев + + def as_dict(self) -> dict[str, Any]: + return { + "by_scenario": dict(self.by_scenario), + "summary": self.summary, + } + + +@dataclass(frozen=True) +class ReportScoring: + """§13.6 scoring — 10 продуктовых скоров + 6 спец-индексов + overall (#954/#985/#986). + + `product_scores` — ProductScoreCard.as_dict() (#985, 10 скоров + overall), + `special_indices` — SpecialIndices.as_dict() (#986, 6 индексов), `overall` — + итоговый продуктовый скор ∈ [0,1] (дубль из карты для удобства экспортёров). + Держит карты как плоские JSON-safe dict'ы. Всё опционально (graceful). + """ + + product_scores: dict[str, Any] | None = None # ProductScoreCard.as_dict() (#985) + special_indices: dict[str, Any] | None = None # SpecialIndices.as_dict() (#986) + overall: float | None = None # итоговый продуктовый скор ∈ [0,1] (дубль, удобство) + + def as_dict(self) -> dict[str, Any]: + return { + "product_scores": self.product_scores, + "special_indices": self.special_indices, + "overall": _round_or_none(self.overall, 3), + } + + +@dataclass(frozen=True) +class ReportConfidence: + """§13.7 confidence — отчётная уверенность (High/Med/Low) + rationale. ПЛЕЙСХОЛДЕР. + + Настоящий §15-движок уверенности — #990; здесь ТИПИЗИРОВАННЫЙ СЛОТ под него: `level` + (High/Med/Low) + RU `rationale` + плоский `factors` (что тянуло уверенность). Дефолт + пуст (уровень ещё не оценён) → секция валидна и до подключения #990 (graceful). + """ + + level: ReportConfidenceLevel | None = None # High/Med/Low (None = ещё не оценено) + rationale: str | None = None # RU-обоснование уровня + factors: dict[str, Any] = field(default_factory=dict) # вклады/флаги уверенности (#990) + + def as_dict(self) -> dict[str, Any]: + return { + "level": self.level, + "rationale": self.rationale, + "factors": dict(self.factors), + } + + +@dataclass(frozen=True) +class ReportMeta: + """§13.8 meta — контекст отчёта (cad_num/district/segment/horizons) + флаги. + + `generated_advisory` ВСЕГДА True (отчёт советующий), `schema_version` — + _SCHEMA_VERSION (контракт формы для экспортёров/чата), `generated_at` нормализуется + в ISO-строку в `as_dict()` (JSON-safe). Всё опционально (graceful). + """ + + cad_num: str | None = None # кадастровый номер участка + district: str | None = None # район + segment: dict[str, Any] = field(default_factory=dict) # целевой сегмент (оси §9.x) + horizons: list[int] = field(default_factory=list) # горизонты прогноза (мес) + generated_at: dt.date | dt.datetime | str | None = None # момент сборки (#988) + generated_advisory: bool = _ADVISORY # ВСЕГДА True + schema_version: str = _SCHEMA_VERSION # версия формы as_dict() + + def as_dict(self) -> dict[str, Any]: + return { + "cad_num": self.cad_num, + "district": self.district, + "segment": dict(self.segment), + "horizons": list(self.horizons), + "generated_at": _iso_or_none(self.generated_at), + "generated_advisory": self.generated_advisory, + "schema_version": self.schema_version, + } + + +# ────────────────────────────────────────────────────────────────────────────── +# Корневой контейнер §13 — собирает восемь секций. PURE-контейнер: НЕ считает, НЕ +# ходит в БД, НЕ зовёт сервисы (это #988 assembler). as_dict() — JSON-safe, всегда +# отдаёт восемь секций (стабильный контракт экспортёров #989 / чата #957). +# ────────────────────────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class SiteFinderReport: + """§13 итоговый советующий отчёт Site Finder v2 — frozen структурный контейнер. + + ВОСЕМЬ секций (exec_summary / market_now / future_market / product_tz / scenarios / + scoring / confidence / meta), каждая — вложенный frozen-dataclass с дефолтом, так + что частично-собранный (или пустой) отчёт ВАЛИДЕН (graceful: тонкий анализ всё равно + даёт отчёт). `advisory` ВСЕГДА True — отчёт собирает advisory-выводы форсайт-стека, + НЕ основание для инвест-решения. + + PURE-контейнер: НИКАКИХ БД/вычислений/сервис-вызовов — НАПОЛНЯЕТ его сборщик #988, + ПОТРЕБЛЯЮТ экспортёры #989 + чат #957. `as_dict()` полностью JSON-сериализуем + (даты→iso, без утечки dataclass'ов) и всегда содержит ВОСЕМЬ секций + advisory + + schema_version — стабильный контракт для экспортёров/JSON-экспорта/чата. + """ + + exec_summary: ReportExecSummary = field(default_factory=ReportExecSummary) + market_now: ReportMarketNow = field(default_factory=ReportMarketNow) + future_market: ReportFutureMarket = field(default_factory=ReportFutureMarket) + product_tz: ReportProductTz = field(default_factory=ReportProductTz) + scenarios: ReportScenarios = field(default_factory=ReportScenarios) + scoring: ReportScoring = field(default_factory=ReportScoring) + confidence: ReportConfidence = field(default_factory=ReportConfidence) + meta: ReportMeta = field(default_factory=ReportMeta) + advisory: bool = _ADVISORY # ВСЕГДА True + + def as_dict(self) -> dict[str, Any]: + """Полностью JSON-сериализуемое представление отчёта (стабильный контракт). + + Всегда отдаёт ВОСЕМЬ секций + `advisory` + `schema_version` (дубль из meta на + верхнем уровне — экспортёрам/чату удобно читать версию без захода в meta). + Внутри секций — только JSON-native типы (даты→iso через `_iso_or_none`, числа + через `_round_or_none`, под-сервис-dict'ы уже JSON-safe). НИКАКОЙ утечки + dataclass'ов/raw-объектов. + """ + return { + "schema_version": self.meta.schema_version, + "advisory": self.advisory, + "exec_summary": self.exec_summary.as_dict(), + "market_now": self.market_now.as_dict(), + "future_market": self.future_market.as_dict(), + "product_tz": self.product_tz.as_dict(), + "scenarios": self.scenarios.as_dict(), + "scoring": self.scoring.as_dict(), + "confidence": self.confidence.as_dict(), + "meta": self.meta.as_dict(), + } diff --git a/backend/tests/services/forecasting/test_report.py b/backend/tests/services/forecasting/test_report.py new file mode 100644 index 00000000..4abb5bf1 --- /dev/null +++ b/backend/tests/services/forecasting/test_report.py @@ -0,0 +1,265 @@ +"""Unit-тесты §13 SiteFinderReport (#987, 955-A1) — структурный контейнер отчёта. + +Чистые тесты БЕЗ БД (объект ничего не считает — это PURE-контейнер): + • полный отчёт из sample-данных всех восьми секций → `as_dict()` JSON-сериализуем + (`json.dumps` проходит), ключевые поля round-trip'ят, все восемь секций присутствуют; + • частичный / пустой отчёт ВАЛИДЕН (graceful) и тоже JSON-сериализуем; + • schema_version присутствует (на верхнем уровне и в meta) и == _SCHEMA_VERSION; + • advisory ВСЕГДА True (на отчёте и в meta.generated_advisory); + • даты в meta нормализуются в ISO-строки (JSON-safe, без утечки `date`); + • под-сервис-dict'ы держатся как есть (отчёт их НЕ пере-деривирует). + +Детерминированно, без LLM, без сети. DATABASE_URL выставляем до импорта app-модулей +(зеркало test_scenarios.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 +import json +from typing import Any + +from app.services.forecasting.report import ( + _SCHEMA_VERSION, + ReportConfidence, + ReportExecSummary, + ReportFutureMarket, + ReportMarketNow, + ReportMeta, + ReportProductTz, + ReportScenarios, + ReportScoring, + SiteFinderReport, +) + +# Восемь обязательных секций §13 — стабильный контракт `as_dict()`. +_SECTION_KEYS: tuple[str, ...] = ( + "exec_summary", + "market_now", + "future_market", + "product_tz", + "scenarios", + "scoring", + "confidence", + "meta", +) + + +def _full_report() -> SiteFinderReport: + """Полный SiteFinderReport из реалистичных sample-данных всех восьми секций. + + Sample-dict'ы зеркалят форму `as_dict()` под-сервисов (плоские, JSON-safe) — не + зовём живые сервисы (контейнер их не считает). meta.generated_at — `date`, чтобы + проверить ISO-нормализацию. + """ + 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, "deficit_index": 0.2, "confidence": "medium"}, + {"horizon_months": 12, "deficit_index": 0.34, "confidence": "medium"}, + ], + 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), + ), + ) + + +# ── Полный отчёт: JSON-serializability + round-trip + восемь секций ──────────── + + +class TestFullReportSerialization: + def test_as_dict_is_json_serializable(self) -> None: + # Главный контракт: as_dict() проходит json.dumps без default= (ничего сырого). + payload = _full_report().as_dict() + dumped = json.dumps(payload, ensure_ascii=False) + assert isinstance(dumped, str) + # И обратно парсится в эквивалентный dict (полный round-trip через JSON). + assert json.loads(dumped) == payload + + def test_all_eight_sections_present(self) -> None: + payload = _full_report().as_dict() + for key in _SECTION_KEYS: + assert key in payload, f"отсутствует секция {key}" + # Каждая секция сериализована в dict (не dataclass — без утечки объектов). + for key in _SECTION_KEYS: + assert isinstance(payload[key], dict) + + def test_key_fields_round_trip(self) -> None: + payload = _full_report().as_dict() + assert payload["exec_summary"]["headline"].startswith("Строить комфорт") + assert payload["exec_summary"]["overall_confidence"] == "medium" + assert payload["product_tz"]["obj_class"] == "комфорт" + assert payload["scoring"]["overall"] == 0.71 + assert payload["confidence"]["level"] == "medium" + assert payload["meta"]["cad_num"] == "66:41:0000000:1" + assert payload["meta"]["horizons"] == [6, 12, 18, 24] + + def test_nested_subservice_dicts_pass_through(self) -> None: + # Контейнер НЕ пере-деривирует под-выводы — держит их как есть. + payload = _full_report().as_dict() + assert payload["market_now"]["market_metrics"]["unit_velocity"] == 8.2 + assert len(payload["market_now"]["competitors"]) == 2 + assert payload["scoring"]["product_scores"]["scores"]["market_fit"]["value"] == 0.67 + assert payload["future_market"]["forecasts_by_horizon"][1]["deficit_index"] == 0.34 + + def test_date_normalized_to_iso(self) -> None: + # meta.generated_at (date) → ISO-строка (JSON-safe, без утечки date). + payload = _full_report().as_dict() + assert payload["meta"]["generated_at"] == "2026-06-03" + assert isinstance(payload["meta"]["generated_at"], str) + + +# ── schema_version + advisory ───────────────────────────────────────────────── + + +class TestSchemaVersionAndAdvisory: + def test_schema_version_present_top_level_and_meta(self) -> None: + payload = _full_report().as_dict() + assert payload["schema_version"] == _SCHEMA_VERSION + assert payload["meta"]["schema_version"] == _SCHEMA_VERSION + + def test_advisory_always_true(self) -> None: + report = _full_report() + assert report.advisory is True + payload = report.as_dict() + assert payload["advisory"] is True + assert payload["meta"]["generated_advisory"] is True + + def test_empty_report_advisory_still_true(self) -> None: + # Дефолтный (пустой) отчёт тоже советующий. + payload = SiteFinderReport().as_dict() + assert payload["advisory"] is True + assert payload["meta"]["generated_advisory"] is True + + +# ── Graceful: частичный / пустой отчёт валиден ──────────────────────────────── + + +class TestPartialAndEmptyReport: + def test_empty_report_is_valid_and_json_serializable(self) -> None: + # Полностью дефолтный отчёт — валиден, восемь секций, JSON-сериализуем. + payload = SiteFinderReport().as_dict() + for key in _SECTION_KEYS: + assert key in payload + dumped = json.dumps(payload, ensure_ascii=False) + assert json.loads(dumped) == payload + + def test_empty_sections_have_safe_defaults(self) -> None: + payload = SiteFinderReport().as_dict() + # Скаляры → None, коллекции → пустые (не падаем, не None-вместо-списка). + assert payload["exec_summary"]["headline"] is None + assert payload["exec_summary"]["key_numbers"] == {} + assert payload["market_now"]["competitors"] == [] + assert payload["product_tz"]["mix"] == [] + assert payload["scenarios"]["by_scenario"] == {} + assert payload["scoring"]["overall"] is None + assert payload["confidence"]["level"] is None + assert payload["meta"]["horizons"] == [] + assert payload["meta"]["generated_at"] is None + + def test_partial_report_only_some_sections(self) -> None: + # Заполнена только meta + exec_summary — остальные дефолтны, отчёт валиден. + report = SiteFinderReport( + exec_summary=ReportExecSummary(headline="Тонкий анализ — только заголовок."), + meta=ReportMeta(cad_num="66:41:0000000:2", horizons=[12]), + ) + payload = report.as_dict() + assert payload["exec_summary"]["headline"] == "Тонкий анализ — только заголовок." + assert payload["meta"]["cad_num"] == "66:41:0000000:2" + # Незаполненные секции присутствуют и пусты (graceful). + assert payload["future_market"]["forecasts_by_horizon"] == [] + assert payload["scoring"]["product_scores"] is None + # И всё ещё JSON-сериализуемо. + json.dumps(payload, ensure_ascii=False) + + def test_iso_passthrough_for_string_and_datetime(self) -> None: + # generated_at принимает date / datetime / готовую ISO-строку — все → str. + as_str = SiteFinderReport(meta=ReportMeta(generated_at="2026-06-03T10:00:00")).as_dict() + assert as_str["meta"]["generated_at"] == "2026-06-03T10:00:00" + as_dtm = SiteFinderReport( + meta=ReportMeta(generated_at=dt.datetime(2026, 6, 3, 10, 0, 0)) + ).as_dict() + assert as_dtm["meta"]["generated_at"].startswith("2026-06-03T10:00:00") + + +# ── Иммутабельность (frozen dataclass — зеркало соседей) ─────────────────────── + + +class TestFrozen: + def test_report_is_frozen(self) -> None: + import dataclasses + + report = SiteFinderReport() + with __import__("pytest").raises(dataclasses.FrozenInstanceError): + report.advisory = False # type: ignore[misc] + + def test_as_dict_returns_plain_jsonable_types(self) -> None: + # Никакой утечки dataclass'ов: все значения секций — dict/list/str/num/bool/None. + payload: dict[str, Any] = _full_report().as_dict() + for value in payload.values(): + assert isinstance(value, (dict, list, str, int, float, bool, type(None)))