feat(site-finder): газ — свободная мощность ГРС + пометка ЕЭСК в §3 (#2119 Фаза B1)
Газовая вертикаль точек подключения: - Миграция 181: gas_grs_capacity (ГРС с выходами; UNIQUE NULLS NOT DISTINCT на (grs_name_norm, output_name) — грабли #140). - gazprom_grs_loader: bs4-парсер инлайн HTML-таблицы «техническая возможность» ГТЕ (~316 ГРС; гео-блок → RUN-ON-PROD): header-driven маппинг колонок, rowspan forward-fill имени (допущение col-0 задокументировано), числа через общий parse_reserve_number, SAVEPOINT per-row, unknown-колонки → raw jsonb. - Celery sync_gas_grs_capacity + beat вт 05:00 MSK. - Endpoint connection-capacity: gas.city_grs (городские ГРС Свердловск/ Екатеринбург, агрегация по выходам) + gas.total_free_th_m3_h; graceful при отсутствии таблицы (schema-first окно). reserve_note у ПС ЕЭСК без опубликованного резерва: «Резерв — по запросу в АО «ЕЭСК» (город)». - Схемы GasGrsRow/GasCapacityBlock/reserve_note аддитивны; api-types +31/-0. - Фронт: блок «ГАЗ» в карточке резервов (по-ГРС «свободно X из Y тыс. м³/ч», бейджи резерв/исчерпана, расшивка, N выходов, суммарная строка) + серая reserve_note-строка в попапе ЦП. Числа через toFiniteNumber. 57 backend-тестов зелёные; deep-review ✅ APPROVE (2 minor-нота учтены).
This commit is contained in:
parent
e1a75d98d4
commit
641f0d56ed
13 changed files with 1004 additions and 3 deletions
|
|
@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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) ───────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
355
backend/app/services/site_finder/gazprom_grs_loader.py
Normal file
355
backend/app/services/site_finder/gazprom_grs_loader.py
Normal file
|
|
@ -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"}
|
||||
)
|
||||
|
||||
# Заголовок-матчинг: подстрока в тексте <th>/первой строки → логическая колонка.
|
||||
# Порядок важен — «свободн» проверяем раньше «загрузк», а «%» отдельным ключом.
|
||||
# Каждая запись: (набор подстрок-триггеров, ключ). Первый матч на колонку выигрывает.
|
||||
_HEADER_PATTERNS: list[tuple[tuple[str, ...], str]] = [
|
||||
(("выход",), "output_name"),
|
||||
(("наименование", "грс", "станц"), "grs_name"),
|
||||
(("проектн",), "design"),
|
||||
(("свободн", "резерв"), "free"),
|
||||
(("загрузк", "фактич", "текущ"), "load"),
|
||||
(("срок", "расшивк", "мероприят"), "upgrade_due"),
|
||||
]
|
||||
|
||||
# Region-колонка (если ГТЕ добавит регион в таблицу) → фильтр Свердловской области.
|
||||
_REGION_HEADER_TOKENS = ("регион", "область", "субъект")
|
||||
_SVERDLOVSK_TOKEN = "свердлов"
|
||||
|
||||
|
||||
def normalize_grs_name(name: str | None) -> str:
|
||||
"""Нормализует имя ГРС для матча/дедупа.
|
||||
|
||||
Правила: lower, ё→е, схлопывание пробелов и дефисов (все виды тире → одиночный
|
||||
дефис без пробелов вокруг), удаление кавычек. Префиксы «ГРС»/«АРП» СОХРАНЯЮТСЯ —
|
||||
они различают станции. Пусто → ''.
|
||||
|
||||
Examples:
|
||||
«ГРС Кольцово» → «грс кольцово»
|
||||
«ГРС Берёзовский» → «грс березовский»
|
||||
«ГРС Северная — 2» → «грс северная-2»
|
||||
"""
|
||||
if not name:
|
||||
return ""
|
||||
s = name.strip().lower().replace("ё", "е")
|
||||
s = s.replace("«", "").replace("»", "").replace('"', "").replace("'", "")
|
||||
# Все виды тире (—, –, -) в одиночный дефис, пробелы вокруг убираем.
|
||||
s = re.sub(r"\s*[—–-]\s*", "-", s)
|
||||
s = _MULTISPACE_RE.sub(" ", s).strip()
|
||||
return s
|
||||
|
||||
|
||||
def _clean_text(value: object | None) -> str | None:
|
||||
"""Текст ячейки → strip + collapse пробелов. Пусто / «н/д»-токен → None."""
|
||||
if value is None:
|
||||
return None
|
||||
s = _MULTISPACE_RE.sub(" ", str(value).replace("\xa0", " ")).strip()
|
||||
if not s or s.lower() in _NODATA_TEXT_TOKENS:
|
||||
return None
|
||||
return s
|
||||
|
||||
|
||||
def _classify_header(header_text: str) -> str | None:
|
||||
"""Заголовок колонки → логический ключ (grs_name/output_name/design/…) или None."""
|
||||
low = header_text.lower()
|
||||
for tokens, key in _HEADER_PATTERNS:
|
||||
if any(tok in low for tok in tokens):
|
||||
return key
|
||||
return None
|
||||
|
||||
|
||||
def _is_pct_header(header_text: str) -> bool:
|
||||
"""Колонка про проценты свободной мощности?"""
|
||||
low = header_text.lower()
|
||||
return "%" in low or "процент" in low
|
||||
|
||||
|
||||
def _is_region_header(header_text: str) -> bool:
|
||||
"""Колонка про регион/область (для Свердловск-фильтра)?"""
|
||||
low = header_text.lower()
|
||||
return any(tok in low for tok in _REGION_HEADER_TOKENS)
|
||||
|
||||
|
||||
def _extract_table(html: str) -> object | None:
|
||||
"""Находит наиболее «содержательную» <table> (с макс. числом строк). None — нет."""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
tables = soup.find_all("table")
|
||||
if not tables:
|
||||
return None
|
||||
# Основная таблица — с наибольшим числом строк (шапки/навигация мелкие).
|
||||
return max(tables, key=lambda t: len(t.find_all("tr")))
|
||||
|
||||
|
||||
def _row_cells(tr: object) -> list[object]:
|
||||
"""Ячейки строки (<th> и <td> в порядке появления)."""
|
||||
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:
|
||||
- основная <table> = с макс. числом строк;
|
||||
- первая строка = заголовок, колонки мапятся ПО ЗАГОЛОВКАМ (contains);
|
||||
- rowspan имени ГРС → forward-fill на строки-выходы (несколько выходов у ГРС).
|
||||
ДОПУЩЕНИЕ: offset-коррекция работает только когда колонка имени — ПЕРВАЯ
|
||||
(в текущей таблице ГТЕ так); rowspan не-первой колонки даст пропуск строк
|
||||
(не порчу данных — безымянные строки скипаются, raw сохраняется);
|
||||
- числа через parse_reserve_number (запятая, «н/д», знак);
|
||||
- неизвестные колонки → raw (не теряются).
|
||||
|
||||
Каждая запись:
|
||||
{grs_name, output_name, region, design_capacity_th_m3_h, current_load_th_m3_h,
|
||||
free_capacity_th_m3_h, free_capacity_pct, upgrade_due, raw}.
|
||||
grs_name пустой (нет forward-fill и нет своего имени) → строка пропускается.
|
||||
"""
|
||||
table = _extract_table(html)
|
||||
if table is None:
|
||||
logger.warning("gazprom_grs: <table> не найдена в HTML")
|
||||
return []
|
||||
|
||||
rows = table.find_all("tr") # type: ignore[union-attr]
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
mapping, headers = _build_column_map(_row_cells(rows[0]))
|
||||
if "grs_name" not in mapping.values():
|
||||
logger.warning("gazprom_grs: колонка имени ГРС не распознана по заголовкам (%s)", headers)
|
||||
return []
|
||||
|
||||
records: list[dict] = []
|
||||
# forward-fill имени ГРС по rowspan: если ячейка имени отсутствует в строке
|
||||
# (schrunk из-за rowspan предыдущей), наследуем последнее известное имя.
|
||||
last_grs_name: str | None = None
|
||||
|
||||
for tr in rows[1:]:
|
||||
cells = _row_cells(tr)
|
||||
if not cells:
|
||||
continue
|
||||
|
||||
# Матчим ячейки к логическим ключам по позиции колонки-заголовка. При
|
||||
# rowspan имени в строке-выходе ячейки имени НЕТ → индексы сдвигаются; мы
|
||||
# определяем это по числу ячеек < числу заголовков и forward-fill'им имя.
|
||||
values: dict[str, str | None] = {}
|
||||
raw_cells: list[str] = []
|
||||
|
||||
name_col = next(i for i, k in mapping.items() if k == "grs_name")
|
||||
shifted = len(cells) < len(headers) and name_col == 0
|
||||
# Если строка короче заголовков и имя-колонка первая — считаем что
|
||||
# rowspan «съел» первую ячейку: логические индексы сдвигаем на +1.
|
||||
offset = 1 if shifted else 0
|
||||
|
||||
for idx, cell in enumerate(cells):
|
||||
# raw_cells — сырой текст ячейки (в т.ч. «н/д»), чтобы raw jsonb не терял
|
||||
# исходное значение; values — очищенный (_clean_text: «н/д» → None).
|
||||
raw_text = _MULTISPACE_RE.sub(
|
||||
" ",
|
||||
cell.get_text(" ").replace("\xa0", " "), # type: ignore[union-attr]
|
||||
).strip()
|
||||
raw_cells.append(raw_text)
|
||||
logical_idx = idx + offset
|
||||
key = mapping.get(logical_idx)
|
||||
if key is not None:
|
||||
values[key] = _clean_text(raw_text)
|
||||
|
||||
# values уже очищены (_clean_text применён при заполнении: str | None).
|
||||
grs_name = values.get("grs_name")
|
||||
if grs_name:
|
||||
last_grs_name = grs_name # type: ignore[assignment]
|
||||
else:
|
||||
grs_name = last_grs_name # forward-fill (rowspan имени ГРС)
|
||||
if not grs_name:
|
||||
continue # шапка/итоговая строка без имени и без forward-fill
|
||||
|
||||
rec = {
|
||||
"grs_name": grs_name,
|
||||
"output_name": values.get("output_name"),
|
||||
"region": values.get("region"),
|
||||
"design_capacity_th_m3_h": parse_reserve_number(values.get("design")),
|
||||
"current_load_th_m3_h": parse_reserve_number(values.get("load")),
|
||||
"free_capacity_th_m3_h": parse_reserve_number(values.get("free")),
|
||||
"free_capacity_pct": parse_reserve_number(values.get("pct")),
|
||||
"upgrade_due": values.get("upgrade_due"),
|
||||
"raw": {"headers": list(headers.values()), "cells": raw_cells},
|
||||
}
|
||||
records.append(rec)
|
||||
|
||||
logger.info("gazprom_grs: распознано записей ГРС=%d", len(records))
|
||||
return records
|
||||
|
||||
|
||||
def _passes_region_filter(records: list[dict]) -> list[dict]:
|
||||
"""Если хоть у одной записи есть region — фильтруем по «свердлов»; иначе всё.
|
||||
|
||||
В таблице ГТЕ обычно НЕТ колонки региона (весь ГТЕ = Свердловская + сопред.
|
||||
области) → грузим всё с region=NULL. Если ГТЕ добавит регион — оставляем только
|
||||
свердловские строки.
|
||||
"""
|
||||
if any(r.get("region") for r in records):
|
||||
return [r for r in records if (r.get("region") or "").lower().find(_SVERDLOVSK_TOKEN) >= 0]
|
||||
return records
|
||||
|
||||
|
||||
def fetch_grs_html() -> str:
|
||||
"""Тянет HTML страницы раскрытия ГТЕ. RUN-ON-PROD (гео-блок). httpx с таймаутом."""
|
||||
resp = httpx.get(DISCLOSURE_URL, timeout=_HTTP_TIMEOUT, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
|
||||
|
||||
def _upsert_grs_rows(db: Session, records: list[dict]) -> dict[str, int]:
|
||||
"""UPSERT записей ГРС в gas_grs_capacity. Per-row SAVEPOINT (битая строка не валит батч).
|
||||
|
||||
ON CONFLICT (grs_name_norm, output_name) — NULLS NOT DISTINCT в схеме (output_name
|
||||
nullable). Returns: счётчики rows/inserted/updated/skipped.
|
||||
"""
|
||||
inserted = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
for rec in records:
|
||||
grs_name = rec["grs_name"]
|
||||
params = {
|
||||
"grs_name": grs_name,
|
||||
"grs_name_norm": normalize_grs_name(grs_name),
|
||||
"output_name": rec["output_name"],
|
||||
"region": rec["region"],
|
||||
"design": rec["design_capacity_th_m3_h"],
|
||||
"load": rec["current_load_th_m3_h"],
|
||||
"free": rec["free_capacity_th_m3_h"],
|
||||
"pct": rec["free_capacity_pct"],
|
||||
"upgrade_due": rec["upgrade_due"],
|
||||
"raw": json.dumps(rec["raw"], ensure_ascii=False),
|
||||
}
|
||||
try:
|
||||
with db.begin_nested(): # SAVEPOINT — откат только этой строки
|
||||
result = db.execute(
|
||||
text("""
|
||||
INSERT INTO gas_grs_capacity
|
||||
(grs_name, grs_name_norm, output_name, region,
|
||||
design_capacity_th_m3_h, current_load_th_m3_h,
|
||||
free_capacity_th_m3_h, free_capacity_pct,
|
||||
upgrade_due, raw, fetched_at)
|
||||
VALUES (
|
||||
:grs_name, :grs_name_norm, :output_name, :region,
|
||||
:design, :load, :free, :pct,
|
||||
:upgrade_due, CAST(:raw AS jsonb), NOW()
|
||||
)
|
||||
ON CONFLICT (grs_name_norm, output_name) DO UPDATE
|
||||
SET grs_name = EXCLUDED.grs_name,
|
||||
region = EXCLUDED.region,
|
||||
design_capacity_th_m3_h = EXCLUDED.design_capacity_th_m3_h,
|
||||
current_load_th_m3_h = EXCLUDED.current_load_th_m3_h,
|
||||
free_capacity_th_m3_h = EXCLUDED.free_capacity_th_m3_h,
|
||||
free_capacity_pct = EXCLUDED.free_capacity_pct,
|
||||
upgrade_due = EXCLUDED.upgrade_due,
|
||||
raw = EXCLUDED.raw,
|
||||
fetched_at = NOW()
|
||||
RETURNING (xmax = 0) AS is_insert
|
||||
"""),
|
||||
params,
|
||||
).scalar()
|
||||
if result:
|
||||
inserted += 1
|
||||
else:
|
||||
updated += 1
|
||||
except Exception as e:
|
||||
logger.warning("gas_grs upsert failed for %r/%r: %s", grs_name, rec["output_name"], e)
|
||||
skipped += 1
|
||||
return {"rows": len(records), "inserted": inserted, "updated": updated, "skipped": skipped}
|
||||
|
||||
|
||||
def load_grs_capacity(db: Session | None = None) -> dict[str, int]:
|
||||
"""Тянет страницу ГТЕ + парсит HTML-таблицу + UPSERT свободной мощности ГРС.
|
||||
|
||||
RUN-ON-PROD (гео-блок источника). ``db`` для совместимости сигнатуры; None →
|
||||
своя SessionLocal (fetch + запись sync, как rosseti_wfs_loader).
|
||||
|
||||
Returns: счётчики {rows, inserted, updated, skipped}.
|
||||
"""
|
||||
html = fetch_grs_html()
|
||||
records = _passes_region_filter(parse_grs_table(html))
|
||||
|
||||
owns_session = db is None
|
||||
if db is None:
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
counts = _upsert_grs_rows(db, records)
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("load_grs_capacity: outer tx rolled back: %s", e)
|
||||
raise
|
||||
finally:
|
||||
if owns_session:
|
||||
db.close()
|
||||
|
||||
logger.info("load_grs_capacity done: %s", counts)
|
||||
return counts
|
||||
|
|
@ -301,6 +301,15 @@ def build_beat_schedule() -> dict:
|
|||
"schedule": _parse_cron("30 4 * * tue"), # 04:30 МСК, вторник
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
# Газовая вертикаль (Forgejo #2119 Фаза B1): свободная мощность ГРС «Газпром
|
||||
# трансгаз Екатеринбург» из инлайн HTML-таблицы раскрытия. Источник ГЕО-БЛОКИРУЕТ
|
||||
# non-RU IP → RUN-ON-PROD. 05:00 МСК вторник — через 30 мин после воды (разные
|
||||
# хосты, не бьём одновременно), в том же вторник-кластере вне monday-нагрузки.
|
||||
schedule["gas-grs-capacity-sync-weekly"] = {
|
||||
"task": "tasks.connection_capacity_sync.sync_gas_grs_capacity",
|
||||
"schedule": _parse_cron("0 5 * * tue"), # 05:00 МСК, вторник
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
# #105 Phase 4: ЕКБ РНС/РВЭ — ежемесячно 1-го числа в 05:00 МСК.
|
||||
# Celery conf.timezone=Europe/Moscow → crontab трактуется в МСК (#1233).
|
||||
|
|
|
|||
|
|
@ -93,3 +93,27 @@ def sync_water_reserves() -> dict:
|
|||
logger.exception("sync_water_reserves failed: %s", e)
|
||||
_log_breadcrumb("water_capacity_sync", "error", f"{type(e).__name__}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="tasks.connection_capacity_sync.sync_gas_grs_capacity",
|
||||
queue="celery",
|
||||
)
|
||||
def sync_gas_grs_capacity() -> dict:
|
||||
"""Свободная мощность ГРС «Газпром трансгаз Екатеринбург» (HTML) → gas_grs_capacity.
|
||||
|
||||
RUN-ON-PROD (гео-блок источника). Инлайн HTML-таблица раскрытия ГТЕ → UPSERT
|
||||
свободной мощности ГРС (по выходам). #2119 Фаза B1.
|
||||
"""
|
||||
_log_breadcrumb("gas_grs_sync", "info", "task started")
|
||||
try:
|
||||
from app.services.site_finder.gazprom_grs_loader import load_grs_capacity
|
||||
|
||||
result = load_grs_capacity()
|
||||
logger.info("sync_gas_grs_capacity done: %s", result)
|
||||
_log_breadcrumb("gas_grs_sync", "info", f"done: {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.exception("sync_gas_grs_capacity failed: %s", e)
|
||||
_log_breadcrumb("gas_grs_sync", "error", f"{type(e).__name__}: {e}")
|
||||
raise
|
||||
|
|
|
|||
274
backend/tests/test_gas_grs_loader.py
Normal file
274
backend/tests/test_gas_grs_loader.py
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
"""Unit-тесты газовой вертикали ГРС «Газпром трансгаз Екатеринбург» (#2119 Фаза B1).
|
||||
|
||||
Pure / mock-based — без реальной сети и БД. Покрывает:
|
||||
- normalize_grs_name: ё→е, схлопывание пробелов/дефисов, сохранение «ГРС»-префикса.
|
||||
- parse_grs_table: заголовок-матчинг колонок, rowspan forward-fill имени ГРС на
|
||||
строки-выходы, запятые/«н/д»-числа, multi-выход.
|
||||
- _passes_region_filter: колонка региона → фильтр «свердлов»; нет → всё.
|
||||
- _upsert_grs_rows: SAVEPOINT + счётчики на fake-DB.
|
||||
- _query_gas_city_grs: агрегация по grs_name (SUM/AVG/MAX/COUNT), пустая таблица → [].
|
||||
- _reserve_note: ЕЭСК + reserve_mva IS NULL → пометка; иначе None.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
from app.services.site_finder import connection_capacity_lookup as ccl
|
||||
from app.services.site_finder import gazprom_grs_loader as gg
|
||||
|
||||
# ── normalize_grs_name ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_normalize_grs_yo_to_e() -> None:
|
||||
assert gg.normalize_grs_name("ГРС Берёзовский") == "грс березовский"
|
||||
|
||||
|
||||
def test_normalize_grs_keeps_prefix() -> None:
|
||||
"""Префикс «ГРС»/«АРП» СОХРАНЯЕТСЯ (различает станции)."""
|
||||
assert gg.normalize_grs_name("ГРС Кольцово") == "грс кольцово"
|
||||
assert gg.normalize_grs_name("АРП Северная") == "арп северная"
|
||||
|
||||
|
||||
def test_normalize_grs_dash_and_spaces_collapse() -> None:
|
||||
assert gg.normalize_grs_name("ГРС Северная — 2") == "грс северная-2"
|
||||
assert gg.normalize_grs_name("ГРС Западная") == "грс западная"
|
||||
|
||||
|
||||
def test_normalize_grs_empty() -> None:
|
||||
assert gg.normalize_grs_name(None) == ""
|
||||
assert gg.normalize_grs_name("") == ""
|
||||
|
||||
|
||||
# ── parse_grs_table: заголовок-матчинг + rowspan forward-fill ─────────────────
|
||||
|
||||
|
||||
_SINGLE_GRS_HTML = """
|
||||
<html><body>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Наименование ГРС</th><th>Выход</th><th>Проектная, тыс. м³/ч</th>
|
||||
<th>Фактическая загрузка</th><th>Свободная мощность</th>
|
||||
<th>Свободная, %</th><th>Срок расшивки</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ГРС Свердловская</td><td>выход 1</td><td>100,5</td>
|
||||
<td>60,0</td><td>40,5</td><td>40,3</td><td>2027</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
def test_parse_single_grs_header_matching_and_numbers() -> None:
|
||||
"""Колонки мапятся по заголовкам; запятая-десятичный → float."""
|
||||
recs = gg.parse_grs_table(_SINGLE_GRS_HTML)
|
||||
assert len(recs) == 1
|
||||
r = recs[0]
|
||||
assert r["grs_name"] == "ГРС Свердловская"
|
||||
assert r["output_name"] == "выход 1"
|
||||
assert r["design_capacity_th_m3_h"] == 100.5
|
||||
assert r["current_load_th_m3_h"] == 60.0
|
||||
assert r["free_capacity_th_m3_h"] == 40.5
|
||||
assert r["free_capacity_pct"] == 40.3
|
||||
assert r["upgrade_due"] == "2027"
|
||||
# raw сохраняет заголовки + ячейки (неизвестные колонки не теряются).
|
||||
assert "headers" in r["raw"] and "cells" in r["raw"]
|
||||
|
||||
|
||||
# rowspan имени ГРС: строка-выход 2/3 НЕ содержит ячейки имени (rowspan «съел» её).
|
||||
_MULTI_OUTPUT_HTML = """
|
||||
<html><body>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Наименование</th><th>Выход</th><th>Проектная</th>
|
||||
<th>Загрузка</th><th>Свободная</th><th>%</th><th>Срок</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="3">ГРС Екатеринбург</td><td>выход 1</td><td>50,0</td>
|
||||
<td>30,0</td><td>20,0</td><td>40,0</td><td>2028</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>выход 2</td><td>60,0</td><td>40,0</td><td>20,0</td><td>33,3</td><td>н/д</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>выход 3</td><td>70,0</td><td>70,0</td><td>0,0</td><td>0,0</td><td>после 2030</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
def test_parse_multi_output_rowspan_forward_fill() -> None:
|
||||
"""rowspan имени ГРС → выходы 2/3 наследуют имя; «н/д» → None."""
|
||||
recs = gg.parse_grs_table(_MULTI_OUTPUT_HTML)
|
||||
assert len(recs) == 3
|
||||
assert [r["grs_name"] for r in recs] == ["ГРС Екатеринбург"] * 3
|
||||
assert [r["output_name"] for r in recs] == ["выход 1", "выход 2", "выход 3"]
|
||||
# Числа корректно смещены (offset при rowspan-строках).
|
||||
assert recs[0]["free_capacity_th_m3_h"] == 20.0
|
||||
assert recs[1]["design_capacity_th_m3_h"] == 60.0
|
||||
assert recs[1]["upgrade_due"] is None # «н/д» → None
|
||||
assert recs[2]["free_capacity_th_m3_h"] == 0.0 # 0 при полной загрузке сохраняется
|
||||
assert recs[2]["upgrade_due"] == "после 2030"
|
||||
|
||||
|
||||
def test_parse_no_table_returns_empty() -> None:
|
||||
assert gg.parse_grs_table("<html><body><p>нет таблиц</p></body></html>") == []
|
||||
|
||||
|
||||
def test_parse_unrecognized_name_column_returns_empty() -> None:
|
||||
"""Заголовок без имени ГРС → пустой результат (defensive)."""
|
||||
html = (
|
||||
"<table><tr><th>Колонка А</th><th>Колонка Б</th></tr>"
|
||||
"<tr><td>1</td><td>2</td></tr></table>"
|
||||
)
|
||||
assert gg.parse_grs_table(html) == []
|
||||
|
||||
|
||||
# ── _passes_region_filter ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_region_filter_no_region_keeps_all() -> None:
|
||||
recs = [{"region": None, "grs_name": "ГРС А"}, {"region": None, "grs_name": "ГРС Б"}]
|
||||
assert gg._passes_region_filter(recs) == recs
|
||||
|
||||
|
||||
def test_region_filter_present_keeps_sverdlovsk() -> None:
|
||||
recs = [
|
||||
{"region": "Свердловская область", "grs_name": "ГРС А"},
|
||||
{"region": "Челябинская область", "grs_name": "ГРС Б"},
|
||||
]
|
||||
out = gg._passes_region_filter(recs)
|
||||
assert len(out) == 1
|
||||
assert out[0]["grs_name"] == "ГРС А"
|
||||
|
||||
|
||||
# ── Fake DB session для upsert- и query-тестов ────────────────────────────────
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(self, scalar_value: Any = True, mappings_rows: list[dict] | None = None) -> None:
|
||||
self._scalar = scalar_value
|
||||
self._mappings_rows = mappings_rows or []
|
||||
|
||||
def scalar(self) -> Any:
|
||||
return self._scalar
|
||||
|
||||
def mappings(self) -> _FakeResult:
|
||||
return self
|
||||
|
||||
def all(self) -> list[dict]:
|
||||
return self._mappings_rows
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""Мини-заглушка Session: execute + begin_nested (как в loaders-тестах)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scalar_value: Any = True,
|
||||
mappings_rows: list[dict] | None = None,
|
||||
execute_raises: Exception | None = None,
|
||||
) -> None:
|
||||
self.calls: list[tuple[str, Any]] = []
|
||||
self._scalar = scalar_value
|
||||
self._mappings_rows = mappings_rows
|
||||
self._execute_raises = execute_raises
|
||||
self.committed = False
|
||||
|
||||
@contextmanager
|
||||
def begin_nested(self): # type: ignore[no-untyped-def]
|
||||
yield
|
||||
|
||||
def execute(self, stmt: Any, params: Any = None) -> _FakeResult:
|
||||
self.calls.append((str(stmt), params))
|
||||
if self._execute_raises is not None:
|
||||
raise self._execute_raises
|
||||
return _FakeResult(self._scalar, self._mappings_rows)
|
||||
|
||||
def commit(self) -> None:
|
||||
self.committed = True
|
||||
|
||||
def rollback(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# ── _upsert_grs_rows ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_upsert_grs_rows_counts_and_savepoint() -> None:
|
||||
db = _FakeSession(scalar_value=True)
|
||||
recs = gg.parse_grs_table(_MULTI_OUTPUT_HTML)
|
||||
counts = gg._upsert_grs_rows(db, recs) # type: ignore[arg-type]
|
||||
assert counts == {"rows": 3, "inserted": 3, "updated": 0, "skipped": 0}
|
||||
# grs_name_norm нормализован в params.
|
||||
assert db.calls[0][1]["grs_name_norm"] == "грс екатеринбург"
|
||||
assert db.calls[1][1]["output_name"] == "выход 2"
|
||||
|
||||
|
||||
# ── _query_gas_city_grs: агрегация ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_query_gas_city_grs_aggregation() -> None:
|
||||
"""Агрегированные строки (per grs_name) маппятся в GasGrsRow-словари + total."""
|
||||
rows = [
|
||||
{
|
||||
"grs_name": "ГРС Свердловская",
|
||||
"design_sum": 210.0,
|
||||
"free_sum": 40.5,
|
||||
"pct_avg": 20.25,
|
||||
"upgrade_due": "2028",
|
||||
"outputs_count": 3,
|
||||
},
|
||||
{
|
||||
"grs_name": "ГРС Екатеринбург Северная",
|
||||
"design_sum": 100.0,
|
||||
"free_sum": 10.0,
|
||||
"pct_avg": 10.0,
|
||||
"upgrade_due": None,
|
||||
"outputs_count": 1,
|
||||
},
|
||||
]
|
||||
db = _FakeSession(mappings_rows=rows)
|
||||
out = ccl._query_gas_city_grs(db) # type: ignore[arg-type]
|
||||
assert len(out["city_grs"]) == 2
|
||||
assert out["city_grs"][0]["grs_name"] == "ГРС Свердловская"
|
||||
assert out["city_grs"][0]["free_capacity_th_m3_h"] == 40.5
|
||||
assert out["city_grs"][0]["free_capacity_pct"] == 20.2 # round(20.25, 1)
|
||||
assert out["city_grs"][0]["outputs_count"] == 3
|
||||
assert out["total_free_th_m3_h"] == 50.5 # 40.5 + 10.0
|
||||
|
||||
|
||||
def test_query_gas_city_grs_empty_table() -> None:
|
||||
db = _FakeSession(mappings_rows=[])
|
||||
out = ccl._query_gas_city_grs(db) # type: ignore[arg-type]
|
||||
assert out == {"city_grs": [], "total_free_th_m3_h": None}
|
||||
|
||||
|
||||
def test_query_gas_city_grs_missing_table_degrades() -> None:
|
||||
"""Таблица не существует (миграция 181 не применена) → пустой блок, не падаем."""
|
||||
db = _FakeSession(execute_raises=RuntimeError('relation "gas_grs_capacity" does not exist'))
|
||||
out = ccl._query_gas_city_grs(db) # type: ignore[arg-type]
|
||||
assert out == {"city_grs": [], "total_free_th_m3_h": None}
|
||||
|
||||
|
||||
# ── _reserve_note: пометка ЕЭСК ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_reserve_note_eesk_null_reserve() -> None:
|
||||
note = ccl._reserve_note("АО «ЕЭСК»", None)
|
||||
assert note == ccl._EESK_RESERVE_NOTE
|
||||
assert "ЕЭСК" in note
|
||||
|
||||
|
||||
def test_reserve_note_eesk_with_reserve_none() -> None:
|
||||
"""ЕЭСК с известным резервом → без пометки."""
|
||||
assert ccl._reserve_note("АО «ЕЭСК»", 12.0) is None
|
||||
|
||||
|
||||
def test_reserve_note_other_dzo_none() -> None:
|
||||
"""ПС не-ЕЭСК без резерва → без пометки."""
|
||||
assert ccl._reserve_note("Россети Урал", None) is None
|
||||
assert ccl._reserve_note(None, None) is None
|
||||
75
data/sql/181_gas_grs_capacity.sql
Normal file
75
data/sql/181_gas_grs_capacity.sql
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
-- 181_gas_grs_capacity.sql
|
||||
-- Forgejo #2119 Phase B1 — газовая вертикаль «свободная мощность ГРС» для ПТИЦА §3.
|
||||
--
|
||||
-- Одна таблица gas_grs_capacity: свободная мощность ГРС (газораспределительная
|
||||
-- станция) из раскрытия «Газпром трансгаз Екатеринбург» (ГТЕ):
|
||||
-- https://ekaterinburg-tr.gazprom.ru/about/informatsiya-o-tekhnicheskoj-vozmo/
|
||||
-- — инлайн HTML-таблица «Информация о технической возможности…».
|
||||
--
|
||||
-- Источник ГЕО-БЛОКИРУЕТ non-RU IP → загрузчик РАБОТАЕТ НА ПРОДЕ (Celery / manual
|
||||
-- docker exec) — как rosseti-/vodokanal-лоадеры (180). Код не должен предполагать
|
||||
-- локальный сетевой доступ.
|
||||
--
|
||||
-- У ГРС бывает несколько выходов («выход 1/2/3») — тогда одна ГРС = N строк с
|
||||
-- разным output_name; output_name IS NULL = вся ГРС целиком (одна строка).
|
||||
-- grs_name_norm — нормализованное имя для матча (lower, ё→е, пробелы/дефисы
|
||||
-- схлопнуты; префиксы «ГРС»/«АРП» СОХРАНЯЮТСЯ, т.к. различают станции).
|
||||
--
|
||||
-- Refresh: weekly via tasks.connection_capacity_sync.sync_gas_grs_capacity.
|
||||
-- Порядок применения: миграция ПЕРЕД деплоем кода (schema-first, sql.md).
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── gas_grs_capacity ─────────────────────────────────────────────────────────
|
||||
-- geom NULL до Фазы C (геокод ГРС). raw jsonb — сырые ячейки строки целиком
|
||||
-- (неизвестные/будущие колонки таблицы ГТЕ не теряются, а падают в raw).
|
||||
CREATE TABLE IF NOT EXISTS gas_grs_capacity (
|
||||
id SERIAL PRIMARY KEY,
|
||||
grs_name TEXT NOT NULL, -- исходное имя ГРС
|
||||
grs_name_norm TEXT NOT NULL, -- нормализованное для матча
|
||||
output_name TEXT, -- «выход 1/2/3»; NULL = вся ГРС
|
||||
region TEXT, -- область (Свердловская и др.)
|
||||
design_capacity_th_m3_h NUMERIC, -- проектная, тыс. м³/ч
|
||||
current_load_th_m3_h NUMERIC, -- текущая загрузка, тыс. м³/ч
|
||||
free_capacity_th_m3_h NUMERIC, -- свободная мощность, тыс. м³/ч
|
||||
free_capacity_pct NUMERIC, -- свободная, %
|
||||
upgrade_due TEXT, -- срок расшивки (как в источнике)
|
||||
asof DATE, -- дата актуальности данных
|
||||
geom GEOMETRY(POINT, 4326), -- NULL до Фазы C геокода
|
||||
raw JSONB, -- сырая строка таблицы целиком
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- NULLS NOT DISTINCT: output_name nullable (у ГРС без разбивки по выходам —
|
||||
-- одна строка с output_name IS NULL). Без этого ON CONFLICT не матчит строку
|
||||
-- с NULL-выходом → weekly-прогон плодит дубли (те же грабли, что #140 /
|
||||
-- 180_connection_capacity.sql power_tp_rp_reserves).
|
||||
UNIQUE NULLS NOT DISTINCT (grs_name_norm, output_name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gas_grs_capacity_name_norm
|
||||
ON gas_grs_capacity (grs_name_norm);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS gist_gas_grs_capacity_geom
|
||||
ON gas_grs_capacity USING GIST (geom);
|
||||
|
||||
COMMENT ON TABLE gas_grs_capacity IS
|
||||
'Свободная мощность ГРС (газораспределительных станций) из раскрытия «Газпром '
|
||||
'трансгаз Екатеринбург». Один output_name на строку («выход 1/2/3»; NULL = вся '
|
||||
'ГРС). geom NULL до Фазы C геокода. Refresh: weekly via '
|
||||
'tasks.connection_capacity_sync.sync_gas_grs_capacity. Forgejo #2119 Phase B1.';
|
||||
|
||||
COMMENT ON COLUMN gas_grs_capacity.grs_name_norm IS
|
||||
'Нормализованное имя ГРС для матча/дедупа (lower, ё→е, пробелы/дефисы схлопнуты; '
|
||||
'префиксы «ГРС»/«АРП» СОХРАНЯЮТСЯ — различают станции).';
|
||||
|
||||
COMMENT ON COLUMN gas_grs_capacity.output_name IS
|
||||
'Выход ГРС («выход 1/2/3»). NULL = вся ГРС целиком (нет разбивки по выходам).';
|
||||
|
||||
COMMENT ON COLUMN gas_grs_capacity.free_capacity_th_m3_h IS
|
||||
'Свободная (резервная) мощность ГРС, тыс. м³/ч. Может быть 0 при полной загрузке.';
|
||||
|
||||
COMMENT ON COLUMN gas_grs_capacity.upgrade_due IS
|
||||
'Срок расшивки (планового увеличения пропускной способности), как в источнике '
|
||||
'(свободный текст: год / «после 2027» / пусто).';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -124,6 +124,11 @@ function PowerPopup({ point }: { point: PowerConnectionPoint }) {
|
|||
{badge.label}
|
||||
</span>
|
||||
)}
|
||||
{point.reserve_note && (
|
||||
<div style={{ marginTop: 4, fontSize: 11, color: "#6b7280" }}>
|
||||
{point.reserve_note}
|
||||
</div>
|
||||
)}
|
||||
{distance && (
|
||||
<div style={{ marginTop: 4, color: "#374151" }}>
|
||||
До участка: {distance}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, color: "var(--fg-primary)" }}>
|
||||
<strong>{row.grs_name}</strong>: свободно{" "}
|
||||
{freeStr ? (
|
||||
<strong
|
||||
style={{
|
||||
color: isDepleted ? "var(--danger)" : "var(--fg-primary)",
|
||||
}}
|
||||
>
|
||||
{freeStr}
|
||||
</strong>
|
||||
) : (
|
||||
<span style={{ color: "var(--fg-tertiary)" }}>—</span>
|
||||
)}
|
||||
{designStr ? ` из ${designStr}` : ""}
|
||||
{row.outputs_count > 1 && (
|
||||
<span style={{ color: "var(--fg-tertiary)" }}>
|
||||
{" "}
|
||||
({row.outputs_count} выхода)
|
||||
</span>
|
||||
)}
|
||||
{upgrade && (
|
||||
<span style={{ color: "var(--fg-tertiary)" }}>
|
||||
{" "}
|
||||
· расшивка: {upgrade}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<StatusBadge label={badge.label} bg={badge.bg} fg={badge.fg} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GasBlock({ gas }: { gas: GasCapacityBlock | null | undefined }) {
|
||||
if (!gas || gas.city_grs.length === 0) {
|
||||
return (
|
||||
<div style={{ fontSize: 13, color: "var(--fg-secondary)" }}>
|
||||
Данные о загрузке ГРС загружаются.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalNum = toFiniteNumber(gas.total_free_th_m3_h);
|
||||
const totalStr = formatThousandM3Hour(gas.total_free_th_m3_h);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{gas.city_grs.map((row, i) => (
|
||||
<GasGrsLine key={`grs-${row.grs_name}-${i}`} row={row} />
|
||||
))}
|
||||
|
||||
{totalNum !== null && totalStr && (
|
||||
<span style={{ fontSize: 13, color: "var(--fg-primary)" }}>
|
||||
Суммарно по городским ГРС: <strong>{totalStr}</strong>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span style={{ fontSize: 12, color: "var(--fg-tertiary)" }}>
|
||||
Газпром трансгаз Екатеринбург · техвозможность ГРС
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Card ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
const SUB_HEADER: React.CSSProperties = {
|
||||
|
|
@ -318,6 +408,15 @@ export function ResourceReservesCard({ data }: Props) {
|
|||
<PowerRow data={data} />
|
||||
</div>
|
||||
|
||||
{/* Газ */}
|
||||
<div>
|
||||
<div style={SUB_HEADER}>
|
||||
<Flame size={14} strokeWidth={1.5} aria-hidden="true" />
|
||||
Газ
|
||||
</div>
|
||||
<GasBlock gas={data.gas} />
|
||||
</div>
|
||||
|
||||
{/* Вода / канализация */}
|
||||
<div>
|
||||
<div style={SUB_HEADER}>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})} тыс. м³/ч`;
|
||||
}
|
||||
|
||||
// ── Вода: выбор «городской» системы + группировка ──────────────────────────────
|
||||
//
|
||||
// Городская система ЦСВ/ЦСК выделяется в карточке отдельной строкой: сначала по
|
||||
|
|
|
|||
|
|
@ -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 км —
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue