diff --git a/backend/app/services/exporters/excel.py b/backend/app/services/exporters/excel.py
index b4c5c815..f3987f81 100644
--- a/backend/app/services/exporters/excel.py
+++ b/backend/app/services/exporters/excel.py
@@ -53,6 +53,11 @@ _ADVISORY_MARKER: str = (
)
# Округление чисел по умолчанию (скоры/индексы ∈ [0,1] и доли — 3 знака читаемо).
_ROUND_DIGITS: int = 3
+# Основной продуктовый горизонт (мес) — из него тянем сводный deficit_index сценария
+# (зеркало report_assembler._PRIMARY_HORIZON_MONTHS). ScenarioForecast.as_dict() несёт
+# список forecasts по горизонтам, скалярного «overall» у сценария НЕТ — берём дефицит
+# основного горизонта как сводный показатель сценария.
+_PRIMARY_HORIZON_MONTHS: int = 12
# RU-метки уровней отчётной уверенности (§15) для листа «Сводка».
_LEVEL_RU: dict[str, str] = {
@@ -135,6 +140,52 @@ def _level_ru(level: Any) -> str:
return _DASH
+def _future_supply_pairs(future_supply: Any) -> dict[str, Any]:
+ """Плоская карта «метка → значение» по FutureSupplyPressure.as_dict() (§9.3). PURE.
+
+ `future_supply` = `FutureSupplyPressure.as_dict()`: верхнеуровневый `index`/контекст +
+ вложенный `breakdown` (объёмы по слоям, поглощение, месяцы давления). Разворачиваем
+ breakdown на верхний уровень с RU-метками. Пустой/None → {} (graceful → «нет данных»).
+ """
+ data = _as_dict(future_supply)
+ if not data:
+ return {}
+ breakdown = _as_dict(data.get("breakdown"))
+ return {
+ "Индекс давления": data.get("index"),
+ "Уверенность": _level_ru(data.get("confidence")),
+ "Открытый сток (ед.)": breakdown.get("open_units"),
+ "Скрытый запас (ед.)": breakdown.get("hidden_units"),
+ "Будущий слой в горизонте (ед.)": breakdown.get("future_units_by_horizon"),
+ "Поглощение (ед./мес)": breakdown.get("monthly_absorption_units"),
+ "Месяцев давления": breakdown.get("months_of_pressure"),
+ }
+
+
+def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
+ """Сводный deficit_index сценария = дефицит основного горизонта (12 мес). PURE.
+
+ `payload` = `ScenarioForecast.as_dict()`: у сценария НЕТ скалярного «overall» — есть
+ список `forecasts` по горизонтам, каждый с `deficit_index`. Берём горизонт
+ `_PRIMARY_HORIZON_MONTHS`, иначе первый с не-None дефицитом. Нет → None (→ "—").
+ """
+ forecasts = _as_list(payload.get("forecasts"))
+ primary = next(
+ (
+ f
+ for f in forecasts
+ if isinstance(f, dict) and f.get("horizon_months") == _PRIMARY_HORIZON_MONTHS
+ ),
+ None,
+ )
+ if primary is not None and primary.get("deficit_index") is not None:
+ return primary.get("deficit_index")
+ for f in forecasts:
+ if isinstance(f, dict) and f.get("deficit_index") is not None:
+ return f.get("deficit_index")
+ return None
+
+
# ──────────────────────────────────────────────────────────────────────────────
# Низкоуровневые помощники записи листа (заголовки/строки/ширины). PURE-побочка
# (мутируют переданный лист). Не ходят в БД/сеть.
@@ -236,6 +287,16 @@ def _build_summary_sheet(ws: Worksheet, report: dict[str, Any]) -> None:
row = _write_kv(ws, row, "Обоснование", confidence.get("rationale"))
row += 1
+ # ── Факторы уверенности (§15 #990 — что тянуло уверенность) ──
+ row = _write_title(ws, row, "Факторы уверенности")
+ factors = _as_dict(confidence.get("factors"))
+ if factors:
+ for key, value in factors.items():
+ row = _write_kv(ws, row, str(key), value)
+ else:
+ row = _write_no_data(ws, row)
+ row += 1
+
# ── Контекст (meta) ──
row = _write_title(ws, row, "Контекст")
row = _write_kv(ws, row, "Кадастровый номер", meta.get("cad_num"))
@@ -308,8 +369,8 @@ def _build_future_market_sheet(ws: Worksheet, report: dict[str, Any]) -> None:
table_rows = [
[
f.get("horizon_months"),
- f.get("demand"),
- f.get("supply"),
+ f.get("projected_demand_units"),
+ f.get("projected_supply_units"),
f.get("deficit_index"),
_level_ru(f.get("confidence")),
]
@@ -319,6 +380,16 @@ def _build_future_market_sheet(ws: Worksheet, report: dict[str, Any]) -> None:
row = _write_table(ws, row, headers, table_rows)
row += 1
+ # ── Давление будущего предложения §9.3 (FutureSupplyPressure.as_dict()) ──
+ row = _write_title(ws, row, "Давление будущего предложения")
+ future_supply_pairs = _future_supply_pairs(future.get("future_supply"))
+ if future_supply_pairs:
+ for label, value in future_supply_pairs.items():
+ row = _write_kv(ws, row, label, value)
+ else:
+ row = _write_no_data(ws, row)
+ row += 1
+
# ── Разброс сценариев по дефициту ──
row = _write_title(ws, row, "Разброс сценариев (индекс дефицита)")
if scenarios_summary:
@@ -383,13 +454,13 @@ def _build_scenarios_sheet(ws: Worksheet, report: dict[str, Any]) -> None:
row = _write_kv(ws, row, "Резюме", scenarios.get("summary"))
row += 1
- # ── Таблица сценариев (имя + overall + advisory; прочие ключи в «детали») ──
+ # ── Таблица сценариев (имя + сводный дефицит 12 мес + advisory) ──
row = _write_title(ws, row, "Сводка по сценариям")
- headers = ["Сценарий", "Overall", "Advisory"]
+ headers = ["Сценарий", "Индекс дефицита (12 мес)", "Advisory"]
table_rows: list[list[Any]] = []
for name, payload in by_scenario.items():
data = _as_dict(payload)
- table_rows.append([name, data.get("overall"), data.get("advisory")])
+ table_rows.append([name, _scenario_deficit_index(data), data.get("advisory")])
_write_table(ws, row, headers, table_rows)
_set_widths(ws, [_LABEL_COL_WIDTH, _VALUE_COL_WIDTH, _VALUE_COL_WIDTH])
diff --git a/backend/app/services/exporters/report_pdf.py b/backend/app/services/exporters/report_pdf.py
index 4fcd4494..538b245d 100644
--- a/backend/app/services/exporters/report_pdf.py
+++ b/backend/app/services/exporters/report_pdf.py
@@ -55,6 +55,11 @@ _ADVISORY_MARKER: str = (
)
# Округление чисел по умолчанию (скоры/индексы ∈ [0,1] и доли — 3 знака читаемо).
_ROUND_DIGITS: int = 3
+# Основной продуктовый горизонт (мес) — из него тянем сводный deficit_index сценария
+# (зеркало report_assembler._PRIMARY_HORIZON_MONTHS). ScenarioForecast.as_dict() несёт
+# список forecasts по горизонтам, скалярного «overall» у сценария НЕТ — берём дефицит
+# основного горизонта как сводный показатель сценария.
+_PRIMARY_HORIZON_MONTHS: int = 12
# Сноска-футер на каждой странице (зеркало advisory-духа форсайт-стека).
_FOOTER_NOTE: str = "GenDesign · Site Finder v2 · советующий отчёт §13 (не оферта)"
@@ -261,6 +266,28 @@ def _join_ints(values: list[Any]) -> Any:
return ", ".join(str(v) for v in values) if values else None
+def _future_supply_pairs(future_supply: Any) -> dict[str, Any]:
+ """Плоская карта «метка → значение» по FutureSupplyPressure.as_dict() (§9.3). PURE.
+
+ `future_supply` = `FutureSupplyPressure.as_dict()`: верхнеуровневый `index`/контекст +
+ вложенный `breakdown` (объёмы по слоям, поглощение, месяцы давления). Разворачиваем
+ breakdown на верхний уровень с RU-метками. Пустой/None → {} (graceful → «нет данных»).
+ """
+ data = _as_dict(future_supply)
+ if not data:
+ return {}
+ breakdown = _as_dict(data.get("breakdown"))
+ return {
+ "Индекс давления": data.get("index"),
+ "Уверенность": _level_ru(data.get("confidence")),
+ "Открытый сток (ед.)": breakdown.get("open_units"),
+ "Скрытый запас (ед.)": breakdown.get("hidden_units"),
+ "Будущий слой в горизонте (ед.)": breakdown.get("future_units_by_horizon"),
+ "Поглощение (ед./мес)": breakdown.get("monthly_absorption_units"),
+ "Месяцев давления": breakdown.get("months_of_pressure"),
+ }
+
+
# ──────────────────────────────────────────────────────────────────────────────
# Построители секций — по одной на содержательную секцию §13. Каждый graceful:
# пустая секция → «нет данных», НИКОГДА не падает. Возвращают HTML-фрагмент.
@@ -276,6 +303,7 @@ def _build_summary(report: dict[str, Any]) -> str:
headline = exec_summary.get("headline")
verdict = exec_summary.get("verdict")
key_numbers = _as_dict(exec_summary.get("key_numbers"))
+ factors = _as_dict(confidence.get("factors"))
cad = _esc(meta.get("cad_num"))
district = _esc(meta.get("district"))
@@ -313,6 +341,9 @@ def _build_summary(report: dict[str, Any]) -> str:
Уверенность отчёта
{_kv_table(confidence_pairs)}
+ Факторы уверенности
+ {_dict_kv_table(factors)}
+
Контекст
{_kv_table(context_pairs)}
@@ -358,8 +389,8 @@ def _build_future_market(report: dict[str, Any]) -> str:
forecast_rows = [
[
f.get("horizon_months"),
- f.get("demand"),
- f.get("supply"),
+ f.get("projected_demand_units"),
+ f.get("projected_supply_units"),
f.get("deficit_index"),
_level_ru(f.get("confidence")),
]
@@ -376,6 +407,9 @@ def _build_future_market(report: dict[str, Any]) -> str:
Прогноз по горизонтам
{_data_table(forecast_headers, forecast_rows)}
+ Давление будущего предложения
+ {_dict_kv_table(_future_supply_pairs(future.get("future_supply")))}
+
Разброс сценариев (индекс дефицита)
{_dict_kv_table(scenarios_summary)}
@@ -418,6 +452,30 @@ def _build_product_tz(report: dict[str, Any]) -> str:
"""
+def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
+ """Сводный deficit_index сценария = дефицит основного горизонта (12 мес). PURE.
+
+ `payload` = `ScenarioForecast.as_dict()`: у сценария НЕТ скалярного «overall» — есть
+ список `forecasts` по горизонтам, каждый с `deficit_index`. Берём горизонт
+ `_PRIMARY_HORIZON_MONTHS`, иначе первый с не-None дефицитом. Нет → None (→ "—").
+ """
+ forecasts = _as_list(payload.get("forecasts"))
+ primary = next(
+ (
+ f
+ for f in forecasts
+ if isinstance(f, dict) and f.get("horizon_months") == _PRIMARY_HORIZON_MONTHS
+ ),
+ None,
+ )
+ if primary is not None and primary.get("deficit_index") is not None:
+ return primary.get("deficit_index")
+ for f in forecasts:
+ if isinstance(f, dict) and f.get("deficit_index") is not None:
+ return f.get("deficit_index")
+ return None
+
+
def _build_scenarios(report: dict[str, Any]) -> str:
"""Блок «Сценарии»: conservative/base/aggressive (таблица). Graceful."""
scenarios = _as_dict(report.get("scenarios"))
@@ -426,15 +484,16 @@ def _build_scenarios(report: dict[str, Any]) -> str:
rows: list[list[Any]] = []
for name, payload in by_scenario.items():
data = _as_dict(payload)
- rows.append([name, data.get("overall"), data.get("advisory")])
+ rows.append([name, _scenario_deficit_index(data), data.get("advisory")])
+ headers = ["Сценарий", "Индекс дефицита (12 мес)", "Advisory"]
return f"""
{html.escape(_TITLE_SCENARIOS)}
{_esc(scenarios.get("summary"))}
Сводка по сценариям
- {_data_table(["Сценарий", "Overall", "Advisory"], rows)}
+ {_data_table(headers, rows)}
"""
diff --git a/backend/tests/services/exporters/test_excel.py b/backend/tests/services/exporters/test_excel.py
index 24b6c72c..2c2422d3 100644
--- a/backend/tests/services/exporters/test_excel.py
+++ b/backend/tests/services/exporters/test_excel.py
@@ -30,8 +30,11 @@ from app.services.exporters.excel import (
_SHEET_SCENARIOS,
_SHEET_SCORING,
_SHEET_SUMMARY,
+ _fmt,
+ _scenario_deficit_index,
export_report_xlsx,
)
+from app.services.forecasting.demand_supply_forecast import DemandSupplyForecast
from app.services.forecasting.report import (
ReportConfidence,
ReportExecSummary,
@@ -43,6 +46,72 @@ from app.services.forecasting.report import (
ReportScoring,
SiteFinderReport,
)
+from app.services.forecasting.scenarios import ScenarioForecast
+
+
+def _forecast(horizon: int, demand: float, supply: float, deficit: float) -> dict[str, object]:
+ """Реальный `DemandSupplyForecast.as_dict()` — ключи гарантированно из контракта #952.
+
+ Строим frozen-dataclass БЕЗ БД и сериализуем через его собственный `as_dict()`: так
+ тест не может разойтись с реальной формой (projected_demand_units / projected_supply_
+ units), как это случилось со старой hand-typed фикстурой (demand/supply — #989/#991).
+ """
+ return DemandSupplyForecast(
+ segment={"obj_class": "комфорт"},
+ horizon_months=horizon,
+ base_pace_units_per_mo=8.0,
+ demand_norm_coefficient=1.0,
+ macro_coefficient=1.0,
+ projected_demand_units=demand,
+ open_units=300,
+ hidden_release_units=80.0,
+ future_online_units=20.0,
+ projected_supply_units=supply,
+ balance_units=demand - supply,
+ balance_ratio=(demand / supply if supply else None),
+ deficit_index=deficit,
+ rate_future=18.0,
+ rate_sensitivity_phrase="спрос эластичен к ставке",
+ future_competitors=[{"obj_id": 3, "comm_name": "ЖК Гамма"}],
+ advisory=True,
+ confidence="medium",
+ ).as_dict()
+
+
+def _scenario(name: str, deficit_12mo: float) -> dict[str, object]:
+ """Реальный `ScenarioForecast.as_dict()` — ключи из контракта #984 (НЕ hand-typed).
+
+ У сценария НЕТ скалярного «overall»: есть `forecasts` (по горизонтам) с deficit_index.
+ Экспортёр тянет сводный дефицит 12-мес горизонта — здесь даём именно его.
+ """
+ return ScenarioForecast(
+ scenario=name, # type: ignore[arg-type] # Literal в проде; в тесте — str-имя
+ rate_path={6: 18.0, 12: 18.0},
+ forecasts=[
+ DemandSupplyForecast(
+ segment={"obj_class": "комфорт"},
+ horizon_months=12,
+ base_pace_units_per_mo=8.0,
+ demand_norm_coefficient=1.0,
+ macro_coefficient=1.0,
+ projected_demand_units=920.0,
+ open_units=300,
+ hidden_release_units=80.0,
+ future_online_units=20.0,
+ projected_supply_units=700.0,
+ balance_units=220.0,
+ balance_ratio=1.314,
+ deficit_index=deficit_12mo,
+ rate_future=18.0,
+ rate_sensitivity_phrase=None,
+ future_competitors=[],
+ advisory=True,
+ confidence="medium",
+ )
+ ],
+ advisory=True,
+ ).as_dict()
+
# Шесть ожидаемых листов (по одному на содержательную секцию §13).
_EXPECTED_SHEETS: tuple[str, ...] = (
@@ -75,10 +144,24 @@ def _full_report() -> SiteFinderReport:
),
future_market=ReportFutureMarket(
forecasts_by_horizon=[
- {"horizon_months": 6, "demand": 410, "supply": 380, "deficit_index": 0.2},
- {"horizon_months": 12, "demand": 920, "supply": 700, "deficit_index": 0.34},
+ _forecast(6, demand=410.0, supply=380.0, deficit=0.2),
+ _forecast(12, demand=920.0, supply=700.0, deficit=0.34),
],
- future_supply={"index": 0.42, "horizon_months": 12, "confidence": "low"},
+ future_supply={
+ "district": "Верх-Исетский",
+ "horizon_months": 12,
+ "premise_kind": "квартира",
+ "confidence": "low",
+ "index": 0.42,
+ "breakdown": {
+ "open_units": 1200,
+ "hidden_units": 800,
+ "future_units_by_horizon": 450.0,
+ "monthly_absorption_units": 8.2,
+ "months_of_pressure": 9.0,
+ "index": 0.42,
+ },
+ },
future_competitors=[{"obj_id": 3, "stage": "котлован"}],
scenarios_summary={"base": 0.34, "conservative": 0.18, "aggressive": 0.49},
summary="Будущий рынок: дефицит сохраняется на 12 мес.",
@@ -97,9 +180,9 @@ def _full_report() -> SiteFinderReport:
),
scenarios=ReportScenarios(
by_scenario={
- "conservative": {"overall": 0.18, "advisory": True},
- "base": {"overall": 0.34, "advisory": True},
- "aggressive": {"overall": 0.49, "advisory": True},
+ "conservative": _scenario("conservative", deficit_12mo=0.18),
+ "base": _scenario("base", deficit_12mo=0.34),
+ "aggressive": _scenario("aggressive", deficit_12mo=0.49),
},
summary="Разброс сценариев умеренный.",
),
@@ -188,6 +271,98 @@ class TestFullReportExport:
assert title in wb.sheetnames
+# ── Regression #989/#991: реальные ключи контракта попадают в ячейки (НЕ "—") ───
+# Тянем значения из РЕАЛЬНОГО `SiteFinderReport.as_dict()` (а не hand-typed строк):
+# если экспортёр снова прочитает несуществующий ключ (demand/supply / per-scenario
+# overall — как до фикса), число пропадёт из ячеек → тест упадёт. Контракт и тест
+# не разойдутся молча (root-cause старого зелёного теста — устаревшая фикстура).
+
+
+def _cell_values(ws) -> list: # type: ignore[no-untyped-def]
+ """Все непустые значения ячеек листа (как есть, без str-приведения)."""
+ return [c.value for col in ws.iter_cols() for c in col if c.value is not None]
+
+
+class TestContractKeysWritten:
+ def test_demand_supply_units_in_cells_not_dash(self) -> None:
+ data = _full_report().as_dict()
+ forecasts = data["future_market"]["forecasts_by_horizon"]
+ assert forecasts, "фикстура должна содержать прогнозы по горизонтам"
+ ws = _reload(export_report_xlsx(_full_report()))[_SHEET_FUTURE_MARKET]
+ values = _cell_values(ws)
+ for f in forecasts:
+ demand = f["projected_demand_units"]
+ supply = f["projected_supply_units"]
+ assert demand is not None and supply is not None
+ assert _fmt(demand) in values, f"спрос {demand} не в ячейках (ключ читается?)"
+ assert _fmt(supply) in values, f"предложение {supply} не в ячейках"
+ # «—» НЕ должен стоять там, где число существует.
+ assert _dash_count_in_forecast_table(ws) == 0
+
+ def test_scenario_deficit_in_cells_not_dash(self) -> None:
+ data = _full_report().as_dict()
+ by_scenario = data["scenarios"]["by_scenario"]
+ assert by_scenario, "фикстура должна содержать сценарии"
+ ws = _reload(export_report_xlsx(_full_report()))[_SHEET_SCENARIOS]
+ values = _cell_values(ws)
+ for payload in by_scenario.values():
+ di = _scenario_deficit_index(payload)
+ assert di is not None
+ assert _fmt(di) in values, f"дефицит сценария {di} не в ячейках"
+
+ def test_overall_score_in_cells(self) -> None:
+ data = _full_report().as_dict()
+ overall = data["scoring"]["overall"]
+ assert overall is not None
+ ws = _reload(export_report_xlsx(_full_report()))[_SHEET_SCORING]
+ assert _fmt(overall) in _cell_values(ws)
+
+ def test_future_supply_section_in_cells(self) -> None:
+ # #991: давление будущего предложения (index + breakdown по слоям) на листе.
+ data = _full_report().as_dict()
+ fs = data["future_market"]["future_supply"]
+ assert fs is not None
+ ws = _reload(export_report_xlsx(_full_report()))[_SHEET_FUTURE_MARKET]
+ flat_str = [str(v) for v in _cell_values(ws)]
+ values = _cell_values(ws)
+ assert any("Давление будущего предложения" in v for v in flat_str)
+ assert _fmt(fs["index"]) in values
+ assert _fmt(fs["breakdown"]["open_units"]) in values
+
+ def test_confidence_factors_in_cells(self) -> None:
+ # #991: confidence.factors (ключ→значение) на листе «Сводка».
+ ws = _reload(export_report_xlsx(_full_report()))[_SHEET_SUMMARY]
+ flat_str = [str(v) for v in _cell_values(ws)]
+ assert any("Факторы уверенности" in v for v in flat_str)
+ assert any("data_coverage" in v for v in flat_str)
+
+
+def _dash_count_in_forecast_table(ws) -> int: # type: ignore[no-untyped-def]
+ """Сколько ячеек спроса/предложения в таблице прогноза равны «—» (должно быть 0).
+
+ Находим строку-шапку «Спрос»/«Предложение», считаем «—» в этих колонках ниже неё.
+ """
+ from app.services.exporters.excel import _DASH
+
+ header_row = None
+ demand_col = supply_col = None
+ for r in range(1, ws.max_row + 1):
+ for c in range(1, ws.max_column + 1):
+ val = ws.cell(row=r, column=c).value
+ if val == "Спрос":
+ header_row, demand_col = r, c
+ elif val == "Предложение":
+ supply_col = c
+ if header_row is None or demand_col is None or supply_col is None:
+ return 0
+ count = 0
+ for r in range(header_row + 1, ws.max_row + 1):
+ for col in (demand_col, supply_col):
+ if ws.cell(row=r, column=col).value == _DASH:
+ count += 1
+ return count
+
+
# ── Graceful: частичный / пустой / мусорный вход → валидный .xlsx без падения ──
diff --git a/backend/tests/services/exporters/test_report_pdf.py b/backend/tests/services/exporters/test_report_pdf.py
index 22618b0b..2eb101b2 100644
--- a/backend/tests/services/exporters/test_report_pdf.py
+++ b/backend/tests/services/exporters/test_report_pdf.py
@@ -31,9 +31,12 @@ from app.services.exporters.report_pdf import (
_TITLE_SCORING,
_TITLE_SUMMARY,
_build_html,
+ _fmt,
_normalize,
+ _scenario_deficit_index,
export_report_pdf,
)
+from app.services.forecasting.demand_supply_forecast import DemandSupplyForecast
from app.services.forecasting.report import (
ReportConfidence,
ReportExecSummary,
@@ -45,6 +48,71 @@ from app.services.forecasting.report import (
ReportScoring,
SiteFinderReport,
)
+from app.services.forecasting.scenarios import ScenarioForecast
+
+
+def _forecast(horizon: int, demand: float, supply: float, deficit: float) -> dict[str, object]:
+ """Реальный `DemandSupplyForecast.as_dict()` — ключи гарантированно из контракта #952.
+
+ Строим frozen-dataclass БЕЗ БД и сериализуем через его собственный `as_dict()`: так
+ тест не может разойтись с реальной формой (projected_demand_units / projected_supply_
+ units), как это случилось со старой hand-typed фикстурой (demand/supply — #989/#991).
+ """
+ return DemandSupplyForecast(
+ segment={"obj_class": "комфорт"},
+ horizon_months=horizon,
+ base_pace_units_per_mo=8.0,
+ demand_norm_coefficient=1.0,
+ macro_coefficient=1.0,
+ projected_demand_units=demand,
+ open_units=300,
+ hidden_release_units=80.0,
+ future_online_units=20.0,
+ projected_supply_units=supply,
+ balance_units=demand - supply,
+ balance_ratio=(demand / supply if supply else None),
+ deficit_index=deficit,
+ rate_future=18.0,
+ rate_sensitivity_phrase="спрос эластичен к ставке",
+ future_competitors=[{"obj_id": 3, "comm_name": "ЖК Гамма"}],
+ advisory=True,
+ confidence="medium",
+ ).as_dict()
+
+
+def _scenario(name: str, deficit_12mo: float) -> dict[str, object]:
+ """Реальный `ScenarioForecast.as_dict()` — ключи из контракта #984 (НЕ hand-typed).
+
+ У сценария НЕТ скалярного «overall»: есть `forecasts` (по горизонтам) с deficit_index.
+ Экспортёр тянет сводный дефицит 12-мес горизонта — здесь даём именно его.
+ """
+ return ScenarioForecast(
+ scenario=name, # type: ignore[arg-type] # Literal в проде; в тесте — str-имя
+ rate_path={6: 18.0, 12: 18.0},
+ forecasts=[
+ DemandSupplyForecast(
+ segment={"obj_class": "комфорт"},
+ horizon_months=12,
+ base_pace_units_per_mo=8.0,
+ demand_norm_coefficient=1.0,
+ macro_coefficient=1.0,
+ projected_demand_units=920.0,
+ open_units=300,
+ hidden_release_units=80.0,
+ future_online_units=20.0,
+ projected_supply_units=700.0,
+ balance_units=220.0,
+ balance_ratio=1.314,
+ deficit_index=deficit_12mo,
+ rate_future=18.0,
+ rate_sensitivity_phrase=None,
+ future_competitors=[],
+ advisory=True,
+ confidence="medium",
+ )
+ ],
+ advisory=True,
+ ).as_dict()
def _weasyprint_native_libs_available() -> tuple[bool, str]:
@@ -102,10 +170,24 @@ def _full_report() -> SiteFinderReport:
),
future_market=ReportFutureMarket(
forecasts_by_horizon=[
- {"horizon_months": 6, "demand": 410, "supply": 380, "deficit_index": 0.2},
- {"horizon_months": 12, "demand": 920, "supply": 700, "deficit_index": 0.34},
+ _forecast(6, demand=410.0, supply=380.0, deficit=0.2),
+ _forecast(12, demand=920.0, supply=700.0, deficit=0.34),
],
- future_supply={"index": 0.42, "horizon_months": 12, "confidence": "low"},
+ future_supply={
+ "district": "Верх-Исетский",
+ "horizon_months": 12,
+ "premise_kind": "квартира",
+ "confidence": "low",
+ "index": 0.42,
+ "breakdown": {
+ "open_units": 1200,
+ "hidden_units": 800,
+ "future_units_by_horizon": 450.0,
+ "monthly_absorption_units": 8.2,
+ "months_of_pressure": 9.0,
+ "index": 0.42,
+ },
+ },
future_competitors=[{"obj_id": 3, "stage": "котлован"}],
scenarios_summary={"base": 0.34, "conservative": 0.18, "aggressive": 0.49},
summary="Будущий рынок: дефицит сохраняется на 12 мес.",
@@ -124,9 +206,9 @@ def _full_report() -> SiteFinderReport:
),
scenarios=ReportScenarios(
by_scenario={
- "conservative": {"overall": 0.18, "advisory": True},
- "base": {"overall": 0.34, "advisory": True},
- "aggressive": {"overall": 0.49, "advisory": True},
+ "conservative": _scenario("conservative", deficit_12mo=0.18),
+ "base": _scenario("base", deficit_12mo=0.34),
+ "aggressive": _scenario("aggressive", deficit_12mo=0.49),
},
summary="Разброс сценариев умеренный.",
),
@@ -219,6 +301,66 @@ class TestRenderedHtmlContent:
assert "& co" in html_str
+# ── Regression #989: реальные ключи контракта попадают в вывод (НЕ "—") ─────────
+# Эти тесты ловят key-drift экспортёра: они тянут значения из РЕАЛЬНОГО
+# `SiteFinderReport.as_dict()` (а не hand-typed строк), поэтому если экспортёр снова
+# начнёт читать несуществующий ключ (как demand/supply / per-scenario overall до фикса),
+# значение пропадёт из HTML → тест упадёт. Так контракт и тест не разойдутся молча.
+
+
+class TestContractKeysRendered:
+ def test_demand_supply_units_rendered_not_dash(self) -> None:
+ # Берём числа спроса/предложения из самого as_dict() (форма #952), затем
+ # проверяем, что они ЕСТЬ в HTML — старый bug читал demand/supply → было "—".
+ data = _full_report().as_dict()
+ forecasts = data["future_market"]["forecasts_by_horizon"]
+ assert forecasts, "фикстура должна содержать прогнозы по горизонтам"
+ html_str = _build_html(_normalize(_full_report()))
+ for f in forecasts:
+ demand = f["projected_demand_units"]
+ supply = f["projected_supply_units"]
+ assert demand is not None and supply is not None
+ assert str(_fmt(demand)) in html_str, f"спрос {demand} не отрисован (читается ключ?)"
+ assert str(_fmt(supply)) in html_str, f"предложение {supply} не отрисован"
+
+ def test_scenario_deficit_rendered_not_dash(self) -> None:
+ # Сводный дефицит 12-мес горизонта каждого сценария должен попасть в HTML.
+ # Старый bug читал per-scenario data.get("overall") (нет такого ключа) → "—".
+ data = _full_report().as_dict()
+ by_scenario = data["scenarios"]["by_scenario"]
+ assert by_scenario, "фикстура должна содержать сценарии"
+ html_str = _build_html(_normalize(_full_report()))
+ for payload in by_scenario.values():
+ di = _scenario_deficit_index(payload)
+ assert di is not None
+ assert str(_fmt(di)) in html_str, f"дефицит сценария {di} не отрисован"
+
+ def test_overall_score_rendered(self) -> None:
+ # scoring.overall (ReportScoring) — корректный ключ; убеждаемся, что он в HTML.
+ data = _full_report().as_dict()
+ overall = data["scoring"]["overall"]
+ assert overall is not None
+ html_str = _build_html(_normalize(_full_report()))
+ assert str(_fmt(overall)) in html_str
+
+ def test_future_supply_section_rendered(self) -> None:
+ # #991/parity: давление будущего предложения (index + breakdown) должно
+ # попасть в HTML — раньше PDF не разворачивал breakdown по слоям.
+ data = _full_report().as_dict()
+ fs = data["future_market"]["future_supply"]
+ assert fs is not None
+ html_str = _build_html(_normalize(_full_report()))
+ assert "Давление будущего предложения" in html_str
+ assert str(_fmt(fs["index"])) in html_str
+ assert str(_fmt(fs["breakdown"]["open_units"])) in html_str
+
+ def test_confidence_factors_rendered(self) -> None:
+ # confidence.factors должны попасть в HTML (ключ→значение).
+ html_str = _build_html(_normalize(_full_report()))
+ assert "Факторы уверенности" in html_str
+ assert "data_coverage" in html_str
+
+
# ── Graceful: частичный / пустой / мусорный вход → валидный PDF без падения ─────