"""§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(), }