feat(forecasting): §13 SiteFinderReport object (#987, 955-A1) #1019

Merged
bot-backend merged 1 commit from feat/955a1-sitefinder-report into main 2026-06-03 08:33:02 +00:00
3 changed files with 617 additions and 0 deletions

View file

@ -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",

View file

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

View file

@ -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)))