feat(gisogd66): permits_nearby — точный радиус-запрос РНС/РВЭ рядом с участком (#2367)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m18s
CI / openapi-codegen-check (pull_request) Successful in 2m12s
CI / backend-tests (pull_request) Successful in 15m4s
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m18s
CI / openapi-codegen-check (pull_request) Successful in 2m12s
CI / backend-tests (pull_request) Successful in 15m4s
Phase 3 наполнила gisogd_permits.geom -> заменяем 5-летний TODO-прокси quarter-prefix точным ST_DWithin(500м) как НОВЫЙ ключ payload permits_nearby. Старый ekburg quarter-prefix путь (recent_permits_in_quarter/permits_summary) не тронут — работает параллельно. Честность счётчиков: total_count/rs_count/rv_count считаются по ПОЛНОЙ выборке радиуса (без LIMIT в SQL), items капается срезом в Python (30), items_truncated сигнализирует капнутый список — без тихих капов. Аддитивно, non-fatal (сбой -> честный ноль), + блок в §6 PDF-отчёта (html.escape на все строки), + AnalyzeResponse schema + regen api-types.ts.
This commit is contained in:
parent
5d51eae705
commit
79eb49e9bc
9 changed files with 538 additions and 4 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
'<div class="caveat">В радиусе 500 м участка новых разрешений на '
|
||||
"строительство не найдено (по данным ГИСОГД Свердловской области).</div>"
|
||||
)
|
||||
|
||||
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'<p class="verdict">{_esc(summary)}</p>' if summary else ""
|
||||
|
|
@ -1510,6 +1553,9 @@ def _build_section_6(forecast: dict[str, Any]) -> str:
|
|||
|
||||
<h3>Сценарии</h3>
|
||||
{_build_scenarios_honesty(forecast)}
|
||||
|
||||
<h3>Разрешения на строительство рядом (500 м)</h3>
|
||||
{_build_permits_nearby(result)}
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
|
@ -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 (четыре `<div class="section">`), готовый как `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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
135
backend/app/services/site_finder/permits_nearby.py
Normal file
135
backend/app/services/site_finder/permits_nearby.py
Normal file
|
|
@ -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",
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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": "<b>hack</b>",
|
||||
"date_doc": "2025-01-01",
|
||||
"approved_organization": "A&B <script>",
|
||||
"distance_m": 10.0,
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
out = _build_permits_nearby(result)
|
||||
assert "<script>" not in out
|
||||
assert "<script>" in out
|
||||
assert "&" in out
|
||||
assert "<b>hack</b>" not in out
|
||||
|
||||
|
||||
def test_section_6_wires_permits_nearby_from_analyze() -> None:
|
||||
"""analyze_result прокинут в §6 → блок разрешений рядом виден в part_b HTML."""
|
||||
analyze = {
|
||||
"permits_nearby": {
|
||||
"total_count": 1,
|
||||
"rs_count": 1,
|
||||
"rv_count": 0,
|
||||
"nearest_distance_m": 42.0,
|
||||
"items": [
|
||||
{
|
||||
"doc_group": "RS",
|
||||
"doc_name": "РНС уникальный-маркер-XYZ",
|
||||
"date_doc": "2025-05-01",
|
||||
"approved_organization": "Администрация",
|
||||
"distance_m": 42.0,
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
html = build_full_report_html_part_b(
|
||||
_synthetic_forecast(), None, cad="X", analyze_result=analyze
|
||||
)
|
||||
assert "Разрешения на строительство рядом" in html
|
||||
assert "РНС уникальный-маркер-XYZ" in html
|
||||
|
||||
|
||||
def test_section_6_permits_nearby_absent_analyze_shows_honest_note() -> None:
|
||||
"""Без analyze_result §6 всё равно рисует честную плашку «не найдено» (не падает)."""
|
||||
html = build_full_report_html_part_b(_synthetic_forecast(), None, cad="X")
|
||||
assert "новых разрешений на строительство не найдено" in html
|
||||
|
||||
|
||||
# ── 5. §7 Концепция: ТЭП/финитог/программа + {{MAP_CONCEPT}} ─────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
191
backend/tests/services/site_finder/test_permits_nearby.py
Normal file
191
backend/tests/services/site_finder/test_permits_nearby.py
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
"""Тесты permits_nearby (#105 Phase 3 — точный радиус-запрос РНС/РВЭ по geom, ГИСОГД-66).
|
||||
|
||||
Живую БД не трогаем — MagicMock-сессия (зеркало test_supply_layers / test_market_metrics):
|
||||
`db.execute().mappings().all()` отдаёт заранее заданные строки. Проверяем:
|
||||
• честный ноль на пустом радиусе (не ошибка, не None),
|
||||
• разделение rs_count/rv_count по doc_group,
|
||||
• округление distance_m + nearest_distance_m,
|
||||
• ISO-сериализацию date_doc,
|
||||
• ЧЕСТНЫЕ счётчики по полной выборке БЕЗ тихого капа (SQL без LIMIT), items капается
|
||||
отдельно до 30 → items_truncated,
|
||||
• отсутствие :name::type в SQL, порядок ORDER BY distance_m ASC,
|
||||
• non-fatal семантику (сбой db.execute не глотается молча — пробрасывается вызывающему).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import re
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.site_finder.permits_nearby import (
|
||||
_PERMITS_NEARBY_SQL,
|
||||
get_permits_nearby,
|
||||
)
|
||||
|
||||
_WKT = "POLYGON((60.6 56.8, 60.7 56.8, 60.7 56.9, 60.6 56.9, 60.6 56.8))"
|
||||
|
||||
|
||||
def _mock_db(rows: list[dict[str, Any]]) -> MagicMock:
|
||||
"""Сессия с одним execute → .mappings().all() == rows."""
|
||||
db = MagicMock()
|
||||
result = MagicMock()
|
||||
result.mappings.return_value.all.return_value = rows
|
||||
db.execute.return_value = result
|
||||
return db
|
||||
|
||||
|
||||
def _row(
|
||||
doc_group: str,
|
||||
distance_m: float,
|
||||
*,
|
||||
doc_name: str = "Разрешение",
|
||||
doc_num: str = "66-01",
|
||||
date_doc: dt.date | None = None,
|
||||
approved_organization: str = "Администрация ЕКБ",
|
||||
date_reg: dt.date | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"doc_group": doc_group,
|
||||
"doc_name": doc_name,
|
||||
"doc_num": doc_num,
|
||||
"date_doc": date_doc,
|
||||
"date_reg": date_reg,
|
||||
"approved_organization": approved_organization,
|
||||
"distance_m": distance_m,
|
||||
}
|
||||
|
||||
|
||||
def test_empty_radius_is_honest_zero_not_none() -> None:
|
||||
"""0 строк в радиусе → честный ноль (total_count=0, items=[]), НЕ None, НЕ ошибка."""
|
||||
out = get_permits_nearby(_mock_db([]), _WKT)
|
||||
assert out is not None
|
||||
assert out["total_count"] == 0
|
||||
assert out["rs_count"] == 0
|
||||
assert out["rv_count"] == 0
|
||||
assert out["nearest_distance_m"] is None
|
||||
assert out["items"] == []
|
||||
assert out["items_truncated"] is False
|
||||
assert out["radius_m"] == 500
|
||||
assert out["source"] == "gisogd66"
|
||||
|
||||
|
||||
def test_rs_rv_counts_split_by_doc_group() -> None:
|
||||
"""rs_count считает 'RS', rv_count — 'RV'; прочие группы не попадают ни в один."""
|
||||
rows = [
|
||||
_row("RS", 100.0),
|
||||
_row("RS", 150.0),
|
||||
_row("RV", 200.0),
|
||||
_row("OTHER", 250.0),
|
||||
]
|
||||
out = get_permits_nearby(_mock_db(rows), _WKT)
|
||||
assert out["total_count"] == 4
|
||||
assert out["rs_count"] == 2
|
||||
assert out["rv_count"] == 1
|
||||
assert len(out["items"]) == 4
|
||||
assert out["items_truncated"] is False
|
||||
|
||||
|
||||
def test_distance_rounding_and_nearest() -> None:
|
||||
"""distance_m округляется до 1 знака; nearest_distance_m = ближайший (rows отсортированы)."""
|
||||
rows = [_row("RS", 123.456), _row("RV", 456.789)]
|
||||
out = get_permits_nearby(_mock_db(rows), _WKT)
|
||||
assert out["items"][0]["distance_m"] == 123.5
|
||||
assert out["items"][1]["distance_m"] == 456.8
|
||||
assert out["nearest_distance_m"] == 123.5
|
||||
|
||||
|
||||
def test_date_doc_serialized_to_iso() -> None:
|
||||
"""date_doc → ISO-строка; None → None (не падает)."""
|
||||
rows = [
|
||||
_row("RS", 100.0, date_doc=dt.date(2025, 3, 14)),
|
||||
_row("RV", 200.0, date_doc=None),
|
||||
]
|
||||
out = get_permits_nearby(_mock_db(rows), _WKT)
|
||||
assert out["items"][0]["date_doc"] == "2025-03-14"
|
||||
assert out["items"][1]["date_doc"] is None
|
||||
|
||||
|
||||
def test_item_shape_fields() -> None:
|
||||
"""Каждый item несёт ровно контрактные ключи (не протекает date_reg и пр.)."""
|
||||
rows = [_row("RS", 42.0, doc_name="РНС №5", approved_organization="Мингос")]
|
||||
out = get_permits_nearby(_mock_db(rows), _WKT)
|
||||
item = out["items"][0]
|
||||
assert set(item.keys()) == {
|
||||
"doc_group",
|
||||
"doc_name",
|
||||
"doc_num",
|
||||
"date_doc",
|
||||
"approved_organization",
|
||||
"distance_m",
|
||||
}
|
||||
assert item["doc_name"] == "РНС №5"
|
||||
assert item["approved_organization"] == "Мингос"
|
||||
|
||||
|
||||
def test_radius_m_passed_through_to_params_and_result() -> None:
|
||||
"""Кастомный radius_m прокидывается в bind-params И в выходной agg."""
|
||||
db = _mock_db([])
|
||||
out = get_permits_nearby(db, _WKT, radius_m=1000)
|
||||
assert out["radius_m"] == 1000
|
||||
_args, _kwargs = db.execute.call_args
|
||||
params = _args[1]
|
||||
assert params["radius_m"] == 1000
|
||||
assert params["wkt"] == _WKT
|
||||
|
||||
|
||||
def test_sql_no_limit_counts_over_full_selection() -> None:
|
||||
"""SQL БЕЗ LIMIT (честные счётчики по всей выборке) + ORDER BY / ST_DWithin.
|
||||
|
||||
Кап 30 делается в Python на items, НЕ в SQL — иначе total_count/rs_count/rv_count
|
||||
занижались бы (тихий кап, запрещён проектной конвенцией).
|
||||
"""
|
||||
sql = str(_PERMITS_NEARBY_SQL).lower()
|
||||
assert "gisogd_permits" in sql
|
||||
assert "st_dwithin" in sql
|
||||
assert "order by distance_m asc" in sql
|
||||
assert "limit" not in sql, "LIMIT в SQL капнул бы счётчики — кап только на items в Python"
|
||||
# items_limit больше не bind-параметр (капаем rows[:30] в Python).
|
||||
db = _mock_db([])
|
||||
get_permits_nearby(db, _WKT)
|
||||
_args, _kwargs = db.execute.call_args
|
||||
assert "items_limit" not in _args[1]
|
||||
|
||||
|
||||
def test_items_capped_but_counts_honest_over_full_selection() -> None:
|
||||
"""35 строк в выборке → items len=30 (кап), но total_count=35 честный + truncated=True.
|
||||
|
||||
Ядро фикса «без тихих капов»: список отдаётся усечённым (progressive-disclosure на
|
||||
фронте по items_truncated), а счётчики отражают РЕАЛЬНОЕ число разрешений в радиусе.
|
||||
"""
|
||||
# 20×RS ближайшие, затем 15×RV дальше — итого 35, отсортированы по distance ASC.
|
||||
rows = [_row("RS", float(d)) for d in range(10, 30)] # 20 строк, 10..29 м
|
||||
rows += [_row("RV", float(d)) for d in range(30, 45)] # 15 строк, 30..44 м
|
||||
assert len(rows) == 35
|
||||
out = get_permits_nearby(_mock_db(rows), _WKT)
|
||||
assert out["total_count"] == 35
|
||||
assert out["rs_count"] == 20
|
||||
assert out["rv_count"] == 15
|
||||
assert len(out["items"]) == 30
|
||||
assert out["items_truncated"] is True
|
||||
# nearest — по ближайшей строке (сохраняется даже при капе списка).
|
||||
assert out["nearest_distance_m"] == 10.0
|
||||
# items — именно первые 30 ближайших (последние 5 дальних RV не попали).
|
||||
assert all(it["distance_m"] <= 39.0 for it in out["items"])
|
||||
|
||||
|
||||
def test_sql_no_double_colon_cast() -> None:
|
||||
"""psycopg v3 / backend.md: никаких :name::type (::geography приклеен к ')', OK)."""
|
||||
sql = str(_PERMITS_NEARBY_SQL)
|
||||
assert re.search(r":[a-z_]+::[a-z]", sql) is None, f"double-colon cast in SQL:\n{sql}"
|
||||
|
||||
|
||||
def test_db_error_propagates_not_swallowed() -> None:
|
||||
"""Сбой db.execute пробрасывается (non-fatal обёртка живёт в parcels.py, не тут)."""
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("db down")
|
||||
with pytest.raises(RuntimeError, match="db down"):
|
||||
get_permits_nearby(db, _WKT)
|
||||
|
|
@ -3168,6 +3168,10 @@ export interface components {
|
|||
permits_summary?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/** Permits Nearby */
|
||||
permits_nearby?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/** Zoning */
|
||||
zoning?: {
|
||||
[key: string]: unknown;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue