fix(eias-heat): column-aligned парсинг листа ТП по реальной сетке (#2119 B2) #2176
2 changed files with 224 additions and 67 deletions
|
|
@ -17,11 +17,24 @@ reg=2644). Данные лежат за 3 GET-переходами:
|
||||||
3. Лист ``ТП`` формы 14 — «Резерв мощности источников тепловой энергии»
|
3. Лист ``ТП`` формы 14 — «Резерв мощности источников тепловой энергии»
|
||||||
(живой пример: СТ №54 → 3.26 Гкал/час).
|
(живой пример: СТ №54 → 3.26 Гкал/час).
|
||||||
|
|
||||||
Точная сетка листа НЕ снята разведкой → парсинг DEFENSIVE: итерируем ВСЕ строки,
|
Реальная сетка листа снята с прода (raw jsonb, все 4 публикации единообразны) —
|
||||||
строка-кандидат = текстовая ячейка матчит «СТ №N | систем | зона» И в той же строке
|
парсинг COLUMN-ALIGNED, а НЕ «правейшее число в строке»:
|
||||||
есть парсибельное число; берём ПРАВЕЙШЕЕ число как резерв. Все ячейки листа целиком
|
|
||||||
→ ``raw`` jsonb (будущие колонки не теряются). Ноль распознанных систем → warning-лог
|
* Строка-заголовок систем: ``col4 == 'Централизованная система'``; ИМЕНА систем
|
||||||
(первые 20 строк) + parsed=0, НЕ падаем.
|
лежат в колонках ``j ≥ 6`` (текст: «СТ № 56», «СТ № 54 » с trailing-space, «Локальные
|
||||||
|
зоны теплоснабжения МУП…»). В хвосте той же строки бывают СЛУЖЕБНЫЕ ЧИСЛА (шаблон
|
||||||
|
ЕИАС) — отсекаем их требованием «имя содержит хотя бы одну букву».
|
||||||
|
* Строка резерва (№5): ``col3.strip() == '5'`` И/ИЛИ ``col4`` начинается с «Резерв
|
||||||
|
мощности источников тепловой энергии» — значения Гкал/ч стоят в ТЕХ ЖЕ колонках
|
||||||
|
``j``, что и имена систем. В хвосте — длинный текст-примечание «Указывается резерв…»
|
||||||
|
и служебные числа: они не в колонках систем и отсекаются сами.
|
||||||
|
* ЛОВУШКА ``Flag_Row_Size``: последняя КОЛОНКА листа (col25) — служебные числа-высоты
|
||||||
|
строк шаблона ЕИАС (74, 52, 15 у рубрик анкеты). Старый «правейшее число строки»
|
||||||
|
хватал именно их и распознавал рубрики анкеты («Количество заявок…», «Причины
|
||||||
|
отказа…», кнопку «Добавить систему») как системы. Column-aligned парс их не видит.
|
||||||
|
|
||||||
|
Все ячейки листа целиком → ``raw`` jsonb (будущие колонки не теряются). Нет строки
|
||||||
|
систем ИЛИ строки резерва → warning-лог + parsed=0, НЕ падаем.
|
||||||
|
|
||||||
Источник ГЕО-БЛОКИРУЕТ non-RU IP + шлёт российский УЦ без intermediate → загрузчик
|
Источник ГЕО-БЛОКИРУЕТ non-RU IP + шлёт российский УЦ без intermediate → загрузчик
|
||||||
РАБОТАЕТ НА ПРОДЕ (Celery weekly / manual docker exec), SSL через общий
|
РАБОТАЕТ НА ПРОДЕ (Celery weekly / manual docker exec), SSL через общий
|
||||||
|
|
@ -97,8 +110,20 @@ _DATAFILE_RE = re.compile(
|
||||||
re.IGNORECASE | re.DOTALL,
|
re.IGNORECASE | re.DOTALL,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Строка-кандидат листа: текстовая ячейка про систему теплоснабжения / зону / СТ №N.
|
# Заголовок листа со строкой систем: в этой строке col4 == этому значению, а имена
|
||||||
_SYSTEM_MARKER_RE = re.compile(r"СТ\s*№\s*\d+|систем|зона", re.IGNORECASE)
|
# систем стоят в колонках j ≥ 6 (см. docstring модуля, п.3).
|
||||||
|
_SYSTEMS_HEADER = "Централизованная система"
|
||||||
|
|
||||||
|
# Строка резерва мощности (№5 формы 14): col3 == '5' И/ИЛИ col4 начинается с этого.
|
||||||
|
_RESERVE_ROW_NUM = "5"
|
||||||
|
_RESERVE_ROW_PREFIX = "Резерв мощности источников тепловой энергии"
|
||||||
|
|
||||||
|
# Первая колонка листа, где могут стоять имена систем (col0..col5 — служебные/№/ед.изм.).
|
||||||
|
_SYSTEM_COL_START = 6
|
||||||
|
|
||||||
|
# Имя системы обязано содержать хотя бы одну букву (кириллица/латиница) — иначе это
|
||||||
|
# служебное число из хвоста строки-заголовка (Flag_Row_Size шаблона ЕИАС).
|
||||||
|
_HAS_LETTER_RE = re.compile(r"[А-Яа-яЁёA-Za-z]")
|
||||||
|
|
||||||
# Разбивка HTML грида на строки (<tr>) — публикации живут построчно, период и
|
# Разбивка HTML грида на строки (<tr>) — публикации живут построчно, период и
|
||||||
# ссылка в одной строке. Достаточно грубого split по <tr, полноценный DOM не нужен.
|
# ссылка в одной строке. Достаточно грубого split по <tr, полноценный DOM не нужен.
|
||||||
|
|
@ -211,57 +236,93 @@ def extract_tpl_xlsx(template_html: str) -> bytes | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _rightmost_number(cells: list[object]) -> float | None:
|
def _cell_str(value: object) -> str:
|
||||||
"""Правейшее парсибельное число в строке (резерв). Нет чисел → None."""
|
"""Ячейка → strip-строка (None → '')."""
|
||||||
for value in reversed(cells):
|
return "" if value is None else str(value).strip()
|
||||||
num = parse_reserve_number(value)
|
|
||||||
if num is not None:
|
|
||||||
return num
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _first_system_text(cells: list[object]) -> str | None:
|
def _is_systems_header(cells: list[object]) -> bool:
|
||||||
"""Левейшая текстовая ячейка, матчащая систему/зону/СТ №N. Нет → None."""
|
"""Строка-заголовок систем: col4 == 'Централизованная система'."""
|
||||||
for value in cells:
|
return len(cells) > 4 and _cell_str(cells[4]) == _SYSTEMS_HEADER
|
||||||
|
|
||||||
|
|
||||||
|
def _is_reserve_row(cells: list[object]) -> bool:
|
||||||
|
"""Строка резерва (№5): col3.strip() == '5' И/ИЛИ col4 начинается с «Резерв мощности…»."""
|
||||||
|
if len(cells) <= 4:
|
||||||
|
return False
|
||||||
|
return _cell_str(cells[3]) == _RESERVE_ROW_NUM or _cell_str(cells[4]).startswith(
|
||||||
|
_RESERVE_ROW_PREFIX
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _system_columns(header_cells: list[object]) -> list[tuple[int, str]]:
|
||||||
|
"""Из строки-заголовка → пары (col_index, system_name) для j ≥ 6.
|
||||||
|
|
||||||
|
Имя обязано быть текстовым и содержать хотя бы одну букву — иначе это служебное
|
||||||
|
число из хвоста строки (Flag_Row_Size шаблона ЕИАС), а не система.
|
||||||
|
"""
|
||||||
|
result: list[tuple[int, str]] = []
|
||||||
|
for j in range(_SYSTEM_COL_START, len(header_cells)):
|
||||||
|
value = header_cells[j]
|
||||||
if value is None:
|
if value is None:
|
||||||
continue
|
continue
|
||||||
s = str(value).strip()
|
name = str(value).strip()
|
||||||
if s and _SYSTEM_MARKER_RE.search(s):
|
if name and _HAS_LETTER_RE.search(name):
|
||||||
return s
|
result.append((j, name))
|
||||||
return None
|
return result
|
||||||
|
|
||||||
|
|
||||||
def parse_heat_sheet(xlsx_bytes: bytes) -> tuple[list[dict], list[list[object]]]:
|
def parse_heat_sheet(xlsx_bytes: bytes) -> tuple[list[dict], list[list[object]]]:
|
||||||
"""Парсит лист ``ТП`` формы 14 → (systems, all_cells).
|
"""Парсит лист ``ТП`` формы 14 → (systems, all_cells).
|
||||||
|
|
||||||
DEFENSIVE (точная сетка не снята): итерируем ВСЕ строки листа. Строка-кандидат =
|
COLUMN-ALIGNED по РЕАЛЬНОЙ сетке (не «правейшее число в строке»):
|
||||||
есть текстовая ячейка про систему («СТ №N | систем | зона») И в той же строке
|
|
||||||
есть парсибельное число → system_name = текст (strip), reserve_gcal_h = ПРАВЕЙШЕЕ
|
1. Строка-заголовок систем — ``col4 == 'Централизованная система'``; имена систем
|
||||||
число строки. Строки-заголовки (без чисел) пропускаются.
|
берём из колонок ``j ≥ 6`` (текст, содержит букву — служебные числа хвоста
|
||||||
|
отсеиваются), имя .strip() (у части систем trailing-space).
|
||||||
|
2. Строка резерва (№5) — ``col3 == '5'`` И/ИЛИ ``col4`` начинается с «Резерв
|
||||||
|
мощности источников тепловой энергии»; резерв каждой системы читаем из ТОЙ ЖЕ
|
||||||
|
колонки ``j``, что и её имя. Нулевой резерв (0.0) — честный дефицит, СОХРАНЯЕМ;
|
||||||
|
непарсибельная ячейка (текст/пусто) — систему пропускаем.
|
||||||
|
|
||||||
|
Нет строки систем ИЛИ строки резерва → warning-лог + ([], all_cells) (graceful).
|
||||||
|
Служебная колонка 25 ``Flag_Row_Size`` (числа-высоты строк) в парс не попадает —
|
||||||
|
именно она заставляла старый парсер хватать рубрики анкеты как «системы».
|
||||||
|
|
||||||
``all_cells`` — ВСЕ ячейки листа (список строк-списков) для сохранения в raw.
|
``all_cells`` — ВСЕ ячейки листа (список строк-списков) для сохранения в raw.
|
||||||
Ноль распознанных систем → warning-лог с первыми 20 строками (в ``load_*``).
|
|
||||||
"""
|
"""
|
||||||
wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
|
wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
|
||||||
ws = wb[_HEAT_SHEET] if _HEAT_SHEET in wb.sheetnames else wb.active
|
ws = wb[_HEAT_SHEET] if _HEAT_SHEET in wb.sheetnames else wb.active
|
||||||
|
|
||||||
systems: list[dict] = []
|
|
||||||
all_cells: list[list[object]] = []
|
all_cells: list[list[object]] = []
|
||||||
|
header_cells: list[object] | None = None
|
||||||
|
reserve_cells: list[object] | None = None
|
||||||
try:
|
try:
|
||||||
for row in ws.iter_rows(values_only=True):
|
for row in ws.iter_rows(values_only=True):
|
||||||
cells = list(row)
|
cells = list(row)
|
||||||
all_cells.append(cells)
|
all_cells.append(cells)
|
||||||
|
if header_cells is None and _is_systems_header(cells):
|
||||||
system_name = _first_system_text(cells)
|
header_cells = cells
|
||||||
if system_name is None:
|
elif reserve_cells is None and _is_reserve_row(cells):
|
||||||
continue # не строка про систему (или заголовок) → скип
|
reserve_cells = cells
|
||||||
reserve = _rightmost_number(cells)
|
|
||||||
if reserve is None:
|
|
||||||
continue # строка-заголовок системы без числа → скип
|
|
||||||
systems.append({"system_name": system_name, "reserve_gcal_h": reserve})
|
|
||||||
finally:
|
finally:
|
||||||
wb.close()
|
wb.close()
|
||||||
|
|
||||||
|
if header_cells is None or reserve_cells is None:
|
||||||
|
logger.warning(
|
||||||
|
"eias_heat: сетка не распознана (header=%s, reserve=%s) — 0 систем",
|
||||||
|
header_cells is not None,
|
||||||
|
reserve_cells is not None,
|
||||||
|
)
|
||||||
|
return [], all_cells
|
||||||
|
|
||||||
|
systems: list[dict] = []
|
||||||
|
for j, name in _system_columns(header_cells):
|
||||||
|
reserve = parse_reserve_number(reserve_cells[j] if j < len(reserve_cells) else None)
|
||||||
|
if reserve is None:
|
||||||
|
continue # непарсибельная ячейка резерва (текст/пусто) → систему скипаем
|
||||||
|
systems.append({"system_name": name, "reserve_gcal_h": reserve})
|
||||||
|
|
||||||
return systems, all_cells
|
return systems, all_cells
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ Pure / mock-based — без реальной сети и БД. Покрывае
|
||||||
- extract_publications: (guid, id, период) из синтетического HTML грида + выбор
|
- extract_publications: (guid, id, период) из синтетического HTML грида + выбор
|
||||||
свежайшего квартала (несколько публикаций одного квартала → все);
|
свежайшего квартала (несколько публикаций одного квартала → все);
|
||||||
- extract_tpl_xlsx: base64 → ZIP → член 'tpl' (генерим zip в памяти с xlsx-членом);
|
- extract_tpl_xlsx: base64 → ZIP → член 'tpl' (генерим zip в памяти с xlsx-членом);
|
||||||
- parse_heat_sheet: детект «СТ №54 … 3.26», скип заголовков, правейшее число;
|
- parse_heat_sheet: column-aligned парс РЕАЛЬНОЙ сетки ЕТК (строка «Централизованная
|
||||||
|
система» ↔ строка №5 «Резерв мощности…»), нуль-резерв сохранён, Flag_Row_Size не ловит
|
||||||
|
рубрики анкеты (регрессия прод-бага);
|
||||||
- _upsert_heat_rows: счётчики parsed/inserted/updated (mock-DB как в eesk-тестах).
|
- _upsert_heat_rows: счётчики parsed/inserted/updated (mock-DB как в eesk-тестах).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -162,14 +164,101 @@ def _make_tpl_zip_html(xlsx_bytes: bytes, member: str = "tpl") -> str:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Сокращённая копия РЕАЛЬНОЙ сетки листа «ТП» ЕТК (снята с прода, 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:
|
def _heat_xlsx_bytes() -> bytes:
|
||||||
"""XLSX с листом 'ТП': строка-заголовок + СТ №54 → 3.26 Гкал/час."""
|
"""XLSX с листом 'ТП' — сокращённая РЕАЛЬНАЯ сетка ЕТК (3 системы + строка №5).
|
||||||
|
|
||||||
|
Воспроизводит прод-структуру: строка «Централизованная система» (имена в col7/8/9 +
|
||||||
|
служебное число '15' в хвосте), строка №5 «Резерв мощности…» (0 / 3.26 / 11.28 в тех
|
||||||
|
же колонках + текст-примечание), рубрики анкеты с большим Flag_Row_Size (регрессия),
|
||||||
|
служебные строки diff_*/Flag_Col_Size.
|
||||||
|
"""
|
||||||
wb = openpyxl.Workbook()
|
wb = openpyxl.Workbook()
|
||||||
ws = wb.active
|
ws = wb.active
|
||||||
ws.title = "ТП"
|
ws.title = "ТП"
|
||||||
ws.append(["Резерв мощности источников тепловой энергии", None, None]) # заголовок, без числа
|
|
||||||
ws.append(["№ п/п", "Наименование", "Гкал/час"]) # шапка — без системного маркера
|
# Служебная строка diff_* (col7.. заголовки-технические) + Flag_Row_Size.
|
||||||
ws.append(["СТ №54", "источник", 3.26]) # система + правейшее число 3.26
|
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()
|
buf = io.BytesIO()
|
||||||
wb.save(buf)
|
wb.save(buf)
|
||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
|
|
@ -202,42 +291,49 @@ def test_extract_tpl_xlsx_bad_zip_returns_none() -> None:
|
||||||
assert eh.extract_tpl_xlsx(html) is None
|
assert eh.extract_tpl_xlsx(html) is None
|
||||||
|
|
||||||
|
|
||||||
# ── parse_heat_sheet: СТ №54 → 3.26, скип заголовков, правейшее число ─────────
|
# ── parse_heat_sheet: column-aligned парс РЕАЛЬНОЙ сетки ЕТК ──────────────────
|
||||||
|
|
||||||
|
|
||||||
def test_parse_heat_sheet_detects_system_and_rightmost_number() -> None:
|
def test_parse_heat_sheet_column_aligned_three_systems() -> None:
|
||||||
"""«СТ №54 … 3.26» распознан; строки-заголовки без чисел скипнуты."""
|
"""Ровно 3 системы, читаемые по колонкам строки №5; имена stripped, нуль сохранён.
|
||||||
|
|
||||||
|
Реальные прод-пары: СТ № 56 → 0.0, СТ № 54 → 3.26, СТ № 60 → 11.28.
|
||||||
|
"""
|
||||||
systems, all_cells = eh.parse_heat_sheet(_heat_xlsx_bytes())
|
systems, all_cells = eh.parse_heat_sheet(_heat_xlsx_bytes())
|
||||||
|
|
||||||
assert len(systems) == 1
|
pairs = [(s["system_name"], s["reserve_gcal_h"]) for s in systems]
|
||||||
assert systems[0]["system_name"] == "СТ №54"
|
assert pairs == [
|
||||||
assert systems[0]["reserve_gcal_h"] == 3.26
|
("СТ № 56", 0.0), # нулевой резерв — честный дефицит, СОХРАНЯЕТСЯ
|
||||||
# all_cells содержит ВЕСЬ лист (3 строки) для raw.
|
("СТ № 54", 3.26), # trailing-space в имени убран .strip()
|
||||||
assert len(all_cells) == 3
|
("СТ № 60", 11.28),
|
||||||
|
]
|
||||||
|
# all_cells содержит ВЕСЬ лист (7 строк) для raw.
|
||||||
|
assert len(all_cells) == 7
|
||||||
|
|
||||||
|
|
||||||
def test_parse_heat_sheet_rightmost_number_wins() -> None:
|
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_no_systems_header_returns_empty() -> None:
|
||||||
|
"""Нет строки «Централизованная система» → ([], all_cells) (graceful, не падаем)."""
|
||||||
wb = openpyxl.Workbook()
|
wb = openpyxl.Workbook()
|
||||||
ws = wb.active
|
ws = wb.active
|
||||||
ws.title = "ТП"
|
ws.title = "ТП"
|
||||||
ws.append(["зона Центральная", 100.0, 12.5]) # правейшее = 12.5 (резерв)
|
ws.append(_pad({4: "Какой-то посторонний заголовок", _ROW_SIZE_COL: 15}))
|
||||||
buf = io.BytesIO()
|
ws.append(_pad({3: 5, 4: "Резерв мощности источников тепловой энергии, в т.ч.:", 7: 3.26}))
|
||||||
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()
|
buf = io.BytesIO()
|
||||||
wb.save(buf)
|
wb.save(buf)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue