diff --git a/backend/app/services/exporters/report_pdf.py b/backend/app/services/exporters/report_pdf.py
index 04452b86..b001aa58 100644
--- a/backend/app/services/exporters/report_pdf.py
+++ b/backend/app/services/exporters/report_pdf.py
@@ -32,7 +32,9 @@ from typing import Any
logger = logging.getLogger(__name__)
# ── Named-константы: заголовки секций (по одной на содержательную секцию §13) ──
-# Тот же набор из шести содержательных секций, что рисует excel.py (#991).
+# Шесть секций зеркалят excel.py (#991); §13.7 «Уверенность» — доп. секция, портирована
+# из report_docx/report_md для parity между экспортёрами (audit epic #2445 item C2;
+# excel.py её пока не несёт — отдельный gap, вне scope этой правки).
_TITLE_DOC: str = "Site Finder v2 — советующий отчёт §13"
_TITLE_SUMMARY: str = "Сводка"
@@ -41,6 +43,7 @@ _TITLE_FUTURE_MARKET: str = "Будущий рынок"
_TITLE_PRODUCT_TZ: str = "Продукт ТЗ"
_TITLE_SCENARIOS: str = "Сценарии"
_TITLE_SCORING: str = "Скоринг"
+_TITLE_CONFIDENCE: str = "Уверенность"
# ── Named-константы: микрокопия / плейсхолдеры (зеркало excel.py) ──────────────
@@ -308,15 +311,21 @@ def _future_supply_pairs(future_supply: Any) -> dict[str, Any]:
def _build_summary(report: dict[str, Any]) -> str:
- """Блок «Сводка»: cover + ADVISORY-маркер + вердикт + ключевые числа + контекст."""
+ """Блок «Сводка»: cover + ADVISORY-маркер + вердикт + ключевые числа + контекст.
+
+ Уровень уверенности здесь — только сводный badge (`overall_confidence`), зеркало
+ report_docx/report_md._build_summary. Полный разбор (rationale + факторы-драйверы)
+ живёт в отдельной секции §13.7 «Уверенность» (`_build_confidence`) — раньше
+ (до parity-фикса #2445 C2) он дублировался здесь тонкой 2-строчной таблицей, что
+ расходилось с docx/md и не переживало dict-значный фактор; убрано, чтобы не было
+ двух версий одних и тех же данных в одном документе.
+ """
exec_summary = _as_dict(report.get("exec_summary"))
meta = _as_dict(report.get("meta"))
- confidence = _as_dict(report.get("confidence"))
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"))
@@ -329,10 +338,6 @@ def _build_summary(report: dict[str, Any]) -> str:
("Сформировано", meta.get("generated_at")),
("Версия схемы", meta.get("schema_version")),
]
- confidence_pairs: list[tuple[str, Any]] = [
- ("Уровень", _level_ru(confidence.get("level"))),
- ("Обоснование", confidence.get("rationale")),
- ]
overall_conf = _esc(_level_ru(exec_summary.get("overall_confidence")))
return f"""
@@ -351,12 +356,6 @@ def _build_summary(report: dict[str, Any]) -> str:
Ключевые числа
{_dict_kv_table(key_numbers)}
- Уверенность отчёта
- {_kv_table(confidence_pairs)}
-
- Факторы уверенности
- {_dict_kv_table(factors)}
-
Контекст
{_kv_table(context_pairs)}
@@ -624,6 +623,43 @@ def _build_scoring(report: dict[str, Any]) -> str:
Специальные индексы
{_data_table(["Индекс", "Значение", "Метка"], index_rows)}
+
+"""
+
+
+def _build_confidence(report: dict[str, Any]) -> str:
+ """§13.7 «Уверенность»: уровень + обоснование + факторы-драйверы (таблица).
+
+ Parity fix (audit epic #2445 item C2): report_docx/report_md уже несли эту секцию
+ (§22.7/§13.7) — PDF был единственным экспортёром без нее. Портировано 1-в-1 (та же
+ 4-колоночная таблица «Фактор/Значение/Уровень/Комментарий»), но через HTML-примитивы
+ report_pdf (`_data_table`), а не python-docx/Markdown API.
+ """
+ confidence = _as_dict(report.get("confidence"))
+ level = _level_ru(confidence.get("level"))
+ rationale = confidence.get("rationale")
+ factors = _as_dict(confidence.get("factors"))
+
+ # Факторы #990: {name: {value, level, note}} ИЛИ плоское {name: value}. Defensive:
+ # если значение — dict, раскладываем на value/level/note; иначе кладём как есть
+ # (зеркало report_docx._build_confidence / report_md._build_confidence).
+ factor_rows: list[list[Any]] = []
+ for name, payload in factors.items():
+ if isinstance(payload, dict):
+ factor_rows.append(
+ [name, payload.get("value"), _level_ru(payload.get("level")), payload.get("note")]
+ )
+ else:
+ factor_rows.append([name, payload, _DASH, _DASH])
+
+ return f"""
+
+
{html.escape(_TITLE_CONFIDENCE)}
+
Уровень: {_esc(level)}
+
{_esc(rationale)}
+
+
Факторы уверенности
+ {_data_table(["Фактор", "Значение", "Уровень", "Комментарий"], factor_rows)}
@@ -631,7 +667,8 @@ def _build_scoring(report: dict[str, Any]) -> str:
# Реестр построителей секций. Порядок = порядок блоков в документе (зеркало
-# `_SHEET_BUILDERS` у excel.py — тот же набор из шести содержательных секций §13).
+# `_SHEET_BUILDERS` у excel.py + report_docx/report_md — Сводка → Рынок сейчас →
+# Будущий рынок → Продукт ТЗ → Сценарии → Скоринг → Уверенность §13.7, #2445 C2).
_SECTION_BUILDERS: tuple[Any, ...] = (
_build_summary,
_build_market_now,
@@ -639,11 +676,12 @@ _SECTION_BUILDERS: tuple[Any, ...] = (
_build_product_tz,
_build_scenarios,
_build_scoring,
+ _build_confidence,
)
def _build_html(report: dict[str, Any]) -> str:
- """Склеить HTML-документ из шести секций §13. PURE (только строки). Graceful."""
+ """Склеить HTML-документ из семи секций §13 (+ §13.7 Уверенность). PURE. Graceful."""
sections = "".join(builder(report) for builder in _SECTION_BUILDERS)
return f"""
@@ -666,10 +704,10 @@ def export_report_pdf(report: Any) -> bytes:
"""§13 Отрендерить `SiteFinderReport` (#987) в PDF-документ и вернуть БАЙТЫ.
По одному блоку на содержательную секцию §13 (Сводка / Рынок сейчас / Будущий
- рынок / Продукт ТЗ / Сценарии / Скоринг — тот же набор, что и `export_report_xlsx`).
- Шапки таблиц с заливкой, RU-метки, числа округлены, None → "—". На блоке «Сводка» —
- заметный ADVISORY-маркер (отчёт советующий). ВСЕ динамические строки экранируются
- `html.escape`.
+ рынок / Продукт ТЗ / Сценарии / Скоринг / Уверенность §13.7 — parity с
+ report_docx/report_md, #2445 C2). Шапки таблиц с заливкой, RU-метки, числа
+ округлены, None → "—". На блоке «Сводка» — заметный ADVISORY-маркер (отчёт
+ советующий). ВСЕ динамические строки экранируются `html.escape`.
ДЕТЕРМИНИРОВАННО, БЕЗ LLM/БД/сети. Принимает КАК `SiteFinderReport`-инстанс, ТАК и
его `as_dict()`-словарь (нормализуется через `_normalize`). GRACEFUL: частичный/
diff --git a/backend/tests/services/exporters/test_report_pdf.py b/backend/tests/services/exporters/test_report_pdf.py
index 5ef08748..c71b3b4b 100644
--- a/backend/tests/services/exporters/test_report_pdf.py
+++ b/backend/tests/services/exporters/test_report_pdf.py
@@ -24,6 +24,7 @@ import pytest
from app.services.exporters.report_pdf import (
_ADVISORY_MARKER,
+ _TITLE_CONFIDENCE,
_TITLE_FUTURE_MARKET,
_TITLE_MARKET_NOW,
_TITLE_PRODUCT_TZ,
@@ -141,7 +142,8 @@ _skip_if_no_weasyprint = pytest.mark.skipif(
not _WP_OK, reason=f"WeasyPrint native libs missing: {_WP_ERR}"
)
-# Шесть ожидаемых заголовков блоков (по одному на содержательную секцию §13).
+# Семь ожидаемых заголовков блоков (по одному на содержательную секцию §13, включая
+# §13.7 «Уверенность» — parity fix #2445 C2, зеркало _EXPECTED_TITLES в test_report_docx/md).
_EXPECTED_TITLES: tuple[str, ...] = (
_TITLE_SUMMARY,
_TITLE_MARKET_NOW,
@@ -149,6 +151,7 @@ _EXPECTED_TITLES: tuple[str, ...] = (
_TITLE_PRODUCT_TZ,
_TITLE_SCENARIOS,
_TITLE_SCORING,
+ _TITLE_CONFIDENCE,
)
@@ -357,12 +360,101 @@ class TestContractKeysRendered:
assert str(_fmt(fs["breakdown"]["open_units"])) in html_str
def test_confidence_factors_rendered(self) -> None:
- # confidence.factors должны попасть в HTML (ключ→значение).
+ # confidence.factors должны попасть в HTML (ключ→значение) через §13.7-секцию.
html_str = _build_html(_normalize(_full_report()))
assert "Факторы уверенности" in html_str
assert "data_coverage" in html_str
+# ── Parity fix #2445 C2: §13.7 «Уверенность» — дедикейтед секция (было только в docx/md) ─
+# report_docx.py / report_md.py уже несли полную секцию с 4-колоночной таблицей
+# (Фактор/Значение/Уровень/Комментарий) и defensive dict-vs-flat разбором факторов;
+# PDF был единственным экспортёром без неё (только тонкий 2-строчный уровень/rationale
+# в «Сводке» + плоский dict-дамп факторов). Эти тесты ловят регресс парности.
+
+
+class TestConfidenceSectionParity:
+ def test_confidence_section_title_present(self) -> None:
+ # Секция §13.7 присутствует как отдельный заголовок (не только внутри «Сводки»).
+ html_str = _build_html(_normalize(_full_report()))
+ assert _TITLE_CONFIDENCE in html_str
+ assert 'id="confidence"' in html_str
+
+ def test_confidence_section_has_four_column_table(self) -> None:
+ # 4-колоночная таблица факторов — зеркало report_docx/report_md._build_confidence.
+ html_str = _build_html(_normalize(_full_report()))
+ assert "Фактор" in html_str
+ assert "Значение" in html_str
+ assert "Уровень" in html_str
+ assert "Комментарий" in html_str
+
+ def test_confidence_level_and_rationale_rendered(self) -> None:
+ report = SiteFinderReport(
+ confidence=ReportConfidence(
+ level="medium",
+ rationale="Источники advisory-capped; данные средней плотности.",
+ factors={"data_coverage": 0.6},
+ ),
+ )
+ html_str = _build_html(_normalize(report))
+ assert "средняя" in html_str # _level_ru("medium")
+ assert "Источники advisory-capped" in html_str
+
+ def test_dict_shaped_factor_renders_without_crashing(self) -> None:
+ # Ровно баг, от которого защищает docx isinstance(payload, dict)-ветка: значение
+ # фактора — САМО dict {value, level, note} (реальная форма §15/#990, см. frontend
+ # mock parcel-forecast.json), а не плоский скаляр. Плоский _dict_kv_table в старой
+ # версии PDF-«Сводки» такое не ожидал — падал/рендерил мусор. Новая §13.7-секция
+ # (_build_confidence) разбирает dict-payload как в report_docx/report_md.
+ report = SiteFinderReport(
+ confidence=ReportConfidence(
+ level="low",
+ rationale="Low потому что мало истории.",
+ factors={
+ "history_months": {"value": 6, "level": "low", "note": "6 мес — мало"},
+ "analog_count": {"value": 32, "level": "high", "note": "32 ЖК — достаточно"},
+ "advisory_capped": False, # плоский (не-dict) фактор — тоже должен уцелеть
+ },
+ ),
+ )
+ # Не должно бросить исключение (регресс-guard); плюс проверяем реальный рендер.
+ html_str = _build_html(_normalize(report))
+ assert "history_months" in html_str
+ assert "6 мес — мало" in html_str
+ assert "analog_count" in html_str
+ assert "advisory_capped" in html_str
+
+ @_skip_if_no_weasyprint
+ def test_dict_shaped_factor_produces_valid_pdf(self) -> None:
+ # То же самое, но конец-в-конец через WeasyPrint (не только HTML-строка).
+ report = SiteFinderReport(
+ confidence=ReportConfidence(
+ level="low",
+ rationale="Low потому что мало истории.",
+ factors={
+ "history_months": {"value": 6, "level": "low", "note": "6 мес — мало"},
+ },
+ ),
+ )
+ payload = export_report_pdf(report)
+ assert payload.startswith(b"%PDF")
+ assert len(payload) > 0
+
+ def test_summary_no_longer_duplicates_confidence_rationale(self) -> None:
+ # Тонкий 2-строчный «уровень+rationale» блок убран из «Сводки» — теперь живёт
+ # только в §13.7, чтобы не было двух версий одних и тех же данных в документе.
+ # Rationale-текст должен встретиться РОВНО один раз (в секции confidence).
+ report = SiteFinderReport(
+ confidence=ReportConfidence(
+ level="medium",
+ rationale="Уникальная-строка-обоснования-для-проверки-дублей",
+ factors={"data_coverage": 0.6},
+ ),
+ )
+ html_str = _build_html(_normalize(report))
+ assert html_str.count("Уникальная-строка-обоснования-для-проверки-дублей") == 1
+
+
# ── Graceful: частичный / пустой / мусорный вход → валидный PDF без падения ─────