gendesign/backend/tests/test_eias_heat_loader.py
Light1YT a5e8208fcb
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / openapi-codegen-check (pull_request) Successful in 1m56s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Successful in 14m31s
fix(eias-heat): parse TemplatePrinter link from onclick + «N кв. YYYY» period
Карточка 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
2026-07-02 22:08:10 +05:00

331 lines
14 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-тесты загрузчика тепло-резервов ФАС (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-энкожены (&amp;), период «1 кв. 2026 года» в тексте строки."""
url = (
"https://ri-loader.eias.ru/TemplatePrinter.aspx?reg=RU.5.66"
"&amp;guid=40665f4f-c7e7-4121-ac15-0bff24c39f81&amp;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}