gendesign/backend/tests/test_connection_capacity_loaders.py
bot-backend 4c23c222f6
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m48s
Deploy / build-backend (push) Successful in 1m33s
Deploy / deploy (push) Successful in 1m27s
fix(vodokanal): column-aligned парсинг DOCX — реальные резервы вместо № п/п (#2119/#2126) (#2238)
2026-07-02 22:03:15 +00:00

651 lines
32 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-тесты загрузчиков «точек подключения со свободной мощностью» (#2119).
Pure / mock-based — без реальной сети и БД. Покрывает:
- normalize_sc_name: «ПС 110/35/10 Нижне-Исетская» ↔ «Нижне-Исетская», ё, тире.
- parse_voltage_class: извлечение класса напряжения из имени ЦП.
- _map_load_index: sc_indexload_id → open/limited/closed/None.
- sanitize_tp_capacity_mva: кВА-санитайз (÷1000 для ТП > порога, РП не трогаем).
- parse_reserve_number / parse_water_number: запятая, знак (дефицит <0), «н/д».
- infer_period: «1kv.-2026» → '2026-Q1'.
- parse_docx_table_rows: forward-fill вертикального merge (vMerge) на xml-фрагменте.
- extract_water_rows: column-aligned извлечение ЦСВ/ЦСК из РЕАЛЬНОЙ сетки прода —
оба layout (water 6-колонок «длинный» / sewerage 5-колонок «блочный»); резерв из
строки «Резерв мощности…», мусор (№ п/п, рубрики, «Итого:», шапки) отфильтрован,
нули сохранены — регрессия прод-бага #2126.
- load_tp_rp_reserves: row-offset (данные с строки 9) на синтетическом workbook.
- load_cp_reserves: row-offset (данные с строки 8) + матч по sc_name_norm.
"""
from __future__ import annotations
import io
from contextlib import contextmanager
from typing import Any
import openpyxl
from app.services.site_finder import rosseti_reserve_loader as rr
from app.services.site_finder import rosseti_wfs_loader as rw
from app.services.site_finder import vodokanal_reserve_loader as vk
# ── normalize_sc_name ─────────────────────────────────────────────────────────
def test_normalize_strips_ps_and_voltage_prefix() -> None:
"""«ПС 110/35/10 Нижне-Исетская» → «нижне-исетская» (совпадает с голым именем)."""
a = rw.normalize_sc_name("ПС 110/35/10 Нижне-Исетская")
b = rw.normalize_sc_name("Нижне-Исетская")
assert a == b == "нижне-исетская"
def test_normalize_yo_to_e() -> None:
"""ё → е при нормализации."""
assert rw.normalize_sc_name("Берёзовская") == rw.normalize_sc_name("Березовская")
assert rw.normalize_sc_name("Берёзовская") == "березовская"
def test_normalize_dash_variants_collapse() -> None:
"""Разные тире (—, , -) и пробелы вокруг схлопываются в один дефис."""
assert rw.normalize_sc_name("Нижне — Исетская") == "нижне-исетская"
assert rw.normalize_sc_name("Нижне–Исетская") == "нижне-исетская"
def test_normalize_quotes_removed() -> None:
"""«ПС "Уктус"» → «уктус» (кавычки и префикс срезаны)."""
assert rw.normalize_sc_name('ПС "Уктус"') == "уктус"
def test_normalize_empty() -> None:
assert rw.normalize_sc_name(None) == ""
assert rw.normalize_sc_name("") == ""
# ── parse_voltage_class ───────────────────────────────────────────────────────
def test_parse_voltage_class_triple() -> None:
assert rw.parse_voltage_class("ПС 110/35/10 Нижне-Исетская") == "110/35/10"
def test_parse_voltage_class_double() -> None:
assert rw.parse_voltage_class("ПС 110/10 Уктус") == "110/10"
def test_parse_voltage_class_none() -> None:
assert rw.parse_voltage_class("Уктус") is None
assert rw.parse_voltage_class(None) is None
# ── _map_load_index ───────────────────────────────────────────────────────────
def test_map_load_index_known() -> None:
assert rw._map_load_index({"sc_indexload_id": 256184}) == "open"
assert rw._map_load_index({"sc_indexload_id": 256186}) == "limited"
assert rw._map_load_index({"sc_indexload_id": 256188}) == "closed"
def test_map_load_index_string_id_coerced() -> None:
"""id приходит строкой из json — коэрсим в int."""
assert rw._map_load_index({"sc_indexload_id": "256184"}) == "open"
def test_map_load_index_unknown_and_missing() -> None:
assert rw._map_load_index({"sc_indexload_id": 999999}) is None
assert rw._map_load_index({}) is None
assert rw._map_load_index({"sc_indexload_id": "мусор"}) is None
# ── sanitize_tp_capacity_mva (кВА-санитайз) ───────────────────────────────────
def test_kva_sanitize_tp_above_threshold_divided() -> None:
"""ТП с installed=630 (кВА в колонке МВА) → 0.63 МВА, флаг True."""
val, sanitized = rr.sanitize_tp_capacity_mva(630.0, is_rp=False)
assert sanitized is True
assert val == 0.63
def test_kva_sanitize_tp_below_threshold_kept() -> None:
"""ТП с installed=1.6 МВА (правдоподобно) → не трогаем."""
val, sanitized = rr.sanitize_tp_capacity_mva(1.6, is_rp=False)
assert sanitized is False
assert val == 1.6
def test_kva_sanitize_rp_never_divided() -> None:
"""РП с высокой мощностью легитимен → не делим даже выше порога."""
val, sanitized = rr.sanitize_tp_capacity_mva(10.0, is_rp=True)
assert sanitized is False
assert val == 10.0
def test_kva_sanitize_none() -> None:
val, sanitized = rr.sanitize_tp_capacity_mva(None, is_rp=False)
assert val is None and sanitized is False
# ── parse_reserve_number ──────────────────────────────────────────────────────
def test_parse_reserve_comma_decimal() -> None:
assert rr.parse_reserve_number("1,63") == 1.63
def test_parse_reserve_negative_kept() -> None:
"""Отрицательный резерв (дефицит) сохраняет знак."""
assert rr.parse_reserve_number("-2,5") == -2.5
def test_parse_reserve_thousands_space() -> None:
"""Неразрывный пробел-разделитель тысяч убирается."""
assert rr.parse_reserve_number("1\xa0234,5") == 1234.5
def test_parse_reserve_nodata() -> None:
assert rr.parse_reserve_number("н/д") is None
assert rr.parse_reserve_number("") is None
assert rr.parse_reserve_number(None) is None
assert rr.parse_reserve_number("текст") is None
def test_parse_reserve_numeric_passthrough() -> None:
assert rr.parse_reserve_number(5) == 5.0
assert rr.parse_reserve_number(2.5) == 2.5
# ── parse_asof_date ───────────────────────────────────────────────────────────
def test_parse_asof_date_from_filename() -> None:
d = rr.parse_asof_date("резерв по ЦП на 30.06.2026.xlsx")
assert d is not None and d.isoformat() == "2026-06-30"
def test_parse_asof_date_none() -> None:
assert rr.parse_asof_date("no date here", None) is None
# ── water: parse_water_number / infer_period ──────────────────────────────────
def test_parse_water_negative_unicode_minus() -> None:
"""Юникод-минус 168,5 (дефицит) → -168.5."""
assert vk.parse_water_number("168,5") == -168.5
def test_parse_water_nodata() -> None:
assert vk.parse_water_number("н/д") is None
assert vk.parse_water_number("") is None
assert vk.parse_water_number(None) is None
def test_infer_period_from_filename() -> None:
assert vk.infer_period("dlya-sajta-1kv.-2026.docx") == "2026-Q1"
assert vk.infer_period("dlya-sajta-3кв-2025.docx") == "2025-Q3"
def test_infer_period_none() -> None:
assert vk.infer_period("dlya-sajta.docx") is None
# ── DOCX vMerge forward-fill ──────────────────────────────────────────────────
_W = vk._W
def _tc(text: str = "", vmerge: str | None = None) -> str:
"""Строит XML одной ячейки <w:tc> с опциональным vMerge."""
tc_pr = ""
if vmerge == "restart":
tc_pr = '<w:tcPr><w:vMerge w:val="restart"/></w:tcPr>'
elif vmerge == "continue":
tc_pr = "<w:tcPr><w:vMerge/></w:tcPr>"
return f"<w:tc>{tc_pr}<w:p><w:r><w:t>{text}</w:t></w:r></w:p></w:tc>"
def _doc_xml(rows: list[str]) -> bytes:
"""Строит минимальный document.xml с одной таблицей из строк-<w:tr>."""
trs = "".join(f"<w:tr>{r}</w:tr>" for r in rows)
xml = f'<w:document xmlns:w="{_W}"><w:body><w:tbl>{trs}</w:tbl></w:body></w:document>'
return xml.encode("utf-8")
def test_docx_vmerge_forward_fill() -> None:
"""Ячейка колонки 0 в 'continue'-строках наследует значение из restart-строки."""
rows = [
_tc("ЗФС, ГСВ", vmerge="restart") + _tc("168,5") + _tc("примечание"),
_tc("", vmerge="continue") + _tc("12,3") + _tc(""),
_tc("", vmerge="continue") + _tc("0,0") + _tc(""),
]
matrix = vk.parse_docx_table_rows(_doc_xml(rows))
assert len(matrix) == 3
# forward-fill: имя системы протянуто во все три строки блока.
assert matrix[0][0] == "ЗФС, ГСВ"
assert matrix[1][0] == "ЗФС, ГСВ"
assert matrix[2][0] == "ЗФС, ГСВ"
# числовая колонка не наследуется.
assert matrix[1][1] == "12,3"
def test_docx_empty_no_table() -> None:
xml = f'<w:document xmlns:w="{_W}"><w:body/></w:document>'.encode()
assert vk.parse_docx_table_rows(xml) == []
# ── extract_water_rows: РЕАЛЬНАЯ сетка прода (регрессия #2126) ────────────────
#
# Обе матрицы — сокращённые копии РЕАЛЬНОЙ сетки, снятой с прода 2026-07-03
# (dlya-sajta-vodosnabzhenie-1kv.-2026v1.docx / vodootvedenie). Прод-баг #2126:
# старый парсер брал «первое число строки» как резерв → в БД попадали № п/п
# (1.1, 2.18…) вместо тыс.м³/сут, а рубрики анкеты / «Итого:» / шапки — как «системы».
# Ниже фикстуры специально несут: шапку, рубрики (заявки/исполненные/отказы),
# строку «Итого:», строку-легенду, ноль-резерв с «Отсутствие резерва мощности».
# Наименования показателей формы (точь-в-точь с прода).
_M_REQ = "Количество поданных и зарегистрированных заявок на подключение"
_M_DONE = "Количество исполненных заявок на подключение"
_M_REFUSED = "Количество заявок на подключение, по которым принято решение об отказе в подключении"
_M_RESERVE_W = "Резерв мощности централизованной системы холодного водоснабжения"
_M_RESERVE_S = "Резерв мощности централизованной системы водоотведения"
def _water_block(
num: str, name: str, req: str, refused: str, reserve: str, note: str
) -> list[list[str]]:
"""4 строки показателей одного ЦСВ водоснабжения (layout «water», 6 колонок)."""
return [
[num, name, _M_REQ, "шт", req, ""],
[num, name, _M_DONE, "шт", "0", ""],
[num, name, _M_REFUSED, "шт", refused, ""],
[num, name, _M_RESERVE_W, "тыс. м3/сут", reserve, note],
]
def _real_water_matrix() -> list[list[str]]:
"""РЕАЛЬНАЯ сетка водоснабжения: шапка + 3 ЦСВ + ПТВ + «Итого:» + легенда."""
return [
["1 квартал 2026г."],
["Информация о наличии\xa0(отсутствии) технической возможности…"],
# шапка (col2 == 'Наименование показателя' → детект layout 'water')
[
"№ ЦСВ",
"Название ЦСВ",
"Наименование показателя",
"Ед. изм.",
"Период 01.01.2026 -31.03.2026",
"Примечание",
],
# ЗФС,ГСВ: резерв 168,5 (дефицит), 49 заявок; № п/п '1.1.' НЕ должен стать резервом
*_water_block("1.1.", 'ЦСВ "ЗФС, ГСВ"', "49", "0", "-168,5", ""),
["", "", "", "", "", ""], # разделитель-пустышка
# Изоплит: ноль-резерв «Отсутствие резерва мощности» — сохраняем 0.0 + note
*_water_block("1.3.", 'ЦСВ "Изоплит"', "0", "0", "0,0", "Отсутствие резерва мощности"),
["", "", "", "", "", ""],
# Шабровский: положительный резерв 1,03
*_water_block("2.10.", 'ЦСВ "пос. Шабровский"', "0", "0", "1,03", ""),
["", "", "", "", "", ""],
# ПТВ (№ ЦСВ = 'ПТВ', не числовой) — резерв 73,34, реальный крупный запас
*_water_block("ПТВ", "ПТВ", "0", "0", "73,34", ""),
["", "", "", ""],
# «Итого:» — агрегат по всем системам, НЕ резерв конкретной ЦСВ
["Итого:", _M_REQ, "шт", "65", ""],
["Итого:", _M_DONE, "шт", "41", ""],
["Итого:", _M_REFUSED, "шт", "0", ""],
# легенда в конце
["ЦСВ централизованная система водоснабжения", "", "", ""],
["ПТВ производственно-техническое водоснабжение", "", "", ""],
]
def _sewer_block(title: str, req: str, reserve: str, note: str) -> list[list[str]]:
"""Строка-заголовок системы + шапка + 4 показателя (layout «block», 5 колонок)."""
return [
[title], # одиночная строка-заголовок системы (только col0)
["№ п/п", "Наименование показателя", "Единица измерения", "Период", "Примечание"],
["1", _M_REQ, "шт.", req, ""],
["2", _M_DONE, "шт.", "0", ""],
["3", _M_REFUSED, "шт.", "0", ""],
["4", _M_RESERVE_S, "тыс.м3/сут", reserve, note],
]
def _real_sewer_matrix() -> list[list[str]]:
"""РЕАЛЬНАЯ сетка водоотведения: 3 системы-блока + «Итого:»."""
return [
["1 квартал 2026г."],
["Информация о наличии\xa0(отсутствии) технической возможности…"],
# Южный: дефицит 296,85 (крупнейший), 62 заявки; '4' в col0 НЕ должен стать резервом
*_sewer_block(
'Централизованная система водоотведения "Южный бассейн канализования"',
"62",
"-296,85",
"Отсутствие резерва мощности",
),
# Северный: положительный 2,02
*_sewer_block(
'Централизованная система водоотведения "Северный бассейн канализования"',
"3",
"2,02",
"",
),
# Транспортировка: ноль-резерв honestly сохраняем
*_sewer_block(
'Транспортировка сточных вод ЦСВО "Бассейн канализования "Чусовское озеро"',
"0",
"0",
"Отсутствие резерва мощности",
),
["", "", "", "", ""],
["Итого:", _M_REQ, "шт.", "65", ""],
["Итого:", _M_DONE, "шт.", "37", ""],
["Итого:", _M_REFUSED, "шт.", "0", ""],
]
def test_vodokanal_water_column_aligned_real_grid() -> None:
"""Layout 'water': резерв из строки «Резерв мощности…», а НЕ № п/п; заявки верны."""
recs = vk.extract_water_rows(_real_water_matrix())
by_name = {r["system_name"]: r for r in recs}
# ровно 4 системы (ЗФС, Изоплит, Шабровский, ПТВ) — ни одной рубрики/«Итого»/легенды
assert len(recs) == 4
# РЕАЛЬНЫЕ резервы из правильной колонки (тыс.м³/сут), знак дефицита сохранён
assert by_name['ЦСВ "ЗФС, ГСВ"']["reserve_thousand_m3_day"] == -168.5
assert by_name['ЦСВ "ЗФС, ГСВ"']["connections_requested"] == 49
assert by_name["ПТВ"]["reserve_thousand_m3_day"] == 73.34
assert by_name['ЦСВ "пос. Шабровский"']["reserve_thousand_m3_day"] == 1.03
# ни один резерв не равен № п/п (1.1 / 1.3 / 2.10) — прод-баг #2126
reserves = {r["reserve_thousand_m3_day"] for r in recs}
assert 1.1 not in reserves
assert 2.1 not in reserves
def test_vodokanal_water_zero_reserve_preserved_with_note() -> None:
"""Ноль-резерв («Отсутствие резерва мощности») сохраняется честно: 0.0 + note."""
recs = vk.extract_water_rows(_real_water_matrix())
izoplit = next(r for r in recs if r["system_name"] == 'ЦСВ "Изоплит"')
assert izoplit["reserve_thousand_m3_day"] == 0.0 # НЕ None, НЕ отброшен
assert izoplit["note"] == "Отсутствие резерва мощности"
def test_vodokanal_water_filters_rubrics_totals_legend() -> None:
"""Рубрики анкеты, «Итого:», шапка, легенда НЕ распознаются как системы."""
recs = vk.extract_water_rows(_real_water_matrix())
names = {r["system_name"] for r in recs}
assert not any("Количество" in n for n in names) # рубрики заявок/отказов
assert not any(n.startswith("Итого") for n in names)
assert "Название ЦСВ" not in names and "№ ЦСВ" not in names # шапка
assert not any(n.startswith("ЦСВ ") or n.startswith("ПТВ ") for n in names) # легенда
def test_vodokanal_sewer_block_layout_real_grid() -> None:
"""Layout 'block' (водоотведение): имя — строка-заголовок, резерв из «Резерв…»."""
recs = vk.extract_water_rows(_real_sewer_matrix())
by_name = {r["system_name"]: r for r in recs}
assert len(recs) == 3 # 3 системы, «Итого:»/шапки отфильтрованы
yuzhny = 'Централизованная система водоотведения "Южный бассейн канализования"'
assert by_name[yuzhny]["reserve_thousand_m3_day"] == -296.85 # крупнейший дефицит
assert by_name[yuzhny]["connections_requested"] == 62
severny = 'Централизованная система водоотведения "Северный бассейн канализования"'
assert by_name[severny]["reserve_thousand_m3_day"] == 2.02
# '4' (№ п/п строки резерва) не попал в резерв
assert 4.0 not in {r["reserve_thousand_m3_day"] for r in recs}
def test_vodokanal_sewer_zero_reserve_preserved() -> None:
"""Ноль-резерв транспортировки сохранён (0.0 + note), не отброшен."""
recs = vk.extract_water_rows(_real_sewer_matrix())
transp = next(r for r in recs if r["system_name"].startswith("Транспортировка"))
assert transp["reserve_thousand_m3_day"] == 0.0
assert transp["note"] == "Отсутствие резерва мощности"
def test_vodokanal_water_no_header_returns_empty() -> None:
"""Нет строки-шапки (layout не распознан) → [] (graceful, warning наверху)."""
matrix = [
["мусор", "без шапки"],
["1.1.", 'ЦСВ "X"', "какой-то показатель", "шт", "5", ""],
]
assert vk.extract_water_rows(matrix) == []
def test_vodokanal_dedupe_names_suffixes_byte_identical() -> None:
"""БАЙТ-В-БАЙТ одинаковые имена разводятся суффиксом « (N)» (защита UNIQUE)."""
recs = [
{"system_name": "ЦСВ X", "reserve_thousand_m3_day": 1.0},
{"system_name": "ЦСВ X", "reserve_thousand_m3_day": -2.0},
{"system_name": "ЦСВ Y", "reserve_thousand_m3_day": 0.0},
]
out = vk._dedupe_names(recs)
assert [r["system_name"] for r in out] == ["ЦСВ X", "ЦСВ X (2)", "ЦСВ Y"]
# первое вхождение — без суффикса, его резерв 1.0 НЕ теряется
assert out[0]["reserve_thousand_m3_day"] == 1.0
# ── Fake DB session для upsert-тестов (SAVEPOINT + execute) ───────────────────
class _FakeResult:
def __init__(self, scalar_value: Any = True, rowcount: int = 1) -> None:
self._scalar = scalar_value
self.rowcount = rowcount
def scalar(self) -> Any:
return self._scalar
class _FakeSession:
"""Мини-заглушка Session: собирает execute-параметры, поддерживает begin_nested."""
def __init__(self, scalar_value: Any = True, rowcount: int = 1) -> None:
self.calls: list[tuple[str, Any]] = []
self._scalar = scalar_value
self._rowcount = rowcount
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, self._rowcount)
def commit(self) -> None:
self.committed = True
def rollback(self) -> None:
pass
# ── load_water_reserves_from_docx: end-to-end (parse → dedupe → upsert) ────────
def _matrix_to_docx(matrix: list[list[str]]) -> bytes:
"""Матрица текстов → байты .docx (zip с word/document.xml, одна таблица)."""
import zipfile as _zip
from xml.sax.saxutils import escape
trs = []
for row in matrix:
tcs = "".join(
f"<w:tc><w:p><w:r><w:t xml:space='preserve'>{escape(c)}</w:t></w:r></w:p></w:tc>"
for c in row
)
trs.append(f"<w:tr>{tcs}</w:tr>")
document = (
f'<w:document xmlns:w="{_W}"><w:body><w:tbl>{"".join(trs)}</w:tbl></w:body></w:document>'
)
buf = io.BytesIO()
with _zip.ZipFile(buf, "w", _zip.ZIP_DEFLATED) as zf:
zf.writestr("word/document.xml", document.encode("utf-8"))
return buf.getvalue()
def test_vodokanal_load_from_docx_end_to_end() -> None:
"""Полный путь water: docx → 4 записи, реальные резервы прокинуты в upsert-params."""
db = _FakeSession(scalar_value=True, rowcount=1)
docx = _matrix_to_docx(_real_water_matrix())
result = vk.load_water_reserves_from_docx(
db, "water", docx, source_url="https://x/dlya-sajta-1kv.-2026.docx"
)
assert result["records"] == 4
assert result["inserted"] == 4
assert db.committed
# период инферится из имени файла источника
assert db.calls[0][1]["period"] == "2026-Q1"
by_name = {c[1]["system_name"]: c[1] for c in db.calls}
# РЕАЛЬНЫЙ резерв ЗФС,ГСВ (168,5), НЕ № п/п 1.1 — прод-баг #2126
assert by_name['ЦСВ "ЗФС, ГСВ"']["reserve"] == -168.5
assert by_name['ЦСВ "ЗФС, ГСВ"']["requested"] == 49
assert by_name["ПТВ"]["reserve"] == 73.34
# ноль-резерв Изоплита сохранён (0.0 + note), не отброшен и не None
assert by_name['ЦСВ "Изоплит"']["reserve"] == 0.0
assert by_name['ЦСВ "Изоплит"']["note"] == "Отсутствие резерва мощности"
def test_vodokanal_load_from_docx_sewer_layout() -> None:
"""Полный путь sewerage (block layout): 3 системы, крупнейший дефицит 296,85."""
db = _FakeSession(scalar_value=True, rowcount=1)
docx = _matrix_to_docx(_real_sewer_matrix())
result = vk.load_water_reserves_from_docx(db, "sewerage", docx, source_url="x-3kv-2025.docx")
assert result["records"] == 3
by_name = {c[1]["system_name"]: c[1] for c in db.calls}
yuzhny = 'Централизованная система водоотведения "Южный бассейн канализования"'
assert by_name[yuzhny]["reserve"] == -296.85
assert db.calls[0][1]["period"] == "2025-Q3"
# ── load_tp_rp_reserves: row-offset (данные с строки 9) на синтетическом xlsx ──
def _build_tp_rp_workbook() -> bytes:
"""Строит xlsx с верифицированной раскладкой ТП/РП: данные с СТРОКИ 9."""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "ТП РП на 30.06.2026"
# Строки 1-8 — заголовки/нумерация (шум), должны игнорироваться.
for r in range(1, 9):
ws.cell(row=r, column=1, value=f"header-{r}")
# Строка 9+ — данные. Колонки (1-based): name, РЭС, region, mun, кВ, inst, load, reserve.
ws.append(["ТП-721", "Центральный РЭС", "СО", "Екатеринбург", "10", 630.0, 100.0, 530.0])
ws.append(["РП-5", "Западный РЭС", "СО", "Екатеринбург", "10", 8.0, 2.0, 6.0])
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def test_load_tp_rp_row_offset_and_kva() -> None:
"""Данные читаются с строки 9; ТП-721 630→0.63 МВА (кВА-санитайз); РП не делится."""
db = _FakeSession(scalar_value=True, rowcount=1)
result = rr.load_tp_rp_reserves(db, _build_tp_rp_workbook(), "тп рп на 30.06.2026.xlsx")
assert result["rows"] == 2, result
assert result["inserted"] == 2
assert result["kva_sanitized"] == 1 # только ТП-721
# Первый upsert — ТП-721 с санитайзнутой мощностью.
tp_params = db.calls[0][1]
assert tp_params["name"] == "ТП-721"
assert tp_params["installed"] == 0.63 # 630 кВА → 0.63 МВА
assert tp_params["reserve"] == 530.0
# РП-5 — мощность не тронута (легитимно высокая для РП, но тут 8 < порога всё равно).
rp_params = db.calls[1][1]
assert rp_params["name"] == "РП-5"
assert rp_params["installed"] == 8.0
# ── load_cp_reserves: row-offset (строка 8) + матч по sc_name_norm ─────────────
def _build_cp_workbook() -> bytes:
"""Строит xlsx с раскладкой ЦП: данные с СТРОКИ 8.
Колонки (1-based): A=№, B=ЦП name, C=ПО, D=?, E=municipality, F=voltage,
G=installed, H=current, I=reserve.
"""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "ЦП на 30.06.2026"
for r in range(1, 8):
ws.cell(row=r, column=1, value=f"header-{r}")
# Строка 8 — первая строка данных.
ws.append([1, "ПС 110/10 Уктус", "ПО-1", "", "Екатеринбург", "110/10", 40.0, 25.0, 15.0])
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def test_load_cp_reserves_row_offset_and_norm_match() -> None:
"""Данные с строки 8; UPDATE по sc_name_norm; matched считает rowcount."""
db = _FakeSession(scalar_value=None, rowcount=1) # UPDATE matched 1 row
result = rr.load_cp_reserves(db, _build_cp_workbook(), "цп на 30.06.2026.xlsx")
assert result["rows"] == 1, result
assert result["matched"] == 1
assert result["unmatched"] == 0
params = db.calls[0][1]
# sc_name_norm совпадает с нормализацией голого имени «Уктус».
assert params["name_norm"] == rw.normalize_sc_name("Уктус") == "уктус"
assert params["installed"] == 40.0
assert params["reserve"] == 15.0
assert params["asof"] is not None and params["asof"].isoformat() == "2026-06-30"
def test_load_cp_reserves_unmatched_counted() -> None:
"""ЦП без совпадения в power_supply_centers (rowcount=0) → unmatched, не падаем."""
db = _FakeSession(scalar_value=None, rowcount=0) # UPDATE matched 0 rows
result = rr.load_cp_reserves(db, _build_cp_workbook(), "цп на 30.06.2026.xlsx")
assert result["matched"] == 0
assert result["unmatched"] == 1
# ── fetch_disclosure_links (прод-инцидент 2026-07-02: URL-энкоженные href) ────
def test_fetch_disclosure_links_decodes_urlencoded_hrefs(monkeypatch):
"""href'ы на странице раскрытия URL-энкоженные (кириллица как %D0%A1...) —
фильтр обязан работать по декодированному имени и выбирать свежайший файл.
Пермский файл отфильтровывается, из двух ЦП-дат берётся поздняя."""
from urllib.parse import quote, unquote
base = "/public/upload/Documents/2026/Transmission/"
cp_tpl = (
"Информация о наличии объема свободной мощности по ЦП ПАО «Россети Урал» "
"на территории {region} (на {d}).xlsx"
)
tp_name = (
"Информация о наличии свободной мощности для ТП по ПС и РП напряжением "
"ниже 35 кВ филиала Свердловэнерго на 30.06.2026.xlsx"
)
hrefs = [
base + quote(cp_tpl.format(region="Свердловской области", d="30.03.2026")),
base + quote(cp_tpl.format(region="Свердловской области", d="30.06.2026")),
base + quote(cp_tpl.format(region="Пермского края", d="30.06.2026")),
base + quote(tp_name),
]
html = "".join(f'<a href="{h}">x</a>' for h in hrefs)
class _Resp:
status_code = 200
text = html
def raise_for_status(self):
return None
monkeypatch.setattr(
"app.services.site_finder.rosseti_reserve_loader.httpx.get",
lambda *a, **k: _Resp(),
)
links = rr.fetch_disclosure_links()
assert "30.06.2026" in unquote(links["cp"])
assert "Пермск" not in unquote(links["cp"])
assert "%D0" in links["cp"] # скачиваем по СЫРОМУ (энкоженному) href
assert "Свердловэнерго" in unquote(links["tp_rp"])