diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py
index bc3f5f43..70661088 100644
--- a/backend/app/api/v1/parcels.py
+++ b/backend/app/api/v1/parcels.py
@@ -92,6 +92,7 @@ from app.services.site_finder.parcel_financial import (
select_calibrated_price,
synthesize_parcel_financial,
)
+from app.services.site_finder.permits_nearby import get_permits_nearby
from app.services.site_finder.poi_score import (
PoiScoreResponse,
compute_poi_routing_decay,
@@ -3503,6 +3504,25 @@ def analyze_parcel(
except Exception as e:
logger.warning("ekburg permits query failed for %s: %s", cad_num, e)
+ # 10d-pre2b) #105 Phase 3: точный радиус-запрос РНС/РВЭ по geom (gisogd_permits,
+ # ST_DWithin 500м). НОВЫЙ ключ payload — заменяет TODO-прокси quarter-prefix выше,
+ # НЕ трогая старый ekburg-путь (recent_permits_in_quarter / permits_summary остаются).
+ # Non-fatal: сбой → honest-ноль вместо падения всего analyze.
+ permits_nearby_data: dict[str, Any] = {
+ "radius_m": 500,
+ "total_count": 0,
+ "rs_count": 0,
+ "rv_count": 0,
+ "nearest_distance_m": None,
+ "items": [],
+ "items_truncated": False,
+ "source": "gisogd66",
+ }
+ try:
+ permits_nearby_data = get_permits_nearby(db, geom_wkt, radius_m=500)
+ except Exception as e:
+ logger.warning("gisogd permits_nearby query failed for %s: %s", cad_num, e)
+
# 10d) Geology stub — реальные данные требуют ВСЕГЕИ-200/1000 шейпы в PostGIS
karpinsky_url = (
f"https://www.karpinskyinstitute.ru/ru/gisatlas/web-gisatlas/"
@@ -3945,6 +3965,8 @@ def analyze_parcel(
# #105 Phase 5: РНС/РВЭ в квартале (quarter prefix match; после Phase 3 → spatial 500m)
"recent_permits_in_quarter": recent_permits,
"permits_summary": permits_summary,
+ # #105 Phase 3: точный радиус-запрос РНС/РВЭ по geom (ГИСОГД-66, ST_DWithin 500м)
+ "permits_nearby": permits_nearby_data,
"zoning": zoning,
"success_recommendation": success_recommendation,
"isochrones_available": bool(settings.openrouteservice_api_key),
diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py
index d8a3988a..251b3343 100644
--- a/backend/app/schemas/parcel.py
+++ b/backend/app/schemas/parcel.py
@@ -904,6 +904,7 @@ class AnalyzeResponse(BaseModel):
parcel_meta: dict[str, Any] | None = None
recent_permits_in_quarter: list[dict[str, Any]] | None = None
permits_summary: dict[str, Any] | None = None
+ permits_nearby: dict[str, Any] | None = None
zoning: dict[str, Any] | None = None
success_recommendation: dict[str, Any] | None = None
isochrones_available: bool | None = None
diff --git a/backend/app/services/exporters/full_report_html.py b/backend/app/services/exporters/full_report_html.py
index 543acc9e..8e079005 100644
--- a/backend/app/services/exporters/full_report_html.py
+++ b/backend/app/services/exporters/full_report_html.py
@@ -1489,8 +1489,51 @@ def _build_scenarios_honesty(forecast: dict[str, Any]) -> str:
return _kv_table(pairs)
-def _build_section_6(forecast: dict[str, Any]) -> str:
- """§6 «Риски и дефицит»: дефицит по горизонтам + давление предложения + риск-индексы."""
+def _build_permits_nearby(result: dict[str, Any]) -> str:
+ """РНС/РВЭ в радиусе 500 м участка (ГИСОГД-66) — короткая сводка + список до 10.
+
+ `result` — analyze-payload (НЕ forecast): читает `permits_nearby` (см.
+ `permits_nearby.get_permits_nearby`). Пусто / total_count=0 → честная плашка-фраза
+ (полное предложение, не аббревиатура). Все динамические строки через `html.escape`.
+ """
+ nearby = _as_dict(result.get("permits_nearby"))
+ total = nearby.get("total_count")
+ if not isinstance(total, int) or total <= 0:
+ return (
+ '
В радиусе 500 м участка новых разрешений на '
+ "строительство не найдено (по данным ГИСОГД Свердловской области).
"
+ )
+
+ rs_count = nearby.get("rs_count") or 0
+ rv_count = nearby.get("rv_count") or 0
+ nearest = nearby.get("nearest_distance_m")
+ kv = [
+ ("Разрешений на строительство (РНС)", _fmt_int_ru(rs_count)),
+ ("Разрешений на ввод (РВЭ)", _fmt_int_ru(rv_count)),
+ ("Ближайшее, м", _fmt_int_ru(nearest) if nearest is not None else _DASH),
+ ]
+
+ 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 = ["Документ", "Дата", "Согласующий орган", "Дистанция, м"]
+ return _kv_table(kv) + _data_table(headers, rows)
+
+
+def _build_section_6(forecast: dict[str, Any], result: dict[str, Any]) -> str:
+ """§6 «Риски и дефицит»: дефицит по горизонтам + давление предложения + риск-индексы.
+
+ `result` — analyze-payload (для блока разрешений рядом, `permits_nearby`); `forecast` —
+ форсайт-ран (дефицит/сценарии). Разные источники — §6 читает оба.
+ """
future = _fc_as_dict(forecast.get("future_market"))
summary = future.get("summary")
summary_html = f'{_esc(summary)}
' if summary else ""
@@ -1510,6 +1553,9 @@ def _build_section_6(forecast: dict[str, Any]) -> str:
Сценарии
{_build_scenarios_honesty(forecast)}
+
+ Разрешения на строительство рядом (500 м)
+ {_build_permits_nearby(result)}
"""
@@ -1641,6 +1687,7 @@ def build_full_report_html_part_b(
concept_result: dict[str, Any] | None,
*,
cad: str,
+ analyze_result: dict[str, Any] | None = None,
) -> str:
"""Собрать HTML Part B полного отчёта: §4 «Рынок» + §5 «Финмодель» + §6 «Риски» + §7.
@@ -1659,6 +1706,9 @@ def build_full_report_html_part_b(
§7 рисует честную заметку «концепция не рассчитана» (§5 — только рыночный
контекст цены).
cad: кадастровый номер участка (для логов; в HTML приходит через каркас).
+ analyze_result: analyze-payload (`analysis_runs.result` analyze-рана) — источник
+ блока «разрешения рядом» §6 (`permits_nearby`). None / не-dict → блок рисует
+ честную плашку «в радиусе 500 м разрешений не найдено».
Returns:
HTML-фрагмент Part B (четыре ``), готовый как `part_b_html`
@@ -1668,7 +1718,7 @@ def build_full_report_html_part_b(
part_b = (
_build_section_4(forecast)
+ _build_section_5(forecast, _as_dict(concept_result))
- + _build_section_6(forecast)
+ + _build_section_6(forecast, _as_dict(analyze_result))
+ _build_section_7(concept_result)
)
logger.info(
diff --git a/backend/app/services/exporters/full_report_pdf.py b/backend/app/services/exporters/full_report_pdf.py
index 36d3aa56..a4ca10d2 100644
--- a/backend/app/services/exporters/full_report_pdf.py
+++ b/backend/app/services/exporters/full_report_pdf.py
@@ -407,7 +407,7 @@ def build_full_report(db: Session, cad: str) -> dict[str, Any]:
part_a = build_full_report_html_part_a(
analyze, cad=cad, connection_capacity=connection_capacity
)
- part_b = build_full_report_html_part_b(forecast, concept, cad=cad)
+ part_b = build_full_report_html_part_b(forecast, concept, cad=cad, analyze_result=analyze)
html = build_full_report_html(
part_a,
part_b,
diff --git a/backend/app/services/site_finder/permits_nearby.py b/backend/app/services/site_finder/permits_nearby.py
new file mode 100644
index 00000000..fb0cee8c
--- /dev/null
+++ b/backend/app/services/site_finder/permits_nearby.py
@@ -0,0 +1,135 @@
+"""Точный радиус-запрос разрешений на строительство/ввод рядом с участком (ГИСОГД-66).
+
+Заменяет TODO-прокси quarter-prefix из analyze_parcel (#105 Phase 5): после того как
+Phase 3 (geocoding → geom) наполнила `gisogd_permits.geom`, соседние РНС/РВЭ ищутся
+точным `ST_DWithin(..., radius_m)` по геометрии, а не грубым совпадением префикса
+кадастрового квартала. Старый quarter-prefix путь (`ekburg_construction_permits` →
+`recent_permits_in_quarter` / `permits_summary`) НЕ трогается — он остаётся параллельно
+как отдельный, более богатый полями источник; этот модуль лишь добавляет второй,
+геометрически честный ключ payload.
+
+`gisogd_permits`: id / doc_group ('RS' — разрешение на строительство, 'RV' — ввод в
+эксплуатацию) / doc_num / doc_name / date_doc / date_reg / approved_organization /
+cad_nums TEXT[] / geom geometry(MultiPolygon, 4326), GIST-индекс на geom.
+
+PURE-по-данным (никаких выдуманных чисел): пустой радиус — честный ноль
+(total_count=0, items=[]), НЕ ошибка и НЕ None. Вызывается из синхронного analyze_parcel
+с той же non-fatal семантикой.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+logger = logging.getLogger(__name__)
+
+# Максимум записей в списке items. Счётчики (total_count/rs_count/rv_count/
+# nearest_distance_m) считаются по ПОЛНОЙ выборке радиуса — БЕЗ LIMIT в SQL, чтобы не было
+# тихого капа (проектная конвенция «без тихих капов»: тот же паттерн чинили в дедупе
+# объявлений и фрешнес-мониторе). LIMIT капает лишь выдачу items → items_truncated=True.
+_ITEMS_LIMIT = 30
+
+# Радиус-запрос по образцу POI-в-радиусе (osm_poi_ekb) из analyze_parcel: ST_DWithin по
+# geography-центроиду участка. `::geography` приклеено к `)`, НЕ к bind-name → psycopg v3
+# его не съедает (backend.md: исключение из :name::type-запрета). БЕЗ LIMIT: агрегаты честны
+# по всей выборке; в радиусе 500м GIST-индекс делает выборку дешёвой, тысяч строк не будет.
+_PERMITS_NEARBY_SQL = text("""
+ SELECT
+ doc_group,
+ doc_name,
+ doc_num,
+ date_doc,
+ date_reg,
+ approved_organization,
+ ST_Distance(
+ geom::geography,
+ ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
+ ) AS distance_m
+ FROM gisogd_permits
+ WHERE geom IS NOT NULL
+ AND ST_DWithin(
+ geom::geography,
+ ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
+ :radius_m
+ )
+ ORDER BY distance_m ASC
+""")
+
+
+def _empty_result(radius_m: int) -> dict[str, Any]:
+ """Честный нулевой агрегат: 0 разрешений в радиусе (НЕ ошибка, НЕ None)."""
+ return {
+ "radius_m": radius_m,
+ "total_count": 0,
+ "rs_count": 0,
+ "rv_count": 0,
+ "nearest_distance_m": None,
+ "items": [],
+ "items_truncated": False,
+ "source": "gisogd66",
+ }
+
+
+def get_permits_nearby(db: Session, geom_wkt: str, radius_m: int = 500) -> dict[str, Any]:
+ """РНС/РВЭ из ГИСОГД-66 в радиусе `radius_m` метров от участка (точный ST_DWithin).
+
+ Args:
+ db: активная сессия SQLAlchemy.
+ geom_wkt: WKT-геометрия анализируемого участка (та же переменная, что для POI).
+ radius_m: радиус поиска в метрах (по умолчанию 500).
+
+ Returns:
+ Агрегат с ЧЕСТНЫМИ счётчиками (см. `_empty_result` для пустой формы):
+ radius_m, total_count / rs_count ('RS') / rv_count ('RV') — по ВСЕЙ выборке радиуса
+ (без тихого капа), nearest_distance_m (round 1, None если пусто),
+ items (≤30 ближайших), items_truncated (True если total_count > len(items)),
+ source="gisogd66". Пустой радиус → честный ноль.
+ """
+ rows = [
+ dict(r)
+ for r in db.execute(
+ _PERMITS_NEARBY_SQL,
+ {"wkt": geom_wkt, "radius_m": radius_m},
+ )
+ .mappings()
+ .all()
+ ]
+
+ if not rows:
+ return _empty_result(radius_m)
+
+ # Счётчики — по ПОЛНОЙ выборке (все rows), список items — только первые _ITEMS_LIMIT.
+ rs_count = sum(1 for r in rows if r["doc_group"] == "RS")
+ rv_count = sum(1 for r in rows if r["doc_group"] == "RV")
+
+ items: list[dict[str, Any]] = []
+ for r in rows[:_ITEMS_LIMIT]:
+ date_doc = r["date_doc"]
+ distance_m = r["distance_m"]
+ items.append(
+ {
+ "doc_group": r["doc_group"],
+ "doc_name": r["doc_name"],
+ "doc_num": r["doc_num"],
+ "date_doc": date_doc.isoformat() if date_doc is not None else None,
+ "approved_organization": r["approved_organization"],
+ "distance_m": round(distance_m, 1) if distance_m is not None else None,
+ }
+ )
+
+ nearest = items[0]["distance_m"] if items else None
+ total_count = len(rows)
+ return {
+ "radius_m": radius_m,
+ "total_count": total_count,
+ "rs_count": rs_count,
+ "rv_count": rv_count,
+ "nearest_distance_m": nearest,
+ "items": items,
+ "items_truncated": total_count > len(items),
+ "source": "gisogd66",
+ }
diff --git a/backend/tests/api/v1/test_run_history_and_response_contract.py b/backend/tests/api/v1/test_run_history_and_response_contract.py
index cbbf5724..e0d5df2f 100644
--- a/backend/tests/api/v1/test_run_history_and_response_contract.py
+++ b/backend/tests/api/v1/test_run_history_and_response_contract.py
@@ -165,6 +165,36 @@ def test_analyze_response_preserves_unmodeled_extra_key() -> None:
_stop_heavy()
+def test_analyze_response_includes_permits_nearby_honest_zero() -> None:
+ """#105 Phase 3: новый ключ permits_nearby присутствует в 200-ответе.
+
+ Mock-БД отдаёт [] на радиус-запрос gisogd_permits → честный ноль (total_count=0,
+ items=[]), НЕ отсутствие ключа. Доказывает wiring get_permits_nearby → result_payload
+ → AnalyzeResponse (рядом со старым permits_summary, который тоже остаётся).
+ """
+ from app.core.db import get_db
+
+ db = _make_db_for_analyze()
+ app.dependency_overrides[get_db] = _override_db(db)
+ _start_heavy()
+ try:
+ with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", MagicMock()):
+ client = TestClient(app)
+ resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
+ assert resp.status_code == 200, resp.text
+ body = resp.json()
+ assert "permits_nearby" in body, "permits_nearby выкинут из ответа"
+ assert "permits_summary" in body, "старый permits_summary не должен исчезнуть"
+ nearby = body["permits_nearby"]
+ assert nearby["total_count"] == 0
+ assert nearby["items"] == []
+ assert nearby["source"] == "gisogd66"
+ assert nearby["radius_m"] == 500
+ finally:
+ app.dependency_overrides.clear()
+ _stop_heavy()
+
+
def test_analyze_response_does_not_drop_keys_vs_raw_payload() -> None:
"""Жёсткая гарантия: key-set 200-ответа ⊇ key-set собранного result_payload.
diff --git a/backend/tests/services/exporters/test_full_report_html_part_b.py b/backend/tests/services/exporters/test_full_report_html_part_b.py
index d81d3c6e..cb5239c8 100644
--- a/backend/tests/services/exporters/test_full_report_html_part_b.py
+++ b/backend/tests/services/exporters/test_full_report_html_part_b.py
@@ -34,6 +34,7 @@ from app.services.exporters.full_report_html import (
_ANCHOR_S7,
_NO_DATA,
MAP_CONCEPT_PLACEHOLDER,
+ _build_permits_nearby,
build_full_report_html,
build_full_report_html_part_a,
build_full_report_html_part_b,
@@ -399,6 +400,106 @@ def test_section_6_scenarios_collapsed_honest_note() -> None:
assert "Чувствительность спроса к ставке не оценена" in html
+def test_permits_nearby_empty_honest_full_sentence() -> None:
+ """Нет permits_nearby / total_count=0 → честная плашка полным предложением (не аббр.)."""
+ assert "новых разрешений на строительство не найдено" in _build_permits_nearby({})
+ zero = {"permits_nearby": {"total_count": 0, "items": []}}
+ out = _build_permits_nearby(zero)
+ assert "ГИСОГД Свердловской области" in out
+ assert "не найдено" in out
+
+
+def test_permits_nearby_non_empty_summary_and_list() -> None:
+ """Есть данные → сводка РНС/РВЭ/ближайшее + список документов."""
+ result = {
+ "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,
+ },
+ ],
+ }
+ }
+ out = _build_permits_nearby(result)
+ assert "не найдено" not in out
+ assert "Разрешение на строительство МКД" in out
+ assert "Администрация ЕКБ" in out
+ assert "123.5" in out
+
+
+def test_permits_nearby_org_name_escaped() -> None:
+ """org-имя проходит html.escape (недоверенный вход)."""
+ result = {
+ "permits_nearby": {
+ "total_count": 1,
+ "rs_count": 1,
+ "rv_count": 0,
+ "nearest_distance_m": 10.0,
+ "items": [
+ {
+ "doc_group": "RS",
+ "doc_name": "hack",
+ "date_doc": "2025-01-01",
+ "approved_organization": "A&B