diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 2dbedb41..447307cc 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -3874,6 +3874,7 @@ def get_parcel_connection_capacity( power_points=data["power_points"], power_summary=data["power_summary"], water=data["water"], + gas=data.get("gas"), ) diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 01e1d4f9..0891406e 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -126,6 +126,9 @@ class PowerConnectionPoint(BaseModel): distance_m: float lat: float | None lon: float | None + # Пометка для ПС ЕЭСК без опубликованного резерва: свободная мощность у АО «ЕЭСК» + # (городская сеть ЕКБ) не раскрывается WFS/xlsx — берётся по запросу. #2119 B1. + reserve_note: str | None = None class PowerCapacitySummary(BaseModel): @@ -148,10 +151,33 @@ class WaterCapacityRow(BaseModel): period: str | None +class GasGrsRow(BaseModel): + """Свободная мощность одной городской ГРС (агрегация по выходам). #2119 B1.""" + + grs_name: str + design_capacity_th_m3_h: float | None # проектная (сумма по выходам), тыс. м³/ч + free_capacity_th_m3_h: float | None # свободная (сумма по выходам), тыс. м³/ч + free_capacity_pct: float | None # средняя свободная, % + upgrade_due: str | None # срок расшивки (макс. по выходам, свободный текст) + outputs_count: int # число строк-выходов ГРС, вошедших в агрегацию + + +class GasCapacityBlock(BaseModel): + """Газовый блок: городские ГРС ЕКБ со свободной мощностью. #2119 Фаза B1.""" + + # Городские ГРС (Свердловск/Екатеринбург) — агрегация по grs_name. Пусто, если + # таблица gas_grs_capacity ещё не наполнена (weekly-sync, RUN-ON-PROD). + city_grs: list[GasGrsRow] + # Суммарная свободная мощность городских ГРС, тыс. м³/ч (None если данных нет). + total_free_th_m3_h: float | None + + class ConnectionCapacityResponse(BaseModel): power_points: list[PowerConnectionPoint] power_summary: PowerCapacitySummary water: list[WaterCapacityRow] + # Газовый блок аддитивен (Phase B1): None если ключ 'gas' не собран. + gas: GasCapacityBlock | None = None # ── NSPD Risk Zones schemas (issue #94 TIER 3) ─────────────────────────────── diff --git a/backend/app/services/site_finder/connection_capacity_lookup.py b/backend/app/services/site_finder/connection_capacity_lookup.py index 17d21a3d..2f11c71b 100644 --- a/backend/app/services/site_finder/connection_capacity_lookup.py +++ b/backend/app/services/site_finder/connection_capacity_lookup.py @@ -3,9 +3,12 @@ Собирает по участку: - 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. Источник координат участка — общий ``_get_parcel_wkt`` (cad_parcels_geom → квартал fallback). Радиус по умолчанию 3000 м (cap 10000). @@ -21,6 +24,10 @@ logger = logging.getLogger(__name__) _DEFAULT_RADIUS_M = 3000 _MAX_RADIUS_M = 10000 +# Пометка для ПС АО «ЕЭСК» (городская сеть ЕКБ) без раскрытого резерва: свободная +# мощность у ЕЭСК не публикуется в WFS/xlsx — уточняется по запросу. #2119 B1. +_EESK_RESERVE_NOTE = "Резерв — по запросу в АО «ЕЭСК» (город)" + def get_connection_capacity( db: Session, @@ -52,11 +59,13 @@ def get_connection_capacity( 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) return { "power_points": power_points, "power_summary": power_summary, "water": water, + "gas": gas, } @@ -91,6 +100,7 @@ def _query_power_points(db: Session, parcel_wkt: str, radius_m: int) -> list[dic points: list[dict] = [] for r in rows: + reserve_mva = _num(r["reserve_mva"]) points.append( { "name": r["sc_name"], @@ -99,8 +109,9 @@ def _query_power_points(db: Session, parcel_wkt: str, radius_m: int) -> list[dic "load_index": r["load_index"], "installed_capacity_mva": _num(r["installed_capacity_mva"]), "current_load_mva": _num(r["current_load_mva"]), - "reserve_mva": _num(r["reserve_mva"]), + "reserve_mva": reserve_mva, "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, @@ -109,6 +120,20 @@ def _query_power_points(db: Session, parcel_wkt: str, radius_m: int) -> list[dic 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] = {} @@ -168,6 +193,69 @@ def _query_water_latest(db: Session) -> list[dict]: ] +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). + + Returns: {"city_grs": [...], "total_free_th_m3_h": float|None}. + """ + try: + 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). + # Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт. + logger.warning("gas_grs_capacity недоступна (миграция 181 не применена?): %s", e) + return {"city_grs": [], "total_free_th_m3_h": None} + + 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, + } + + def _num(value: object) -> float | None: """NUMERIC (Decimal) из БД → float | None.""" return float(value) if value is not None else None # type: ignore[arg-type] diff --git a/backend/app/services/site_finder/gazprom_grs_loader.py b/backend/app/services/site_finder/gazprom_grs_loader.py new file mode 100644 index 00000000..329a2a16 --- /dev/null +++ b/backend/app/services/site_finder/gazprom_grs_loader.py @@ -0,0 +1,355 @@ +"""Загрузчик свободной мощности ГРС «Газпром трансгаз Екатеринбург» (#2119 B1). + +No-B2B верифицированный источник: страница раскрытия ГТЕ содержит ИНЛАЙН +HTML-таблицу «Информация о технической возможности…» (~316 строк) со свободной +мощностью газораспределительных станций (ГРС) — проектная / фактическая загрузка / +свободная мощность (тыс. м³/ч) + срок расшивки. UPSERT-ит в ``gas_grs_capacity``. + +Источник ГЕО-БЛОКИРУЕТ non-RU IP → загрузчик РАБОТАЕТ НА ПРОДЕ (Celery weekly / +manual docker exec) — как rosseti-/vodokanal-лоадеры. Код не предполагает +локальный сетевой доступ. + +Парсинг DEFENSIVE: колонки мапятся ПО ЗАГОЛОВКАМ (не по фикс-позиции — ГТЕ может +переставить/добавить столбцы), имя ГРС с rowspan («выход 1/2/3») forward-fill'ится +на строки-выходы (идея из vodokanal_reserve_loader.parse_docx_table_rows). Числа — +через ОБЩИЙ ``parse_reserve_number`` из rosseti_reserve_loader (запятая, «н/д», +знак). Неизвестные колонки не теряются — падают в ``raw`` jsonb. +""" + +import json +import logging +import re + +import httpx +from bs4 import BeautifulSoup +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.db import SessionLocal +from app.services.site_finder.rosseti_reserve_loader import parse_reserve_number + +logger = logging.getLogger(__name__) + +# Страница расклытия «Газпром трансгаз Екатеринбург» с инлайн HTML-таблицей. +# Punycode не требуется — латинский хост. +DISCLOSURE_URL = "https://ekaterinburg-tr.gazprom.ru/about/informatsiya-o-tekhnicheskoj-vozmo/" + +_HTTP_TIMEOUT = 60 + +_MULTISPACE_RE = re.compile(r"\s+") + +# «н/д» / «-» / прочерк / пусто в текстовой ячейке (напр. срок расшивки) → None. +_NODATA_TEXT_TOKENS = frozenset( + {"", "н/д", "нд", "n/a", "na", "-", "—", "–", "нет данных", "х", "x"} +) + +# Заголовок-матчинг: подстрока в тексте
| и | в порядке появления)."""
+ return tr.find_all(["th", "td"]) # type: ignore[union-attr]
+
+
+def _build_column_map(header_cells: list[object]) -> tuple[dict[int, str], dict[int, str]]:
+ """Из ячеек строки-заголовка строит col_idx→logical_key + col_idx→raw_header.
+
+ Возвращает (mapping, headers): mapping только для распознанных колонок
+ (grs_name/output_name/design/load/free/pct/upgrade_due/region); headers — сырые
+ тексты ВСЕХ колонок (для raw + region-детекта). «%»-колонка → ключ 'pct'.
+ """
+ mapping: dict[int, str] = {}
+ headers: dict[int, str] = {}
+ for idx, cell in enumerate(header_cells):
+ htext = _clean_text(cell.get_text(" ")) or "" # type: ignore[union-attr]
+ headers[idx] = htext
+ if _is_region_header(htext):
+ mapping[idx] = "region"
+ continue
+ if _is_pct_header(htext):
+ mapping[idx] = "pct"
+ continue
+ key = _classify_header(htext)
+ if key is not None:
+ mapping[idx] = key
+ return mapping, headers
+
+
+def parse_grs_table(html: str) -> list[dict]:
+ """Парсит инлайн HTML-таблицу ГТЕ → список записей ГРС (по выходам).
+
+ DEFENSIVE:
+ - основная
нет таблиц ") == [] + + +def test_parse_unrecognized_name_column_returns_empty() -> None: + """Заголовок без имени ГРС → пустой результат (defensive).""" + html = ( + "
+ {point.reserve_note}
+
+ )}
{distance && (
До участка: {distance}
diff --git a/frontend/src/components/site-finder/analysis/ResourceReservesCard.tsx b/frontend/src/components/site-finder/analysis/ResourceReservesCard.tsx
index f32c08d0..c46fceb4 100644
--- a/frontend/src/components/site-finder/analysis/ResourceReservesCard.tsx
+++ b/frontend/src/components/site-finder/analysis/ResourceReservesCard.tsx
@@ -15,10 +15,12 @@
* бэка не отработали, показываем нейтральный «загружаются», а не «нет данных».
*/
-import { Info, Zap, Droplets } from "lucide-react";
+import { Info, Zap, Droplets, Flame } from "lucide-react";
import type {
ConnectionCapacityResponse,
+ GasCapacityBlock,
+ GasGrsRow,
WaterCapacityRow,
} from "@/hooks/useConnectionCapacity";
import { CONNECTION_CAPACITY_RADIUS_M } from "@/hooks/useConnectionCapacity";
@@ -28,6 +30,7 @@ import {
formatMeters,
formatMva,
formatThousandM3Day,
+ formatThousandM3Hour,
humanizeLoadIndex,
pickCitySystem,
pluralizeSystems,
@@ -48,6 +51,11 @@ const RESERVE_BADGE = {
bg: "var(--danger-soft)",
fg: "var(--danger)",
},
+ depleted: {
+ label: "исчерпана",
+ bg: "var(--danger-soft)",
+ fg: "var(--danger)",
+ },
} as const;
function StatusBadge({
@@ -266,6 +274,88 @@ function WaterBlock({ rows }: { rows: WaterCapacityRow[] }) {
);
}
+// ── Газ ────────────────────────────────────────────────────────────────────────
+
+function GasGrsLine({ row }: { row: GasGrsRow }) {
+ const freeNum = toFiniteNumber(row.free_capacity_th_m3_h);
+ const freeStr = formatThousandM3Hour(row.free_capacity_th_m3_h);
+ const designStr = formatThousandM3Hour(row.design_capacity_th_m3_h);
+ const isDepleted = freeNum !== null && freeNum <= 0;
+ const badge = isDepleted ? RESERVE_BADGE.depleted : RESERVE_BADGE.reserve;
+ const upgrade = row.upgrade_due?.trim() || null;
+
+ return (
+
+ {/* Газ */}
+
+
+ {row.grs_name}: свободно{" "}
+ {freeStr ? (
+
+ {freeStr}
+
+ ) : (
+ —
+ )}
+ {designStr ? ` из ${designStr}` : ""}
+ {row.outputs_count > 1 && (
+
+ {" "}
+ ({row.outputs_count} выхода)
+
+ )}
+ {upgrade && (
+
+ {" "}
+ · расшивка: {upgrade}
+
+ )}
+
+
+ );
+}
+
+function GasBlock({ gas }: { gas: GasCapacityBlock | null | undefined }) {
+ if (!gas || gas.city_grs.length === 0) {
+ return (
+
+ Данные о загрузке ГРС загружаются.
+
+ );
+ }
+
+ const totalNum = toFiniteNumber(gas.total_free_th_m3_h);
+ const totalStr = formatThousandM3Hour(gas.total_free_th_m3_h);
+
+ return (
+
+ {gas.city_grs.map((row, i) => (
+
+ );
+}
+
// ── Card ───────────────────────────────────────────────────────────────────────
const SUB_HEADER: React.CSSProperties = {
@@ -318,6 +408,15 @@ export function ResourceReservesCard({ data }: Props) {
+
+
{/* Вода / канализация */}
+
+ Газ
+
+
diff --git a/frontend/src/components/site-finder/connection-capacity.ts b/frontend/src/components/site-finder/connection-capacity.ts
index b55a454c..696119cf 100644
--- a/frontend/src/components/site-finder/connection-capacity.ts
+++ b/frontend/src/components/site-finder/connection-capacity.ts
@@ -126,6 +126,18 @@ export function formatMeters(
return `${Math.round(n).toLocaleString("ru-RU")} м`;
}
+// тыс. м³/ч (свободная/проектная мощность ГРС). Приводит строку→число (Decimal→str).
+export function formatThousandM3Hour(
+ v: number | string | null | undefined,
+): string | null {
+ const n = toFiniteNumber(v);
+ if (n === null) return null;
+ return `${n.toLocaleString("ru-RU", {
+ minimumFractionDigits: 1,
+ maximumFractionDigits: 1,
+ })} тыс. м³/ч`;
+}
+
// ── Вода: выбор «городской» системы + группировка ──────────────────────────────
//
// Городская система ЦСВ/ЦСК выделяется в карточке отдельной строкой: сначала по
diff --git a/frontend/src/hooks/useConnectionCapacity.ts b/frontend/src/hooks/useConnectionCapacity.ts
index 9979a28b..4ed02a8d 100644
--- a/frontend/src/hooks/useConnectionCapacity.ts
+++ b/frontend/src/hooks/useConnectionCapacity.ts
@@ -13,6 +13,8 @@ export type PowerConnectionPoint =
export type PowerCapacitySummary =
components["schemas"]["PowerCapacitySummary"];
export type WaterCapacityRow = components["schemas"]["WaterCapacityRow"];
+export type GasCapacityBlock = components["schemas"]["GasCapacityBlock"];
+export type GasGrsRow = components["schemas"]["GasGrsRow"];
/**
* Радиус поиска центров питания (ЦП, ПС 35/110 кВ) вокруг участка. Дефолт 3 км —
diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts
index 9e70bba1..5d802932 100644
--- a/frontend/src/lib/api-types.ts
+++ b/frontend/src/lib/api-types.ts
@@ -3456,6 +3456,7 @@ export interface components {
power_summary: components["schemas"]["PowerCapacitySummary"];
/** Water */
water: components["schemas"]["WaterCapacityRow"][];
+ gas?: components["schemas"]["GasCapacityBlock"] | null;
};
/** ConnectionPointsResponse */
ConnectionPointsResponse: {
@@ -3850,6 +3851,34 @@ export interface components {
*/
price_source: string;
};
+ /**
+ * GasCapacityBlock
+ * @description Газовый блок: городские ГРС ЕКБ со свободной мощностью. #2119 Фаза B1.
+ */
+ GasCapacityBlock: {
+ /** City Grs */
+ city_grs: components["schemas"]["GasGrsRow"][];
+ /** Total Free Th M3 H */
+ total_free_th_m3_h: number | null;
+ };
+ /**
+ * GasGrsRow
+ * @description Свободная мощность одной городской ГРС (агрегация по выходам). #2119 B1.
+ */
+ GasGrsRow: {
+ /** Grs Name */
+ grs_name: string;
+ /** Design Capacity Th M3 H */
+ design_capacity_th_m3_h: number | null;
+ /** Free Capacity Th M3 H */
+ free_capacity_th_m3_h: number | null;
+ /** Free Capacity Pct */
+ free_capacity_pct: number | null;
+ /** Upgrade Due */
+ upgrade_due: string | null;
+ /** Outputs Count */
+ outputs_count: number;
+ };
/**
* GroundedIn
* @description Привязка ответа к источнику — какой ран/схема/секции легли в основу ответа.
@@ -4712,6 +4741,8 @@ export interface components {
lat: number | null;
/** Lon */
lon: number | null;
+ /** Reserve Note */
+ reserve_note?: string | null;
};
/** RecommendBucket */
RecommendBucket: {
|
|---|