gendesign/backend/app/services/site_finder/connection_capacity_lookup.py
bot-backend 39dd63333e
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 3m29s
Deploy / build-worker (push) Successful in 4m48s
Deploy / deploy (push) Successful in 1m43s
fix(site_finder): SAVEPOINT-isolate DB errors across cluster A (#2464) (#2465)
2026-07-07 12:50:11 +00:00

634 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Резолвер «точки подключения СО СВОБОДНОЙ МОЩНОСТЬЮ» для ПТИЦА §3 (#2119).
Собирает по участку:
- power_points: центры питания (power_supply_centers) в радиусе от центроида
участка (ST_DWithin geography) с классом напряжения / индексом загрузки /
резервом свободной мощности. Для ПС ЕЭСК без раскрытого резерва — reserve_note
«резерв по запросу в АО «ЕЭСК» (город)».
- power_summary: ближайший ЦП с резервом + count по load_index.
- water: все строки water_supply_reserves за ПОСЛЕДНИЙ period (city-level).
- gas: городские ГРС «Газпром трансгаз Екатеринбург» со свободной мощностью
(gas_grs_capacity), агрегация по grs_name + суммарный свободный объём. #2119 B1.
+ счётчики точек выхода (gas_grs_outlet_points, форма 6 ФАС №960 АО
«Екатеринбурггаз») последнего period_month: всего / дефицитных / «нужен
гидрорасчёт». #2119 B2 PR-4.
- gas_outlet_points: гео-точки выхода газосети (gas_grs_outlet_points) В РАДИУСЕ от
участка (ST_DWithin geom ~2000 м, latest period, LIMIT 40 по расстоянию) — для слоя
на карте §3. Свободная мощность в млн.куб.м/МЕС (НЕ тыс.м³/ч как у ГРС!). #2119 B2.
- heat: резервы тепловой мощности систем теплоснабжения (heat_system_reserves)
за последний период ПО КАЖДОЙ организации + суммарный свободный объём. #2119 B2.
- nearby_network_zones: ПОЗИТИВ-разрез — сетевые охранные зоны из cad_zouit в радиусе
300 м от участка (там физически ПРОХОДИТ сеть → «теплотрасса в 24 м, врезка
дешёвая»). До 2 ближайших зон НА КАЖДЫЙ net_kind (thermal/electricity/gas/…), с
гуманизированной подписью (кад.номер трубопровода / напряжение ВЛ + ТП). #2119 C2/C3.
Источник координат участка — общий ``_get_parcel_wkt`` (cad_parcels_geom → квартал
fallback). Радиус по умолчанию 3000 м (cap 10000).
"""
import logging
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.services.site_finder.network_zone_humanize import human_label, refine_net_kind
logger = logging.getLogger(__name__)
_DEFAULT_RADIUS_M = 3000
_MAX_RADIUS_M = 10000
# Радиус позитив-разреза сетевых охранных зон (cad_zouit): фиксированный 300 м — «сеть
# рядом с участком». Не зависит от radius_m ЦП (тот про магистральные ЦП за км).
# Degree-эквивалент для GIST-префильтра: 300 м / ~60.8 км/° долготы у ЕКБ (56.8° N) ≈
# 0.0050° — берём с запасом 0.006°, точный отсев делает geography ST_DWithin поверх. C2.
_NETWORK_ZONE_RADIUS_M = 300
_NETWORK_ZONE_RADIUS_DEG = 0.006
# До скольких ближайших зон отдаём НА КАЖДЫЙ net_kind (thermal/electricity/gas/…). C2.
_NETWORK_ZONE_PER_KIND = 2
# Предохранитель: запрос GIST-индексирован (~70 мс на плотном центре ЕКБ, EXPLAIN прод
# 2026-07-03), но эндпоинт синхронный — таймаут-guard как у offer_trend (parcels.py).
_NETWORK_ZONE_TIMEOUT_MS = 4000
# Радиус для гео-точек выхода газосети (gas_grs_outlet_points): фиксированный ~2000 м —
# точки привязаны к конкретным объектам (котельные/промплощадки), не к магистральным
# ЦП, поэтому свой узкий радиус независимо от radius_m ЦП. LIMIT — топ-N по расстоянию,
# чтобы слой на карте §3 не разбухал (у ~19% строк есть geom после геокода). #2119 B2.
_GAS_OUTLET_RADIUS_M = 2000
_GAS_OUTLET_LIMIT = 40
# Пометка для ПС АО «ЕЭСК» (городская сеть ЕКБ) без раскрытого резерва: свободная
# мощность у ЕЭСК не публикуется в WFS/xlsx — уточняется по запросу. #2119 B1.
_EESK_RESERVE_NOTE = "Резерв — по запросу в АО «ЕЭСК» (город)"
def get_connection_capacity(
db: Session,
cad_num: str,
radius_m: int = _DEFAULT_RADIUS_M,
) -> dict:
"""Точки подключения со свободной мощностью вблизи участка.
Args:
db: SQLAlchemy session.
cad_num: кадастровый номер участка.
radius_m: радиус поиска ЦП, м (caller клампит 1..10000).
Returns:
{"power_points": [...], "power_summary": {...}, "water": [...],
"gas": {...}|None, "gas_outlet_points": [...], "heat": {...}|None,
"nearby_network_zones": [...]}
Raises:
ValueError: участок не найден в БД (нет geom).
"""
# Lazy import — избегаем циклического импорта на module-load.
from app.services.site_finder.quarter_dump_lookup import _get_parcel_wkt
radius_m = max(1, min(int(radius_m), _MAX_RADIUS_M))
parcel_wkt = _get_parcel_wkt(db, cad_num)
if parcel_wkt is None:
raise ValueError(f"Участок {cad_num!r} не найден в БД")
power_points = _query_power_points(db, parcel_wkt, radius_m)
power_summary = _build_power_summary(power_points)
water = _query_water_latest(db)
gas = _query_gas_city_grs(db)
gas_outlet_points = _query_gas_outlet_points(db, parcel_wkt)
heat = _query_heat_latest(db)
nearby_network_zones = _query_nearby_network_zones(db, parcel_wkt)
return {
"power_points": power_points,
"power_summary": power_summary,
"water": water,
"gas": gas,
"gas_outlet_points": gas_outlet_points,
"heat": heat,
"nearby_network_zones": nearby_network_zones,
}
def _query_power_points(db: Session, parcel_wkt: str, radius_m: int) -> list[dict]:
"""Центры питания в радиусе от центроида участка (ST_DWithin geography)."""
rows = (
db.execute(
text("""
SELECT sc_name, dzo_name, voltage_class, load_index,
installed_capacity_mva, current_load_mva, reserve_mva,
reserve_unit, district, reserve_asof,
ST_Distance(
geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m,
ST_Y(geom) AS lat,
ST_X(geom) AS lon
FROM power_supply_centers
WHERE geom IS NOT NULL
AND ST_DWithin(
geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
CAST(:radius_m AS float)
)
ORDER BY distance_m ASC
"""),
{"wkt": parcel_wkt, "radius_m": radius_m},
)
.mappings()
.all()
)
points: list[dict] = []
for r in rows:
reserve_mva = _num(r["reserve_mva"])
points.append(
{
"name": r["sc_name"],
"dzo_name": r["dzo_name"],
"voltage_class": r["voltage_class"],
"load_index": r["load_index"],
"installed_capacity_mva": _num(r["installed_capacity_mva"]),
"current_load_mva": _num(r["current_load_mva"]),
"reserve_mva": reserve_mva,
# Единица резерва: 'МВА' (Россети, default) или 'МВт' (ЕЭСК ПС 35-220).
# district — адм. район ЕКБ из xlsx ЕЭСК (NULL у Россети-строк). #2119 B2.
"reserve_unit": r["reserve_unit"],
"district": r["district"],
"reserve_asof": r["reserve_asof"].isoformat() if r["reserve_asof"] else None,
"reserve_note": _reserve_note(r["dzo_name"], reserve_mva),
"distance_m": round(float(r["distance_m"]), 1),
"lat": round(float(r["lat"]), 6) if r["lat"] is not None else None,
"lon": round(float(r["lon"]), 6) if r["lon"] is not None else None,
}
)
return points
def _reserve_note(dzo_name: str | None, reserve_mva: float | None) -> str | None:
"""Пометка резерва для ПС ЕЭСК без опубликованного резерва (#2119 B1).
Для ПС АО «ЕЭСК» (городская сеть ЕКБ) свободная мощность не раскрывается в
WFS/xlsx-источниках → reserve_mva IS NULL. Показываем честную пометку «резерв по
запросу». Для ПС других ДЗО (или ЕЭСК с известным резервом) → None.
"""
if reserve_mva is not None:
return None
if dzo_name and "еэск" in dzo_name.lower():
return _EESK_RESERVE_NOTE
return None
def _build_power_summary(points: list[dict]) -> dict:
"""Сводка: ближайший ЦП с положительным резервом + count по load_index."""
by_load_index: dict[str, int] = {}
for p in points:
li = p["load_index"] or "unknown"
by_load_index[li] = by_load_index.get(li, 0) + 1
# points уже отсортированы по distance ASC → первый с reserve_mva>0 = ближайший.
nearest_with_reserve = next(
(p for p in points if p["reserve_mva"] is not None and p["reserve_mva"] > 0),
None,
)
return {
"total_power_points": len(points),
"by_load_index": by_load_index,
"nearest_with_reserve": nearest_with_reserve,
}
def _query_water_latest(db: Session) -> list[dict]:
"""Все строки water_supply_reserves за ПОСЛЕДНИЙ period ПО КАЖДОМУ system_kind.
«Последний» — max(period) лексикографически ('2026-Q1' < '2026-Q2' < …),
что для формата 'YYYY-Qn' совпадает с хронологией. period IS NULL исключаем.
MAX(period) берём per-system_kind (коррелированный подзапрос): Водоканал может
опубликовать водоотведение на квартал позже водоснабжения — глобальный MAX
молча выкинул бы отстающий вид целиком.
"""
rows = (
db.execute(
text("""
SELECT w.system_kind, w.system_name, w.reserve_thousand_m3_day,
w.note, w.period
FROM water_supply_reserves w
WHERE w.period IS NOT NULL
AND w.period = (
SELECT MAX(w2.period) FROM water_supply_reserves w2
WHERE w2.period IS NOT NULL
AND w2.system_kind = w.system_kind
)
ORDER BY w.system_kind, w.system_name
""")
)
.mappings()
.all()
)
return [
{
"system_kind": r["system_kind"],
"system_name": r["system_name"],
"reserve_thousand_m3_day": _num(r["reserve_thousand_m3_day"]),
"note": r["note"],
"period": r["period"],
}
for r in rows
]
def _query_gas_city_grs(db: Session) -> dict:
"""Городские ГРС ЕКБ со свободной мощностью (gas_grs_capacity), агрегация по ГРС.
Фильтр «городских» ГРС по имени (ILIKE '%свердловск%' / '%екатеринбург%').
Агрегация по grs_name (у ГРС несколько строк-выходов): SUM(design), SUM(free),
AVG(pct), MAX(upgrade_due), COUNT(*) outputs_count. Пустая/несуществующая таблица
→ city_grs=[] (не падаем — блок аддитивный, gas_grs_capacity может ещё не
существовать до применения миграции 181).
Дополнительно (#2119 B2 PR-4): счётчики точек выхода газосети из
gas_grs_outlet_points (форма 6 ФАС №960 АО «Екатеринбурггаз») за последний
period_month — outlets_total / outlets_deficit (free<0) / outlets_needs_calc.
См. _query_gas_outlet_counts для обоснования «по ВСЕМ строкам без разбивки по ГРС».
Returns: {"city_grs": [...], "total_free_th_m3_h": float|None,
"outlets_total": int, "outlets_deficit": int, "outlets_needs_calc": int}.
Размерности: свободная мощность ГРС — тыс. м³/ЧАС; свободная мощность точек выхода
(в счётчиках/gas_outlet_points) — млн. м³/МЕС. НЕ путать при агрегации.
"""
outlet_counts = _query_gas_outlet_counts(db)
try:
# SAVEPOINT — db shared с caller (GET .../connection-capacity, Depends(get_db)).
# Голый db.rollback() orphan'ит outer SessionTransaction (см. velocity.py:170) —
# begin_nested откатывает только эту savepoint, outer tx остаётся usable.
with db.begin_nested():
rows = (
db.execute(
text("""
SELECT grs_name,
SUM(design_capacity_th_m3_h) AS design_sum,
SUM(free_capacity_th_m3_h) AS free_sum,
AVG(free_capacity_pct) AS pct_avg,
MAX(upgrade_due) AS upgrade_due,
COUNT(*) AS outputs_count
FROM gas_grs_capacity
WHERE grs_name ILIKE '%свердловск%'
OR grs_name ILIKE '%екатеринбург%'
GROUP BY grs_name
ORDER BY grs_name
""")
)
.mappings()
.all()
)
except Exception as e:
# Таблица может не существовать до применения миграции 181 (schema-first).
# Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт. Счётчики
# точек выхода (миграция 184, независимая) — оставляем, что удалось собрать.
logger.warning("gas_grs_capacity недоступна (миграция 181 не применена?): %s", e)
return {"city_grs": [], "total_free_th_m3_h": None, **outlet_counts}
city_grs: list[dict] = []
total_free = 0.0
has_free = False
for r in rows:
free = _num(r["free_sum"])
if free is not None:
total_free += free
has_free = True
city_grs.append(
{
"grs_name": r["grs_name"],
"design_capacity_th_m3_h": _num(r["design_sum"]),
"free_capacity_th_m3_h": free,
"free_capacity_pct": (
round(float(r["pct_avg"]), 1) if r["pct_avg"] is not None else None
),
"upgrade_due": r["upgrade_due"],
"outputs_count": int(r["outputs_count"]),
}
)
return {
"city_grs": city_grs,
"total_free_th_m3_h": round(total_free, 3) if has_free else None,
**outlet_counts,
}
def _query_gas_outlet_counts(db: Session) -> dict:
"""Счётчики точек выхода газосети (gas_grs_outlet_points) за последний period_month.
По ВСЕМ строкам последнего среза БЕЗ разбивки по ГРС (обоснование): grs_name_norm в
форме 6 АО «Екатеринбурггаз» — это block-marker «Точка входа» (5 блоков ГТЕ вроде
«грс-1 свердловск»), НЕ строгий ключ городской связки gas_grs_capacity (ГТЕ, другой
источник). Матч outlet→ГРС по grs_name_norm без FK ненадёжен на per-набор уровне
(имена ГРС в двух раскрытиях расходятся) → честнее агрегировать счётчики ПО ГОРОДУ
целиком, чем молча терять строки на непопавшем join. Это city-level индикатор §3
«сколько точек выхода в дефиците / требуют гидрорасчёта», не per-ГРС.
Считаем за MAX(period_month) (свежайший помесячный срез); is_forecast не фильтруем —
в проде факт/прогноз разведены, но для счётчика важен объём точек последнего среза.
Returns: {"outlets_total": int, "outlets_deficit": int, "outlets_needs_calc": int}.
- outlets_deficit — free_capacity_mln_m3 < 0 (дефицит со знаком, млн.куб.м/МЕС);
- outlets_needs_calc — free_capacity_needs_calc = TRUE («0.000*», нужен гидрорасчёт).
Таблицы может не быть (миграция 184 не применена) → все нули (graceful, аддитивно).
"""
try:
# SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше).
with db.begin_nested():
row = (
db.execute(
text("""
SELECT COUNT(*) AS outlets_total,
COUNT(*) FILTER (
WHERE free_capacity_mln_m3 < 0
) AS outlets_deficit,
COUNT(*) FILTER (
WHERE free_capacity_needs_calc
) AS outlets_needs_calc
FROM gas_grs_outlet_points
WHERE period_month = (
SELECT MAX(period_month) FROM gas_grs_outlet_points
)
""")
)
.mappings()
.first()
)
except Exception as e:
# Таблица может не существовать до применения миграции 184 (schema-first).
# Счётчики аддитивны — деградируем в нули, не роняя весь эндпоинт.
logger.warning("gas_grs_outlet_points недоступна (миграция 184 не применена?): %s", e)
return {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0}
if row is None:
return {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0}
return {
"outlets_total": int(row["outlets_total"] or 0),
"outlets_deficit": int(row["outlets_deficit"] or 0),
"outlets_needs_calc": int(row["outlets_needs_calc"] or 0),
}
def _query_gas_outlet_points(db: Session, parcel_wkt: str) -> list[dict]:
"""Гео-точки выхода газосети (gas_grs_outlet_points) В РАДИУСЕ от участка — слой §3.
ST_DWithin(geom::geography, центроид::geography, _GAS_OUTLET_RADIUS_M) за последний
period_month, ORDER BY distance ASC, LIMIT _GAS_OUTLET_LIMIT. geom есть лишь у ~19%
строк (геокод DaData, PR-3) → в радиус попадает малое подмножество. Размерность
free_capacity_mln_m3 — млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!). Таблицы может не быть
(миграция 184) → [] (graceful, блок аддитивный).
План (EXPLAIN прод 2026-07-03): Seq Scan ~13 мс — geography-каст НЕ использует
geometry-GIST; при 1037 строках это норм. Вырастет 10-50× → функциональный GIST
по (CAST(geom AS geography)) или ST_DWithin по geometry с градусным радиусом.
Returns: [{outlet_name, consumer_type, free_capacity_mln_m3, needs_calc,
distance_m, lat, lon}] — отсортировано по distance ASC.
"""
try:
# SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше).
with db.begin_nested():
rows = (
db.execute(
text("""
SELECT outlet_name, consumer_type, free_capacity_mln_m3,
free_capacity_needs_calc AS needs_calc,
ST_Distance(
geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m,
ST_Y(geom) AS lat,
ST_X(geom) AS lon
FROM gas_grs_outlet_points
WHERE geom IS NOT NULL
AND period_month = (
SELECT MAX(period_month) FROM gas_grs_outlet_points
)
AND ST_DWithin(
geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
CAST(:radius_m AS float)
)
ORDER BY distance_m ASC
LIMIT CAST(:limit AS int)
"""),
{
"wkt": parcel_wkt,
"radius_m": _GAS_OUTLET_RADIUS_M,
"limit": _GAS_OUTLET_LIMIT,
},
)
.mappings()
.all()
)
except Exception as e:
# Таблица может не существовать до применения миграции 184 (schema-first).
# Слой аддитивный — деградируем в пустой, не роняя весь эндпоинт.
logger.warning("gas_grs_outlet_points (радиус) недоступна (миграция 184?): %s", e)
return []
return [
{
"outlet_name": r["outlet_name"],
"consumer_type": r["consumer_type"],
# млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!); может быть <0 — дефицит со знаком.
"free_capacity_mln_m3": _num(r["free_capacity_mln_m3"]),
"needs_calc": bool(r["needs_calc"]),
"distance_m": round(float(r["distance_m"]), 1),
"lat": round(float(r["lat"]), 6) if r["lat"] is not None else None,
"lon": round(float(r["lon"]), 6) if r["lon"] is not None else None,
}
for r in rows
]
def _query_heat_latest(db: Session) -> dict:
"""Резервы тепловой мощности систем за ПОСЛЕДНИЙ период ПО КАЖДОЙ организации.
Строки heat_system_reserves за MAX(period) PER org (коррелированный подзапрос —
как в _query_water_latest: одна организация может опубликовать свежий квартал
раньше другой, глобальный MAX молча выкинул бы отстающую). period IS NULL
исключаем; MAX(period) лексикографически совпадает с хронологией для 'YYYY-Qn'.
Сортировка org, system_name. Graceful при отсутствии таблицы (как gas-блок).
Returns: {"systems": [{org, system_name, reserve_gcal_h, period}],
"total_reserve_gcal_h": float|None}.
"""
try:
# SAVEPOINT — db shared с caller (см. _query_gas_city_grs выше).
with db.begin_nested():
rows = (
db.execute(
text("""
SELECT h.org, h.system_name, h.reserve_gcal_h, h.period
FROM heat_system_reserves h
WHERE h.period IS NOT NULL
AND h.period = (
SELECT MAX(h2.period) FROM heat_system_reserves h2
WHERE h2.period IS NOT NULL
AND h2.org = h.org
)
ORDER BY h.org, h.system_name
""")
)
.mappings()
.all()
)
except Exception as e:
# Таблица может не существовать до применения миграции 182 (schema-first).
# Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт.
logger.warning("heat_system_reserves недоступна (миграция 182 не применена?): %s", e)
return {"systems": [], "total_reserve_gcal_h": None}
systems: list[dict] = []
total_reserve = 0.0
has_reserve = False
for r in rows:
reserve = _num(r["reserve_gcal_h"])
if reserve is not None:
total_reserve += reserve
has_reserve = True
systems.append(
{
"org": r["org"],
"system_name": r["system_name"],
"reserve_gcal_h": reserve,
"period": r["period"],
}
)
return {
"systems": systems,
"total_reserve_gcal_h": round(total_reserve, 3) if has_reserve else None,
}
def _query_nearby_network_zones(db: Session, parcel_wkt: str) -> list[dict]:
"""Сетевые охранные зоны cad_zouit в радиусе 300 м от участка — позитив-развёртка §3.
Охранная зона сети = коридор, где физически ПРОХОДИТ сеть. «Теплотрасса в 24 м →
врезка дешёвая» — решающий сигнал для финдиректора (особенно heat, у которого в §3
нет своей гео-привязки). Зоны, ПЕРЕСЕКАЮЩИЕ участок, включаем с distance_m=0.
План (EXPLAIN прод 2026-07-03, cad_zouit_geom_gist существует): GIST-префильтр
``ST_DWithin(geom, parcel, 0.006°)`` + точный ``ST_DWithin(geom::geography,
parcel::geography, 300)`` — Index Scan, ~70 мс на плотном центре ЕКБ. cad_zouit.geom
= geometry(MultiPolygon,4326); geography-каст приклеен к ``)``, не к bind-имени
(psycopg3-safe, как ``ARRAY[...]::int[]`` / power_points-запрос).
#1957: одна физическая зона хранится 2× (одинаковый reg_numb_border, разный
category_name) → DISTINCT ON (reg_numb_border) схлопывает, оставляя ближайшую строку.
Гуманизация (C3, ``network_zone_humanize``): net_kind дочищается по name_by_doc
(general «инженерные коммуникации» на проде часто РЕАЛЬНО теплосеть/газопровод),
human_label вытаскивает кад.номер трубопровода / напряжение ВЛ + ТП. Итог — до
_NETWORK_ZONE_PER_KIND ближайших зон НА КАЖДЫЙ net_kind (сорт по distance ASC).
Таймаут-guard в savepoint: запрос лёгкий, но эндпоинт синхронный — при аномально
медленном скане (QueryCanceled) деградируем в [], не роняя весь эндпоинт. Блок
аддитивный (позитив-сигнал), отсутствие данных читается как «сеть рядом не найдена».
"""
try:
with db.begin_nested():
# SET не принимает bind-параметры (extended protocol → syntax error
# at or near "$1") — только литерал, как у offer_trend (parcels.py).
db.execute(text(f"SET LOCAL statement_timeout = '{_NETWORK_ZONE_TIMEOUT_MS:d}'"))
rows = (
db.execute(
text("""
SELECT DISTINCT ON (z.reg_numb_border)
z.type_zone,
z.name_by_doc,
z.reg_numb_border,
round(
ST_Distance(
z.geom::geography,
ST_GeomFromText(:wkt, 4326)::geography
)::numeric
) AS distance_m
FROM cad_zouit z
WHERE z.geom IS NOT NULL
AND z.type_zone IN (
'Охранная зона тепловых сетей',
'Охранная зона инженерных коммуникаций',
'Охранная зона объектов электроэнергетики'
' (объектов электросетевого хозяйства и объектов по'
' производству электрической энергии)',
'Охранная зона трубопроводов (газопроводов,'
' нефтепроводов и нефтепродуктопроводов, аммиакопроводов)',
'Охранная зона линий и сооружений связи'
)
AND ST_DWithin(
z.geom,
ST_GeomFromText(:wkt, 4326),
CAST(:radius_deg AS float)
)
AND ST_DWithin(
z.geom::geography,
ST_GeomFromText(:wkt, 4326)::geography,
CAST(:radius_m AS float)
)
ORDER BY z.reg_numb_border, distance_m ASC
"""),
{
"wkt": parcel_wkt,
"radius_deg": _NETWORK_ZONE_RADIUS_DEG,
"radius_m": _NETWORK_ZONE_RADIUS_M,
},
)
.mappings()
.all()
)
# SET LOCAL переживает RELEASE SAVEPOINT (transaction-scoped) —
# сбрасываем явно, как в offer_trend (parcels.py), чтобы таймаут
# не утёк на последующие запросы этой транзакции.
db.execute(text("SET LOCAL statement_timeout = 0"))
except SQLAlchemyError as e:
# QueryCanceled (таймаут) / отсутствие таблицы / прочие SQL-сбои — блок аддитивный
# (позитив-сигнал), деградируем в пустой, не роняя весь эндпоинт connection-capacity.
logger.warning("nearby_network_zones query failed: %s", e)
return []
return _shape_network_zones(rows)
def _shape_network_zones(rows: list) -> list[dict]:
"""DISTINCT-строки cad_zouit → гуманизированные зоны, до N ближайших per net_kind.
Классификацию net_kind (refine) и подпись (human_label) считаем в Python (regex по
name_by_doc), затем сортируем по distance ASC и берём _NETWORK_ZONE_PER_KIND на вид.
Возвращаемый список отсортирован по (distance_m, net_kind) для стабильного порядка.
"""
per_kind: dict[str, list[dict]] = {}
for r in rows:
type_zone = r["type_zone"] or ""
name_by_doc = r["name_by_doc"]
net_kind = refine_net_kind(type_zone, name_by_doc)
if net_kind is None:
# type_zone из белого списка, но не классифицировался — пропускаем (не должно).
continue
distance_m = int(r["distance_m"]) if r["distance_m"] is not None else 0
per_kind.setdefault(net_kind, []).append(
{
"net_kind": net_kind,
"distance_m": distance_m,
"name_by_doc": name_by_doc,
"human_label": human_label(net_kind, name_by_doc),
}
)
result: list[dict] = []
for kind_rows in per_kind.values():
kind_rows.sort(key=lambda z: z["distance_m"])
result.extend(kind_rows[:_NETWORK_ZONE_PER_KIND])
# Стабильный внешний порядок: ближайшее сверху, затем по виду сети.
result.sort(key=lambda z: (z["distance_m"], z["net_kind"]))
return result
def _num(value: object) -> float | None:
"""NUMERIC (Decimal) из БД → float | None."""
return float(value) if value is not None else None # type: ignore[arg-type]