From e626a544aa41a85488cdfa07e515e72dafbdb314 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 4 Jul 2026 18:12:07 +0500 Subject: [PATCH] =?UTF-8?q?feat(exporters):=20=D0=B7=D0=B5=D1=80=D0=BA?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=BC=20permits=5Fnearby=20(=D0=93=D0=98?= =?UTF-8?q?=D0=A1=D0=9E=D0=93=D0=94=20500=D0=BC)=20=D0=B2=20DOCX=20=C2=A76?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DOCX полного отчёта не показывал блок «Разрешения на строительство рядом», который уже есть в PDF/HTML (#2367) — сознательное сужение границ в #2401. Портирован 1-в-1: та же честная плашка при total_count<=0, те же поля, тот же кап на 10 записей — через существующие DOCX-хелперы (_add_kv_table/_add_data_table/_add_caveat), без дублирования логики (общие PURE-форматтеры _fmt/_fmt_int_ru шарятся с HTML-версией). _build_section_6 расширена третьим параметром result (analyze-payload, уже нормализован в build_full_report_docx как _as_dict(analyze_result) — новый параметр функции не понадобился). Refs #2367 --- .../services/exporters/full_report_docx.py | 55 ++++++- .../exporters/test_full_report_docx.py | 148 +++++++++++++++++- 2 files changed, 199 insertions(+), 4 deletions(-) diff --git a/backend/app/services/exporters/full_report_docx.py b/backend/app/services/exporters/full_report_docx.py index 3106a658..248b4038 100644 --- a/backend/app/services/exporters/full_report_docx.py +++ b/backend/app/services/exporters/full_report_docx.py @@ -1001,8 +1001,55 @@ def _build_scenarios_honesty(doc: _DocxDocument, forecast: dict[str, Any]) -> No _add_kv_table(doc, pairs) -def _build_section_6(doc: _DocxDocument, forecast: dict[str, Any]) -> None: - """§6 «Риски и дефицит»: дефицит по горизонтам + давление предложения + риск-индексы.""" +def _build_permits_nearby(doc: _DocxDocument, result: dict[str, Any]) -> None: + """РНС/РВЭ в радиусе 500 м участка (ГИСОГД-66) — короткая сводка + список до 10. + + `result` — analyze-payload (НЕ forecast): читает `permits_nearby` (см. + `permits_nearby.get_permits_nearby`). Пусто / total_count=0 → честная плашка-фраза + (полное предложение, не аббревиатура). Зеркало `_build_permits_nearby` в + full_report_html. + """ + nearby = _as_dict(result.get("permits_nearby")) + total = nearby.get("total_count") + if not isinstance(total, int) or total <= 0: + _add_caveat( + doc, + "В радиусе 500 м участка новых разрешений на строительство не найдено " + "(по данным ГИСОГД Свердловской области).", + ) + return + + rs_count = nearby.get("rs_count") or 0 + rv_count = nearby.get("rv_count") or 0 + nearest = nearby.get("nearest_distance_m") + kv: list[tuple[str, Any]] = [ + ("Разрешений на строительство (РНС)", _fmt_int_ru(rs_count)), + ("Разрешений на ввод (РВЭ)", _fmt_int_ru(rv_count)), + ("Ближайшее, м", _fmt_int_ru(nearest) if nearest is not None else _DASH), + ] + _add_kv_table(doc, kv) + + rows: list[list[Any]] = [] + for item in _as_list(nearby.get("items"))[:10]: + data = _as_dict(item) + rows.append( + [ + data.get("doc_name"), + data.get("date_doc"), + data.get("approved_organization"), + data.get("distance_m"), + ] + ) + headers = ["Документ", "Дата", "Согласующий орган", "Дистанция, м"] + _add_data_table(doc, headers, rows) + + +def _build_section_6(doc: _DocxDocument, forecast: dict[str, Any], result: dict[str, Any]) -> None: + """§6 «Риски и дефицит»: дефицит по горизонтам + давление предложения + риск-индексы. + + `result` — analyze-payload (для блока разрешений рядом, `permits_nearby`); `forecast` — + форсайт-ран (дефицит/сценарии). Разные источники — §6 читает оба (зеркало HTML-версии). + """ _add_heading(doc, _TITLE_S6, level=1) future = _fc_as_dict(forecast.get("future_market")) summary = future.get("summary") @@ -1016,6 +1063,8 @@ def _build_section_6(doc: _DocxDocument, forecast: dict[str, Any]) -> None: _build_risk_indices(doc, forecast) _add_heading(doc, "Сценарии", level=2) _build_scenarios_honesty(doc, forecast) + _add_heading(doc, "Разрешения на строительство рядом (500 м)", level=2) + _build_permits_nearby(doc, result) def _build_concept_program(doc: _DocxDocument, variant: dict[str, Any]) -> None: @@ -1193,7 +1242,7 @@ def build_full_report_docx( # ── §4–§7 (Part B) ────────────────────────────────────────────────────────── _build_section_4(doc, forecast) _build_section_5(doc, forecast, concept) - _build_section_6(doc, forecast) + _build_section_6(doc, forecast, result) _build_section_7(doc, concept_result, concept_map_png) # ── Футер-дисклеймер ──────────────────────────────────────────────────────── diff --git a/backend/tests/services/exporters/test_full_report_docx.py b/backend/tests/services/exporters/test_full_report_docx.py index d8d0ae9e..39a43428 100644 --- a/backend/tests/services/exporters/test_full_report_docx.py +++ b/backend/tests/services/exporters/test_full_report_docx.py @@ -26,7 +26,11 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost: import pytest from docx import Document -from app.services.exporters.full_report_docx import build_full_report_docx +from app.services.exporters.full_report_docx import ( + _build_permits_nearby, + _build_section_6, + build_full_report_docx, +) _CAD = "66:41:0204016:10" @@ -402,3 +406,145 @@ def test_garbage_payload_does_not_crash() -> None: concept_map_png=None, ) assert out[:2] == b"PK" + + +# ── §6 «Разрешения на строительство рядом (500 м)» (ГИСОГД-66, зеркало HTML) ────── + + +def _permits_result() -> dict[str, Any]: + """analyze-payload с непустым permits_nearby (2 документа: РНС + РВЭ).""" + return { + "permits_nearby": { + "total_count": 2, + "rs_count": 1, + "rv_count": 1, + "nearest_distance_m": 123.5, + "items": [ + { + "doc_group": "RS", + "doc_name": "Разрешение на строительство МКД", + "date_doc": "2025-03-14", + "approved_organization": "Администрация ЕКБ", + "distance_m": 123.5, + }, + { + "doc_group": "RV", + "doc_name": "Разрешение на ввод", + "date_doc": "2024-11-01", + "approved_organization": "Минстрой СО", + "distance_m": 340.0, + }, + ], + } + } + + +def _new_doc() -> Document: + """Пустой python-docx Document для юнит-теста билдера в изоляции.""" + return Document() + + +def test_permits_nearby_empty_honest_full_sentence() -> None: + """Нет permits_nearby → честная плашка-параграф полным предложением, БЕЗ таблицы.""" + doc = _new_doc() + _build_permits_nearby(doc, {}) + text = _all_text(doc) + assert "новых разрешений на строительство не найдено" in text + assert "ГИСОГД Свердловской области" in text + assert len(doc.tables) == 0 # плашка — параграф, не таблица + + +def test_permits_nearby_zero_total_honest_note() -> None: + """total_count=0 → та же честная плашка (не аббревиатура), таблицы нет.""" + doc = _new_doc() + _build_permits_nearby(doc, {"permits_nearby": {"total_count": 0, "items": []}}) + text = _all_text(doc) + assert "новых разрешений на строительство не найдено" in text + assert len(doc.tables) == 0 + + +def test_permits_nearby_non_empty_summary_and_list() -> None: + """Есть данные → сводка РНС/РВЭ/ближайшее (kv-таблица) + таблица документов.""" + doc = _new_doc() + _build_permits_nearby(doc, _permits_result()) + text = _all_text(doc) + assert "не найдено" not in text + assert "Разрешений на строительство (РНС)" in text + assert "Разрешений на ввод (РВЭ)" in text + assert "Разрешение на строительство МКД" in text + assert "Администрация ЕКБ" in text + assert "123.5" in text + # kv-сводка + таблица items = две таблицы. + assert len(doc.tables) == 2 + + +def test_permits_nearby_items_capped_at_ten() -> None: + """items[:10] — таблица документов не превышает шапку + 10 строк (зеркало HTML).""" + items = [ + { + "doc_group": "RS", + "doc_name": f"РНС-{i}", + "date_doc": "2025-01-01", + "approved_organization": "Орган", + "distance_m": float(i), + } + for i in range(15) + ] + result = { + "permits_nearby": { + "total_count": 15, + "rs_count": 15, + "rv_count": 0, + "nearest_distance_m": 0.0, + "items": items, + } + } + doc = _new_doc() + _build_permits_nearby(doc, result) + # Вторая таблица (документы): шапка + не более 10 строк. + docs_table = doc.tables[1] + assert len(docs_table.rows) == 11 + text = _all_text(doc) + assert "РНС-0" in text + assert "РНС-9" in text + assert "РНС-10" not in text + + +def test_section_6_wires_permits_nearby_from_analyze() -> None: + """_build_section_6(doc, forecast, result) прокидывает result → блок разрешений виден.""" + doc = _new_doc() + _build_section_6(doc, {}, _permits_result()) + text = _all_text(doc) + headings = {p.text for p in doc.paragraphs} + assert "Разрешения на строительство рядом (500 м)" in headings + assert "Разрешение на строительство МКД" in text + + +def test_section_6_permits_nearby_absent_analyze_shows_honest_note() -> None: + """§6 без permits_nearby в result всё равно рисует честную плашку (не падает).""" + doc = _new_doc() + _build_section_6(doc, {}, {}) + text = _all_text(doc) + assert "Разрешения на строительство рядом (500 м)" in {p.text for p in doc.paragraphs} + assert "новых разрешений на строительство не найдено" in text + + +def test_full_report_wires_permits_nearby_end_to_end() -> None: + """build_full_report_docx прокидывает analyze.permits_nearby в §6 (интеграция).""" + analyze = _analyze() + analyze["permits_nearby"] = _permits_result()["permits_nearby"] + out = _render(analyze_result=analyze) + doc = Document(io.BytesIO(out)) + headings = {p.text for p in doc.paragraphs} + assert "Разрешения на строительство рядом (500 м)" in headings + assert "Разрешение на строительство МКД" in _all_text(doc) + + +def test_full_report_permits_nearby_honest_note_when_absent() -> None: + """Дефолтный _analyze() без permits_nearby → честная плашка «не найдено» в §6.""" + out = _render() + text = _all_text(Document(io.BytesIO(out))) + assert "Разрешения на строительство рядом (500 м)" in { + p.text for p in Document(io.BytesIO(out)).paragraphs + } + assert "новых разрешений на строительство не найдено" in text -- 2.45.3