From 930630c2cf8564199c1f267f053b6ac55f3436e6 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Thu, 2 Jul 2026 19:28:55 +0500 Subject: [PATCH] =?UTF-8?q?fix(site-finder):=20=D0=93=D0=A0=D0=A1-=D0=BF?= =?UTF-8?q?=D0=B0=D1=80=D1=81=D0=B5=D1=80=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=B0=D0=BD=20=D0=BF=D0=BE=D0=B4=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D1=83=D1=8E=20=D1=81=D1=82=D1=80=D1=83?= =?UTF-8?q?=D0=BA=D1=82=D1=83=D1=80=D1=83=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D1=86=D1=8B=20=D0=93=D0=A2=D0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../site_finder/gazprom_grs_loader.py | 333 +++++++++++------- backend/tests/test_gas_grs_loader.py | 171 +++++---- 2 files changed, 310 insertions(+), 194 deletions(-) diff --git a/backend/app/services/site_finder/gazprom_grs_loader.py b/backend/app/services/site_finder/gazprom_grs_loader.py index 1b721f00..cab71d35 100644 --- a/backend/app/services/site_finder/gazprom_grs_loader.py +++ b/backend/app/services/site_finder/gazprom_grs_loader.py @@ -1,25 +1,40 @@ """Загрузчик свободной мощности ГРС «Газпром трансгаз Екатеринбург» (#2119 B1). No-B2B верифицированный источник: страница раскрытия ГТЕ содержит ИНЛАЙН -HTML-таблицу «Информация о технической возможности…» (~316 строк) со свободной -мощностью газораспределительных станций (ГРС) — проектная / фактическая загрузка / -свободная мощность (тыс. м³/ч) + срок расшивки. UPSERT-ит в ``gas_grs_capacity``. +HTML-таблицу «Информация о наличии (отсутствии) технической возможности…» +(~350 строк) со свободной мощностью газораспределительных станций (ГРС) — +проектная / текущая загрузка / свободная мощность (тыс. м³/ч) + уведомление о +расшивке. UPSERT-ит в ``gas_grs_capacity``. Источник ГЕО-БЛОКИРУЕТ non-RU IP → загрузчик РАБОТАЕТ НА ПРОДЕ (Celery weekly / manual docker exec) — как rosseti-/vodokanal-лоадеры. Код не предполагает локальный сетевой доступ. -Парсинг DEFENSIVE: колонки мапятся ПО ЗАГОЛОВКАМ (не по фикс-позиции — ГТЕ может -переставить/добавить столбцы), имя ГРС с rowspan («выход 1/2/3») forward-fill'ится -на строки-выходы (идея из vodokanal_reserve_loader.parse_docx_table_rows). Числа — -через ОБЩИЙ ``parse_reserve_number`` из rosseti_reserve_loader (запятая, «н/д», -знак). Неизвестные колонки не теряются — падают в ``raw`` jsonb. +Парсинг под РЕАЛЬНУЮ структуру таблицы ГТЕ (снята с прода 2026-07-02): + + * caption-строка (1 ячейка) → скип; + * «по состоянию на DD.MM.YYYY» в первых строках → asof; + * строка-заголовок = ячейки содержат «наименование» И «проектн» (НЕ caption); + * ниже идут суб-заголовки + строка нумерации «0..7» → скипаются как не-данные; + * single-cell строка = регион-разделитель («СВЕРДЛОВСКАЯ ОБЛАСТЬ») → задаёт + ``current_region``; данные пишутся ТОЛЬКО пока текущий регион — Свердловский; + * FULL-строка (len>=10): idx 2=имя, 3=проектная, 4=загрузка, 5=объём по ТУ, + 6=свободная, 7=свободная %, 8+=уведомление (основание/срок/параметры); + * CONTINUATION-строка (5<=len<=9, доп. выход): сдвиг на 1 — idx 0=имя (с выходом), + 1=проектная, 2=загрузка, 3=объём ТУ, 4=свободная, 5=%, 6=уведомление; + * имя с суффиксом «(выход №N)» → output_name отдельно; суффикс-число «(5)» — + НЕ выход, остаётся в имени. + +Числа — через ОБЩИЙ ``parse_reserve_number`` из rosseti_reserve_loader (запятая, +«—» → None). Объём по ТУ хранится ТОЛЬКО в ``raw`` (без миграции колонки). +Полная строка целиком → ``raw`` jsonb (неизвестные/будущие колонки не теряются). """ import json import logging import re import ssl +from datetime import date from functools import lru_cache from pathlib import Path @@ -29,7 +44,10 @@ from sqlalchemy import text from sqlalchemy.orm import Session from app.core.db import SessionLocal -from app.services.site_finder.rosseti_reserve_loader import parse_reserve_number +from app.services.site_finder.rosseti_reserve_loader import ( + parse_asof_date, + parse_reserve_number, +) logger = logging.getLogger(__name__) @@ -63,22 +81,48 @@ _NODATA_TEXT_TOKENS = frozenset( {"", "н/д", "нд", "n/a", "na", "-", "—", "–", "нет данных", "х", "x"} ) -# Заголовок-матчинг: подстрока в тексте /первой строки → логическая колонка. -# Порядок важен — «свободн» проверяем раньше «загрузк», а «%» отдельным ключом. -# Каждая запись: (набор подстрок-триггеров, ключ). Первый матч на колонку выигрывает. -_HEADER_PATTERNS: list[tuple[tuple[str, ...], str]] = [ - (("выход",), "output_name"), - (("наименование", "грс", "станц"), "grs_name"), - (("проектн",), "design"), - (("свободн", "резерв"), "free"), - (("загрузк", "фактич", "текущ"), "load"), - (("срок", "расшивк", "мероприят"), "upgrade_due"), -] +# Строка-заголовок таблицы ГТЕ: ячейки содержат «наименование» И «проектн». +# Отличает РЕАЛЬНЫЙ header от caption-строки («Информация о наличии…») и от +# суб-заголовков / строки нумерации. +_HEADER_MARKERS = ("наименование", "проектн") -# Region-колонка (если ГТЕ добавит регион в таблицу) → фильтр Свердловской области. -_REGION_HEADER_TOKENS = ("регион", "область", "субъект") +# Свердловская область — единственный регион, который грузим (ГТЕ отдаёт и +# сопредельные: Курганская, Челябинская и т.д. — они отсекаются region-фильтром). _SVERDLOVSK_TOKEN = "свердлов" +# Суффикс «(выход №N)» в имени ГРС → отдельный output_name. «(5)» (только цифры) — +# НЕ выход (это доп. нумерация станции), остаётся в имени. +_OUTPUT_SUFFIX_RE = re.compile(r"\s*\((выход\s*№?\s*\d+)\)\s*$", re.IGNORECASE) + +# FULL-строка: idx имени=2, проектная=3, загрузка=4, объём ТУ=5, свободная=6, +# свободная %=7, уведомление(основание/срок/параметры)=8,9,10. +_FULL_IDX = { + "grs_name": 2, + "design": 3, + "load": 4, + "volume_tu": 5, + "free": 6, + "pct": 7, + "notice": (8, 9, 10), +} +# CONTINUATION-строка (доп. выход, сдвиг на -1): имя=0, проектная=1, загрузка=2, +# объём ТУ=3, свободная=4, %=5, уведомление=6+. +_CONT_IDX = { + "grs_name": 0, + "design": 1, + "load": 2, + "volume_tu": 3, + "free": 4, + "pct": 5, + "notice": (6, 7, 8), +} + +# Границы длины строки: full = данные с полным набором столбцов; continuation = +# усечённый доп. выход. Иначе строка не-данные (шапка/суб/нумерация) → скип. +_FULL_MIN_LEN = 10 +_CONT_MIN_LEN = 5 +_CONT_MAX_LEN = 9 + def normalize_grs_name(name: str | None) -> str: """Нормализует имя ГРС для матча/дедупа. @@ -102,35 +146,41 @@ def normalize_grs_name(name: str | None) -> str: return s -def _clean_text(value: object | None) -> str | None: - """Текст ячейки → strip + collapse пробелов. Пусто / «н/д»-токен → None.""" +def _parse_pct(value: str | None) -> float | None: + """Процент свободной мощности «(96 %)» → 96.0. «(—)»/пусто → None. + + Ячейка приходит в скобках и с неразрывным пробелом перед «%». Снимаем скобки, + «%», пробелы — и парсим числом. Отрицательные (дефицит) сохраняются знаком. + """ if value is None: return None - s = _MULTISPACE_RE.sub(" ", str(value).replace("\xa0", " ")).strip() - if not s or s.lower() in _NODATA_TEXT_TOKENS: + stripped = value.strip("()%\xa0 ").strip() + if not stripped or stripped in _NODATA_TEXT_TOKENS: return None - return s + return parse_reserve_number(stripped) -def _classify_header(header_text: str) -> str | None: - """Заголовок колонки → логический ключ (grs_name/output_name/design/…) или None.""" - low = header_text.lower() - for tokens, key in _HEADER_PATTERNS: - if any(tok in low for tok in tokens): - return key - return None +def _split_grs_name(raw_name: str) -> tuple[str, str | None]: + """Имя ячейки → (grs_name без выхода, output_name|None). + + «ГРС Арти (выход №2)» → («ГРС Арти», «выход №2»); «ГРС Арамиль (5)» → без + изменений (цифра в скобках — НЕ выход, часть имени). + """ + m = _OUTPUT_SUFFIX_RE.search(raw_name) + if not m: + return raw_name, None + output = _MULTISPACE_RE.sub(" ", m.group(1)).strip() + grs_name = raw_name[: m.start()].strip() + return grs_name, output -def _is_pct_header(header_text: str) -> bool: - """Колонка про проценты свободной мощности?""" - low = header_text.lower() - return "%" in low or "процент" in low +def _is_header_row(texts: list[str]) -> bool: + """Строка — РЕАЛЬНЫЙ заголовок (ячейки содержат «наименование» И «проектн»)? - -def _is_region_header(header_text: str) -> bool: - """Колонка про регион/область (для Свердловск-фильтра)?""" - low = header_text.lower() - return any(tok in low for tok in _REGION_HEADER_TOKENS) + Отличает header от caption («Информация о наличии…») и суб-заголовков. + """ + joined = " ".join(texts).lower() + return all(marker in joined for marker in _HEADER_MARKERS) def _extract_table(html: str) -> object | None: @@ -148,47 +198,76 @@ def _row_cells(tr: object) -> list[object]: return tr.find_all(["th", "td"]) # type: ignore[union-attr] -def _build_column_map(header_cells: list[object]) -> tuple[dict[int, str], dict[int, str]]: - """Из ячеек строки-заголовка строит col_idx→logical_key + col_idx→raw_header. +def _row_texts(cells: list[object]) -> list[str]: + """Ячейки строки → список очищенных (collapse пробелов, \\xa0→пробел) текстов.""" + out: list[str] = [] + for cell in cells: + raw = cell.get_text(" ").replace("\xa0", " ") # type: ignore[union-attr] + out.append(_MULTISPACE_RE.sub(" ", raw).strip()) + return out - Возвращает (mapping, headers): mapping только для распознанных колонок - (grs_name/output_name/design/load/free/pct/upgrade_due/region); headers — сырые - тексты ВСЕХ колонок (для raw + region-детекта). «%»-колонка → ключ 'pct'. + +def _cell_at(texts: list[str], idx: int) -> str | None: + """Текст ячейки по индексу → None если пусто / «—»-токен / за границей.""" + if idx >= len(texts): + return None + s = texts[idx].strip() + if not s or s.lower() in _NODATA_TEXT_TOKENS: + return None + return s + + +def _build_upgrade_due(texts: list[str], notice_idxs: tuple[int, ...]) -> str | None: + """Ячейки уведомления (Основание/Срок/Параметры) → « · »-join непустых. + + Все «—»/пусто → None (нет уведомления о расшивке). """ - mapping: dict[int, str] = {} - headers: dict[int, str] = {} - for idx, cell in enumerate(header_cells): - htext = _clean_text(cell.get_text(" ")) or "" # type: ignore[union-attr] - headers[idx] = htext - if _is_region_header(htext): - mapping[idx] = "region" - continue - if _is_pct_header(htext): - mapping[idx] = "pct" - continue - key = _classify_header(htext) - if key is not None: - mapping[idx] = key - return mapping, headers + parts = [t for i in notice_idxs if (t := _cell_at(texts, i)) is not None] + return " · ".join(parts) if parts else None + + +def _build_record( + texts: list[str], idx: dict, current_region: str, asof: date | None +) -> dict | None: + """Строит запись ГРС из строки данных по карте индексов (FULL или CONTINUATION). + + Имя обязательно — иначе None (не-данные). Объём по ТУ уходит ТОЛЬКО в raw. + """ + raw_name = _cell_at(texts, idx["grs_name"]) + if not raw_name: + return None + grs_name, output_name = _split_grs_name(raw_name) + return { + "grs_name": grs_name, + "output_name": output_name, + "region": current_region, + "design_capacity_th_m3_h": parse_reserve_number(_cell_at(texts, idx["design"])), + "current_load_th_m3_h": parse_reserve_number(_cell_at(texts, idx["load"])), + "free_capacity_th_m3_h": parse_reserve_number(_cell_at(texts, idx["free"])), + "free_capacity_pct": _parse_pct(_cell_at(texts, idx["pct"])), + "upgrade_due": _build_upgrade_due(texts, idx["notice"]), + "asof": asof, + # Объём газа по ТУ — БЕЗ отдельной колонки в схеме → сохраняем в raw. + "raw": {"cells": texts, "volume_by_tu": _cell_at(texts, idx["volume_tu"])}, + } def parse_grs_table(html: str) -> list[dict]: """Парсит инлайн HTML-таблицу ГТЕ → список записей ГРС (по выходам). - DEFENSIVE: + Под РЕАЛЬНУЮ структуру таблицы (см. модульный docstring): - основная = с макс. числом строк; - - первая строка = заголовок, колонки мапятся ПО ЗАГОЛОВКАМ (contains); - - rowspan имени ГРС → forward-fill на строки-выходы (несколько выходов у ГРС). - ДОПУЩЕНИЕ: offset-коррекция работает только когда колонка имени — ПЕРВАЯ - (в текущей таблице ГТЕ так); rowspan не-первой колонки даст пропуск строк - (не порчу данных — безымянные строки скипаются, raw сохраняется); - - числа через parse_reserve_number (запятая, «н/д», знак); - - неизвестные колонки → raw (не теряются). + - asof («по состоянию на DD.MM.YYYY») ищется в первых 4 строках; + - строка-заголовок детектится по маркерам «наименование» + «проектн»; всё до + неё (caption / asof) и суб-заголовки/нумерация сразу после — скипаются; + - single-cell строка = регион-разделитель → current_region (title-case); + - данные пишутся ТОЛЬКО когда current_region содержит «свердлов»; + - FULL-строка (len>=10) и CONTINUATION-строка (5<=len<=9) → фиксированные + индексы; иначе строка не-данные → скип с warning-логом. Каждая запись: {grs_name, output_name, region, design_capacity_th_m3_h, current_load_th_m3_h, - free_capacity_th_m3_h, free_capacity_pct, upgrade_due, raw}. - grs_name пустой (нет forward-fill и нет своего имени) → строка пропускается. + free_capacity_th_m3_h, free_capacity_pct, upgrade_due, asof, raw}. """ table = _extract_table(html) if table is None: @@ -199,81 +278,67 @@ def parse_grs_table(html: str) -> list[dict]: if not rows: return [] - mapping, headers = _build_column_map(_row_cells(rows[0])) - if "grs_name" not in mapping.values(): - logger.warning("gazprom_grs: колонка имени ГРС не распознана по заголовкам (%s)", headers) + row_texts = [_row_texts(_row_cells(tr)) for tr in rows] + + # asof: «по состоянию на DD.MM.YYYY» в первых 4 строках (caption/asof-строка). + asof = parse_asof_date(*(" ".join(t) for t in row_texts[:4])) + + # Находим строку-заголовок (маркеры «наименование» + «проектн»). Данные — ниже. + header_idx: int | None = None + for i, texts in enumerate(row_texts): + if _is_header_row(texts): + header_idx = i + break + if header_idx is None: + logger.warning("gazprom_grs: строка-заголовок не найдена (маркеры %s)", _HEADER_MARKERS) return [] records: list[dict] = [] - # forward-fill имени ГРС по rowspan: если ячейка имени отсутствует в строке - # (schrunk из-за rowspan предыдущей), наследуем последнее известное имя. - last_grs_name: str | None = None + current_region: str | None = None + in_sverdlovsk = False - for tr in rows[1:]: - cells = _row_cells(tr) - if not cells: + for texts in row_texts[header_idx + 1 :]: + n = len(texts) + if n == 0 or not any(t for t in texts): + continue # пустая строка → скип + + if n == 1: + # Single-cell → регион-разделитель. title-case, обновляем контекст. + region = _cell_at(texts, 0) + if region: + current_region = region.title() + in_sverdlovsk = _SVERDLOVSK_TOKEN in region.lower() continue - # Матчим ячейки к логическим ключам по позиции колонки-заголовка. При - # rowspan имени в строке-выходе ячейки имени НЕТ → индексы сдвигаются; мы - # определяем это по числу ячеек < числу заголовков и forward-fill'им имя. - values: dict[str, str | None] = {} - raw_cells: list[str] = [] + if not in_sverdlovsk: + continue # регион не Свердловский → строку не пишем - name_col = next(i for i, k in mapping.items() if k == "grs_name") - shifted = len(cells) < len(headers) and name_col == 0 - # Если строка короче заголовков и имя-колонка первая — считаем что - # rowspan «съел» первую ячейку: логические индексы сдвигаем на +1. - offset = 1 if shifted else 0 - - for idx, cell in enumerate(cells): - # raw_cells — сырой текст ячейки (в т.ч. «н/д»), чтобы raw jsonb не терял - # исходное значение; values — очищенный (_clean_text: «н/д» → None). - raw_text = _MULTISPACE_RE.sub( - " ", - cell.get_text(" ").replace("\xa0", " "), # type: ignore[union-attr] - ).strip() - raw_cells.append(raw_text) - logical_idx = idx + offset - key = mapping.get(logical_idx) - if key is not None: - values[key] = _clean_text(raw_text) - - # values уже очищены (_clean_text применён при заполнении: str | None). - grs_name = values.get("grs_name") - if grs_name: - last_grs_name = grs_name # type: ignore[assignment] + if n >= _FULL_MIN_LEN: + idx_map = _FULL_IDX + elif _CONT_MIN_LEN <= n <= _CONT_MAX_LEN: + idx_map = _CONT_IDX else: - grs_name = last_grs_name # forward-fill (rowspan имени ГРС) - if not grs_name: - continue # шапка/итоговая строка без имени и без forward-fill + logger.warning("gazprom_grs: строка неожиданной длины n=%d → скип: %s", n, texts) + continue - rec = { - "grs_name": grs_name, - "output_name": values.get("output_name"), - "region": values.get("region"), - "design_capacity_th_m3_h": parse_reserve_number(values.get("design")), - "current_load_th_m3_h": parse_reserve_number(values.get("load")), - "free_capacity_th_m3_h": parse_reserve_number(values.get("free")), - "free_capacity_pct": parse_reserve_number(values.get("pct")), - "upgrade_due": values.get("upgrade_due"), - "raw": {"headers": list(headers.values()), "cells": raw_cells}, - } - records.append(rec) + rec = _build_record(texts, idx_map, current_region or "", asof) + if rec is not None: + records.append(rec) - logger.info("gazprom_grs: распознано записей ГРС=%d", len(records)) + logger.info("gazprom_grs: распознано записей ГРС=%d (asof=%s)", len(records), asof) return records def _passes_region_filter(records: list[dict]) -> list[dict]: - """Если хоть у одной записи есть region — фильтруем по «свердлов»; иначе всё. + """Фильтр Свердловской области по полю region записи. - В таблице ГТЕ обычно НЕТ колонки региона (весь ГТЕ = Свердловская + сопред. - области) → грузим всё с region=NULL. Если ГТЕ добавит регион — оставляем только - свердловские строки. + Основной регион-фильтр УЖЕ применён в parse_grs_table (по current_region- + разделителям). Эта функция — второй барьер на случай записей с чужим/пустым + region (совместимость с прежним пайплайном load_grs_capacity). Если region есть + хоть у одной — оставляем только «свердлов»; иначе всё. """ if any(r.get("region") for r in records): - return [r for r in records if (r.get("region") or "").lower().find(_SVERDLOVSK_TOKEN) >= 0] + return [r for r in records if _SVERDLOVSK_TOKEN in (r.get("region") or "").lower()] return records @@ -307,6 +372,7 @@ def _upsert_grs_rows(db: Session, records: list[dict]) -> dict[str, int]: "free": rec["free_capacity_th_m3_h"], "pct": rec["free_capacity_pct"], "upgrade_due": rec["upgrade_due"], + "asof": rec.get("asof"), "raw": json.dumps(rec["raw"], ensure_ascii=False), } try: @@ -317,11 +383,11 @@ def _upsert_grs_rows(db: Session, records: list[dict]) -> dict[str, int]: (grs_name, grs_name_norm, output_name, region, design_capacity_th_m3_h, current_load_th_m3_h, free_capacity_th_m3_h, free_capacity_pct, - upgrade_due, raw, fetched_at) + upgrade_due, asof, raw, fetched_at) VALUES ( :grs_name, :grs_name_norm, :output_name, :region, :design, :load, :free, :pct, - :upgrade_due, CAST(:raw AS jsonb), NOW() + :upgrade_due, :asof, CAST(:raw AS jsonb), NOW() ) ON CONFLICT (grs_name_norm, output_name) DO UPDATE SET grs_name = EXCLUDED.grs_name, @@ -331,6 +397,7 @@ def _upsert_grs_rows(db: Session, records: list[dict]) -> dict[str, int]: free_capacity_th_m3_h = EXCLUDED.free_capacity_th_m3_h, free_capacity_pct = EXCLUDED.free_capacity_pct, upgrade_due = EXCLUDED.upgrade_due, + asof = EXCLUDED.asof, raw = EXCLUDED.raw, fetched_at = NOW() RETURNING (xmax = 0) AS is_insert diff --git a/backend/tests/test_gas_grs_loader.py b/backend/tests/test_gas_grs_loader.py index 195dedfb..f938cc51 100644 --- a/backend/tests/test_gas_grs_loader.py +++ b/backend/tests/test_gas_grs_loader.py @@ -13,6 +13,7 @@ Pure / mock-based — без реальной сети и БД. Покрывае from __future__ import annotations from contextlib import contextmanager +from datetime import date from typing import Any from app.services.site_finder import connection_capacity_lookup as ccl @@ -41,85 +42,130 @@ def test_normalize_grs_empty() -> None: assert gg.normalize_grs_name("") == "" -# ── parse_grs_table: заголовок-матчинг + rowspan forward-fill ───────────────── +# ── parse_grs_table: РЕАЛЬНАЯ структура таблицы ГТЕ ─────────────────────────── +# +# Структура (снята с прода 2026-07-02): +# row0: caption (1 ячейка); +# row1: «по состоянию на DD.MM.YYYY» (asof); +# row2: РЕАЛЬНЫЕ заголовки (маркеры «Наименование» + «Проектная»); +# row3: суб-заголовки (Основание/Срок/Параметры) — n=3, скип; +# row4: строка нумерации «0..7» — n=8, скип (регион ещё не Свердловский); +# регион-разделители (1 ячейка) → current_region; +# FULL-строка (n>=10): idx 2=имя … 6=свободная, 7=%, 8+=уведомление; +# CONTINUATION-строка (n=7, доп. выход): idx 0=имя(с выходом) … 4=свободная, 5=%. - -_SINGLE_GRS_HTML = """ +_GTE_HTML = """
+ + - - - + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + +
Информация о наличии (отсутствии) технической возможности…
по состоянию на 01.06.2026
Наименование ГРСВыходПроектная, тыс. м³/чФактическая загрузкаСвободная мощностьСвободная, %Срок расшивки№ п/пСубъект Российской ФедерацииНаименование газораспределительной станцииПроектная мощность (производительность)Загрузка газораспределительной станцииСуммарный объём газа по действующим ТУНаличие (дефицит) пропускной способностиНаличие (дефицит) пропускной способности, %Наличие уведомления ГРО
ОснованиеСрок мероприятийПараметры увеличения
01234567
СВЕРДЛОВСКАЯ ОБЛАСТЬ
114Свердловская областьГРС Арамиль (5)50,00031,35017,4451,205(2 %)
ГРС Свердловскаявыход 1100,560,040,540,32027115Свердловская областьГРС Арти (выход №1)44,0002,0001,80042,200(96 %)ТУ №772028увеличение до 60
ГРС Арти (выход №2)22,000
КУРГАНСКАЯ ОБЛАСТЬ
200Курганская областьГРС Кетово30,00010,0005,00020,000(66 %)
""" -def test_parse_single_grs_header_matching_and_numbers() -> None: - """Колонки мапятся по заголовкам; запятая-десятичный → float.""" - recs = gg.parse_grs_table(_SINGLE_GRS_HTML) - assert len(recs) == 1 - r = recs[0] - assert r["grs_name"] == "ГРС Свердловская" - assert r["output_name"] == "выход 1" - assert r["design_capacity_th_m3_h"] == 100.5 - assert r["current_load_th_m3_h"] == 60.0 - assert r["free_capacity_th_m3_h"] == 40.5 - assert r["free_capacity_pct"] == 40.3 - assert r["upgrade_due"] == "2027" - # raw сохраняет заголовки + ячейки (неизвестные колонки не теряются). - assert "headers" in r["raw"] and "cells" in r["raw"] +def test_parse_asof_extracted() -> None: + """«по состоянию на 01.06.2026» из первых строк → asof на каждой записи.""" + recs = gg.parse_grs_table(_GTE_HTML) + assert recs, "ожидались записи по Свердловской области" + assert all(r["asof"] == date(2026, 6, 1) for r in recs) -# rowspan имени ГРС: строка-выход 2/3 НЕ содержит ячейки имени (rowspan «съел» её). -_MULTI_OUTPUT_HTML = """ - - - - - - - - - - - - - - - - -
НаименованиеВыходПроектнаяЗагрузкаСвободная%Срок
ГРС Екатеринбургвыход 150,030,020,040,02028
выход 260,040,020,033,3н/д
выход 370,070,00,00,0после 2030
- -""" +def test_parse_region_filter_kurgan_excluded() -> None: + """Регион-разделитель «КУРГАНСКАЯ ОБЛАСТЬ» отсекает не-свердловские строки.""" + recs = gg.parse_grs_table(_GTE_HTML) + names = [r["grs_name"] for r in recs] + assert "ГРС Кетово" not in names # Курганская отсечена регион-гейтом + assert all(r["region"] == "Свердловская Область" for r in recs) # title-case -def test_parse_multi_output_rowspan_forward_fill() -> None: - """rowspan имени ГРС → выходы 2/3 наследуют имя; «н/д» → None.""" - recs = gg.parse_grs_table(_MULTI_OUTPUT_HTML) - assert len(recs) == 3 - assert [r["grs_name"] for r in recs] == ["ГРС Екатеринбург"] * 3 - assert [r["output_name"] for r in recs] == ["выход 1", "выход 2", "выход 3"] - # Числа корректно смещены (offset при rowspan-строках). - assert recs[0]["free_capacity_th_m3_h"] == 20.0 - assert recs[1]["design_capacity_th_m3_h"] == 60.0 - assert recs[1]["upgrade_due"] is None # «н/д» → None - assert recs[2]["free_capacity_th_m3_h"] == 0.0 # 0 при полной загрузке сохраняется - assert recs[2]["upgrade_due"] == "после 2030" +def test_parse_full_row_mapping_and_pct() -> None: + """FULL-строка (n>=10): idx-маппинг + процент «(2 %)» → 2.0; объём ТУ → raw.""" + recs = gg.parse_grs_table(_GTE_HTML) + aramil = next(r for r in recs if r["grs_name"] == "ГРС Арамиль (5)") + # «(5)» — НЕ выход, остаётся в имени; output_name пуст. + assert aramil["output_name"] is None + assert aramil["design_capacity_th_m3_h"] == 50.0 # idx3 + assert aramil["current_load_th_m3_h"] == 31.35 # idx4 + assert aramil["free_capacity_th_m3_h"] == 1.205 # idx6 (свободная) + assert aramil["free_capacity_pct"] == 2.0 # idx7 «(2 %)» → 2.0 + assert aramil["upgrade_due"] is None # все ячейки уведомления «—» + # Объём газа по ТУ (idx5) — ТОЛЬКО в raw, без колонки в схеме. + assert aramil["raw"]["volume_by_tu"] == "17,445" + assert "cells" in aramil["raw"] + + +def test_parse_output_suffix_extracted() -> None: + """«(выход №1)»/«(выход №2)» → output_name отдельно, grs_name без суффикса.""" + recs = gg.parse_grs_table(_GTE_HTML) + arti = [r for r in recs if r["grs_name"] == "ГРС Арти"] + assert len(arti) == 2 # FULL «выход №1» + CONTINUATION «выход №2» + assert {r["output_name"] for r in arti} == {"выход №1", "выход №2"} + + +def test_parse_continuation_row_shifted_and_pct96() -> None: + """CONTINUATION-строка (n=7): сдвиг индексов; «(96 %)» из FULL «выход №1».""" + recs = gg.parse_grs_table(_GTE_HTML) + out1 = next(r for r in recs if r["output_name"] == "выход №1") + assert out1["free_capacity_pct"] == 96.0 # «(96 %)» → 96.0 + assert out1["design_capacity_th_m3_h"] == 44.0 + # Уведомление собрано join(« · ») из непустых Основание/Срок/Параметры. + assert out1["upgrade_due"] == "ТУ №77 · 2028 · увеличение до 60" + + out2 = next(r for r in recs if r["output_name"] == "выход №2") + # CONTINUATION: имя=idx0, проектная=idx1 → 22.0; остальное «—» → None. + assert out2["grs_name"] == "ГРС Арти" + assert out2["design_capacity_th_m3_h"] == 22.0 + assert out2["current_load_th_m3_h"] is None # «—» → None + assert out2["free_capacity_th_m3_h"] is None + assert out2["free_capacity_pct"] is None + assert out2["upgrade_due"] is None def test_parse_no_table_returns_empty() -> None: assert gg.parse_grs_table("

нет таблиц

") == [] -def test_parse_unrecognized_name_column_returns_empty() -> None: - """Заголовок без имени ГРС → пустой результат (defensive).""" +def test_parse_no_header_row_returns_empty() -> None: + """Нет строки-заголовка (маркеры «наименование»+«проектн») → пусто (defensive).""" html = ( "" "
Колонка АКолонка Б
12
" @@ -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: агрегация ────────────────────────────────────────────