fix(site-finder): ГРС-парсер переписан под реальную структуру таблицы ГТЕ
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m55s
CI / backend-tests (pull_request) Successful in 14m24s

3-я итерация: старый header-per-column парсер давал rows=0. Реальная структура
(снята с прода): caption → asof «по состоянию на DD.MM.YYYY» → header-строка
(детект «наименование»+«проектн») → суб-заголовки/нумерация → регионы-разделители
(single-cell) → data full n=13 / continuation n=7 (доп. выход ГРС со сдвигом).

- Фиксированные индекс-карты full/continuation; gate по текущему региону
  («свердлов»); «(выход №N)» → output_name, числовой «(5)» остаётся в имени;
  «(96 %)» → 96.0 (знак/ноль сохраняются); уведомление → upgrade_due через « · »;
  объём по действующим ТУ → raw (без миграции); asof пишется в upsert.
- Тест-fixture повторяет реальную разметку (вкл. нумерацию и суб-заголовки),
  27 тестов зелёные. Ревью  APPROVE.
This commit is contained in:
Light1YT 2026-07-02 19:28:55 +05:00
parent 102ef8392b
commit 930630c2cf
2 changed files with 310 additions and 194 deletions

View file

@ -1,25 +1,40 @@
"""Загрузчик свободной мощности ГРС «Газпром трансгаз Екатеринбург» (#2119 B1). """Загрузчик свободной мощности ГРС «Газпром трансгаз Екатеринбург» (#2119 B1).
No-B2B верифицированный источник: страница раскрытия ГТЕ содержит ИНЛАЙН No-B2B верифицированный источник: страница раскрытия ГТЕ содержит ИНЛАЙН
HTML-таблицу «Информация о технической возможности» (~316 строк) со свободной HTML-таблицу «Информация о наличии (отсутствии) технической возможности»
мощностью газораспределительных станций (ГРС) проектная / фактическая загрузка / (~350 строк) со свободной мощностью газораспределительных станций (ГРС)
свободная мощность (тыс. м³/ч) + срок расшивки. UPSERT-ит в ``gas_grs_capacity``. проектная / текущая загрузка / свободная мощность (тыс. м³/ч) + уведомление о
расшивке. UPSERT-ит в ``gas_grs_capacity``.
Источник ГЕО-БЛОКИРУЕТ non-RU IP загрузчик РАБОТАЕТ НА ПРОДЕ (Celery weekly / Источник ГЕО-БЛОКИРУЕТ non-RU IP загрузчик РАБОТАЕТ НА ПРОДЕ (Celery weekly /
manual docker exec) как rosseti-/vodokanal-лоадеры. Код не предполагает manual docker exec) как rosseti-/vodokanal-лоадеры. Код не предполагает
локальный сетевой доступ. локальный сетевой доступ.
Парсинг DEFENSIVE: колонки мапятся ПО ЗАГОЛОВКАМ (не по фикс-позиции ГТЕ может Парсинг под РЕАЛЬНУЮ структуру таблицы ГТЕ (снята с прода 2026-07-02):
переставить/добавить столбцы), имя ГРС с rowspan («выход 1/2/3») forward-fill'ится
на строки-выходы (идея из vodokanal_reserve_loader.parse_docx_table_rows). Числа * caption-строка (1 ячейка) скип;
через ОБЩИЙ ``parse_reserve_number`` из rosseti_reserve_loader (запятая, «н/д», * «по состоянию на DD.MM.YYYY» в первых строках asof;
знак). Неизвестные колонки не теряются падают в ``raw`` jsonb. * строка-заголовок = ячейки содержат «наименование» И «проектн» (НЕ 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 json
import logging import logging
import re import re
import ssl import ssl
from datetime import date
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
@ -29,7 +44,10 @@ from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.db import SessionLocal 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__) logger = logging.getLogger(__name__)
@ -63,22 +81,48 @@ _NODATA_TEXT_TOKENS = frozenset(
{"", "н/д", "нд", "n/a", "na", "-", "", "", "нет данных", "х", "x"} {"", "н/д", "нд", "n/a", "na", "-", "", "", "нет данных", "х", "x"}
) )
# Заголовок-матчинг: подстрока в тексте <th>/первой строки → логическая колонка. # Строка-заголовок таблицы ГТЕ: ячейки содержат «наименование» И «проектн».
# Порядок важен — «свободн» проверяем раньше «загрузк», а «%» отдельным ключом. # Отличает РЕАЛЬНЫЙ header от caption-строки («Информация о наличии…») и от
# Каждая запись: (набор подстрок-триггеров, ключ). Первый матч на колонку выигрывает. # суб-заголовков / строки нумерации.
_HEADER_PATTERNS: list[tuple[tuple[str, ...], str]] = [ _HEADER_MARKERS = ("наименование", "проектн")
(("выход",), "output_name"),
(("наименование", "грс", "станц"), "grs_name"),
(("проектн",), "design"),
(("свободн", "резерв"), "free"),
(("загрузк", "фактич", "текущ"), "load"),
(("срок", "расшивк", "мероприят"), "upgrade_due"),
]
# Region-колонка (если ГТЕ добавит регион в таблицу) → фильтр Свердловской области. # Свердловская область — единственный регион, который грузим (ГТЕ отдаёт и
_REGION_HEADER_TOKENS = ("регион", "область", "субъект") # сопредельные: Курганская, Челябинская и т.д. — они отсекаются region-фильтром).
_SVERDLOVSK_TOKEN = "свердлов" _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: def normalize_grs_name(name: str | None) -> str:
"""Нормализует имя ГРС для матча/дедупа. """Нормализует имя ГРС для матча/дедупа.
@ -102,35 +146,41 @@ def normalize_grs_name(name: str | None) -> str:
return s return s
def _clean_text(value: object | None) -> str | None: def _parse_pct(value: str | None) -> float | None:
"""Текст ячейки → strip + collapse пробелов. Пусто / «н/д»-токен → None.""" """Процент свободной мощности «(96 %)» → 96.0. «(—)»/пусто → None.
Ячейка приходит в скобках и с неразрывным пробелом перед «%». Снимаем скобки,
«%», пробелы и парсим числом. Отрицательные (дефицит) сохраняются знаком.
"""
if value is None: if value is None:
return None return None
s = _MULTISPACE_RE.sub(" ", str(value).replace("\xa0", " ")).strip() stripped = value.strip("()%\xa0 ").strip()
if not s or s.lower() in _NODATA_TEXT_TOKENS: if not stripped or stripped in _NODATA_TEXT_TOKENS:
return None return None
return s return parse_reserve_number(stripped)
def _classify_header(header_text: str) -> str | None: def _split_grs_name(raw_name: str) -> tuple[str, str | None]:
"""Заголовок колонки → логический ключ (grs_name/output_name/design/…) или None.""" """Имя ячейки → (grs_name без выхода, output_name|None).
low = header_text.lower()
for tokens, key in _HEADER_PATTERNS: «ГРС Арти (выход 2)» («ГРС Арти», «выход 2»); «ГРС Арамиль (5)» без
if any(tok in low for tok in tokens): изменений (цифра в скобках НЕ выход, часть имени).
return key """
return None 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: def _is_header_row(texts: list[str]) -> bool:
"""Колонка про проценты свободной мощности?""" """Строка — РЕАЛЬНЫЙ заголовок (ячейки содержат «наименование» И «проектн»)?
low = header_text.lower()
return "%" in low or "процент" in low
Отличает header от caption («Информация о наличии») и суб-заголовков.
def _is_region_header(header_text: str) -> bool: """
"""Колонка про регион/область (для Свердловск-фильтра)?""" joined = " ".join(texts).lower()
low = header_text.lower() return all(marker in joined for marker in _HEADER_MARKERS)
return any(tok in low for tok in _REGION_HEADER_TOKENS)
def _extract_table(html: str) -> object | None: 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] 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]]: def _row_texts(cells: list[object]) -> list[str]:
"""Из ячеек строки-заголовка строит col_idx→logical_key + col_idx→raw_header. """Ячейки строки → список очищенных (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 сырые def _cell_at(texts: list[str], idx: int) -> str | None:
тексты ВСЕХ колонок (для raw + region-детекта). «%»-колонка ключ 'pct'. """Текст ячейки по индексу → 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] = {} parts = [t for i in notice_idxs if (t := _cell_at(texts, i)) is not None]
headers: dict[int, str] = {} return " · ".join(parts) if parts else None
for idx, cell in enumerate(header_cells):
htext = _clean_text(cell.get_text(" ")) or "" # type: ignore[union-attr]
headers[idx] = htext def _build_record(
if _is_region_header(htext): texts: list[str], idx: dict, current_region: str, asof: date | None
mapping[idx] = "region" ) -> dict | None:
continue """Строит запись ГРС из строки данных по карте индексов (FULL или CONTINUATION).
if _is_pct_header(htext):
mapping[idx] = "pct" Имя обязательно иначе None (не-данные). Объём по ТУ уходит ТОЛЬКО в raw.
continue """
key = _classify_header(htext) raw_name = _cell_at(texts, idx["grs_name"])
if key is not None: if not raw_name:
mapping[idx] = key return None
return mapping, headers 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]: def parse_grs_table(html: str) -> list[dict]:
"""Парсит инлайн HTML-таблицу ГТЕ → список записей ГРС (по выходам). """Парсит инлайн HTML-таблицу ГТЕ → список записей ГРС (по выходам).
DEFENSIVE: Под РЕАЛЬНУЮ структуру таблицы (см. модульный docstring):
- основная <table> = с макс. числом строк; - основная <table> = с макс. числом строк;
- первая строка = заголовок, колонки мапятся ПО ЗАГОЛОВКАМ (contains); - asof («по состоянию на DD.MM.YYYY») ищется в первых 4 строках;
- rowspan имени ГРС forward-fill на строки-выходы (несколько выходов у ГРС). - строка-заголовок детектится по маркерам «наименование» + «проектн»; всё до
ДОПУЩЕНИЕ: offset-коррекция работает только когда колонка имени ПЕРВАЯ неё (caption / asof) и суб-заголовки/нумерация сразу после скипаются;
(в текущей таблице ГТЕ так); rowspan не-первой колонки даст пропуск строк - single-cell строка = регион-разделитель current_region (title-case);
(не порчу данных безымянные строки скипаются, raw сохраняется); - данные пишутся ТОЛЬКО когда current_region содержит «свердлов»;
- числа через parse_reserve_number (запятая, «н/д», знак); - FULL-строка (len>=10) и CONTINUATION-строка (5<=len<=9) фиксированные
- неизвестные колонки raw (не теряются). индексы; иначе строка не-данные скип с warning-логом.
Каждая запись: Каждая запись:
{grs_name, output_name, region, design_capacity_th_m3_h, current_load_th_m3_h, {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}. free_capacity_th_m3_h, free_capacity_pct, upgrade_due, asof, raw}.
grs_name пустой (нет forward-fill и нет своего имени) строка пропускается.
""" """
table = _extract_table(html) table = _extract_table(html)
if table is None: if table is None:
@ -199,81 +278,67 @@ def parse_grs_table(html: str) -> list[dict]:
if not rows: if not rows:
return [] return []
mapping, headers = _build_column_map(_row_cells(rows[0])) row_texts = [_row_texts(_row_cells(tr)) for tr in rows]
if "grs_name" not in mapping.values():
logger.warning("gazprom_grs: колонка имени ГРС не распознана по заголовкам (%s)", headers) # 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 [] return []
records: list[dict] = [] records: list[dict] = []
# forward-fill имени ГРС по rowspan: если ячейка имени отсутствует в строке current_region: str | None = None
# (schrunk из-за rowspan предыдущей), наследуем последнее известное имя. in_sverdlovsk = False
last_grs_name: str | None = None
for tr in rows[1:]: for texts in row_texts[header_idx + 1 :]:
cells = _row_cells(tr) n = len(texts)
if not cells: 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 continue
# Матчим ячейки к логическим ключам по позиции колонки-заголовка. При if not in_sverdlovsk:
# rowspan имени в строке-выходе ячейки имени НЕТ → индексы сдвигаются; мы continue # регион не Свердловский → строку не пишем
# определяем это по числу ячеек < числу заголовков и 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") if n >= _FULL_MIN_LEN:
shifted = len(cells) < len(headers) and name_col == 0 idx_map = _FULL_IDX
# Если строка короче заголовков и имя-колонка первая — считаем что elif _CONT_MIN_LEN <= n <= _CONT_MAX_LEN:
# rowspan «съел» первую ячейку: логические индексы сдвигаем на +1. idx_map = _CONT_IDX
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: else:
grs_name = last_grs_name # forward-fill (rowspan имени ГРС) logger.warning("gazprom_grs: строка неожиданной длины n=%d → скип: %s", n, texts)
if not grs_name: continue
continue # шапка/итоговая строка без имени и без forward-fill
rec = { rec = _build_record(texts, idx_map, current_region or "", asof)
"grs_name": grs_name, if rec is not None:
"output_name": values.get("output_name"), records.append(rec)
"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)) logger.info("gazprom_grs: распознано записей ГРС=%d (asof=%s)", len(records), asof)
return records return records
def _passes_region_filter(records: list[dict]) -> list[dict]: def _passes_region_filter(records: list[dict]) -> list[dict]:
"""Если хоть у одной записи есть region — фильтруем по «свердлов»; иначе всё. """Фильтр Свердловской области по полю region записи.
В таблице ГТЕ обычно НЕТ колонки региона (весь ГТЕ = Свердловская + сопред. Основной регион-фильтр УЖЕ применён в parse_grs_table (по current_region-
области) грузим всё с region=NULL. Если ГТЕ добавит регион оставляем только разделителям). Эта функция второй барьер на случай записей с чужим/пустым
свердловские строки. region (совместимость с прежним пайплайном load_grs_capacity). Если region есть
хоть у одной оставляем только «свердлов»; иначе всё.
""" """
if any(r.get("region") for r in records): 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 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"], "free": rec["free_capacity_th_m3_h"],
"pct": rec["free_capacity_pct"], "pct": rec["free_capacity_pct"],
"upgrade_due": rec["upgrade_due"], "upgrade_due": rec["upgrade_due"],
"asof": rec.get("asof"),
"raw": json.dumps(rec["raw"], ensure_ascii=False), "raw": json.dumps(rec["raw"], ensure_ascii=False),
} }
try: 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, (grs_name, grs_name_norm, output_name, region,
design_capacity_th_m3_h, current_load_th_m3_h, design_capacity_th_m3_h, current_load_th_m3_h,
free_capacity_th_m3_h, free_capacity_pct, free_capacity_th_m3_h, free_capacity_pct,
upgrade_due, raw, fetched_at) upgrade_due, asof, raw, fetched_at)
VALUES ( VALUES (
:grs_name, :grs_name_norm, :output_name, :region, :grs_name, :grs_name_norm, :output_name, :region,
:design, :load, :free, :pct, :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 ON CONFLICT (grs_name_norm, output_name) DO UPDATE
SET grs_name = EXCLUDED.grs_name, 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_th_m3_h = EXCLUDED.free_capacity_th_m3_h,
free_capacity_pct = EXCLUDED.free_capacity_pct, free_capacity_pct = EXCLUDED.free_capacity_pct,
upgrade_due = EXCLUDED.upgrade_due, upgrade_due = EXCLUDED.upgrade_due,
asof = EXCLUDED.asof,
raw = EXCLUDED.raw, raw = EXCLUDED.raw,
fetched_at = NOW() fetched_at = NOW()
RETURNING (xmax = 0) AS is_insert RETURNING (xmax = 0) AS is_insert

View file

@ -13,6 +13,7 @@ Pure / mock-based — без реальной сети и БД. Покрывае
from __future__ import annotations from __future__ import annotations
from contextlib import contextmanager from contextlib import contextmanager
from datetime import date
from typing import Any from typing import Any
from app.services.site_finder import connection_capacity_lookup as ccl 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("") == "" 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=%.
_GTE_HTML = """
_SINGLE_GRS_HTML = """
<html><body> <html><body>
<table> <table>
<tr><td>Информация о наличии (отсутствии) технической возможности</td></tr>
<tr><td></td><td>по состоянию на 01.06.2026</td><td></td></tr>
<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>
<tr> <tr>
<td>ГРС Свердловская</td><td>выход 1</td><td>100,5</td> <td>115</td><td>Свердловская область</td><td>ГРС Арти (выход 1)</td>
<td>60,0</td><td>40,5</td><td>40,3</td><td>2027</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> </tr>
</table> </table>
</body></html> </body></html>
""" """
def test_parse_single_grs_header_matching_and_numbers() -> None: def test_parse_asof_extracted() -> None:
"""Колонки мапятся по заголовкам; запятая-десятичный → float.""" """«по состоянию на 01.06.2026» из первых строк → asof на каждой записи."""
recs = gg.parse_grs_table(_SINGLE_GRS_HTML) recs = gg.parse_grs_table(_GTE_HTML)
assert len(recs) == 1 assert recs, "ожидались записи по Свердловской области"
r = recs[0] assert all(r["asof"] == date(2026, 6, 1) for r in recs)
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 «съел» её). def test_parse_region_filter_kurgan_excluded() -> None:
_MULTI_OUTPUT_HTML = """ """Регион-разделитель «КУРГАНСКАЯ ОБЛАСТЬ» отсекает не-свердловские строки."""
<html><body> recs = gg.parse_grs_table(_GTE_HTML)
<table> names = [r["grs_name"] for r in recs]
<tr> assert "ГРС Кетово" not in names # Курганская отсечена регион-гейтом
<th>Наименование</th><th>Выход</th><th>Проектная</th> assert all(r["region"] == "Свердловская Область" for r in recs) # title-case
<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: def test_parse_full_row_mapping_and_pct() -> None:
"""rowspan имени ГРС → выходы 2/3 наследуют имя; «н/д» → None.""" """FULL-строка (n>=10): idx-маппинг + процент «(2 %)» → 2.0; объём ТУ → raw."""
recs = gg.parse_grs_table(_MULTI_OUTPUT_HTML) recs = gg.parse_grs_table(_GTE_HTML)
assert len(recs) == 3 aramil = next(r for r in recs if r["grs_name"] == "ГРС Арамиль (5)")
assert [r["grs_name"] for r in recs] == ["ГРС Екатеринбург"] * 3 # «(5)» — НЕ выход, остаётся в имени; output_name пуст.
assert [r["output_name"] for r in recs] == ["выход 1", "выход 2", "выход 3"] assert aramil["output_name"] is None
# Числа корректно смещены (offset при rowspan-строках). assert aramil["design_capacity_th_m3_h"] == 50.0 # idx3
assert recs[0]["free_capacity_th_m3_h"] == 20.0 assert aramil["current_load_th_m3_h"] == 31.35 # idx4
assert recs[1]["design_capacity_th_m3_h"] == 60.0 assert aramil["free_capacity_th_m3_h"] == 1.205 # idx6 (свободная)
assert recs[1]["upgrade_due"] is None # «н/д» → None assert aramil["free_capacity_pct"] == 2.0 # idx7 «(2 %)» → 2.0
assert recs[2]["free_capacity_th_m3_h"] == 0.0 # 0 при полной загрузке сохраняется assert aramil["upgrade_due"] is None # все ячейки уведомления «—»
assert recs[2]["upgrade_due"] == "после 2030" # Объём газа по ТУ (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: def test_parse_no_table_returns_empty() -> None:
assert gg.parse_grs_table("<html><body><p>нет таблиц</p></body></html>") == [] assert gg.parse_grs_table("<html><body><p>нет таблиц</p></body></html>") == []
def test_parse_unrecognized_name_column_returns_empty() -> None: def test_parse_no_header_row_returns_empty() -> None:
"""Заголовок без имени ГРС → пустой результат (defensive).""" """Нет строки-заголовка (маркеры «наименование»+«проектн») → пусто (defensive)."""
html = ( html = (
"<table><tr><th>Колонка А</th><th>Колонка Б</th></tr>" "<table><tr><th>Колонка А</th><th>Колонка Б</th></tr>"
"<tr><td>1</td><td>2</td></tr></table>" "<tr><td>1</td><td>2</td></tr></table>"
@ -200,12 +246,15 @@ class _FakeSession:
def test_upsert_grs_rows_counts_and_savepoint() -> None: def test_upsert_grs_rows_counts_and_savepoint() -> None:
db = _FakeSession(scalar_value=True) 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] counts = gg._upsert_grs_rows(db, recs) # type: ignore[arg-type]
assert counts == {"rows": 3, "inserted": 3, "updated": 0, "skipped": 0} assert counts == {"rows": 3, "inserted": 3, "updated": 0, "skipped": 0}
# grs_name_norm нормализован в params. # grs_name_norm нормализован в params; asof проброшен в upsert.
assert db.calls[0][1]["grs_name_norm"] == "грс екатеринбург" assert db.calls[0][1]["grs_name_norm"] == "грс арамиль (5)"
assert db.calls[1][1]["output_name"] == "выход 2" 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: агрегация ──────────────────────────────────────────── # ── _query_gas_city_grs: агрегация ────────────────────────────────────────────