476 lines
21 KiB
Python
476 lines
21 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: column-aligned парс РЕАЛЬНОЙ сетки ЕТК (строка «Централизованная
|
||
система» ↔ строка №5 «Резерв мощности…»), нуль-резерв сохранён, Flag_Row_Size не ловит
|
||
рубрики анкеты (регрессия прод-бага);
|
||
- _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>"
|
||
)
|
||
|
||
|
||
# Сокращённая копия РЕАЛЬНОЙ сетки листа «ТП» ЕТК (снята с прода, raw jsonb). Колонки
|
||
# 0-indexed. Систем 3 (col7/8/9), последняя колонка = служебный Flag_Row_Size (числа-
|
||
# высоты строк шаблона ЕИАС) — ловушка, которая заставляла старый парсер хватать рубрики
|
||
# анкеты. Строки-рубрики анкеты (col3 == 1..4) специально несут БОЛЬШИЕ Flag_Row_Size
|
||
# (74/52) в последней колонке — регрессия прод-бага.
|
||
_ROW_SIZE_COL = 25 # индекс служебной колонки Flag_Row_Size
|
||
|
||
_LONG_RESERVE_NOTE = (
|
||
"Указывается резерв мощности для системы теплоснабжения, тариф для которой не "
|
||
"является отличным от тарифов других систем теплоснабжения регулируемой организации."
|
||
)
|
||
|
||
|
||
def _pad(cells: dict[int, object]) -> list[object]:
|
||
"""Строка листа как список из 26 колонок: dict{col_index: value} → выровненный ряд."""
|
||
row: list[object] = [None] * 26
|
||
for idx, value in cells.items():
|
||
row[idx] = value
|
||
return row
|
||
|
||
|
||
def _heat_xlsx_bytes() -> bytes:
|
||
"""XLSX с листом 'ТП' — сокращённая РЕАЛЬНАЯ сетка ЕТК (3 системы + строка №5).
|
||
|
||
Воспроизводит прод-структуру: строка «Централизованная система» (имена в col7/8/9 +
|
||
служебное число '15' в хвосте), строка №5 «Резерв мощности…» (0 / 3.26 / 11.28 в тех
|
||
же колонках + текст-примечание), рубрики анкеты с большим Flag_Row_Size (регрессия),
|
||
служебные строки diff_*/Flag_Col_Size.
|
||
"""
|
||
wb = openpyxl.Workbook()
|
||
ws = wb.active
|
||
ws.title = "ТП"
|
||
|
||
# Служебная строка diff_* (col7.. заголовки-технические) + Flag_Row_Size.
|
||
ws.append(_pad({7: "diff_1", 8: "diff_5", 9: "diff_51", _ROW_SIZE_COL: 1}))
|
||
# Строка-заголовок систем: имена в col7/8/9 (trailing-space у части), '15' в хвосте.
|
||
ws.append(
|
||
_pad(
|
||
{
|
||
4: "Централизованная система",
|
||
7: "СТ № 56",
|
||
8: "СТ № 54 ", # trailing space — должен быть stripped
|
||
9: "СТ № 60 ",
|
||
20: "15", # служебное ЧИСЛО в хвосте строки-заголовка → фильтруется
|
||
_ROW_SIZE_COL: 15,
|
||
}
|
||
)
|
||
)
|
||
# Рубрика анкеты (col3==3): большой Flag_Row_Size 74 — старый парсер ловил его.
|
||
ws.append(
|
||
_pad(
|
||
{
|
||
3: 3,
|
||
4: "Количество заявок, по которым отказано в заключении договора",
|
||
5: "ед",
|
||
7: 0,
|
||
8: 0,
|
||
9: 0,
|
||
_ROW_SIZE_COL: 74, # ЛОВУШКА: не должен попасть в системы
|
||
}
|
||
)
|
||
)
|
||
# Рубрика анкеты (col3==4): Flag_Row_Size 52 — тоже ловушка.
|
||
ws.append(
|
||
_pad(
|
||
{
|
||
3: 4,
|
||
4: "Причины отказа в заключении договора",
|
||
5: "x",
|
||
10: "Указывается текстовое описание причин.",
|
||
_ROW_SIZE_COL: 52, # ЛОВУШКА
|
||
}
|
||
)
|
||
)
|
||
# Строка резерва №5: значения Гкал/ч в col7/8/9 (0 / 3.26 / 11.28) + примечание.
|
||
ws.append(
|
||
_pad(
|
||
{
|
||
3: 5,
|
||
4: "Резерв мощности источников тепловой энергии, входящих в систему, в т.ч.:",
|
||
5: "Гкал/час",
|
||
6: 0, # слот без имени системы (col6 header = None) → игнорируется
|
||
7: 0, # СТ № 56 → 0.0 (нулевой резерв — честный дефицит, сохраняем)
|
||
8: 3.26, # СТ № 54
|
||
9: 11.28, # СТ № 60
|
||
10: _LONG_RESERVE_NOTE, # текст-примечание в хвосте
|
||
_ROW_SIZE_COL: 57,
|
||
}
|
||
)
|
||
)
|
||
# Кнопка «Добавить систему» с Flag_Row_Size 15 — тоже не должна стать системой.
|
||
ws.append(_pad({4: "Добавить систему", _ROW_SIZE_COL: 15}))
|
||
# Служебная последняя строка Flag_Col_Size.
|
||
ws.append(_pad({0: "Flag_Col_Size", 1: 0, 4: 36, _ROW_SIZE_COL: 23}))
|
||
|
||
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: column-aligned парс РЕАЛЬНОЙ сетки ЕТК ──────────────────
|
||
|
||
|
||
def test_parse_heat_sheet_column_aligned_three_systems() -> None:
|
||
"""Ровно 3 системы, читаемые по колонкам строки №5; имена stripped, нуль сохранён.
|
||
|
||
Реальные прод-пары: СТ № 56 → 0.0, СТ № 54 → 3.26, СТ № 60 → 11.28.
|
||
"""
|
||
systems, all_cells = eh.parse_heat_sheet(_heat_xlsx_bytes())
|
||
|
||
pairs = [(s["system_name"], s["reserve_gcal_h"]) for s in systems]
|
||
assert pairs == [
|
||
("СТ № 56", 0.0), # нулевой резерв — честный дефицит, СОХРАНЯЕТСЯ
|
||
("СТ № 54", 3.26), # trailing-space в имени убран .strip()
|
||
("СТ № 60", 11.28),
|
||
]
|
||
# all_cells содержит ВЕСЬ лист (7 строк) для raw.
|
||
assert len(all_cells) == 7
|
||
|
||
|
||
def test_parse_heat_sheet_ignores_anketa_rubrics_flag_row_size() -> None:
|
||
"""Регрессия прод-бага: рубрики анкеты (Flag_Row_Size 74/52 в служебной колонке)
|
||
и кнопка «Добавить систему» НЕ распознаются как системы теплоснабжения."""
|
||
systems, _ = eh.parse_heat_sheet(_heat_xlsx_bytes())
|
||
|
||
names = {s["system_name"] for s in systems}
|
||
reserves = {s["reserve_gcal_h"] for s in systems}
|
||
# ни одна рубрика/кнопка не просочилась
|
||
assert not any("Количество" in n for n in names)
|
||
assert not any("Причины" in n for n in names)
|
||
assert "Добавить систему" not in names
|
||
# значения Flag_Row_Size (74, 52, 15) не попали в резервы
|
||
assert 74.0 not in reserves
|
||
assert 52.0 not in reserves
|
||
|
||
|
||
def test_parse_heat_sheet_suffixes_duplicate_system_names() -> None:
|
||
"""Разные системы с БАЙТ-В-БАЙТ одинаковым именем не схлопываются.
|
||
|
||
Живой прод-кейс (Екатеринбургэнерго, guid fe0c8c57): col7 и col8 обе называются
|
||
«Локальные зоны теплоснабжения МУП "Екатеринбургэнерго"» с резервами 62.198 и 0 —
|
||
UPSERT по (org, sphere, system_name, period) перезаписывал 62.198 нулём. Вторым
|
||
и далее вхождениям добавляется суффикс « (N)».
|
||
"""
|
||
wb = openpyxl.Workbook()
|
||
ws = wb.active
|
||
ws.title = "ТП"
|
||
dup = 'Локальные зоны теплоснабжения МУП "Екатеринбургэнерго"'
|
||
ws.append(
|
||
_pad(
|
||
{
|
||
4: "Централизованная система",
|
||
7: dup,
|
||
8: dup,
|
||
9: 'Локальные зоны теплоснабжения ПАО "Т Плюс"',
|
||
_ROW_SIZE_COL: 15,
|
||
}
|
||
)
|
||
)
|
||
ws.append(
|
||
_pad(
|
||
{
|
||
3: 5,
|
||
4: "Резерв мощности источников тепловой энергии, в т.ч.:",
|
||
5: "Гкал/час",
|
||
7: 62.198,
|
||
8: 0,
|
||
9: 0,
|
||
_ROW_SIZE_COL: 57,
|
||
}
|
||
)
|
||
)
|
||
buf = io.BytesIO()
|
||
wb.save(buf)
|
||
|
||
systems, _ = eh.parse_heat_sheet(buf.getvalue())
|
||
|
||
pairs = [(s["system_name"], s["reserve_gcal_h"]) for s in systems]
|
||
assert pairs == [
|
||
(dup, 62.198), # первое вхождение — без суффикса, 62.198 НЕ теряется
|
||
(f"{dup} (2)", 0.0),
|
||
('Локальные зоны теплоснабжения ПАО "Т Плюс"', 0.0),
|
||
]
|
||
|
||
|
||
def test_parse_heat_sheet_no_systems_header_returns_empty() -> None:
|
||
"""Нет строки «Централизованная система» → ([], all_cells) (graceful, не падаем)."""
|
||
wb = openpyxl.Workbook()
|
||
ws = wb.active
|
||
ws.title = "ТП"
|
||
ws.append(_pad({4: "Какой-то посторонний заголовок", _ROW_SIZE_COL: 15}))
|
||
ws.append(_pad({3: 5, 4: "Резерв мощности источников тепловой энергии, в т.ч.:", 7: 3.26}))
|
||
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}
|