fix(export): недостающая §13.7 «Уверенность» в PDF-экспортёре отчёта (#2445-C2) #2456
2 changed files with 152 additions and 22 deletions
|
|
@ -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:
|
|||
<h3>Ключевые числа</h3>
|
||||
{_dict_kv_table(key_numbers)}
|
||||
|
||||
<h3>Уверенность отчёта</h3>
|
||||
{_kv_table(confidence_pairs)}
|
||||
|
||||
<h3>Факторы уверенности</h3>
|
||||
{_dict_kv_table(factors)}
|
||||
|
||||
<h3>Контекст</h3>
|
||||
{_kv_table(context_pairs)}
|
||||
</div>
|
||||
|
|
@ -624,6 +623,43 @@ def _build_scoring(report: dict[str, Any]) -> str:
|
|||
|
||||
<h3>Специальные индексы</h3>
|
||||
{_data_table(["Индекс", "Значение", "Метка"], index_rows)}
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
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"""
|
||||
<div class="section" id="confidence">
|
||||
<h2>{html.escape(_TITLE_CONFIDENCE)}</h2>
|
||||
<span class="badge">Уровень: {_esc(level)}</span>
|
||||
<p class="verdict">{_esc(rationale)}</p>
|
||||
|
||||
<h3>Факторы уверенности</h3>
|
||||
{_data_table(["Фактор", "Значение", "Уровень", "Комментарий"], factor_rows)}
|
||||
|
||||
<div class="footer">{html.escape(_FOOTER_NOTE)}</div>
|
||||
</div>
|
||||
|
|
@ -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"""<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
|
|
@ -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: частичный/
|
||||
|
|
|
|||
|
|
@ -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 без падения ─────
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue