diff --git a/backend/app/services/exporters/excel.py b/backend/app/services/exporters/excel.py index b4c5c815..f3987f81 100644 --- a/backend/app/services/exporters/excel.py +++ b/backend/app/services/exporters/excel.py @@ -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]) diff --git a/backend/app/services/exporters/report_pdf.py b/backend/app/services/exporters/report_pdf.py index 4fcd4494..538b245d 100644 --- a/backend/app/services/exporters/report_pdf.py +++ b/backend/app/services/exporters/report_pdf.py @@ -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:

Уверенность отчёта

{_kv_table(confidence_pairs)} +

Факторы уверенности

+ {_dict_kv_table(factors)} +

Контекст

{_kv_table(context_pairs)} @@ -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:

Прогноз по горизонтам

{_data_table(forecast_headers, forecast_rows)} +

Давление будущего предложения

+ {_dict_kv_table(_future_supply_pairs(future.get("future_supply")))} +

Разброс сценариев (индекс дефицита)

{_dict_kv_table(scenarios_summary)} @@ -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"""

{html.escape(_TITLE_SCENARIOS)}

{_esc(scenarios.get("summary"))}

Сводка по сценариям

- {_data_table(["Сценарий", "Overall", "Advisory"], rows)} + {_data_table(headers, rows)}
""" diff --git a/backend/tests/services/exporters/test_excel.py b/backend/tests/services/exporters/test_excel.py index 24b6c72c..2c2422d3 100644 --- a/backend/tests/services/exporters/test_excel.py +++ b/backend/tests/services/exporters/test_excel.py @@ -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 без падения ── diff --git a/backend/tests/services/exporters/test_report_pdf.py b/backend/tests/services/exporters/test_report_pdf.py index 22618b0b..2eb101b2 100644 --- a/backend/tests/services/exporters/test_report_pdf.py +++ b/backend/tests/services/exporters/test_report_pdf.py @@ -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 без падения ─────