fix(exporters): annotate scenario deficit horizon in excel too (#1590 follow-up) #1702

Merged
lekss361 merged 1 commit from fix/excel-deficit-horizon-1590 into main 2026-06-17 18:34:49 +00:00
2 changed files with 99 additions and 9 deletions

View file

@ -61,7 +61,9 @@ _FORMULA_INJECTION_PREFIXES: tuple[str, ...] = ("=", "+", "-", "@", "\t", "\r")
# Основной продуктовый горизонт (мес) — из него тянем сводный deficit_index сценария
# (зеркало report_assembler._PRIMARY_HORIZON_MONTHS). ScenarioForecast.as_dict() несёт
# список forecasts по горизонтам, скалярного «overall» у сценария НЕТ — берём дефицит
# основного горизонта как сводный показатель сценария.
# основного горизонта как сводный показатель. При fallback на иной горизонт ячейка
# несёт «(гор. N мес)» через _scenario_deficit_cell, чтобы шапка «(12 мес)» не лгала
# (#1590).
_PRIMARY_HORIZON_MONTHS: int = 12
# RU-метки уровней отчётной уверенности (§15) для листа «Сводка».
@ -140,7 +142,7 @@ def _fmt(value: Any) -> Any:
return _sanitize_formula(value)
if isinstance(value, int):
return value
if isinstance(value, (dict, list)):
if isinstance(value, dict | list):
return _sanitize_formula(str(value))
return value
@ -208,6 +210,48 @@ def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
return None
def _scenario_deficit_horizon(payload: dict[str, Any]) -> int | None:
"""Фактический горизонт (мес) дефицита, выбранного `_scenario_deficit_index`. PURE.
Зеркалит порядок выбора `_scenario_deficit_index` (primary 12 мес первый с не-None
дефицитом), но возвращает `horizon_months` выбранного прогноза, чтобы подпись не лгала
при fallback на чужой горизонт. Нет дефицита None (#1590, зеркало report_pdf.py).
"""
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_HORIZON_MONTHS
for f in forecasts:
if isinstance(f, dict) and f.get("deficit_index") is not None:
h = f.get("horizon_months")
return h if isinstance(h, int) else None
return None
def _scenario_deficit_cell(payload: dict[str, Any]) -> Any:
"""Ячейка дефицита сценария: значение + «(гор. N мес)» при N != основного горизонта.
PURE. При отсутствии 12-мес прогноза `_scenario_deficit_index` делает fallback на чужой
горизонт помечаем ячейку реальным горизонтом, чтобы число под общей шапкой
«(12 мес)» не читалось как 12-месячное. Дефицит None отдаём как есть ( "").
Зеркалит report_pdf._scenario_deficit_cell (#1590).
"""
deficit = _scenario_deficit_index(payload)
if deficit is None:
return deficit
horizon = _scenario_deficit_horizon(payload)
if horizon is not None and horizon != _PRIMARY_HORIZON_MONTHS:
return f"{_fmt(deficit)} (гор. {horizon} мес)"
return deficit
# ──────────────────────────────────────────────────────────────────────────────
# Низкоуровневые помощники записи листа (заголовки/строки/ширины). PURE-побочка
# (мутируют переданный лист). Не ходят в БД/сеть.
@ -481,12 +525,14 @@ def _build_scenarios_sheet(ws: Worksheet, report: dict[str, Any]) -> None:
row += 1
# ── Таблица сценариев (имя + сводный дефицит 12 мес + advisory) ──
# _scenario_deficit_cell: при fallback на нестандартный горизонт ячейка несёт
# «(гор. N мес)» — шапка «(12 мес)» не лжёт (#1590).
row = _write_title(ws, row, "Сводка по сценариям")
headers = ["Сценарий", "Индекс дефицита (12 мес)", "Advisory"]
headers = ["Сценарий", f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)", "Advisory"]
table_rows: list[list[Any]] = []
for name, payload in by_scenario.items():
data = _as_dict(payload)
table_rows.append([name, _scenario_deficit_index(data), data.get("advisory")])
table_rows.append([name, _scenario_deficit_cell(data), data.get("advisory")])
_write_table(ws, row, headers, table_rows)
_set_widths(ws, [_LABEL_COL_WIDTH, _VALUE_COL_WIDTH, _VALUE_COL_WIDTH])

View file

@ -32,6 +32,7 @@ from app.services.exporters.excel import (
_SHEET_SUMMARY,
_fmt,
_sanitize_formula,
_scenario_deficit_cell,
_scenario_deficit_index,
export_report_xlsx,
)
@ -313,6 +314,53 @@ class TestContractKeysWritten:
assert di is not None
assert _fmt(di) in values, f"дефицит сценария {di} не в ячейках"
def test_scenario_deficit_cell_annotates_fallback_horizon(self) -> None:
"""#1590: при fallback на горизонт != 12 ячейка несёт «(гор. N мес)»."""
# Сценарий без 12-мес прогноза — только 6-мес.
payload_6mo = ScenarioForecast(
scenario="base", # type: ignore[arg-type]
rate_path={6: 18.0},
forecasts=[
DemandSupplyForecast(
segment={"obj_class": "комфорт"},
horizon_months=6,
base_pace_units_per_mo=8.0,
demand_norm_coefficient=1.0,
macro_coefficient=1.0,
projected_demand_units=410.0,
open_units=300,
hidden_release_units=80.0,
future_online_units=20.0,
projected_supply_units=380.0,
balance_units=30.0,
balance_ratio=1.08,
deficit_index=0.22,
months_of_inventory=5.5,
rate_future=18.0,
rate_sensitivity_phrase=None,
future_competitors=[],
advisory=True,
confidence="low",
)
],
advisory=True,
).as_dict()
cell_value = _scenario_deficit_cell(payload_6mo)
# Должна быть строка с аннотацией горизонта, не голое число.
assert isinstance(cell_value, str), "ожидается строка с аннотацией горизонта"
assert "гор. 6 мес" in cell_value, f"ожидалось «гор. 6 мес» в '{cell_value}'"
assert "0.22" in cell_value or "0.22" in str(cell_value)
def test_scenario_deficit_cell_no_annotation_for_primary_horizon(self) -> None:
"""#1590: при 12-мес горизонте ячейка возвращает скаляр без аннотации."""
payload_12mo = _scenario("base", deficit_12mo=0.34)
cell_value = _scenario_deficit_cell(payload_12mo)
# Для основного горизонта — голое число, не строка с «(гор. N мес)».
assert not isinstance(
cell_value, str
), f"для 12-мес горизонта ожидается скаляр, получено '{cell_value}'"
assert cell_value == 0.34
def test_overall_score_in_cells(self) -> None:
data = _full_report().as_dict()
overall = data["scoring"]["overall"]
@ -466,10 +514,6 @@ class TestFormulaInjection:
payload = export_report_xlsx(report)
wb = _reload(payload)
formula_cells = [
c
for ws in wb.worksheets
for col in ws.iter_cols()
for c in col
if c.data_type == "f"
c for ws in wb.worksheets for col in ws.iter_cols() for c in col if c.data_type == "f"
]
assert not formula_cells, f"ни одна ячейка не должна быть формулой: {formula_cells}"