diff --git a/backend/app/services/exporters/excel.py b/backend/app/services/exporters/excel.py index 3639e987..22b487d9 100644 --- a/backend/app/services/exporters/excel.py +++ b/backend/app/services/exporters/excel.py @@ -516,20 +516,56 @@ def _build_product_tz_sheet(ws: Worksheet, report: dict[str, Any]) -> None: def _build_scenarios_sheet(ws: Worksheet, report: dict[str, Any]) -> None: - """Лист «Сценарии»: conservative/base/aggressive (таблица). Graceful.""" + """Лист «Сценарии»: conservative/base/aggressive (таблица). Graceful. + + §22 audit-issue #1871 P1 (collapse): если `scenarios_collapsed=True` (failed + β-gate в §9.6 → §9.4 coefficient=1.0 → три сценария идентичны), выводим ОДНУ + строку «Базовый» + плашку-caveat с причиной collapse — НЕ три одинаковых + столбца (иначе таблица читается как баг). Поля `scenarios_collapsed`/ + `scenarios_collapse_reason` гарантированы в схеме (report.ReportScenarios). + """ scenarios = _as_dict(report.get("scenarios")) by_scenario = _as_dict(scenarios.get("by_scenario")) + collapsed = bool(scenarios.get("scenarios_collapsed")) + collapse_reason = scenarios.get("scenarios_collapse_reason") + row = _write_title(ws, 1, "Сценарии") row = _write_kv(ws, row, "Резюме", scenarios.get("summary")) row += 1 - # ── Таблица сценариев (имя + сводный дефицит 12 мес + advisory) ── + if collapsed: + # Плашка-caveat: причина collapse (italic, янтарный — зеркало _ADVISORY_FONT). + caveat_cell = ws.cell(row=row, column=1, value="Сценарная дифференциация недоступна") + caveat_cell.font = Font(bold=True, color="B45309") + row += 1 + if isinstance(collapse_reason, str) and collapse_reason: + reason_cell = ws.cell(row=row, column=1, value=collapse_reason) + reason_cell.font = _ADVISORY_FONT + reason_cell.alignment = _WRAP_TOP + row += 1 + row += 1 + + # ── ОДНА строка «Базовый» вместо трёх идентичных (collapse) ── + row = _write_title(ws, row, "Базовый сценарий") + headers = ["Сценарий", f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)", "Advisory"] + base_payload = _as_dict(by_scenario.get("base")) + table_rows: list[list[Any]] = ( + [["base", _scenario_deficit_cell(base_payload), base_payload.get("advisory")]] + if base_payload + else [] + ) + _write_table(ws, row, headers, table_rows) + + _set_widths(ws, [_LABEL_COL_WIDTH, _VALUE_COL_WIDTH, _VALUE_COL_WIDTH]) + return + + # ── Штатная таблица сценариев (имя + сводный дефицит 12 мес + advisory) ── # _scenario_deficit_cell: при fallback на нестандартный горизонт ячейка несёт # «(гор. N мес)» — шапка «(12 мес)» не лжёт (#1590). row = _write_title(ws, row, "Сводка по сценариям") headers = ["Сценарий", f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)", "Advisory"] - table_rows: list[list[Any]] = [] + table_rows = [] for name, payload in by_scenario.items(): data = _as_dict(payload) table_rows.append([name, _scenario_deficit_cell(data), data.get("advisory")]) diff --git a/backend/app/services/exporters/report_pdf.py b/backend/app/services/exporters/report_pdf.py index 3a9faefd..04452b86 100644 --- a/backend/app/services/exporters/report_pdf.py +++ b/backend/app/services/exporters/report_pdf.py @@ -143,6 +143,19 @@ td.empty, .no-data { text-align: center; color: #9ca3af; font-style: italic; pad margin-top: 18pt; padding-top: 8pt; border-top: 1px solid #e6e8ec; font-size: 8pt; color: #9ca3af; } + +/* §22 audit-issue #1871 P1: collapse-плашка вместо таблицы трёх сценариев. */ +.scenarios-collapse { + margin: 0 0 10pt 0; padding: 12pt 14pt; + background: #1f2937; color: #f9fafb; + border-radius: 4pt; +} +.scenarios-collapse-title { + font-size: 11pt; font-weight: 700; margin-bottom: 6pt; color: #fef3c7; +} +.scenarios-collapse-body { + font-size: 9.5pt; line-height: 1.4; color: #e5e7eb; +} """ @@ -523,10 +536,52 @@ def _scenario_deficit_cell(payload: dict[str, Any]) -> Any: def _build_scenarios(report: dict[str, Any]) -> str: - """Блок «Сценарии»: conservative/base/aggressive (таблица). Graceful.""" + """Блок «Сценарии»: conservative/base/aggressive (таблица). Graceful. + + §22 audit-issue #1871 P1 (collapse-плашка): если `scenarios_collapsed=True` + (failed β-gate в §9.6 → §9.4 coefficient=1.0 → три сценария идентичны), + рисуем тёмную плашку-объяснение ВМЕСТО таблицы трёх одинаковых столбцов. + Сохраняем deficit_index базы как информационный фон (без сравнения с + conservative/aggressive — они идентичны). Поля `scenarios_collapsed`/ + `scenarios_collapse_reason` гарантированы в схеме (см. report.ReportScenarios). + """ scenarios = _as_dict(report.get("scenarios")) by_scenario = _as_dict(scenarios.get("by_scenario")) + collapsed = bool(scenarios.get("scenarios_collapsed")) + collapse_reason = scenarios.get("scenarios_collapse_reason") + + if collapsed: + base_payload = _as_dict(by_scenario.get("base")) + base_deficit = _scenario_deficit_cell(base_payload) if base_payload else None + base_horizon = _scenario_deficit_horizon(base_payload) if base_payload else None + info_label = "Базовый сценарий" + if base_horizon is not None: + info_label += f" (индекс дефицита, гор. {base_horizon} мес)" + info_row = ( + f'
| {html.escape(info_label)} | ' + f'{_esc(base_deficit)} | ' + f"