feat(gisogd66): permits_nearby — точный радиус-запрос РНС/РВЭ (#2367 PR-2, backend-half) (#2401)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 2m25s
Deploy / build-worker (push) Successful in 3m38s
Deploy / build-frontend (push) Successful in 4m18s
Deploy / deploy (push) Successful in 1m51s

This commit is contained in:
bot-backend 2026-07-04 09:59:49 +00:00
parent 5d51eae705
commit b3a59b7cf1
9 changed files with 538 additions and 4 deletions

View file

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

View file

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

View file

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

View file

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

View 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",
}

View file

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

View file

@ -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 "&lt;script&gt;" in out
assert "&amp;" 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}} ─────────────────────

View 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)

View file

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