From b3a59b7cf136e55fa07809114474609771626ec2 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 4 Jul 2026 09:59:49 +0000 Subject: [PATCH] =?UTF-8?q?feat(gisogd66):=20permits=5Fnearby=20=E2=80=94?= =?UTF-8?q?=20=D1=82=D0=BE=D1=87=D0=BD=D1=8B=D0=B9=20=D1=80=D0=B0=D0=B4?= =?UTF-8?q?=D0=B8=D1=83=D1=81-=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=20?= =?UTF-8?q?=D0=A0=D0=9D=D0=A1/=D0=A0=D0=92=D0=AD=20(#2367=20PR-2,=20backen?= =?UTF-8?q?d-half)=20(#2401)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/parcels.py | 22 ++ backend/app/schemas/parcel.py | 1 + .../services/exporters/full_report_html.py | 56 ++++- .../app/services/exporters/full_report_pdf.py | 2 +- .../services/site_finder/permits_nearby.py | 135 +++++++++++++ .../test_run_history_and_response_contract.py | 30 +++ .../exporters/test_full_report_html_part_b.py | 101 +++++++++ .../site_finder/test_permits_nearby.py | 191 ++++++++++++++++++ frontend/src/lib/api-types.ts | 4 + 9 files changed, 538 insertions(+), 4 deletions(-) create mode 100644 backend/app/services/site_finder/permits_nearby.py create mode 100644 backend/tests/services/site_finder/test_permits_nearby.py 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