Merge pull request 'fix(exporters): annotate scenario deficit horizon in excel too (#1590 follow-up)' (#1702) from fix/excel-deficit-horizon-1590 into main
Some checks are pending
Deploy / changes (push) Waiting to run
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Some checks are pending
Deploy / changes (push) Waiting to run
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
This commit is contained in:
commit
3d2d6d5da2
2 changed files with 99 additions and 9 deletions
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue