gendesign/backend/tests/test_eesk_reserve_loader.py
Light1YT 372856c4cc
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Successful in 1m10s
CI / openapi-codegen-check (pull_request) Successful in 2m0s
CI / backend-tests (pull_request) Successful in 14m36s
feat(site-finder): ЕЭСК-резервы городских ПС/ТП + тепло из ФГИС ФАС (#2119 B2)
Городские числа вместо «по запросу» + четвёртый ресурс (тепло) в карточке §3.

- Миграция 182: power_supply_centers +reserve_unit ('МВА'/'МВт') +district;
  heat_system_reserves (UNIQUE NULLS NOT DISTINCT).
- eesk_reserve_loader (RUN-ON-PROD): хаб раскрытия eesk.ru → свежайшие xlsx по
  (год, квартал) из текста ссылки; ПС 35-220 (структура снята: r7+, доля
  0.41→41.0, свободная МВт) → UPDATE 70 городских ПС (только dzo ЕЭСК;
  «ФСК ЕЭС» не матчится — проверено ревью); ТП/РП ниже 35 (r4+, ~2014 строк
  города) → power_tp_rp_reserves. ДОПУЩЕНИЕ: % загрузки в источнике — доля
  (0..1); если ЕЭСК перейдёт на целые проценты, load_pct тихо ×100.
- eias_heat_loader (RUN-ON-PROD, SSL: в CA-бандл добавлен GlobalSign-интермедиат
  — ri.eias.ru не шлёт цепочку): карточка орг (ЕТК 30814352, Екатеринбургэнерго
  26479450) → публикации свежайшего квартала → TemplatePrinter base64-ZIP → tpl
  XLSX → defensive-парс «СТ №N»+правейшее число → heat_system_reserves.
- Celery вт 05:30/06:00; endpoint: power_points +reserve_unit/district,
  heat-блок (per-org MAX(period), graceful); схемы аддитивны, api-types +29/-0.
- Фронт: блок «ТЕПЛО» (бейджи резерв/исчерпана, свёртка >5, суммарно Гкал/ч);
  попап ПС: район + unit ТОЛЬКО на строке резерва (у ЕЭСК installed МВА,
  резерв МВт — разные единицы источника).
- B1-хвост: «(выход №N)» матчится в середине имени ГРС.

64+ тестов зелёные; deep-review  APPROVE.
2026-07-02 21:26:39 +05:00

277 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Unit-тесты загрузчика резервов АО «ЕЭСК» (#2119 Phase B2).
Pure / mock-based — без реальной сети и БД. Покрывает:
- select_freshest_links: 2 серии (ПС 35-220 / ТП «ниже 35»), выбор свежайшего
квартала из HTML-фрагмента; классификация по тексту ссылки.
- _classify_series / _quarter_from_text / quarter_period / quarter_last_day.
- _pct_share_to_percent: доля 0.41 → 41.0.
- load_ps_35_220: шапка r4, данные r7+/r65+, строка-разделитель, доля→41.0,
матч ЕЭСК-only (dzo_name ILIKE %ЕЭСК%) / анматч.
- load_tp_rp_sub35: данные с r4, name «ТП/РП <A>», municipality='Екатеринбург'.
"""
from __future__ import annotations
import io
from contextlib import contextmanager
from typing import Any
import openpyxl
from app.services.site_finder import eesk_reserve_loader as ee
from app.services.site_finder import rosseti_wfs_loader as rw
# ── select_freshest_links / классификация серий ──────────────────────────────
def _link(href: str, text: str) -> str:
return f'<a href="{href}">{text}</a>'
def test_select_freshest_links_two_series_quarters() -> None:
"""Из HTML-хаба выбирается свежайший квартал каждой серии; серии различаются."""
base = "/upload/site1/document_file/"
html = "".join(
[
_link(
base + "aaa.xlsx",
"Резерв мощности по центрам питания ПС 35-220 кВ 1 квартал 2026",
),
_link(
base + "bbb.xlsx",
"Резерв мощности по центрам питания ПС 35-220 кВ 2 квартала 2026",
),
_link(
base + "ccc.xls",
"Наличие свободной мощности ТП и РП ниже 35 кВ 1 квартал 2026",
),
_link(
base + "ddd.xls",
"Наличие свободной мощности ТП и РП ниже 35 кВ 2 квартала 2026",
),
]
)
links = ee.select_freshest_links(html)
assert set(links) == {"ps_35_220", "tp_rp_sub35"}
# Свежайший ПС — 2 квартал 2026 (bbb), абсолютный URL достроен.
assert links["ps_35_220"]["url"].endswith("bbb.xlsx")
assert links["ps_35_220"]["url"].startswith("https://www.eesk.ru")
assert (links["ps_35_220"]["year"], links["ps_35_220"]["quarter"]) == (2026, 2)
# Свежайший ТП — 2 квартал 2026 (ddd).
assert links["tp_rp_sub35"]["url"].endswith("ddd.xls")
assert (links["tp_rp_sub35"]["year"], links["tp_rp_sub35"]["quarter"]) == (2026, 2)
def test_select_freshest_picks_latest_year_over_quarter() -> None:
"""4 квартал 2025 старее, чем 1 квартал 2026 — берём 2026."""
base = "/upload/site1/document_file/"
html = "".join(
[
_link(base + "old.xlsx", "Резерв по центрам питания 35-220 4 квартал 2025"),
_link(base + "new.xlsx", "Резерв по центрам питания 35-220 1 квартал 2026"),
]
)
links = ee.select_freshest_links(html)
assert links["ps_35_220"]["url"].endswith("new.xlsx")
assert (links["ps_35_220"]["year"], links["ps_35_220"]["quarter"]) == (2026, 1)
def test_classify_series_below_wins_over_cp_phrase() -> None:
"""«ниже 35» классифицирует как ТП даже если рядом «по центрам питания»."""
assert ee._classify_series("резерв по центрам питания ниже 35 кв") == "tp_rp_sub35"
assert ee._classify_series("резерв мощности по центрам питания 35-220") == "ps_35_220"
assert ee._classify_series("что-то постороннее") is None
def test_quarter_from_text_and_helpers() -> None:
assert ee._quarter_from_text("… 3 квартал 2025 …") == (2025, 3)
assert ee._quarter_from_text("… 2 квартала 2026 …") == (2026, 2)
assert ee._quarter_from_text("без квартала 2026") is None
assert ee.quarter_period(2026, 2) == "2026-Q2"
assert ee.quarter_last_day(2026, 2).isoformat() == "2026-06-30"
assert ee.quarter_last_day(2026, 1).isoformat() == "2026-03-31"
assert ee.quarter_last_day(2025, 4).isoformat() == "2025-12-31"
def test_pct_share_to_percent() -> None:
"""Доля 0.41 → 41.0; уже-проценты не трогаем; мусор → None."""
assert ee._pct_share_to_percent(0.41) == 41.0
assert ee._pct_share_to_percent("0,41") == 41.0
assert ee._pct_share_to_percent(1.0) == 100.0
assert ee._pct_share_to_percent(41.0) == 41.0 # >1 → уже проценты
assert ee._pct_share_to_percent("н/д") is None
# ── Fake DB session (SAVEPOINT + execute), как в test_connection_capacity ────
class _FakeResult:
def __init__(self, scalar_value: Any = True, rowcount: int = 1) -> None:
self._scalar = scalar_value
self.rowcount = rowcount
def scalar(self) -> Any:
return self._scalar
class _FakeSession:
"""Мини-заглушка Session: собирает execute-параметры, поддерживает begin_nested."""
def __init__(self, scalar_value: Any = True, rowcount: int = 1) -> None:
self.calls: list[tuple[str, Any]] = []
self._scalar = scalar_value
self._rowcount = rowcount
self.committed = False
@contextmanager
def begin_nested(self): # type: ignore[no-untyped-def]
yield
def execute(self, stmt: Any, params: Any = None) -> _FakeResult:
self.calls.append((str(stmt), params))
return _FakeResult(self._scalar, self._rowcount)
def commit(self) -> None:
self.committed = True
def rollback(self) -> None:
pass
# ── load_ps_35_220: r4 шапка, данные r7+/r65+, разделитель, доля→41.0 ─────────
def _build_ps_workbook() -> bytes:
"""xlsx ПС 35-220: лист «Лист1», шапка r4, данные с r7, строка-разделитель.
Колонки 0-based: A=№, B=адм.район, C=имя ПС, D=макс полн. МВА, E=доля загрузки,
F=степень МУ250, G=свободная МВт.
"""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Лист1"
# Строки 1-6 — заголовки/шапка (r4 — реальная шапка), должны игнорироваться.
for r in range(1, 7):
ws.cell(row=r, column=1, value=f"header-{r}")
# Строка 7 — первая строка данных.
ws.append([1, "Ленинский", "ПС 110/10 Уктус", 40.0, 0.41, "МУ250", 15.0])
# Строка 8 — вторая ПС.
ws.append([2, "Кировский", "ПС 35/6 Изоплит", 16.0, 0.60, "МУ250", 4.0])
# Строка 9 — РАЗДЕЛИТЕЛЬ секций: C пусто И D не число → скип.
ws.append([None, None, None, None, None, None, None])
# Строка 10 — ещё одна ПС (эмулирует секцию r65+).
ws.append([3, "Октябрьский", "ПС 110/10 Сибирь", 25.0, 0.50, "МУ250", 8.0])
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def test_load_ps_35_220_parse_and_match() -> None:
"""Данные с r7, разделитель пропущен, доля→%, матч по sc_name_norm."""
from datetime import date
db = _FakeSession(scalar_value=None, rowcount=1) # UPDATE matched 1 row
result = ee.load_ps_35_220(db, _build_ps_workbook(), date(2026, 6, 30))
# 3 строки данных (разделитель отсеян), все смэтчились.
assert result["rows"] == 3, result
assert result["matched"] == 3
assert result["unmatched"] == 0
first = db.calls[0][1]
assert first["name_norm"] == rw.normalize_sc_name("Уктус") == "уктус"
assert first["installed"] == 40.0
assert first["reserve"] == 15.0
assert first["district"] == "Ленинский"
assert first["load_pct"] == 41.0 # доля 0.41 → 41.0%
assert first["asof"] == date(2026, 6, 30)
def test_load_ps_35_220_unmatched_counted() -> None:
"""ПС без совпадения (rowcount=0 — напр. не ЕЭСК) → unmatched, не падаем."""
from datetime import date
db = _FakeSession(scalar_value=None, rowcount=0) # UPDATE matched 0 rows
result = ee.load_ps_35_220(db, _build_ps_workbook(), date(2026, 6, 30))
assert result["rows"] == 3
assert result["matched"] == 0
assert result["unmatched"] == 3
def test_load_ps_35_220_sql_scopes_to_eesk() -> None:
"""UPDATE ограничен dzo_name ILIKE '%ЕЭСК%' — не затирает Россети-строки."""
from datetime import date
db = _FakeSession(scalar_value=None, rowcount=1)
ee.load_ps_35_220(db, _build_ps_workbook(), date(2026, 6, 30))
sql = db.calls[0][0]
assert "ILIKE '%ЕЭСК%'" in sql
assert "capacity_source = 'eesk_35_220'" in sql
assert "reserve_unit = 'МВт'" in sql
# ── load_tp_rp_sub35: данные с r4, name «ТП/РП <A>», municipality ────────────
def _build_tp_workbook() -> bytes:
"""xlsx ТП/РП «ниже 35»: лист «Публикация», r1 title, r3 шапка, данные r4+.
Колонки 0-based: A=номер, B=предельно доп. МВт, C=загрузка МВт, D=резерв МВт.
"""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Публикация"
ws.cell(row=1, column=1, value="Заголовок публикации") # r1 title
# r2 пустая, r3 — шапка.
ws.cell(row=3, column=1, value="№ ТП/РП")
ws.cell(row=3, column=2, value="предельно допустимая, МВт")
# Строка 4+ — данные.
ws.cell(row=4, column=1, value=10003) # int-номер (Excel часто float)
ws.cell(row=4, column=2, value=1.6)
ws.cell(row=4, column=3, value=1.0)
ws.cell(row=4, column=4, value=0.6)
ws.cell(row=5, column=1, value="10004") # уже строка
ws.cell(row=5, column=2, value=2.5)
ws.cell(row=5, column=3, value=2.0)
ws.cell(row=5, column=4, value=0.5)
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def test_load_tp_rp_sub35_parse() -> None:
"""Данные с r4; name «ТП/РП 10003», name_norm='10003', municipality='Екатеринбург'."""
from datetime import date
db = _FakeSession(scalar_value=True, rowcount=1)
result = ee.load_tp_rp_sub35(db, _build_tp_workbook(), date(2026, 6, 30))
assert result["rows"] == 2, result
assert result["inserted"] == 2
first = db.calls[0][1]
# float-номер 10003.0 из Excel нормализован к «10003» (без .0).
assert first["name"] == "ТП/РП 10003"
assert first["name_norm"] == "10003"
assert first["municipality"] == "Екатеринбург"
assert first["installed"] == 1.6
assert first["current"] == 1.0
assert first["reserve"] == 0.6
assert first["asof"] == date(2026, 6, 30)
second = db.calls[1][1]
assert second["name"] == "ТП/РП 10004"
assert second["name_norm"] == "10004"
def test_load_tp_rp_sub35_voltage_null_no_kva_sanitize() -> None:
"""voltage_kv NULL в SQL; installed НЕ делится на 1000 (без кВА-санитайза)."""
from datetime import date
db = _FakeSession(scalar_value=True, rowcount=1)
ee.load_tp_rp_sub35(db, _build_tp_workbook(), date(2026, 6, 30))
sql = db.calls[0][0]
assert "voltage_kv" in sql and "NULL" in sql
# installed сохранён как есть (1.6), без деления.
assert db.calls[0][1]["installed"] == 1.6