fix(site-finder): ГРС-парсер переписан под реальную структуру таблицы ГТЕ
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:
parent
102ef8392b
commit
930630c2cf
2 changed files with 310 additions and 194 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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: агрегация ────────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue