feat(exporters): зеркалим permits_nearby (ГИСОГД 500м) в DOCX §6 #2421

Merged
bot-backend merged 1 commit from feat/docx-permits-nearby-mirror into main 2026-07-04 13:28:01 +00:00
2 changed files with 199 additions and 4 deletions

View file

@ -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)
# ── Футер-дисклеймер ────────────────────────────────────────────────────────

View file

@ -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