Карточка PublicDisclosureInfoOrg.aspx на проде держит ссылку в JS onclick=openTemplateDialog(...) при href="#" (не в href), амперсанды HTML-энкожены, а период — «1 кв. 2026 года». Старые регэкспы давали 0 публикаций для обеих организаций (PR #2165). - _PUB_HREF_RE: матч URL TemplatePrinter где угодно в чанке (onclick + href) - extract_publications: html.unescape чанка перед поиском (&guid=) - _QUARTER_RE: поддержка «N кв. YYYY» / «N кв YYYY» помимо «N квартал YYYY» - дедуп публикаций по (guid, id) — одна публикация в 3 строках грида - тесты: реальная onclick-форма + дедуп + новые кейсы _quarter_from_text
331 lines
14 KiB
Python
331 lines
14 KiB
Python
"""Unit-тесты загрузчика тепло-резервов ФАС (ri.eias.ru) (#2119 Phase B2).
|
||
|
||
Pure / mock-based — без реальной сети и БД. Покрывает:
|
||
- extract_publications: (guid, id, период) из синтетического HTML грида + выбор
|
||
свежайшего квартала (несколько публикаций одного квартала → все);
|
||
- extract_tpl_xlsx: base64 → ZIP → член 'tpl' (генерим zip в памяти с xlsx-членом);
|
||
- parse_heat_sheet: детект «СТ №54 … 3.26», скип заголовков, правейшее число;
|
||
- _upsert_heat_rows: счётчики parsed/inserted/updated (mock-DB как в eesk-тестах).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import io
|
||
import zipfile
|
||
from contextlib import contextmanager
|
||
from typing import Any
|
||
|
||
import openpyxl
|
||
|
||
from app.services.site_finder import eias_heat_loader as eh
|
||
|
||
# ── extract_publications: (guid, id, период) + свежайший квартал ──────────────
|
||
|
||
|
||
def _pub_row(guid: str, pub_id: str, period_text: str) -> str:
|
||
"""<tr> строки грида: ссылка на TemplatePrinter (guid+id) + текст периода."""
|
||
href = f"/Discl/TemplatePrinter.aspx?reg=RU.5.66&guid={guid}&id={pub_id}"
|
||
return f'<tr><td><a href="{href}">выгрузить</a></td><td>{period_text}</td></tr>'
|
||
|
||
|
||
def test_extract_publications_real_onclick_form() -> None:
|
||
"""РЕАЛЬНАЯ разметка прода: ссылка в onclick=openTemplateDialog(...), href="#",
|
||
амперсанды HTML-энкожены (&), период «1 кв. 2026 года» в тексте строки."""
|
||
url = (
|
||
"https://ri-loader.eias.ru/TemplatePrinter.aspx?reg=RU.5.66"
|
||
"&guid=40665f4f-c7e7-4121-ac15-0bff24c39f81&id=9125184878"
|
||
)
|
||
html = (
|
||
"<table><tr>"
|
||
'<td id="ASPxGridViewDet_tccell0_15" class="dxgv">'
|
||
f"<a class='get_template' onclick=\"openTemplateDialog('{url}', "
|
||
"'Акционерное общество «Екатеринбургская теплосетевая компания» "
|
||
'(ИНН 6671019770, КПП 667101001)\');return false;" href="#">'
|
||
"выгрузить</a></td>"
|
||
"<td>1 кв. 2026 года</td>"
|
||
"</tr></table>"
|
||
)
|
||
|
||
pubs = eh.extract_publications(html)
|
||
|
||
assert len(pubs) == 1
|
||
assert pubs[0]["guid"] == "40665f4f-c7e7-4121-ac15-0bff24c39f81"
|
||
assert pubs[0]["id"] == "9125184878"
|
||
assert pubs[0]["year"] == 2026
|
||
assert pubs[0]["quarter"] == 1
|
||
|
||
|
||
def test_extract_publications_freshest_quarter_all_pubs() -> None:
|
||
"""Берём ВСЕ публикации свежайшего квартала; старее — отбрасываем."""
|
||
html = (
|
||
"<table>"
|
||
+ "".join(
|
||
[
|
||
_pub_row("g-old", "101", "1 квартал 2026"),
|
||
_pub_row("g-new-a", "201", "2 квартал 2026"), # свежайший, ЦС A
|
||
_pub_row("g-new-b", "202", "2 квартала 2026"), # свежайший, ЦС B
|
||
]
|
||
)
|
||
+ "</table>"
|
||
)
|
||
|
||
pubs = eh.extract_publications(html)
|
||
|
||
assert len(pubs) == 2
|
||
guids = {p["guid"] for p in pubs}
|
||
assert guids == {"g-new-a", "g-new-b"}
|
||
assert all((p["year"], p["quarter"]) == (2026, 2) for p in pubs)
|
||
by_guid = {p["guid"]: p for p in pubs}
|
||
assert by_guid["g-new-a"]["id"] == "201"
|
||
assert by_guid["g-new-b"]["id"] == "202"
|
||
|
||
|
||
def test_extract_publications_dedupes_repeated_guid_id() -> None:
|
||
"""Одна публикация в нескольких строках грида (передача/сбыт) → одна запись.
|
||
|
||
На проде каждый (guid, id) повторяется ~3 раза — без дедупа лоадер качал бы
|
||
один и тот же ZIP многократно.
|
||
"""
|
||
html = (
|
||
"<table>"
|
||
+ "".join(
|
||
[
|
||
_pub_row("g-dup", "301", "1 кв. 2026 года"), # передача
|
||
_pub_row("g-dup", "301", "1 кв. 2026 года"), # сбыт (та же публикация)
|
||
_pub_row("g-other", "302", "1 кв. 2026 года"),
|
||
]
|
||
)
|
||
+ "</table>"
|
||
)
|
||
|
||
pubs = eh.extract_publications(html)
|
||
|
||
assert len(pubs) == 2
|
||
assert [(p["guid"], p["id"]) for p in pubs] == [("g-dup", "301"), ("g-other", "302")]
|
||
|
||
|
||
def test_extract_publications_year_beats_quarter() -> None:
|
||
"""4 квартал 2025 старее 1 квартала 2026 — берём 2026."""
|
||
html = (
|
||
"<table>"
|
||
+ "".join(
|
||
[
|
||
_pub_row("g-2025q4", "10", "4 квартал 2025"),
|
||
_pub_row("g-2026q1", "20", "1 квартал 2026"),
|
||
]
|
||
)
|
||
+ "</table>"
|
||
)
|
||
|
||
pubs = eh.extract_publications(html)
|
||
|
||
assert len(pubs) == 1
|
||
assert pubs[0]["guid"] == "g-2026q1"
|
||
assert (pubs[0]["year"], pubs[0]["quarter"]) == (2026, 1)
|
||
|
||
|
||
def test_extract_publications_skips_rows_without_period_or_link() -> None:
|
||
"""Строка без ссылки ИЛИ без квартала игнорируется; нет валидных → []."""
|
||
html = (
|
||
"<table>"
|
||
"<tr><td>шапка без ссылки 2 квартал 2026</td></tr>"
|
||
+ _pub_row("g-noperiod", "99", "без указания периода")
|
||
+ "</table>"
|
||
)
|
||
assert eh.extract_publications(html) == []
|
||
|
||
|
||
def test_quarter_from_text_helper() -> None:
|
||
assert eh._quarter_from_text("… 3 квартал 2025 …") == (2025, 3)
|
||
assert eh._quarter_from_text("… 2 квартала 2026 …") == (2026, 2)
|
||
assert eh._quarter_from_text("1 кв. 2026 года") == (2026, 1) # реальная форма прода
|
||
assert eh._quarter_from_text("1 кв 2026") == (2026, 1) # без точки
|
||
assert eh._quarter_from_text("5 квартал 2026") is None # квартал вне 1..4
|
||
assert eh._quarter_from_text("без квартала 2026") is None
|
||
|
||
|
||
# ── extract_tpl_xlsx: base64 → ZIP → член 'tpl' ──────────────────────────────
|
||
|
||
|
||
def _make_tpl_zip_html(xlsx_bytes: bytes, member: str = "tpl") -> str:
|
||
"""Строит HTML TemplatePrinter: zip с членом <member>=xlsx → base64 в data-file."""
|
||
zbuf = io.BytesIO()
|
||
with zipfile.ZipFile(zbuf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||
zf.writestr(member, xlsx_bytes)
|
||
zf.writestr("meta.xml", b"<meta/>") # посторонний член — должен игнорироваться
|
||
b64 = base64.b64encode(zbuf.getvalue()).decode("ascii")
|
||
return (
|
||
"<html><body>"
|
||
f'<div id="dataFile" data-filename="f.zip" data-file="{b64}"></div>'
|
||
"</body></html>"
|
||
)
|
||
|
||
|
||
def _heat_xlsx_bytes() -> bytes:
|
||
"""XLSX с листом 'ТП': строка-заголовок + СТ №54 → 3.26 Гкал/час."""
|
||
wb = openpyxl.Workbook()
|
||
ws = wb.active
|
||
ws.title = "ТП"
|
||
ws.append(["Резерв мощности источников тепловой энергии", None, None]) # заголовок, без числа
|
||
ws.append(["№ п/п", "Наименование", "Гкал/час"]) # шапка — без системного маркера
|
||
ws.append(["СТ №54", "источник", 3.26]) # система + правейшее число 3.26
|
||
buf = io.BytesIO()
|
||
wb.save(buf)
|
||
return buf.getvalue()
|
||
|
||
|
||
def test_extract_tpl_xlsx_roundtrip() -> None:
|
||
"""base64 → zip → член 'tpl' извлекается и открывается openpyxl."""
|
||
html = _make_tpl_zip_html(_heat_xlsx_bytes())
|
||
out = eh.extract_tpl_xlsx(html)
|
||
assert out is not None
|
||
wb = openpyxl.load_workbook(io.BytesIO(out))
|
||
assert "ТП" in wb.sheetnames
|
||
|
||
|
||
def test_extract_tpl_xlsx_missing_member_returns_none() -> None:
|
||
"""ZIP без члена 'tpl' → None (graceful, не падаем)."""
|
||
html = _make_tpl_zip_html(_heat_xlsx_bytes(), member="other")
|
||
assert eh.extract_tpl_xlsx(html) is None
|
||
|
||
|
||
def test_extract_tpl_xlsx_no_datafile_div_returns_none() -> None:
|
||
"""Нет <div id=dataFile> → None."""
|
||
assert eh.extract_tpl_xlsx("<html><body>пусто</body></html>") is None
|
||
|
||
|
||
def test_extract_tpl_xlsx_bad_zip_returns_none() -> None:
|
||
"""data-file — валидный base64, но не ZIP → None (graceful)."""
|
||
b64 = base64.b64encode(b"not a zip at all").decode("ascii")
|
||
html = f'<div id="dataFile" data-file="{b64}"></div>'
|
||
assert eh.extract_tpl_xlsx(html) is None
|
||
|
||
|
||
# ── parse_heat_sheet: СТ №54 → 3.26, скип заголовков, правейшее число ─────────
|
||
|
||
|
||
def test_parse_heat_sheet_detects_system_and_rightmost_number() -> None:
|
||
"""«СТ №54 … 3.26» распознан; строки-заголовки без чисел скипнуты."""
|
||
systems, all_cells = eh.parse_heat_sheet(_heat_xlsx_bytes())
|
||
|
||
assert len(systems) == 1
|
||
assert systems[0]["system_name"] == "СТ №54"
|
||
assert systems[0]["reserve_gcal_h"] == 3.26
|
||
# all_cells содержит ВЕСЬ лист (3 строки) для raw.
|
||
assert len(all_cells) == 3
|
||
|
||
|
||
def test_parse_heat_sheet_rightmost_number_wins() -> None:
|
||
"""Если в строке несколько чисел — берётся правейшее."""
|
||
wb = openpyxl.Workbook()
|
||
ws = wb.active
|
||
ws.title = "ТП"
|
||
ws.append(["зона Центральная", 100.0, 12.5]) # правейшее = 12.5 (резерв)
|
||
buf = io.BytesIO()
|
||
wb.save(buf)
|
||
|
||
systems, _ = eh.parse_heat_sheet(buf.getvalue())
|
||
assert len(systems) == 1
|
||
assert systems[0]["system_name"] == "зона Центральная"
|
||
assert systems[0]["reserve_gcal_h"] == 12.5
|
||
|
||
|
||
def test_parse_heat_sheet_zero_systems() -> None:
|
||
"""Лист без системных строк → systems пуст, но all_cells собран."""
|
||
wb = openpyxl.Workbook()
|
||
ws = wb.active
|
||
ws.title = "ТП"
|
||
ws.append(["Совершенно посторонний заголовок", None])
|
||
ws.append(["ещё строка", 5.0]) # число есть, но нет системного маркера → скип
|
||
buf = io.BytesIO()
|
||
wb.save(buf)
|
||
|
||
systems, all_cells = eh.parse_heat_sheet(buf.getvalue())
|
||
assert systems == []
|
||
assert len(all_cells) == 2
|
||
|
||
|
||
# ── Fake DB (SAVEPOINT + execute), как в test_eesk_reserve_loader.py ──────────
|
||
|
||
|
||
class _FakeResult:
|
||
def __init__(self, scalar_value: Any = True) -> None:
|
||
self._scalar = scalar_value
|
||
|
||
def scalar(self) -> Any:
|
||
return self._scalar
|
||
|
||
|
||
class _FakeSession:
|
||
"""Мини-заглушка Session: собирает execute-параметры, поддерживает begin_nested."""
|
||
|
||
def __init__(self, scalar_value: Any = True) -> None:
|
||
self.calls: list[tuple[str, Any]] = []
|
||
self._scalar = scalar_value
|
||
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)
|
||
|
||
def commit(self) -> None:
|
||
self.committed = True
|
||
|
||
def rollback(self) -> None:
|
||
pass
|
||
|
||
|
||
def test_upsert_heat_rows_counters_and_params() -> None:
|
||
"""parsed/inserted счётчики; params корректны; raw содержит cells; CAST jsonb."""
|
||
db = _FakeSession(scalar_value=True) # xmax=0 → insert
|
||
systems = [
|
||
{"system_name": "СТ №54", "reserve_gcal_h": 3.26},
|
||
{"system_name": "зона Юг", "reserve_gcal_h": -1.2}, # дефицит: знак сохраняется
|
||
]
|
||
all_cells = [["СТ №54", "источник", 3.26], ["зона Юг", None, -1.2]]
|
||
|
||
counts = eh._upsert_heat_rows(db, "ЕТК", "2026-Q2", "guid-xyz", systems, all_cells)
|
||
|
||
assert counts == {"parsed": 2, "inserted": 2, "updated": 0, "skipped": 0}
|
||
assert len(db.calls) == 2
|
||
|
||
sql, first = db.calls[0]
|
||
assert first["org"] == "ЕТК"
|
||
assert first["system_name"] == "СТ №54"
|
||
assert first["reserve"] == 3.26
|
||
assert first["period"] == "2026-Q2"
|
||
assert first["source_guid"] == "guid-xyz"
|
||
# raw — JSON-строка с ключом cells (весь лист), CAST в jsonb в SQL.
|
||
assert '"cells"' in first["raw"]
|
||
assert "CAST(:raw AS jsonb)" in sql
|
||
assert "ON CONFLICT (org, sphere, system_name, period)" in sql
|
||
# negative reserve (дефицит) прокинут знаком.
|
||
assert db.calls[1][1]["reserve"] == -1.2
|
||
|
||
|
||
def test_upsert_heat_rows_update_counter() -> None:
|
||
"""scalar()=False (xmax≠0) → строка учтена как update, не insert."""
|
||
db = _FakeSession(scalar_value=False)
|
||
systems = [{"system_name": "СТ №1", "reserve_gcal_h": 1.0}]
|
||
counts = eh._upsert_heat_rows(
|
||
db, "Екатеринбургэнерго", "2026-Q2", "g1", systems, [["СТ №1", 1.0]]
|
||
)
|
||
assert counts == {"parsed": 1, "inserted": 0, "updated": 1, "skipped": 0}
|
||
|
||
|
||
def test_upsert_heat_rows_skip_on_error() -> None:
|
||
"""execute бросает → строка skipped, счётчик не растёт в inserted/updated."""
|
||
|
||
class _RaisingSession(_FakeSession):
|
||
def execute(self, stmt: Any, params: Any = None) -> _FakeResult:
|
||
raise RuntimeError("db boom")
|
||
|
||
db = _RaisingSession()
|
||
systems = [{"system_name": "СТ №9", "reserve_gcal_h": 2.0}]
|
||
counts = eh._upsert_heat_rows(db, "ЕТК", "2026-Q2", "g", systems, [["СТ №9", 2.0]])
|
||
assert counts == {"parsed": 1, "inserted": 0, "updated": 0, "skipped": 1}
|