"""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: """ строки грида: ссылка на TemplatePrinter (guid+id) + текст периода.""" href = f"/Discl/TemplatePrinter.aspx?reg=RU.5.66&guid={guid}&id={pub_id}" return f'выгрузить{period_text}' 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 = ( "" '" "" "
' f"' "выгрузить1 кв. 2026 года
" ) 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 = ( "" + "".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 ] ) + "
" ) 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 = ( "" + "".join( [ _pub_row("g-dup", "301", "1 кв. 2026 года"), # передача _pub_row("g-dup", "301", "1 кв. 2026 года"), # сбыт (та же публикация) _pub_row("g-other", "302", "1 кв. 2026 года"), ] ) + "
" ) 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 = ( "" + "".join( [ _pub_row("g-2025q4", "10", "4 квартал 2025"), _pub_row("g-2026q1", "20", "1 квартал 2026"), ] ) + "
" ) 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 = ( "" "" + _pub_row("g-noperiod", "99", "без указания периода") + "
шапка без ссылки 2 квартал 2026
" ) 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 с членом =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"") # посторонний член — должен игнорироваться b64 = base64.b64encode(zbuf.getvalue()).decode("ascii") return ( "" f'
' "" ) # Сокращённая копия РЕАЛЬНОЙ сетки листа «ТП» ЕТК (снята с прода, 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: """Нет
→ None.""" assert eh.extract_tpl_xlsx("пусто") 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'
' 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}