fix(site-finder): ГРС-парсер переписан под реальную структуру таблицы ГТЕ #2145

Merged
bot-backend merged 1 commit from fix/grs-parser-real-table into main 2026-07-02 14:45:43 +00:00
2 changed files with 310 additions and 194 deletions

View file

@ -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"}
)
# Заголовок-матчинг: подстрока в тексте <th>/первой строки → логическая колонка.
# Порядок важен — «свободн» проверяем раньше «загрузк», а «%» отдельным ключом.
# Каждая запись: (набор подстрок-триггеров, ключ). Первый матч на колонку выигрывает.
_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):
- основная <table> = с макс. числом строк;
- первая строка = заголовок, колонки мапятся ПО ЗАГОЛОВКАМ (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

View file

@ -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 = """
<html><body>
<table>
<tr><td>Информация о наличии (отсутствии) технической возможности</td></tr>
<tr><td></td><td>по состоянию на 01.06.2026</td><td></td></tr>
<tr>
<th>Наименование ГРС</th><th>Выход</th><th>Проектная, тыс. м³/ч</th>
<th>Фактическая загрузка</th><th>Свободная мощность</th>
<th>Свободная, %</th><th>Срок расшивки</th>
<th> п/п</th><th>Субъект Российской Федерации</th>
<th>Наименование газораспределительной станции</th>
<th>Проектная мощность (производительность)</th>
<th>Загрузка газораспределительной станции</th>
<th>Суммарный объём газа по действующим ТУ</th>
<th>Наличие (дефицит) пропускной способности</th>
<th>Наличие (дефицит) пропускной способности, %</th>
<th>Наличие уведомления ГРО</th>
</tr>
<tr><th>Основание</th><th>Срок мероприятий</th><th>Параметры увеличения</th></tr>
<tr>
<td>0</td><td>1</td><td>2</td><td>3</td><td>4</td>
<td>5</td><td>6</td><td>7</td>
</tr>
<tr><td>СВЕРДЛОВСКАЯ ОБЛАСТЬ</td></tr>
<tr>
<td>114</td><td>Свердловская область</td><td>ГРС Арамиль (5)</td>
<td>50,000</td><td>31,350</td><td>17,445</td><td>1,205</td><td>(2 %)</td>
<td></td><td></td><td></td><td></td><td></td>
</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>
<td>115</td><td>Свердловская область</td><td>ГРС Арти (выход 1)</td>
<td>44,000</td><td>2,000</td><td>1,800</td><td>42,200</td><td>(96 %)</td>
<td>ТУ 77</td><td>2028</td><td>увеличение до 60</td><td></td><td></td>
</tr>
<tr>
<td>ГРС Арти (выход 2)</td><td>22,000</td><td></td><td></td>
<td></td><td></td><td></td>
</tr>
<tr><td>КУРГАНСКАЯ ОБЛАСТЬ</td></tr>
<tr>
<td>200</td><td>Курганская область</td><td>ГРС Кетово</td>
<td>30,000</td><td>10,000</td><td>5,000</td><td>20,000</td><td>(66 %)</td>
<td></td><td></td><td></td><td></td><td></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"]
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 = """
<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_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("<html><body><p>нет таблиц</p></body></html>") == []
def test_parse_unrecognized_name_column_returns_empty() -> None:
"""Заголовок без имени ГРС → пустой результат (defensive)."""
def test_parse_no_header_row_returns_empty() -> None:
"""Нет строки-заголовка (маркеры «наименование»+«проектн») → пусто (defensive)."""
html = (
"<table><tr><th>Колонка А</th><th>Колонка Б</th></tr>"
"<tr><td>1</td><td>2</td></tr></table>"
@ -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: агрегация ────────────────────────────────────────────