fix(exporters): annotate scenario deficit horizon in excel too (#1590 follow-up)
Some checks failed
CI / backend-tests (push) Has been cancelled
CI / frontend-tests (push) Has been cancelled
CI / openapi-codegen-check (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / changes (pull_request) Has been cancelled
CI / backend-tests (pull_request) Has been cancelled
CI / frontend-tests (pull_request) Has been cancelled
CI / openapi-codegen-check (pull_request) Has been cancelled
Some checks failed
CI / backend-tests (push) Has been cancelled
CI / frontend-tests (push) Has been cancelled
CI / openapi-codegen-check (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / changes (pull_request) Has been cancelled
CI / backend-tests (pull_request) Has been cancelled
CI / frontend-tests (pull_request) Has been cancelled
CI / openapi-codegen-check (pull_request) Has been cancelled
excel.py рендерил _scenario_deficit_index (скаляр) в таблицу сценариев под
шапкой «(12 мес)» — при fallback на другой горизонт подпись лгала (то же что
docx/pptx до 764db617). Добавлены _scenario_deficit_horizon и
_scenario_deficit_cell (зеркало report_pdf.py/764db617): при fallback ячейка
несёт «(гор. N мес)». _build_scenarios_sheet переключён на _scenario_deficit_cell.
Тесты: два новых кейса — fallback (6 мес → аннотация) и primary (12 мес → скаляр).
This commit is contained in:
parent
f55e83a150
commit
c54502ebf8
2 changed files with 99 additions and 9 deletions
|
|
@ -61,7 +61,9 @@ _FORMULA_INJECTION_PREFIXES: tuple[str, ...] = ("=", "+", "-", "@", "\t", "\r")
|
||||||
# Основной продуктовый горизонт (мес) — из него тянем сводный deficit_index сценария
|
# Основной продуктовый горизонт (мес) — из него тянем сводный deficit_index сценария
|
||||||
# (зеркало report_assembler._PRIMARY_HORIZON_MONTHS). ScenarioForecast.as_dict() несёт
|
# (зеркало report_assembler._PRIMARY_HORIZON_MONTHS). ScenarioForecast.as_dict() несёт
|
||||||
# список forecasts по горизонтам, скалярного «overall» у сценария НЕТ — берём дефицит
|
# список forecasts по горизонтам, скалярного «overall» у сценария НЕТ — берём дефицит
|
||||||
# основного горизонта как сводный показатель сценария.
|
# основного горизонта как сводный показатель. При fallback на иной горизонт ячейка
|
||||||
|
# несёт «(гор. N мес)» через _scenario_deficit_cell, чтобы шапка «(12 мес)» не лгала
|
||||||
|
# (#1590).
|
||||||
_PRIMARY_HORIZON_MONTHS: int = 12
|
_PRIMARY_HORIZON_MONTHS: int = 12
|
||||||
|
|
||||||
# RU-метки уровней отчётной уверенности (§15) для листа «Сводка».
|
# RU-метки уровней отчётной уверенности (§15) для листа «Сводка».
|
||||||
|
|
@ -140,7 +142,7 @@ def _fmt(value: Any) -> Any:
|
||||||
return _sanitize_formula(value)
|
return _sanitize_formula(value)
|
||||||
if isinstance(value, int):
|
if isinstance(value, int):
|
||||||
return value
|
return value
|
||||||
if isinstance(value, (dict, list)):
|
if isinstance(value, dict | list):
|
||||||
return _sanitize_formula(str(value))
|
return _sanitize_formula(str(value))
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
@ -208,6 +210,48 @@ def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
|
||||||
return None
|
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-побочка
|
# Низкоуровневые помощники записи листа (заголовки/строки/ширины). PURE-побочка
|
||||||
# (мутируют переданный лист). Не ходят в БД/сеть.
|
# (мутируют переданный лист). Не ходят в БД/сеть.
|
||||||
|
|
@ -481,12 +525,14 @@ def _build_scenarios_sheet(ws: Worksheet, report: dict[str, Any]) -> None:
|
||||||
row += 1
|
row += 1
|
||||||
|
|
||||||
# ── Таблица сценариев (имя + сводный дефицит 12 мес + advisory) ──
|
# ── Таблица сценариев (имя + сводный дефицит 12 мес + advisory) ──
|
||||||
|
# _scenario_deficit_cell: при fallback на нестандартный горизонт ячейка несёт
|
||||||
|
# «(гор. N мес)» — шапка «(12 мес)» не лжёт (#1590).
|
||||||
row = _write_title(ws, row, "Сводка по сценариям")
|
row = _write_title(ws, row, "Сводка по сценариям")
|
||||||
headers = ["Сценарий", "Индекс дефицита (12 мес)", "Advisory"]
|
headers = ["Сценарий", f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)", "Advisory"]
|
||||||
table_rows: list[list[Any]] = []
|
table_rows: list[list[Any]] = []
|
||||||
for name, payload in by_scenario.items():
|
for name, payload in by_scenario.items():
|
||||||
data = _as_dict(payload)
|
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)
|
_write_table(ws, row, headers, table_rows)
|
||||||
|
|
||||||
_set_widths(ws, [_LABEL_COL_WIDTH, _VALUE_COL_WIDTH, _VALUE_COL_WIDTH])
|
_set_widths(ws, [_LABEL_COL_WIDTH, _VALUE_COL_WIDTH, _VALUE_COL_WIDTH])
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ from app.services.exporters.excel import (
|
||||||
_SHEET_SUMMARY,
|
_SHEET_SUMMARY,
|
||||||
_fmt,
|
_fmt,
|
||||||
_sanitize_formula,
|
_sanitize_formula,
|
||||||
|
_scenario_deficit_cell,
|
||||||
_scenario_deficit_index,
|
_scenario_deficit_index,
|
||||||
export_report_xlsx,
|
export_report_xlsx,
|
||||||
)
|
)
|
||||||
|
|
@ -313,6 +314,53 @@ class TestContractKeysWritten:
|
||||||
assert di is not None
|
assert di is not None
|
||||||
assert _fmt(di) in values, f"дефицит сценария {di} не в ячейках"
|
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:
|
def test_overall_score_in_cells(self) -> None:
|
||||||
data = _full_report().as_dict()
|
data = _full_report().as_dict()
|
||||||
overall = data["scoring"]["overall"]
|
overall = data["scoring"]["overall"]
|
||||||
|
|
@ -466,10 +514,6 @@ class TestFormulaInjection:
|
||||||
payload = export_report_xlsx(report)
|
payload = export_report_xlsx(report)
|
||||||
wb = _reload(payload)
|
wb = _reload(payload)
|
||||||
formula_cells = [
|
formula_cells = [
|
||||||
c
|
c for ws in wb.worksheets for col in ws.iter_cols() for c in col if c.data_type == "f"
|
||||||
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}"
|
assert not formula_cells, f"ни одна ячейка не должна быть формулой: {formula_cells}"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue