diff --git a/backend/app/services/exporters/excel.py b/backend/app/services/exporters/excel.py index 508a82d4..3639e987 100644 --- a/backend/app/services/exporters/excel.py +++ b/backend/app/services/exporters/excel.py @@ -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]) diff --git a/backend/tests/services/exporters/test_excel.py b/backend/tests/services/exporters/test_excel.py index ad84e50e..b639775f 100644 --- a/backend/tests/services/exporters/test_excel.py +++ b/backend/tests/services/exporters/test_excel.py @@ -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}"