feat(site-finder): газ — свободная мощность ГРС + пометка ЕЭСК в §3 (#2119 Фаза B1) (#2123)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 2m2s
Deploy / build-worker (push) Successful in 3m17s
Deploy / build-frontend (push) Successful in 3m57s
Deploy / deploy (push) Successful in 1m31s

This commit is contained in:
bot-backend 2026-07-02 12:54:05 +00:00
parent 2bb3ceb9de
commit 5839680998
13 changed files with 1004 additions and 3 deletions

View file

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

View file

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

View file

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

View 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

View file

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

View file

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

View 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

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

View file

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

View file

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

View file

@ -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,
})} тыс. м³/ч`;
}
// ── Вода: выбор «городской» системы + группировка ──────────────────────────────
//
// Городская система ЦСВ/ЦСК выделяется в карточке отдельной строкой: сначала по

View file

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

View file

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