diff --git a/backend/app/services/site_finder/gazprom_grs_loader.py b/backend/app/services/site_finder/gazprom_grs_loader.py
index 1b721f00..cab71d35 100644
--- a/backend/app/services/site_finder/gazprom_grs_loader.py
+++ b/backend/app/services/site_finder/gazprom_grs_loader.py
@@ -1,25 +1,40 @@
"""Загрузчик свободной мощности ГРС «Газпром трансгаз Екатеринбург» (#2119 B1).
No-B2B верифицированный источник: страница раскрытия ГТЕ содержит ИНЛАЙН
-HTML-таблицу «Информация о технической возможности…» (~316 строк) со свободной
-мощностью газораспределительных станций (ГРС) — проектная / фактическая загрузка /
-свободная мощность (тыс. м³/ч) + срок расшивки. UPSERT-ит в ``gas_grs_capacity``.
+HTML-таблицу «Информация о наличии (отсутствии) технической возможности…»
+(~350 строк) со свободной мощностью газораспределительных станций (ГРС) —
+проектная / текущая загрузка / свободная мощность (тыс. м³/ч) + уведомление о
+расшивке. 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.
+Парсинг под РЕАЛЬНУЮ структуру таблицы ГТЕ (снята с прода 2026-07-02):
+
+ * caption-строка (1 ячейка) → скип;
+ * «по состоянию на DD.MM.YYYY» в первых строках → asof;
+ * строка-заголовок = ячейки содержат «наименование» И «проектн» (НЕ caption);
+ * ниже идут суб-заголовки + строка нумерации «0..7» → скипаются как не-данные;
+ * single-cell строка = регион-разделитель («СВЕРДЛОВСКАЯ ОБЛАСТЬ») → задаёт
+ ``current_region``; данные пишутся ТОЛЬКО пока текущий регион — Свердловский;
+ * FULL-строка (len>=10): idx 2=имя, 3=проектная, 4=загрузка, 5=объём по ТУ,
+ 6=свободная, 7=свободная %, 8+=уведомление (основание/срок/параметры);
+ * CONTINUATION-строка (5<=len<=9, доп. выход): сдвиг на 1 — idx 0=имя (с выходом),
+ 1=проектная, 2=загрузка, 3=объём ТУ, 4=свободная, 5=%, 6=уведомление;
+ * имя с суффиксом «(выход №N)» → output_name отдельно; суффикс-число «(5)» —
+ НЕ выход, остаётся в имени.
+
+Числа — через ОБЩИЙ ``parse_reserve_number`` из rosseti_reserve_loader (запятая,
+«—» → None). Объём по ТУ хранится ТОЛЬКО в ``raw`` (без миграции колонки).
+Полная строка целиком → ``raw`` jsonb (неизвестные/будущие колонки не теряются).
"""
import json
import logging
import re
import ssl
+from datetime import date
from functools import lru_cache
from pathlib import Path
@@ -29,7 +44,10 @@ 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
+from app.services.site_finder.rosseti_reserve_loader import (
+ parse_asof_date,
+ parse_reserve_number,
+)
logger = logging.getLogger(__name__)
@@ -63,22 +81,48 @@ _NODATA_TEXT_TOKENS = frozenset(
{"", "н/д", "нд", "n/a", "na", "-", "—", "–", "нет данных", "х", "x"}
)
-# Заголовок-матчинг: подстрока в тексте
/первой строки → логическая колонка.
-# Порядок важен — «свободн» проверяем раньше «загрузк», а «%» отдельным ключом.
-# Каждая запись: (набор подстрок-триггеров, ключ). Первый матч на колонку выигрывает.
-_HEADER_PATTERNS: list[tuple[tuple[str, ...], str]] = [
- (("выход",), "output_name"),
- (("наименование", "грс", "станц"), "grs_name"),
- (("проектн",), "design"),
- (("свободн", "резерв"), "free"),
- (("загрузк", "фактич", "текущ"), "load"),
- (("срок", "расшивк", "мероприят"), "upgrade_due"),
-]
+# Строка-заголовок таблицы ГТЕ: ячейки содержат «наименование» И «проектн».
+# Отличает РЕАЛЬНЫЙ header от caption-строки («Информация о наличии…») и от
+# суб-заголовков / строки нумерации.
+_HEADER_MARKERS = ("наименование", "проектн")
-# Region-колонка (если ГТЕ добавит регион в таблицу) → фильтр Свердловской области.
-_REGION_HEADER_TOKENS = ("регион", "область", "субъект")
+# Свердловская область — единственный регион, который грузим (ГТЕ отдаёт и
+# сопредельные: Курганская, Челябинская и т.д. — они отсекаются region-фильтром).
_SVERDLOVSK_TOKEN = "свердлов"
+# Суффикс «(выход №N)» в имени ГРС → отдельный output_name. «(5)» (только цифры) —
+# НЕ выход (это доп. нумерация станции), остаётся в имени.
+_OUTPUT_SUFFIX_RE = re.compile(r"\s*\((выход\s*№?\s*\d+)\)\s*$", re.IGNORECASE)
+
+# FULL-строка: idx имени=2, проектная=3, загрузка=4, объём ТУ=5, свободная=6,
+# свободная %=7, уведомление(основание/срок/параметры)=8,9,10.
+_FULL_IDX = {
+ "grs_name": 2,
+ "design": 3,
+ "load": 4,
+ "volume_tu": 5,
+ "free": 6,
+ "pct": 7,
+ "notice": (8, 9, 10),
+}
+# CONTINUATION-строка (доп. выход, сдвиг на -1): имя=0, проектная=1, загрузка=2,
+# объём ТУ=3, свободная=4, %=5, уведомление=6+.
+_CONT_IDX = {
+ "grs_name": 0,
+ "design": 1,
+ "load": 2,
+ "volume_tu": 3,
+ "free": 4,
+ "pct": 5,
+ "notice": (6, 7, 8),
+}
+
+# Границы длины строки: full = данные с полным набором столбцов; continuation =
+# усечённый доп. выход. Иначе строка не-данные (шапка/суб/нумерация) → скип.
+_FULL_MIN_LEN = 10
+_CONT_MIN_LEN = 5
+_CONT_MAX_LEN = 9
+
def normalize_grs_name(name: str | None) -> str:
"""Нормализует имя ГРС для матча/дедупа.
@@ -102,35 +146,41 @@ def normalize_grs_name(name: str | None) -> str:
return s
-def _clean_text(value: object | None) -> str | None:
- """Текст ячейки → strip + collapse пробелов. Пусто / «н/д»-токен → None."""
+def _parse_pct(value: str | None) -> float | None:
+ """Процент свободной мощности «(96 %)» → 96.0. «(—)»/пусто → 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:
+ stripped = value.strip("()%\xa0 ").strip()
+ if not stripped or stripped in _NODATA_TEXT_TOKENS:
return None
- return s
+ return parse_reserve_number(stripped)
-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 _split_grs_name(raw_name: str) -> tuple[str, str | None]:
+ """Имя ячейки → (grs_name без выхода, output_name|None).
+
+ «ГРС Арти (выход №2)» → («ГРС Арти», «выход №2»); «ГРС Арамиль (5)» → без
+ изменений (цифра в скобках — НЕ выход, часть имени).
+ """
+ m = _OUTPUT_SUFFIX_RE.search(raw_name)
+ if not m:
+ return raw_name, None
+ output = _MULTISPACE_RE.sub(" ", m.group(1)).strip()
+ grs_name = raw_name[: m.start()].strip()
+ return grs_name, output
-def _is_pct_header(header_text: str) -> bool:
- """Колонка про проценты свободной мощности?"""
- low = header_text.lower()
- return "%" in low or "процент" in low
+def _is_header_row(texts: list[str]) -> bool:
+ """Строка — РЕАЛЬНЫЙ заголовок (ячейки содержат «наименование» И «проектн»)?
-
-def _is_region_header(header_text: str) -> bool:
- """Колонка про регион/область (для Свердловск-фильтра)?"""
- low = header_text.lower()
- return any(tok in low for tok in _REGION_HEADER_TOKENS)
+ Отличает header от caption («Информация о наличии…») и суб-заголовков.
+ """
+ joined = " ".join(texts).lower()
+ return all(marker in joined for marker in _HEADER_MARKERS)
def _extract_table(html: str) -> object | None:
@@ -148,47 +198,76 @@ def _row_cells(tr: object) -> list[object]:
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.
+def _row_texts(cells: list[object]) -> list[str]:
+ """Ячейки строки → список очищенных (collapse пробелов, \\xa0→пробел) текстов."""
+ out: list[str] = []
+ for cell in cells:
+ raw = cell.get_text(" ").replace("\xa0", " ") # type: ignore[union-attr]
+ out.append(_MULTISPACE_RE.sub(" ", raw).strip())
+ return out
- Возвращает (mapping, headers): mapping только для распознанных колонок
- (grs_name/output_name/design/load/free/pct/upgrade_due/region); headers — сырые
- тексты ВСЕХ колонок (для raw + region-детекта). «%»-колонка → ключ 'pct'.
+
+def _cell_at(texts: list[str], idx: int) -> str | None:
+ """Текст ячейки по индексу → None если пусто / «—»-токен / за границей."""
+ if idx >= len(texts):
+ return None
+ s = texts[idx].strip()
+ if not s or s.lower() in _NODATA_TEXT_TOKENS:
+ return None
+ return s
+
+
+def _build_upgrade_due(texts: list[str], notice_idxs: tuple[int, ...]) -> str | None:
+ """Ячейки уведомления (Основание/Срок/Параметры) → « · »-join непустых.
+
+ Все «—»/пусто → None (нет уведомления о расшивке).
"""
- 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
+ parts = [t for i in notice_idxs if (t := _cell_at(texts, i)) is not None]
+ return " · ".join(parts) if parts else None
+
+
+def _build_record(
+ texts: list[str], idx: dict, current_region: str, asof: date | None
+) -> dict | None:
+ """Строит запись ГРС из строки данных по карте индексов (FULL или CONTINUATION).
+
+ Имя обязательно — иначе None (не-данные). Объём по ТУ уходит ТОЛЬКО в raw.
+ """
+ raw_name = _cell_at(texts, idx["grs_name"])
+ if not raw_name:
+ return None
+ grs_name, output_name = _split_grs_name(raw_name)
+ return {
+ "grs_name": grs_name,
+ "output_name": output_name,
+ "region": current_region,
+ "design_capacity_th_m3_h": parse_reserve_number(_cell_at(texts, idx["design"])),
+ "current_load_th_m3_h": parse_reserve_number(_cell_at(texts, idx["load"])),
+ "free_capacity_th_m3_h": parse_reserve_number(_cell_at(texts, idx["free"])),
+ "free_capacity_pct": _parse_pct(_cell_at(texts, idx["pct"])),
+ "upgrade_due": _build_upgrade_due(texts, idx["notice"]),
+ "asof": asof,
+ # Объём газа по ТУ — БЕЗ отдельной колонки в схеме → сохраняем в raw.
+ "raw": {"cells": texts, "volume_by_tu": _cell_at(texts, idx["volume_tu"])},
+ }
def parse_grs_table(html: str) -> list[dict]:
"""Парсит инлайн HTML-таблицу ГТЕ → список записей ГРС (по выходам).
- DEFENSIVE:
+ Под РЕАЛЬНУЮ структуру таблицы (см. модульный docstring):
- основная = с макс. числом строк;
- - первая строка = заголовок, колонки мапятся ПО ЗАГОЛОВКАМ (contains);
- - rowspan имени ГРС → forward-fill на строки-выходы (несколько выходов у ГРС).
- ДОПУЩЕНИЕ: offset-коррекция работает только когда колонка имени — ПЕРВАЯ
- (в текущей таблице ГТЕ так); rowspan не-первой колонки даст пропуск строк
- (не порчу данных — безымянные строки скипаются, raw сохраняется);
- - числа через parse_reserve_number (запятая, «н/д», знак);
- - неизвестные колонки → raw (не теряются).
+ - asof («по состоянию на DD.MM.YYYY») ищется в первых 4 строках;
+ - строка-заголовок детектится по маркерам «наименование» + «проектн»; всё до
+ неё (caption / asof) и суб-заголовки/нумерация сразу после — скипаются;
+ - single-cell строка = регион-разделитель → current_region (title-case);
+ - данные пишутся ТОЛЬКО когда current_region содержит «свердлов»;
+ - FULL-строка (len>=10) и CONTINUATION-строка (5<=len<=9) → фиксированные
+ индексы; иначе строка не-данные → скип с warning-логом.
Каждая запись:
{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 и нет своего имени) → строка пропускается.
+ free_capacity_th_m3_h, free_capacity_pct, upgrade_due, asof, raw}.
"""
table = _extract_table(html)
if table is None:
@@ -199,81 +278,67 @@ def parse_grs_table(html: str) -> list[dict]:
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)
+ row_texts = [_row_texts(_row_cells(tr)) for tr in rows]
+
+ # asof: «по состоянию на DD.MM.YYYY» в первых 4 строках (caption/asof-строка).
+ asof = parse_asof_date(*(" ".join(t) for t in row_texts[:4]))
+
+ # Находим строку-заголовок (маркеры «наименование» + «проектн»). Данные — ниже.
+ header_idx: int | None = None
+ for i, texts in enumerate(row_texts):
+ if _is_header_row(texts):
+ header_idx = i
+ break
+ if header_idx is None:
+ logger.warning("gazprom_grs: строка-заголовок не найдена (маркеры %s)", _HEADER_MARKERS)
return []
records: list[dict] = []
- # forward-fill имени ГРС по rowspan: если ячейка имени отсутствует в строке
- # (schrunk из-за rowspan предыдущей), наследуем последнее известное имя.
- last_grs_name: str | None = None
+ current_region: str | None = None
+ in_sverdlovsk = False
- for tr in rows[1:]:
- cells = _row_cells(tr)
- if not cells:
+ for texts in row_texts[header_idx + 1 :]:
+ n = len(texts)
+ if n == 0 or not any(t for t in texts):
+ continue # пустая строка → скип
+
+ if n == 1:
+ # Single-cell → регион-разделитель. title-case, обновляем контекст.
+ region = _cell_at(texts, 0)
+ if region:
+ current_region = region.title()
+ in_sverdlovsk = _SVERDLOVSK_TOKEN in region.lower()
continue
- # Матчим ячейки к логическим ключам по позиции колонки-заголовка. При
- # rowspan имени в строке-выходе ячейки имени НЕТ → индексы сдвигаются; мы
- # определяем это по числу ячеек < числу заголовков и forward-fill'им имя.
- values: dict[str, str | None] = {}
- raw_cells: list[str] = []
+ if not in_sverdlovsk:
+ continue # регион не Свердловский → строку не пишем
- 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]
+ if n >= _FULL_MIN_LEN:
+ idx_map = _FULL_IDX
+ elif _CONT_MIN_LEN <= n <= _CONT_MAX_LEN:
+ idx_map = _CONT_IDX
else:
- grs_name = last_grs_name # forward-fill (rowspan имени ГРС)
- if not grs_name:
- continue # шапка/итоговая строка без имени и без forward-fill
+ logger.warning("gazprom_grs: строка неожиданной длины n=%d → скип: %s", n, texts)
+ continue
- 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)
+ rec = _build_record(texts, idx_map, current_region or "", asof)
+ if rec is not None:
+ records.append(rec)
- logger.info("gazprom_grs: распознано записей ГРС=%d", len(records))
+ logger.info("gazprom_grs: распознано записей ГРС=%d (asof=%s)", len(records), asof)
return records
def _passes_region_filter(records: list[dict]) -> list[dict]:
- """Если хоть у одной записи есть region — фильтруем по «свердлов»; иначе всё.
+ """Фильтр Свердловской области по полю region записи.
- В таблице ГТЕ обычно НЕТ колонки региона (весь ГТЕ = Свердловская + сопред.
- области) → грузим всё с region=NULL. Если ГТЕ добавит регион — оставляем только
- свердловские строки.
+ Основной регион-фильтр УЖЕ применён в parse_grs_table (по current_region-
+ разделителям). Эта функция — второй барьер на случай записей с чужим/пустым
+ region (совместимость с прежним пайплайном load_grs_capacity). Если region есть
+ хоть у одной — оставляем только «свердлов»; иначе всё.
"""
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 [r for r in records if _SVERDLOVSK_TOKEN in (r.get("region") or "").lower()]
return records
@@ -307,6 +372,7 @@ def _upsert_grs_rows(db: Session, records: list[dict]) -> dict[str, int]:
"free": rec["free_capacity_th_m3_h"],
"pct": rec["free_capacity_pct"],
"upgrade_due": rec["upgrade_due"],
+ "asof": rec.get("asof"),
"raw": json.dumps(rec["raw"], ensure_ascii=False),
}
try:
@@ -317,11 +383,11 @@ def _upsert_grs_rows(db: Session, records: list[dict]) -> dict[str, int]:
(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)
+ upgrade_due, asof, raw, fetched_at)
VALUES (
:grs_name, :grs_name_norm, :output_name, :region,
:design, :load, :free, :pct,
- :upgrade_due, CAST(:raw AS jsonb), NOW()
+ :upgrade_due, :asof, CAST(:raw AS jsonb), NOW()
)
ON CONFLICT (grs_name_norm, output_name) DO UPDATE
SET grs_name = EXCLUDED.grs_name,
@@ -331,6 +397,7 @@ def _upsert_grs_rows(db: Session, records: list[dict]) -> dict[str, int]:
free_capacity_th_m3_h = EXCLUDED.free_capacity_th_m3_h,
free_capacity_pct = EXCLUDED.free_capacity_pct,
upgrade_due = EXCLUDED.upgrade_due,
+ asof = EXCLUDED.asof,
raw = EXCLUDED.raw,
fetched_at = NOW()
RETURNING (xmax = 0) AS is_insert
diff --git a/backend/tests/test_gas_grs_loader.py b/backend/tests/test_gas_grs_loader.py
index 195dedfb..f938cc51 100644
--- a/backend/tests/test_gas_grs_loader.py
+++ b/backend/tests/test_gas_grs_loader.py
@@ -13,6 +13,7 @@ Pure / mock-based — без реальной сети и БД. Покрывае
from __future__ import annotations
from contextlib import contextmanager
+from datetime import date
from typing import Any
from app.services.site_finder import connection_capacity_lookup as ccl
@@ -41,85 +42,130 @@ def test_normalize_grs_empty() -> None:
assert gg.normalize_grs_name("") == ""
-# ── parse_grs_table: заголовок-матчинг + rowspan forward-fill ─────────────────
+# ── parse_grs_table: РЕАЛЬНАЯ структура таблицы ГТЕ ───────────────────────────
+#
+# Структура (снята с прода 2026-07-02):
+# row0: caption (1 ячейка);
+# row1: «по состоянию на DD.MM.YYYY» (asof);
+# row2: РЕАЛЬНЫЕ заголовки (маркеры «Наименование» + «Проектная»);
+# row3: суб-заголовки (Основание/Срок/Параметры) — n=3, скип;
+# row4: строка нумерации «0..7» — n=8, скип (регион ещё не Свердловский);
+# регион-разделители (1 ячейка) → current_region;
+# FULL-строка (n>=10): idx 2=имя … 6=свободная, 7=%, 8+=уведомление;
+# CONTINUATION-строка (n=7, доп. выход): idx 0=имя(с выходом) … 4=свободная, 5=%.
-
-_SINGLE_GRS_HTML = """
+_GTE_HTML = """
+ | Информация о наличии (отсутствии) технической возможности… |
+ | по состоянию на 01.06.2026 | |
- | Наименование ГРС | Выход | Проектная, тыс. м³/ч |
- Фактическая загрузка | Свободная мощность |
- Свободная, % | Срок расшивки |
+ № п/п | Субъект Российской Федерации |
+ Наименование газораспределительной станции |
+ Проектная мощность (производительность) |
+ Загрузка газораспределительной станции |
+ Суммарный объём газа по действующим ТУ |
+ Наличие (дефицит) пропускной способности |
+ Наличие (дефицит) пропускной способности, % |
+ Наличие уведомления ГРО |
+
+ | Основание | Срок мероприятий | Параметры увеличения |
+
+ | 0 | 1 | 2 | 3 | 4 |
+ 5 | 6 | 7 |
+
+
+ | СВЕРДЛОВСКАЯ ОБЛАСТЬ |
+
+ | 114 | Свердловская область | ГРС Арамиль (5) |
+ 50,000 | 31,350 | 17,445 | 1,205 | (2 %) |
+ — | — | — | — | — |
- | ГРС Свердловская | выход 1 | 100,5 |
- 60,0 | 40,5 | 40,3 | 2027 |
+ 115 | Свердловская область | ГРС Арти (выход №1) |
+ 44,000 | 2,000 | 1,800 | 42,200 | (96 %) |
+ ТУ №77 | 2028 | увеличение до 60 | — | — |
+
+
+ | ГРС Арти (выход №2) | 22,000 | — | — |
+ — | — | — |
+
+
+ | КУРГАНСКАЯ ОБЛАСТЬ |
+
+ | 200 | Курганская область | ГРС Кетово |
+ 30,000 | 10,000 | 5,000 | 20,000 | (66 %) |
+ — | — | — | — | — |
"""
-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"]
+def test_parse_asof_extracted() -> None:
+ """«по состоянию на 01.06.2026» из первых строк → asof на каждой записи."""
+ recs = gg.parse_grs_table(_GTE_HTML)
+ assert recs, "ожидались записи по Свердловской области"
+ assert all(r["asof"] == date(2026, 6, 1) for r in recs)
-# rowspan имени ГРС: строка-выход 2/3 НЕ содержит ячейки имени (rowspan «съел» её).
-_MULTI_OUTPUT_HTML = """
-
-
-
- | Наименование | Выход | Проектная |
- Загрузка | Свободная | % | Срок |
-
-
- | ГРС Екатеринбург | выход 1 | 50,0 |
- 30,0 | 20,0 | 40,0 | 2028 |
-
-
- | выход 2 | 60,0 | 40,0 | 20,0 | 33,3 | н/д |
-
-
- | выход 3 | 70,0 | 70,0 | 0,0 | 0,0 | после 2030 |
-
-
-
-"""
+def test_parse_region_filter_kurgan_excluded() -> None:
+ """Регион-разделитель «КУРГАНСКАЯ ОБЛАСТЬ» отсекает не-свердловские строки."""
+ recs = gg.parse_grs_table(_GTE_HTML)
+ names = [r["grs_name"] for r in recs]
+ assert "ГРС Кетово" not in names # Курганская отсечена регион-гейтом
+ assert all(r["region"] == "Свердловская Область" for r in recs) # title-case
-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_full_row_mapping_and_pct() -> None:
+ """FULL-строка (n>=10): idx-маппинг + процент «(2 %)» → 2.0; объём ТУ → raw."""
+ recs = gg.parse_grs_table(_GTE_HTML)
+ aramil = next(r for r in recs if r["grs_name"] == "ГРС Арамиль (5)")
+ # «(5)» — НЕ выход, остаётся в имени; output_name пуст.
+ assert aramil["output_name"] is None
+ assert aramil["design_capacity_th_m3_h"] == 50.0 # idx3
+ assert aramil["current_load_th_m3_h"] == 31.35 # idx4
+ assert aramil["free_capacity_th_m3_h"] == 1.205 # idx6 (свободная)
+ assert aramil["free_capacity_pct"] == 2.0 # idx7 «(2 %)» → 2.0
+ assert aramil["upgrade_due"] is None # все ячейки уведомления «—»
+ # Объём газа по ТУ (idx5) — ТОЛЬКО в raw, без колонки в схеме.
+ assert aramil["raw"]["volume_by_tu"] == "17,445"
+ assert "cells" in aramil["raw"]
+
+
+def test_parse_output_suffix_extracted() -> None:
+ """«(выход №1)»/«(выход №2)» → output_name отдельно, grs_name без суффикса."""
+ recs = gg.parse_grs_table(_GTE_HTML)
+ arti = [r for r in recs if r["grs_name"] == "ГРС Арти"]
+ assert len(arti) == 2 # FULL «выход №1» + CONTINUATION «выход №2»
+ assert {r["output_name"] for r in arti} == {"выход №1", "выход №2"}
+
+
+def test_parse_continuation_row_shifted_and_pct96() -> None:
+ """CONTINUATION-строка (n=7): сдвиг индексов; «(96 %)» из FULL «выход №1»."""
+ recs = gg.parse_grs_table(_GTE_HTML)
+ out1 = next(r for r in recs if r["output_name"] == "выход №1")
+ assert out1["free_capacity_pct"] == 96.0 # «(96 %)» → 96.0
+ assert out1["design_capacity_th_m3_h"] == 44.0
+ # Уведомление собрано join(« · ») из непустых Основание/Срок/Параметры.
+ assert out1["upgrade_due"] == "ТУ №77 · 2028 · увеличение до 60"
+
+ out2 = next(r for r in recs if r["output_name"] == "выход №2")
+ # CONTINUATION: имя=idx0, проектная=idx1 → 22.0; остальное «—» → None.
+ assert out2["grs_name"] == "ГРС Арти"
+ assert out2["design_capacity_th_m3_h"] == 22.0
+ assert out2["current_load_th_m3_h"] is None # «—» → None
+ assert out2["free_capacity_th_m3_h"] is None
+ assert out2["free_capacity_pct"] is None
+ assert out2["upgrade_due"] is None
def test_parse_no_table_returns_empty() -> None:
assert gg.parse_grs_table("нет таблиц ") == []
-def test_parse_unrecognized_name_column_returns_empty() -> None:
- """Заголовок без имени ГРС → пустой результат (defensive)."""
+def test_parse_no_header_row_returns_empty() -> None:
+ """Нет строки-заголовка (маркеры «наименование»+«проектн») → пусто (defensive)."""
html = (
""
@@ -200,12 +246,15 @@ class _FakeSession:
def test_upsert_grs_rows_counts_and_savepoint() -> None:
db = _FakeSession(scalar_value=True)
- recs = gg.parse_grs_table(_MULTI_OUTPUT_HTML)
+ recs = gg.parse_grs_table(_GTE_HTML)
+ assert len(recs) == 3 # Свердловская: Арамиль + Арти(вых1) + Арти(вых2)
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"
+ # grs_name_norm нормализован в params; asof проброшен в upsert.
+ assert db.calls[0][1]["grs_name_norm"] == "грс арамиль (5)"
+ assert db.calls[0][1]["asof"] == date(2026, 6, 1)
+ # «(выход №N)» ушёл в отдельный output_name (не в grs_name_norm).
+ assert {c[1]["output_name"] for c in db.calls} == {None, "выход №1", "выход №2"}
# ── _query_gas_city_grs: агрегация ────────────────────────────────────────────
|