REOPENED. PDF + Excel exporters read non-existent dict keys, so demand/supply/ scenario columns silently rendered "—". Tests passed only because the fixtures were stale (hand-typed the same wrong keys → fixture agreed with buggy exporter). - future_market: demand/supply → projected_demand_units/projected_supply_units - scenarios: drop non-existent per-scenario "overall"; show primary-horizon deficit_index from ScenarioForecast.forecasts (scoring.overall was NOT broken) - Excel #991: add missing future_supply (index + breakdown) + confidence.factors sections; add future_supply to PDF for parity - tests: rebuild forecast/scenario fixtures from real DemandSupplyForecast / ScenarioForecast as_dict(); contract-key regression guards fail on key-drift (verified: reintroducing old keys fails the new tests). 28 passed. Refs #989 #991
This commit is contained in:
parent
2b3759af6a
commit
33d1a63bc8
4 changed files with 468 additions and 21 deletions
|
|
@ -53,6 +53,11 @@ _ADVISORY_MARKER: str = (
|
|||
)
|
||||
# Округление чисел по умолчанию (скоры/индексы ∈ [0,1] и доли — 3 знака читаемо).
|
||||
_ROUND_DIGITS: int = 3
|
||||
# Основной продуктовый горизонт (мес) — из него тянем сводный deficit_index сценария
|
||||
# (зеркало report_assembler._PRIMARY_HORIZON_MONTHS). ScenarioForecast.as_dict() несёт
|
||||
# список forecasts по горизонтам, скалярного «overall» у сценария НЕТ — берём дефицит
|
||||
# основного горизонта как сводный показатель сценария.
|
||||
_PRIMARY_HORIZON_MONTHS: int = 12
|
||||
|
||||
# RU-метки уровней отчётной уверенности (§15) для листа «Сводка».
|
||||
_LEVEL_RU: dict[str, str] = {
|
||||
|
|
@ -135,6 +140,52 @@ def _level_ru(level: Any) -> str:
|
|||
return _DASH
|
||||
|
||||
|
||||
def _future_supply_pairs(future_supply: Any) -> dict[str, Any]:
|
||||
"""Плоская карта «метка → значение» по FutureSupplyPressure.as_dict() (§9.3). PURE.
|
||||
|
||||
`future_supply` = `FutureSupplyPressure.as_dict()`: верхнеуровневый `index`/контекст +
|
||||
вложенный `breakdown` (объёмы по слоям, поглощение, месяцы давления). Разворачиваем
|
||||
breakdown на верхний уровень с RU-метками. Пустой/None → {} (graceful → «нет данных»).
|
||||
"""
|
||||
data = _as_dict(future_supply)
|
||||
if not data:
|
||||
return {}
|
||||
breakdown = _as_dict(data.get("breakdown"))
|
||||
return {
|
||||
"Индекс давления": data.get("index"),
|
||||
"Уверенность": _level_ru(data.get("confidence")),
|
||||
"Открытый сток (ед.)": breakdown.get("open_units"),
|
||||
"Скрытый запас (ед.)": breakdown.get("hidden_units"),
|
||||
"Будущий слой в горизонте (ед.)": breakdown.get("future_units_by_horizon"),
|
||||
"Поглощение (ед./мес)": breakdown.get("monthly_absorption_units"),
|
||||
"Месяцев давления": breakdown.get("months_of_pressure"),
|
||||
}
|
||||
|
||||
|
||||
def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
|
||||
"""Сводный deficit_index сценария = дефицит основного горизонта (12 мес). PURE.
|
||||
|
||||
`payload` = `ScenarioForecast.as_dict()`: у сценария НЕТ скалярного «overall» — есть
|
||||
список `forecasts` по горизонтам, каждый с `deficit_index`. Берём горизонт
|
||||
`_PRIMARY_HORIZON_MONTHS`, иначе первый с не-None дефицитом. Нет → None (→ "—").
|
||||
"""
|
||||
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.get("deficit_index")
|
||||
for f in forecasts:
|
||||
if isinstance(f, dict) and f.get("deficit_index") is not None:
|
||||
return f.get("deficit_index")
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Низкоуровневые помощники записи листа (заголовки/строки/ширины). PURE-побочка
|
||||
# (мутируют переданный лист). Не ходят в БД/сеть.
|
||||
|
|
@ -236,6 +287,16 @@ def _build_summary_sheet(ws: Worksheet, report: dict[str, Any]) -> None:
|
|||
row = _write_kv(ws, row, "Обоснование", confidence.get("rationale"))
|
||||
row += 1
|
||||
|
||||
# ── Факторы уверенности (§15 #990 — что тянуло уверенность) ──
|
||||
row = _write_title(ws, row, "Факторы уверенности")
|
||||
factors = _as_dict(confidence.get("factors"))
|
||||
if factors:
|
||||
for key, value in factors.items():
|
||||
row = _write_kv(ws, row, str(key), value)
|
||||
else:
|
||||
row = _write_no_data(ws, row)
|
||||
row += 1
|
||||
|
||||
# ── Контекст (meta) ──
|
||||
row = _write_title(ws, row, "Контекст")
|
||||
row = _write_kv(ws, row, "Кадастровый номер", meta.get("cad_num"))
|
||||
|
|
@ -308,8 +369,8 @@ def _build_future_market_sheet(ws: Worksheet, report: dict[str, Any]) -> None:
|
|||
table_rows = [
|
||||
[
|
||||
f.get("horizon_months"),
|
||||
f.get("demand"),
|
||||
f.get("supply"),
|
||||
f.get("projected_demand_units"),
|
||||
f.get("projected_supply_units"),
|
||||
f.get("deficit_index"),
|
||||
_level_ru(f.get("confidence")),
|
||||
]
|
||||
|
|
@ -319,6 +380,16 @@ def _build_future_market_sheet(ws: Worksheet, report: dict[str, Any]) -> None:
|
|||
row = _write_table(ws, row, headers, table_rows)
|
||||
row += 1
|
||||
|
||||
# ── Давление будущего предложения §9.3 (FutureSupplyPressure.as_dict()) ──
|
||||
row = _write_title(ws, row, "Давление будущего предложения")
|
||||
future_supply_pairs = _future_supply_pairs(future.get("future_supply"))
|
||||
if future_supply_pairs:
|
||||
for label, value in future_supply_pairs.items():
|
||||
row = _write_kv(ws, row, label, value)
|
||||
else:
|
||||
row = _write_no_data(ws, row)
|
||||
row += 1
|
||||
|
||||
# ── Разброс сценариев по дефициту ──
|
||||
row = _write_title(ws, row, "Разброс сценариев (индекс дефицита)")
|
||||
if scenarios_summary:
|
||||
|
|
@ -383,13 +454,13 @@ def _build_scenarios_sheet(ws: Worksheet, report: dict[str, Any]) -> None:
|
|||
row = _write_kv(ws, row, "Резюме", scenarios.get("summary"))
|
||||
row += 1
|
||||
|
||||
# ── Таблица сценариев (имя + overall + advisory; прочие ключи в «детали») ──
|
||||
# ── Таблица сценариев (имя + сводный дефицит 12 мес + advisory) ──
|
||||
row = _write_title(ws, row, "Сводка по сценариям")
|
||||
headers = ["Сценарий", "Overall", "Advisory"]
|
||||
headers = ["Сценарий", "Индекс дефицита (12 мес)", "Advisory"]
|
||||
table_rows: list[list[Any]] = []
|
||||
for name, payload in by_scenario.items():
|
||||
data = _as_dict(payload)
|
||||
table_rows.append([name, data.get("overall"), data.get("advisory")])
|
||||
table_rows.append([name, _scenario_deficit_index(data), data.get("advisory")])
|
||||
_write_table(ws, row, headers, table_rows)
|
||||
|
||||
_set_widths(ws, [_LABEL_COL_WIDTH, _VALUE_COL_WIDTH, _VALUE_COL_WIDTH])
|
||||
|
|
|
|||
|
|
@ -55,6 +55,11 @@ _ADVISORY_MARKER: str = (
|
|||
)
|
||||
# Округление чисел по умолчанию (скоры/индексы ∈ [0,1] и доли — 3 знака читаемо).
|
||||
_ROUND_DIGITS: int = 3
|
||||
# Основной продуктовый горизонт (мес) — из него тянем сводный deficit_index сценария
|
||||
# (зеркало report_assembler._PRIMARY_HORIZON_MONTHS). ScenarioForecast.as_dict() несёт
|
||||
# список forecasts по горизонтам, скалярного «overall» у сценария НЕТ — берём дефицит
|
||||
# основного горизонта как сводный показатель сценария.
|
||||
_PRIMARY_HORIZON_MONTHS: int = 12
|
||||
# Сноска-футер на каждой странице (зеркало advisory-духа форсайт-стека).
|
||||
_FOOTER_NOTE: str = "GenDesign · Site Finder v2 · советующий отчёт §13 (не оферта)"
|
||||
|
||||
|
|
@ -261,6 +266,28 @@ def _join_ints(values: list[Any]) -> Any:
|
|||
return ", ".join(str(v) for v in values) if values else None
|
||||
|
||||
|
||||
def _future_supply_pairs(future_supply: Any) -> dict[str, Any]:
|
||||
"""Плоская карта «метка → значение» по FutureSupplyPressure.as_dict() (§9.3). PURE.
|
||||
|
||||
`future_supply` = `FutureSupplyPressure.as_dict()`: верхнеуровневый `index`/контекст +
|
||||
вложенный `breakdown` (объёмы по слоям, поглощение, месяцы давления). Разворачиваем
|
||||
breakdown на верхний уровень с RU-метками. Пустой/None → {} (graceful → «нет данных»).
|
||||
"""
|
||||
data = _as_dict(future_supply)
|
||||
if not data:
|
||||
return {}
|
||||
breakdown = _as_dict(data.get("breakdown"))
|
||||
return {
|
||||
"Индекс давления": data.get("index"),
|
||||
"Уверенность": _level_ru(data.get("confidence")),
|
||||
"Открытый сток (ед.)": breakdown.get("open_units"),
|
||||
"Скрытый запас (ед.)": breakdown.get("hidden_units"),
|
||||
"Будущий слой в горизонте (ед.)": breakdown.get("future_units_by_horizon"),
|
||||
"Поглощение (ед./мес)": breakdown.get("monthly_absorption_units"),
|
||||
"Месяцев давления": breakdown.get("months_of_pressure"),
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Построители секций — по одной на содержательную секцию §13. Каждый graceful:
|
||||
# пустая секция → «нет данных», НИКОГДА не падает. Возвращают HTML-фрагмент.
|
||||
|
|
@ -276,6 +303,7 @@ def _build_summary(report: dict[str, Any]) -> str:
|
|||
headline = exec_summary.get("headline")
|
||||
verdict = exec_summary.get("verdict")
|
||||
key_numbers = _as_dict(exec_summary.get("key_numbers"))
|
||||
factors = _as_dict(confidence.get("factors"))
|
||||
|
||||
cad = _esc(meta.get("cad_num"))
|
||||
district = _esc(meta.get("district"))
|
||||
|
|
@ -313,6 +341,9 @@ def _build_summary(report: dict[str, Any]) -> str:
|
|||
<h3>Уверенность отчёта</h3>
|
||||
{_kv_table(confidence_pairs)}
|
||||
|
||||
<h3>Факторы уверенности</h3>
|
||||
{_dict_kv_table(factors)}
|
||||
|
||||
<h3>Контекст</h3>
|
||||
{_kv_table(context_pairs)}
|
||||
</div>
|
||||
|
|
@ -358,8 +389,8 @@ def _build_future_market(report: dict[str, Any]) -> str:
|
|||
forecast_rows = [
|
||||
[
|
||||
f.get("horizon_months"),
|
||||
f.get("demand"),
|
||||
f.get("supply"),
|
||||
f.get("projected_demand_units"),
|
||||
f.get("projected_supply_units"),
|
||||
f.get("deficit_index"),
|
||||
_level_ru(f.get("confidence")),
|
||||
]
|
||||
|
|
@ -376,6 +407,9 @@ def _build_future_market(report: dict[str, Any]) -> str:
|
|||
<h3>Прогноз по горизонтам</h3>
|
||||
{_data_table(forecast_headers, forecast_rows)}
|
||||
|
||||
<h3>Давление будущего предложения</h3>
|
||||
{_dict_kv_table(_future_supply_pairs(future.get("future_supply")))}
|
||||
|
||||
<h3>Разброс сценариев (индекс дефицита)</h3>
|
||||
{_dict_kv_table(scenarios_summary)}
|
||||
</div>
|
||||
|
|
@ -418,6 +452,30 @@ def _build_product_tz(report: dict[str, Any]) -> str:
|
|||
"""
|
||||
|
||||
|
||||
def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
|
||||
"""Сводный deficit_index сценария = дефицит основного горизонта (12 мес). PURE.
|
||||
|
||||
`payload` = `ScenarioForecast.as_dict()`: у сценария НЕТ скалярного «overall» — есть
|
||||
список `forecasts` по горизонтам, каждый с `deficit_index`. Берём горизонт
|
||||
`_PRIMARY_HORIZON_MONTHS`, иначе первый с не-None дефицитом. Нет → None (→ "—").
|
||||
"""
|
||||
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.get("deficit_index")
|
||||
for f in forecasts:
|
||||
if isinstance(f, dict) and f.get("deficit_index") is not None:
|
||||
return f.get("deficit_index")
|
||||
return None
|
||||
|
||||
|
||||
def _build_scenarios(report: dict[str, Any]) -> str:
|
||||
"""Блок «Сценарии»: conservative/base/aggressive (таблица). Graceful."""
|
||||
scenarios = _as_dict(report.get("scenarios"))
|
||||
|
|
@ -426,15 +484,16 @@ def _build_scenarios(report: dict[str, Any]) -> str:
|
|||
rows: list[list[Any]] = []
|
||||
for name, payload in by_scenario.items():
|
||||
data = _as_dict(payload)
|
||||
rows.append([name, data.get("overall"), data.get("advisory")])
|
||||
rows.append([name, _scenario_deficit_index(data), data.get("advisory")])
|
||||
|
||||
headers = ["Сценарий", "Индекс дефицита (12 мес)", "Advisory"]
|
||||
return f"""
|
||||
<div class="section" id="scenarios">
|
||||
<h2>{html.escape(_TITLE_SCENARIOS)}</h2>
|
||||
<p class="verdict">{_esc(scenarios.get("summary"))}</p>
|
||||
|
||||
<h3>Сводка по сценариям</h3>
|
||||
{_data_table(["Сценарий", "Overall", "Advisory"], rows)}
|
||||
{_data_table(headers, rows)}
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -30,8 +30,11 @@ from app.services.exporters.excel import (
|
|||
_SHEET_SCENARIOS,
|
||||
_SHEET_SCORING,
|
||||
_SHEET_SUMMARY,
|
||||
_fmt,
|
||||
_scenario_deficit_index,
|
||||
export_report_xlsx,
|
||||
)
|
||||
from app.services.forecasting.demand_supply_forecast import DemandSupplyForecast
|
||||
from app.services.forecasting.report import (
|
||||
ReportConfidence,
|
||||
ReportExecSummary,
|
||||
|
|
@ -43,6 +46,72 @@ from app.services.forecasting.report import (
|
|||
ReportScoring,
|
||||
SiteFinderReport,
|
||||
)
|
||||
from app.services.forecasting.scenarios import ScenarioForecast
|
||||
|
||||
|
||||
def _forecast(horizon: int, demand: float, supply: float, deficit: float) -> dict[str, object]:
|
||||
"""Реальный `DemandSupplyForecast.as_dict()` — ключи гарантированно из контракта #952.
|
||||
|
||||
Строим frozen-dataclass БЕЗ БД и сериализуем через его собственный `as_dict()`: так
|
||||
тест не может разойтись с реальной формой (projected_demand_units / projected_supply_
|
||||
units), как это случилось со старой hand-typed фикстурой (demand/supply — #989/#991).
|
||||
"""
|
||||
return DemandSupplyForecast(
|
||||
segment={"obj_class": "комфорт"},
|
||||
horizon_months=horizon,
|
||||
base_pace_units_per_mo=8.0,
|
||||
demand_norm_coefficient=1.0,
|
||||
macro_coefficient=1.0,
|
||||
projected_demand_units=demand,
|
||||
open_units=300,
|
||||
hidden_release_units=80.0,
|
||||
future_online_units=20.0,
|
||||
projected_supply_units=supply,
|
||||
balance_units=demand - supply,
|
||||
balance_ratio=(demand / supply if supply else None),
|
||||
deficit_index=deficit,
|
||||
rate_future=18.0,
|
||||
rate_sensitivity_phrase="спрос эластичен к ставке",
|
||||
future_competitors=[{"obj_id": 3, "comm_name": "ЖК Гамма"}],
|
||||
advisory=True,
|
||||
confidence="medium",
|
||||
).as_dict()
|
||||
|
||||
|
||||
def _scenario(name: str, deficit_12mo: float) -> dict[str, object]:
|
||||
"""Реальный `ScenarioForecast.as_dict()` — ключи из контракта #984 (НЕ hand-typed).
|
||||
|
||||
У сценария НЕТ скалярного «overall»: есть `forecasts` (по горизонтам) с deficit_index.
|
||||
Экспортёр тянет сводный дефицит 12-мес горизонта — здесь даём именно его.
|
||||
"""
|
||||
return ScenarioForecast(
|
||||
scenario=name, # type: ignore[arg-type] # Literal в проде; в тесте — str-имя
|
||||
rate_path={6: 18.0, 12: 18.0},
|
||||
forecasts=[
|
||||
DemandSupplyForecast(
|
||||
segment={"obj_class": "комфорт"},
|
||||
horizon_months=12,
|
||||
base_pace_units_per_mo=8.0,
|
||||
demand_norm_coefficient=1.0,
|
||||
macro_coefficient=1.0,
|
||||
projected_demand_units=920.0,
|
||||
open_units=300,
|
||||
hidden_release_units=80.0,
|
||||
future_online_units=20.0,
|
||||
projected_supply_units=700.0,
|
||||
balance_units=220.0,
|
||||
balance_ratio=1.314,
|
||||
deficit_index=deficit_12mo,
|
||||
rate_future=18.0,
|
||||
rate_sensitivity_phrase=None,
|
||||
future_competitors=[],
|
||||
advisory=True,
|
||||
confidence="medium",
|
||||
)
|
||||
],
|
||||
advisory=True,
|
||||
).as_dict()
|
||||
|
||||
|
||||
# Шесть ожидаемых листов (по одному на содержательную секцию §13).
|
||||
_EXPECTED_SHEETS: tuple[str, ...] = (
|
||||
|
|
@ -75,10 +144,24 @@ def _full_report() -> SiteFinderReport:
|
|||
),
|
||||
future_market=ReportFutureMarket(
|
||||
forecasts_by_horizon=[
|
||||
{"horizon_months": 6, "demand": 410, "supply": 380, "deficit_index": 0.2},
|
||||
{"horizon_months": 12, "demand": 920, "supply": 700, "deficit_index": 0.34},
|
||||
_forecast(6, demand=410.0, supply=380.0, deficit=0.2),
|
||||
_forecast(12, demand=920.0, supply=700.0, deficit=0.34),
|
||||
],
|
||||
future_supply={"index": 0.42, "horizon_months": 12, "confidence": "low"},
|
||||
future_supply={
|
||||
"district": "Верх-Исетский",
|
||||
"horizon_months": 12,
|
||||
"premise_kind": "квартира",
|
||||
"confidence": "low",
|
||||
"index": 0.42,
|
||||
"breakdown": {
|
||||
"open_units": 1200,
|
||||
"hidden_units": 800,
|
||||
"future_units_by_horizon": 450.0,
|
||||
"monthly_absorption_units": 8.2,
|
||||
"months_of_pressure": 9.0,
|
||||
"index": 0.42,
|
||||
},
|
||||
},
|
||||
future_competitors=[{"obj_id": 3, "stage": "котлован"}],
|
||||
scenarios_summary={"base": 0.34, "conservative": 0.18, "aggressive": 0.49},
|
||||
summary="Будущий рынок: дефицит сохраняется на 12 мес.",
|
||||
|
|
@ -97,9 +180,9 @@ def _full_report() -> SiteFinderReport:
|
|||
),
|
||||
scenarios=ReportScenarios(
|
||||
by_scenario={
|
||||
"conservative": {"overall": 0.18, "advisory": True},
|
||||
"base": {"overall": 0.34, "advisory": True},
|
||||
"aggressive": {"overall": 0.49, "advisory": True},
|
||||
"conservative": _scenario("conservative", deficit_12mo=0.18),
|
||||
"base": _scenario("base", deficit_12mo=0.34),
|
||||
"aggressive": _scenario("aggressive", deficit_12mo=0.49),
|
||||
},
|
||||
summary="Разброс сценариев умеренный.",
|
||||
),
|
||||
|
|
@ -188,6 +271,98 @@ class TestFullReportExport:
|
|||
assert title in wb.sheetnames
|
||||
|
||||
|
||||
# ── Regression #989/#991: реальные ключи контракта попадают в ячейки (НЕ "—") ───
|
||||
# Тянем значения из РЕАЛЬНОГО `SiteFinderReport.as_dict()` (а не hand-typed строк):
|
||||
# если экспортёр снова прочитает несуществующий ключ (demand/supply / per-scenario
|
||||
# overall — как до фикса), число пропадёт из ячеек → тест упадёт. Контракт и тест
|
||||
# не разойдутся молча (root-cause старого зелёного теста — устаревшая фикстура).
|
||||
|
||||
|
||||
def _cell_values(ws) -> list: # type: ignore[no-untyped-def]
|
||||
"""Все непустые значения ячеек листа (как есть, без str-приведения)."""
|
||||
return [c.value for col in ws.iter_cols() for c in col if c.value is not None]
|
||||
|
||||
|
||||
class TestContractKeysWritten:
|
||||
def test_demand_supply_units_in_cells_not_dash(self) -> None:
|
||||
data = _full_report().as_dict()
|
||||
forecasts = data["future_market"]["forecasts_by_horizon"]
|
||||
assert forecasts, "фикстура должна содержать прогнозы по горизонтам"
|
||||
ws = _reload(export_report_xlsx(_full_report()))[_SHEET_FUTURE_MARKET]
|
||||
values = _cell_values(ws)
|
||||
for f in forecasts:
|
||||
demand = f["projected_demand_units"]
|
||||
supply = f["projected_supply_units"]
|
||||
assert demand is not None and supply is not None
|
||||
assert _fmt(demand) in values, f"спрос {demand} не в ячейках (ключ читается?)"
|
||||
assert _fmt(supply) in values, f"предложение {supply} не в ячейках"
|
||||
# «—» НЕ должен стоять там, где число существует.
|
||||
assert _dash_count_in_forecast_table(ws) == 0
|
||||
|
||||
def test_scenario_deficit_in_cells_not_dash(self) -> None:
|
||||
data = _full_report().as_dict()
|
||||
by_scenario = data["scenarios"]["by_scenario"]
|
||||
assert by_scenario, "фикстура должна содержать сценарии"
|
||||
ws = _reload(export_report_xlsx(_full_report()))[_SHEET_SCENARIOS]
|
||||
values = _cell_values(ws)
|
||||
for payload in by_scenario.values():
|
||||
di = _scenario_deficit_index(payload)
|
||||
assert di is not None
|
||||
assert _fmt(di) in values, f"дефицит сценария {di} не в ячейках"
|
||||
|
||||
def test_overall_score_in_cells(self) -> None:
|
||||
data = _full_report().as_dict()
|
||||
overall = data["scoring"]["overall"]
|
||||
assert overall is not None
|
||||
ws = _reload(export_report_xlsx(_full_report()))[_SHEET_SCORING]
|
||||
assert _fmt(overall) in _cell_values(ws)
|
||||
|
||||
def test_future_supply_section_in_cells(self) -> None:
|
||||
# #991: давление будущего предложения (index + breakdown по слоям) на листе.
|
||||
data = _full_report().as_dict()
|
||||
fs = data["future_market"]["future_supply"]
|
||||
assert fs is not None
|
||||
ws = _reload(export_report_xlsx(_full_report()))[_SHEET_FUTURE_MARKET]
|
||||
flat_str = [str(v) for v in _cell_values(ws)]
|
||||
values = _cell_values(ws)
|
||||
assert any("Давление будущего предложения" in v for v in flat_str)
|
||||
assert _fmt(fs["index"]) in values
|
||||
assert _fmt(fs["breakdown"]["open_units"]) in values
|
||||
|
||||
def test_confidence_factors_in_cells(self) -> None:
|
||||
# #991: confidence.factors (ключ→значение) на листе «Сводка».
|
||||
ws = _reload(export_report_xlsx(_full_report()))[_SHEET_SUMMARY]
|
||||
flat_str = [str(v) for v in _cell_values(ws)]
|
||||
assert any("Факторы уверенности" in v for v in flat_str)
|
||||
assert any("data_coverage" in v for v in flat_str)
|
||||
|
||||
|
||||
def _dash_count_in_forecast_table(ws) -> int: # type: ignore[no-untyped-def]
|
||||
"""Сколько ячеек спроса/предложения в таблице прогноза равны «—» (должно быть 0).
|
||||
|
||||
Находим строку-шапку «Спрос»/«Предложение», считаем «—» в этих колонках ниже неё.
|
||||
"""
|
||||
from app.services.exporters.excel import _DASH
|
||||
|
||||
header_row = None
|
||||
demand_col = supply_col = None
|
||||
for r in range(1, ws.max_row + 1):
|
||||
for c in range(1, ws.max_column + 1):
|
||||
val = ws.cell(row=r, column=c).value
|
||||
if val == "Спрос":
|
||||
header_row, demand_col = r, c
|
||||
elif val == "Предложение":
|
||||
supply_col = c
|
||||
if header_row is None or demand_col is None or supply_col is None:
|
||||
return 0
|
||||
count = 0
|
||||
for r in range(header_row + 1, ws.max_row + 1):
|
||||
for col in (demand_col, supply_col):
|
||||
if ws.cell(row=r, column=col).value == _DASH:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
# ── Graceful: частичный / пустой / мусорный вход → валидный .xlsx без падения ──
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -31,9 +31,12 @@ from app.services.exporters.report_pdf import (
|
|||
_TITLE_SCORING,
|
||||
_TITLE_SUMMARY,
|
||||
_build_html,
|
||||
_fmt,
|
||||
_normalize,
|
||||
_scenario_deficit_index,
|
||||
export_report_pdf,
|
||||
)
|
||||
from app.services.forecasting.demand_supply_forecast import DemandSupplyForecast
|
||||
from app.services.forecasting.report import (
|
||||
ReportConfidence,
|
||||
ReportExecSummary,
|
||||
|
|
@ -45,6 +48,71 @@ from app.services.forecasting.report import (
|
|||
ReportScoring,
|
||||
SiteFinderReport,
|
||||
)
|
||||
from app.services.forecasting.scenarios import ScenarioForecast
|
||||
|
||||
|
||||
def _forecast(horizon: int, demand: float, supply: float, deficit: float) -> dict[str, object]:
|
||||
"""Реальный `DemandSupplyForecast.as_dict()` — ключи гарантированно из контракта #952.
|
||||
|
||||
Строим frozen-dataclass БЕЗ БД и сериализуем через его собственный `as_dict()`: так
|
||||
тест не может разойтись с реальной формой (projected_demand_units / projected_supply_
|
||||
units), как это случилось со старой hand-typed фикстурой (demand/supply — #989/#991).
|
||||
"""
|
||||
return DemandSupplyForecast(
|
||||
segment={"obj_class": "комфорт"},
|
||||
horizon_months=horizon,
|
||||
base_pace_units_per_mo=8.0,
|
||||
demand_norm_coefficient=1.0,
|
||||
macro_coefficient=1.0,
|
||||
projected_demand_units=demand,
|
||||
open_units=300,
|
||||
hidden_release_units=80.0,
|
||||
future_online_units=20.0,
|
||||
projected_supply_units=supply,
|
||||
balance_units=demand - supply,
|
||||
balance_ratio=(demand / supply if supply else None),
|
||||
deficit_index=deficit,
|
||||
rate_future=18.0,
|
||||
rate_sensitivity_phrase="спрос эластичен к ставке",
|
||||
future_competitors=[{"obj_id": 3, "comm_name": "ЖК Гамма"}],
|
||||
advisory=True,
|
||||
confidence="medium",
|
||||
).as_dict()
|
||||
|
||||
|
||||
def _scenario(name: str, deficit_12mo: float) -> dict[str, object]:
|
||||
"""Реальный `ScenarioForecast.as_dict()` — ключи из контракта #984 (НЕ hand-typed).
|
||||
|
||||
У сценария НЕТ скалярного «overall»: есть `forecasts` (по горизонтам) с deficit_index.
|
||||
Экспортёр тянет сводный дефицит 12-мес горизонта — здесь даём именно его.
|
||||
"""
|
||||
return ScenarioForecast(
|
||||
scenario=name, # type: ignore[arg-type] # Literal в проде; в тесте — str-имя
|
||||
rate_path={6: 18.0, 12: 18.0},
|
||||
forecasts=[
|
||||
DemandSupplyForecast(
|
||||
segment={"obj_class": "комфорт"},
|
||||
horizon_months=12,
|
||||
base_pace_units_per_mo=8.0,
|
||||
demand_norm_coefficient=1.0,
|
||||
macro_coefficient=1.0,
|
||||
projected_demand_units=920.0,
|
||||
open_units=300,
|
||||
hidden_release_units=80.0,
|
||||
future_online_units=20.0,
|
||||
projected_supply_units=700.0,
|
||||
balance_units=220.0,
|
||||
balance_ratio=1.314,
|
||||
deficit_index=deficit_12mo,
|
||||
rate_future=18.0,
|
||||
rate_sensitivity_phrase=None,
|
||||
future_competitors=[],
|
||||
advisory=True,
|
||||
confidence="medium",
|
||||
)
|
||||
],
|
||||
advisory=True,
|
||||
).as_dict()
|
||||
|
||||
|
||||
def _weasyprint_native_libs_available() -> tuple[bool, str]:
|
||||
|
|
@ -102,10 +170,24 @@ def _full_report() -> SiteFinderReport:
|
|||
),
|
||||
future_market=ReportFutureMarket(
|
||||
forecasts_by_horizon=[
|
||||
{"horizon_months": 6, "demand": 410, "supply": 380, "deficit_index": 0.2},
|
||||
{"horizon_months": 12, "demand": 920, "supply": 700, "deficit_index": 0.34},
|
||||
_forecast(6, demand=410.0, supply=380.0, deficit=0.2),
|
||||
_forecast(12, demand=920.0, supply=700.0, deficit=0.34),
|
||||
],
|
||||
future_supply={"index": 0.42, "horizon_months": 12, "confidence": "low"},
|
||||
future_supply={
|
||||
"district": "Верх-Исетский",
|
||||
"horizon_months": 12,
|
||||
"premise_kind": "квартира",
|
||||
"confidence": "low",
|
||||
"index": 0.42,
|
||||
"breakdown": {
|
||||
"open_units": 1200,
|
||||
"hidden_units": 800,
|
||||
"future_units_by_horizon": 450.0,
|
||||
"monthly_absorption_units": 8.2,
|
||||
"months_of_pressure": 9.0,
|
||||
"index": 0.42,
|
||||
},
|
||||
},
|
||||
future_competitors=[{"obj_id": 3, "stage": "котлован"}],
|
||||
scenarios_summary={"base": 0.34, "conservative": 0.18, "aggressive": 0.49},
|
||||
summary="Будущий рынок: дефицит сохраняется на 12 мес.",
|
||||
|
|
@ -124,9 +206,9 @@ def _full_report() -> SiteFinderReport:
|
|||
),
|
||||
scenarios=ReportScenarios(
|
||||
by_scenario={
|
||||
"conservative": {"overall": 0.18, "advisory": True},
|
||||
"base": {"overall": 0.34, "advisory": True},
|
||||
"aggressive": {"overall": 0.49, "advisory": True},
|
||||
"conservative": _scenario("conservative", deficit_12mo=0.18),
|
||||
"base": _scenario("base", deficit_12mo=0.34),
|
||||
"aggressive": _scenario("aggressive", deficit_12mo=0.49),
|
||||
},
|
||||
summary="Разброс сценариев умеренный.",
|
||||
),
|
||||
|
|
@ -219,6 +301,66 @@ class TestRenderedHtmlContent:
|
|||
assert "& co" in html_str
|
||||
|
||||
|
||||
# ── Regression #989: реальные ключи контракта попадают в вывод (НЕ "—") ─────────
|
||||
# Эти тесты ловят key-drift экспортёра: они тянут значения из РЕАЛЬНОГО
|
||||
# `SiteFinderReport.as_dict()` (а не hand-typed строк), поэтому если экспортёр снова
|
||||
# начнёт читать несуществующий ключ (как demand/supply / per-scenario overall до фикса),
|
||||
# значение пропадёт из HTML → тест упадёт. Так контракт и тест не разойдутся молча.
|
||||
|
||||
|
||||
class TestContractKeysRendered:
|
||||
def test_demand_supply_units_rendered_not_dash(self) -> None:
|
||||
# Берём числа спроса/предложения из самого as_dict() (форма #952), затем
|
||||
# проверяем, что они ЕСТЬ в HTML — старый bug читал demand/supply → было "—".
|
||||
data = _full_report().as_dict()
|
||||
forecasts = data["future_market"]["forecasts_by_horizon"]
|
||||
assert forecasts, "фикстура должна содержать прогнозы по горизонтам"
|
||||
html_str = _build_html(_normalize(_full_report()))
|
||||
for f in forecasts:
|
||||
demand = f["projected_demand_units"]
|
||||
supply = f["projected_supply_units"]
|
||||
assert demand is not None and supply is not None
|
||||
assert str(_fmt(demand)) in html_str, f"спрос {demand} не отрисован (читается ключ?)"
|
||||
assert str(_fmt(supply)) in html_str, f"предложение {supply} не отрисован"
|
||||
|
||||
def test_scenario_deficit_rendered_not_dash(self) -> None:
|
||||
# Сводный дефицит 12-мес горизонта каждого сценария должен попасть в HTML.
|
||||
# Старый bug читал per-scenario data.get("overall") (нет такого ключа) → "—".
|
||||
data = _full_report().as_dict()
|
||||
by_scenario = data["scenarios"]["by_scenario"]
|
||||
assert by_scenario, "фикстура должна содержать сценарии"
|
||||
html_str = _build_html(_normalize(_full_report()))
|
||||
for payload in by_scenario.values():
|
||||
di = _scenario_deficit_index(payload)
|
||||
assert di is not None
|
||||
assert str(_fmt(di)) in html_str, f"дефицит сценария {di} не отрисован"
|
||||
|
||||
def test_overall_score_rendered(self) -> None:
|
||||
# scoring.overall (ReportScoring) — корректный ключ; убеждаемся, что он в HTML.
|
||||
data = _full_report().as_dict()
|
||||
overall = data["scoring"]["overall"]
|
||||
assert overall is not None
|
||||
html_str = _build_html(_normalize(_full_report()))
|
||||
assert str(_fmt(overall)) in html_str
|
||||
|
||||
def test_future_supply_section_rendered(self) -> None:
|
||||
# #991/parity: давление будущего предложения (index + breakdown) должно
|
||||
# попасть в HTML — раньше PDF не разворачивал breakdown по слоям.
|
||||
data = _full_report().as_dict()
|
||||
fs = data["future_market"]["future_supply"]
|
||||
assert fs is not None
|
||||
html_str = _build_html(_normalize(_full_report()))
|
||||
assert "Давление будущего предложения" in html_str
|
||||
assert str(_fmt(fs["index"])) in html_str
|
||||
assert str(_fmt(fs["breakdown"]["open_units"])) in html_str
|
||||
|
||||
def test_confidence_factors_rendered(self) -> None:
|
||||
# confidence.factors должны попасть в HTML (ключ→значение).
|
||||
html_str = _build_html(_normalize(_full_report()))
|
||||
assert "Факторы уверенности" in html_str
|
||||
assert "data_coverage" in html_str
|
||||
|
||||
|
||||
# ── Graceful: частичный / пустой / мусорный вход → валидный PDF без падения ─────
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue