From 497f992c678cebf9bcc06d0600723dc22d0f7af3 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Thu, 2 Jul 2026 22:39:43 +0500 Subject: [PATCH] =?UTF-8?q?fix(eias-heat):=20column-align=20=D0=A2=D0=9F?= =?UTF-8?q?=20sheet=20parse=20to=20real=20grid=20(drop=20Flag=5FRow=5FSize?= =?UTF-8?q?=20trap)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse_heat_sheet брал правейшее число строки — на проде это оказалась служебная колонка шаблона ЕИАС Flag_Row_Size (высоты строк), и в heat_system_reserves попали рубрики анкеты («Количество заявок»=74, кнопка «Добавить систему»=15) как тепло-системы. Переписан column-aligned по реальной сетке (снята из raw всех 4 публикаций): строка «Централизованная система» даёт имена систем по колонкам j≥6 (текст с буквой), строка №5 «Резерв мощности…» — Гкал/ч из тех же колонок. Нулевой резерв сохраняется (честный дефицит), None/текст — скип системы. Тесты пересобраны из реальной сетки ЕТК (СТ № 56→0.0, СТ № 54→3.26, СТ № 60→11.28) + регрессия на рубрики. --- .../services/site_finder/eias_heat_loader.py | 131 ++++++++++---- backend/tests/test_eias_heat_loader.py | 160 ++++++++++++++---- 2 files changed, 224 insertions(+), 67 deletions(-) diff --git a/backend/app/services/site_finder/eias_heat_loader.py b/backend/app/services/site_finder/eias_heat_loader.py index d4799749..21eab3ab 100644 --- a/backend/app/services/site_finder/eias_heat_loader.py +++ b/backend/app/services/site_finder/eias_heat_loader.py @@ -17,11 +17,24 @@ reg=2644). Данные лежат за 3 GET-переходами: 3. Лист ``ТП`` формы 14 — «Резерв мощности источников тепловой энергии» (живой пример: СТ №54 → 3.26 Гкал/час). -Точная сетка листа НЕ снята разведкой → парсинг DEFENSIVE: итерируем ВСЕ строки, -строка-кандидат = текстовая ячейка матчит «СТ №N | систем | зона» И в той же строке -есть парсибельное число; берём ПРАВЕЙШЕЕ число как резерв. Все ячейки листа целиком -→ ``raw`` jsonb (будущие колонки не теряются). Ноль распознанных систем → warning-лог -(первые 20 строк) + parsed=0, НЕ падаем. +Реальная сетка листа снята с прода (raw jsonb, все 4 публикации единообразны) — +парсинг COLUMN-ALIGNED, а НЕ «правейшее число в строке»: + + * Строка-заголовок систем: ``col4 == 'Централизованная система'``; ИМЕНА систем + лежат в колонках ``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 → загрузчик РАБОТАЕТ НА ПРОДЕ (Celery weekly / manual docker exec), SSL через общий @@ -97,8 +110,20 @@ _DATAFILE_RE = re.compile( re.IGNORECASE | re.DOTALL, ) -# Строка-кандидат листа: текстовая ячейка про систему теплоснабжения / зону / СТ №N. -_SYSTEM_MARKER_RE = re.compile(r"СТ\s*№\s*\d+|систем|зона", re.IGNORECASE) +# Заголовок листа со строкой систем: в этой строке col4 == этому значению, а имена +# систем стоят в колонках 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 грида на строки () — публикации живут построчно, период и # ссылка в одной строке. Достаточно грубого split по bytes | None: return None -def _rightmost_number(cells: list[object]) -> float | None: - """Правейшее парсибельное число в строке (резерв). Нет чисел → None.""" - for value in reversed(cells): - num = parse_reserve_number(value) - if num is not None: - return num - return None +def _cell_str(value: object) -> str: + """Ячейка → strip-строка (None → '').""" + return "" if value is None else str(value).strip() -def _first_system_text(cells: list[object]) -> str | None: - """Левейшая текстовая ячейка, матчащая систему/зону/СТ №N. Нет → None.""" - for value in cells: +def _is_systems_header(cells: list[object]) -> bool: + """Строка-заголовок систем: col4 == 'Централизованная система'.""" + 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: continue - s = str(value).strip() - if s and _SYSTEM_MARKER_RE.search(s): - return s - return None + name = str(value).strip() + if name and _HAS_LETTER_RE.search(name): + result.append((j, name)) + return result def parse_heat_sheet(xlsx_bytes: bytes) -> tuple[list[dict], list[list[object]]]: """Парсит лист ``ТП`` формы 14 → (systems, all_cells). - DEFENSIVE (точная сетка не снята): итерируем ВСЕ строки листа. Строка-кандидат = - есть текстовая ячейка про систему («СТ №N | систем | зона») И в той же строке - есть парсибельное число → system_name = текст (strip), reserve_gcal_h = ПРАВЕЙШЕЕ - число строки. Строки-заголовки (без чисел) пропускаются. + COLUMN-ALIGNED по РЕАЛЬНОЙ сетке (не «правейшее число в строке»): + + 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. - Ноль распознанных систем → warning-лог с первыми 20 строками (в ``load_*``). """ 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 - systems: list[dict] = [] all_cells: list[list[object]] = [] + header_cells: list[object] | None = None + reserve_cells: list[object] | None = None try: for row in ws.iter_rows(values_only=True): cells = list(row) all_cells.append(cells) - - system_name = _first_system_text(cells) - if system_name is None: - continue # не строка про систему (или заголовок) → скип - reserve = _rightmost_number(cells) - if reserve is None: - continue # строка-заголовок системы без числа → скип - systems.append({"system_name": system_name, "reserve_gcal_h": reserve}) + if header_cells is None and _is_systems_header(cells): + header_cells = cells + elif reserve_cells is None and _is_reserve_row(cells): + reserve_cells = cells finally: 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 diff --git a/backend/tests/test_eias_heat_loader.py b/backend/tests/test_eias_heat_loader.py index b8bdc011..ec232011 100644 --- a/backend/tests/test_eias_heat_loader.py +++ b/backend/tests/test_eias_heat_loader.py @@ -4,7 +4,9 @@ Pure / mock-based — без реальной сети и БД. Покрывае - extract_publications: (guid, id, период) из синтетического HTML грида + выбор свежайшего квартала (несколько публикаций одного квартала → все); - 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-тестах). """ @@ -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: - """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() ws = wb.active ws.title = "ТП" - ws.append(["Резерв мощности источников тепловой энергии", None, None]) # заголовок, без числа - ws.append(["№ п/п", "Наименование", "Гкал/час"]) # шапка — без системного маркера - ws.append(["СТ №54", "источник", 3.26]) # система + правейшее число 3.26 + + # Служебная строка 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() @@ -202,42 +291,49 @@ def test_extract_tpl_xlsx_bad_zip_returns_none() -> 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: - """«СТ №54 … 3.26» распознан; строки-заголовки без чисел скипнуты.""" +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()) - assert len(systems) == 1 - assert systems[0]["system_name"] == "СТ №54" - assert systems[0]["reserve_gcal_h"] == 3.26 - # all_cells содержит ВЕСЬ лист (3 строки) для raw. - assert len(all_cells) == 3 + 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_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() ws = wb.active ws.title = "ТП" - ws.append(["зона Центральная", 100.0, 12.5]) # правейшее = 12.5 (резерв) - buf = io.BytesIO() - 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]) # число есть, но нет системного маркера → скип + ws.append(_pad({4: "Какой-то посторонний заголовок", _ROW_SIZE_COL: 15})) + ws.append(_pad({3: 5, 4: "Резерв мощности источников тепловой энергии, в т.ч.:", 7: 3.26})) buf = io.BytesIO() wb.save(buf)