gendesign/backend/tests/test_eias_heat_loader.py
bot-backend 139bf16a8a
All checks were successful
Deploy / build-backend (push) Successful in 2m3s
Deploy / build-worker (push) Successful in 3m25s
Deploy / deploy (push) Successful in 1m40s
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
fix(eias-heat): не терять резерв при одинаковых именах систем (#2119 B2) (#2191)
2026-07-02 18:34:42 +00:00

476 lines
21 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: 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-энкожены (&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>"
)
# Сокращённая копия РЕАЛЬНОЙ сетки листа «ТП» ЕТК (снята с прода, 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}