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