From 9e6176c4862adc6a491b948abea35bb4a7657fdb Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 07:35:55 +0500 Subject: [PATCH] =?UTF-8?q?fix(ekgas):=20label-=D0=B4=D0=B5=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=20=D1=81=D0=B5=D1=82=D0=BA=D0=B8=20=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D1=8B=206?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../site_finder/ekgas_outlet_loader.py | 108 ++++++++++++------ backend/tests/test_ekgas_outlet_loader.py | 71 ++++++++++-- 2 files changed, 134 insertions(+), 45 deletions(-) diff --git a/backend/app/services/site_finder/ekgas_outlet_loader.py b/backend/app/services/site_finder/ekgas_outlet_loader.py index ec0dbdef..479a179f 100644 --- a/backend/app/services/site_finder/ekgas_outlet_loader.py +++ b/backend/app/services/site_finder/ekgas_outlet_loader.py @@ -69,17 +69,45 @@ _HTTP_TIMEOUT = 60 # Целевой лист XLSX (единственный) — берём активный, если имя не совпало. # 1-indexed строки листа: подписи колонок = row 10, нумерация 1..7 = row 11, данные с 12. -_HEADER_ROW = 10 # строка подписей («Точка входа»/«Точка выхода»/…) -_DATA_START_ROW = 12 # первая строка данных (после нумерации row 11) +# Сетка НЕ хардкодится по номерам строк/колонок: живой файл за май 2026 имеет +# header на row 11 (не 10 из разведки) и колонку «№ п/п» слева (данные в col 2-8, +# сдвиг +1) — первый прод-прогон распарсил 5 строк вместо 1037. Урок воды/тепла: +# опорные строки и колонки ищем ПО ПОДПИСЯМ (_find_layout), скан первых N строк. +_LAYOUT_SCAN_ROWS = 25 # Колонки формы 6 (0-indexed для списка ячеек строки). -_COL_ENTRY = 0 # «Точка входа» (ГРС, block-marker → forward-fill) -_COL_OUTLET = 1 # «Точка выхода» (свободный текст) -_COL_TYPE = 2 # тип потребителя (котельная/ТЭЦ/…) -_COL_GROUP = 3 # группа потребления -_COL_REQUESTED = 4 # объём заявок, млн. м³/мес -_COL_APPROVED = 5 # удовлетворено, млн. м³/мес -_COL_FREE = 6 # свободная мощность, млн. м³/мес (float ИЛИ «0.000*») +# Подписи колонок header-строки → внутренние ключи (substring-матч, lower). +_COLUMN_LABELS: dict[str, str] = { + "точка входа": "entry", + "точка выхода": "outlet", + "наименование потребителя": "consumer_type", + "номер группы": "group", + "поступивш": "requested", + "удовлетво": "approved", + "свободная мощность": "free", +} + + +def _find_layout(rows: list[list[object]]) -> tuple[int, dict[str, int]] | None: + """Ищет header-строку по подписям в первых _LAYOUT_SCAN_ROWS строках. + + Returns (data_start_idx, {key: col_idx}) — данные начинаются через строку + после header (между ними строка-нумерация «1..7»). None — сетка не распознана + (graceful: 0 записей + warning, не мусор — урок vodokanal). + """ + for i, row in enumerate(rows[:_LAYOUT_SCAN_ROWS]): + cols: dict[str, int] = {} + for j, cell in enumerate(row): + text = _clean_text(cell).lower() + if not text: + continue + for label, key in _COLUMN_LABELS.items(): + if label in text and key not in cols: + cols[key] = j + if "entry" in cols and "outlet" in cols and "free" in cols: + return (i + 2, cols) # +1 строка-нумерация, +1 → первая строка данных + return None + # «0.000*» (и вариации с запятой/пробелами) в колонке свободной мощности = НЕ ноль, а # «требуется гидравлический расчёт» → free_capacity_mln_m3=NULL, needs_calc=True. @@ -292,13 +320,19 @@ def extract_address(outlet_name: str) -> str | None: return address or None -def _forward_fill_grs(rows: list[list[object]]) -> list[dict]: +def _forward_fill_grs(rows: list[list[object]], cols: dict[str, int]) -> list[dict]: """Строки данных → записи с forward-fill «Точки входа» (block-marker ГРС). - «Точка входа» (col0) заполнена ТОЛЬКО в первой строке блока ГРС → протягиваем - последнее непустое значение вниз (5 блоков). Строки до первого block-marker (нет - ГРС) и single-cell строки (сноска «*» в конце листа) пропускаются. + «Точка входа» заполнена ТОЛЬКО в первой строке блока ГРС → протягиваем + последнее непустое значение вниз (5 блоков). Индексы колонок — из + _find_layout (label-детект). Строки до первого block-marker (нет ГРС) и + single-cell строки (сноска «*» в конце листа) пропускаются. """ + + def _cell(cells: list[object], key: str) -> object: + idx = cols.get(key, -1) + return cells[idx] if 0 <= idx < len(cells) else None + records: list[dict] = [] current_grs: str | None = None for cells in rows: @@ -306,30 +340,22 @@ def _forward_fill_grs(rows: list[list[object]]) -> list[dict]: non_empty = [c for c in cells if _clean_text(c)] if len(non_empty) <= 1: continue - entry = _clean_text(cells[_COL_ENTRY]) if len(cells) > _COL_ENTRY else "" + entry = _clean_text(_cell(cells, "entry")) if entry: current_grs = entry - outlet = _clean_text(cells[_COL_OUTLET]) if len(cells) > _COL_OUTLET else "" + outlet = _clean_text(_cell(cells, "outlet")) if not current_grs or not outlet: continue # строка до первого block-marker ИЛИ без точки выхода → скип - free_raw = cells[_COL_FREE] if len(cells) > _COL_FREE else None + free_raw = _cell(cells, "free") free, needs_calc = _parse_free_capacity(free_raw) records.append( { "grs_name": current_grs, "outlet_name": outlet, - "consumer_type": _clean_text(cells[_COL_TYPE]) or None - if len(cells) > _COL_TYPE - else None, - "consumption_group": _clean_text(cells[_COL_GROUP]) or None - if len(cells) > _COL_GROUP - else None, - "volume_requested_mln_m3": parse_reserve_number( - cells[_COL_REQUESTED] if len(cells) > _COL_REQUESTED else None - ), - "volume_approved_mln_m3": parse_reserve_number( - cells[_COL_APPROVED] if len(cells) > _COL_APPROVED else None - ), + "consumer_type": _clean_text(_cell(cells, "consumer_type")) or None, + "consumption_group": _clean_text(_cell(cells, "group")) or None, + "volume_requested_mln_m3": parse_reserve_number(_cell(cells, "requested")), + "volume_approved_mln_m3": parse_reserve_number(_cell(cells, "approved")), "free_capacity_mln_m3": free, "free_capacity_needs_calc": needs_calc, "address_extracted": extract_address(outlet), @@ -370,10 +396,11 @@ def _dedup_outlet_names(records: list[dict]) -> list[dict]: def parse_outlet_sheet(xlsx_bytes: bytes) -> list[dict]: """Парсит лист формы 6 → записи точек выхода (forward-fill ГРС + дедуп имён). - РЕАЛЬНАЯ сетка (снята с прода): подписи row 10 (1-indexed), нумерация 1..7 row 11, - данные с row 12; col1=ГРС (block-marker, forward-fill), col2=точка выхода, - col3=тип, col4=группа, col5=заявки, col6=удовлетворено, col7=свободная мощность - (float ИЛИ «0.000*» → needs_calc). Single-cell сноска про «*» в конце листа → скип. + Сетка детектится ПО ПОДПИСЯМ (_find_layout): живой файл за май 2026 — header + row 11, слева колонка «№ п/п», данные с row 13 (разведка видела r10/r12 без + «№ п/п» — формат плавает). Свободная мощность: float ИЛИ «0.000*» → needs_calc. + Single-cell сноска про «*» в конце листа → скип. Сетка не распознана → 0 записей + + warning (не мусор — урок vodokanal). Каждая запись: {grs_name, outlet_name, consumer_type, consumption_group, volume_requested_mln_m3, volume_approved_mln_m3, free_capacity_mln_m3, @@ -383,15 +410,20 @@ def parse_outlet_sheet(xlsx_bytes: bytes) -> list[dict]: wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True) ws = wb.active try: - data_rows: list[list[object]] = [] - for i, row in enumerate(ws.iter_rows(values_only=True), start=1): - if i < _DATA_START_ROW: - continue # подписи (row 10) + нумерация (row 11) пропускаем - data_rows.append(list(row)) + all_rows: list[list[object]] = [list(row) for row in ws.iter_rows(values_only=True)] finally: wb.close() - records = _forward_fill_grs(data_rows) + layout = _find_layout(all_rows) + if layout is None: + logger.warning( + "ekgas: сетка листа не распознана (нет header с подписями) — 0 записей; " + "первые 5 строк: %s", + [[_clean_text(c) for c in r[:9]] for r in all_rows[:5]], + ) + return [] + data_start, cols = layout + records = _forward_fill_grs(all_rows[data_start:], cols) records = _dedup_outlet_names(records) logger.info("ekgas: распознано точек выхода=%d", len(records)) return records diff --git a/backend/tests/test_ekgas_outlet_loader.py b/backend/tests/test_ekgas_outlet_loader.py index 83ca5ce7..d8052cf0 100644 --- a/backend/tests/test_ekgas_outlet_loader.py +++ b/backend/tests/test_ekgas_outlet_loader.py @@ -162,13 +162,13 @@ def _outlet_xlsx_bytes() -> bytes: ws.append( _pad( { - 0: "Точка входа", - 1: "Точка выхода", - 2: "Тип потребителя", - 3: "Группа потребления", - 4: "Объём заявок, млн.куб.м", - 5: "Удовлетворено, млн.куб.м", - 6: "Свободная мощность, млн.куб.м", + 0: "Точка входа в газораспределительную сеть", + 1: "Точка выхода из газораспределительной сети", + 2: "Наименование потребителя", + 3: "Номер группы газопотребления/ транзит", + 4: "Объемы газа в соответствии с поступившими заявками, млн.куб.м", + 5: "Объемы газа в соответствии с удовлетворенными заявками, млн.куб.м", + 6: "Свободная мощность газораспределительной сети, млн.куб.м", } ) ) @@ -255,6 +255,63 @@ def test_parse_outlet_sheet_forward_fill_and_markers() -> None: assert r0["raw_cells"][0] == "ГРС Арамиль" +def _pad8(cells: dict[int, object]) -> list[object]: + """Строка live-layout (8 колонок: № п/п + 7 данных).""" + return _pad(cells, width=8) + + +def test_parse_outlet_sheet_live_layout_num_column_shifted() -> None: + """ЖИВОЙ прод-layout за май 2026: слева колонка «№ п/п», header на row 11, + данные с row 13 (сдвиг +1 строка и +1 колонка против разведки). Label-детект + обязан распарсить без хардкод-индексов — первый прогон дал 5 строк из 1037.""" + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "Май 2026" + for _ in range(10): + ws.append(_pad8({})) + ws.append( + _pad8( + { + 0: "№ п/п", + 1: "Точка входа в газораспределительную сеть", + 2: "Точка выхода из газораспределительной сети", + 3: "Наименование потребителя", + 4: "Номер группы газопотребления/ транзит", + 5: "Объемы газа в соответствии с поступившими заявками, млн.куб.м", + 6: "Объемы газа в соответствии с удовлетворенными заявками, млн.куб.м", + 7: "Свободная мощность газораспределительной сети, млн.куб.м", + } + ) + ) + ws.append(_pad8({1: "1", 2: "2", 3: "3", 4: "4", 5: "5", 6: "6", 7: "7"})) + ws.append( + _pad8( + { + 0: "1", + 1: "ГРС-1 Свердловск; ГРС-2 Екатеринбург", + 2: "МПРО Приход (г.Екатеринбург, ул. Тверитина, 20)", + 3: "котельная", + 4: "8 - 0,01-500млн.куб.м", + 5: 0.006, + 6: 0.006, + 7: 0, + } + ) + ) + ws.append(_pad8({2: "ИП Без Адреса", 3: "котельная", 5: 0.001, 6: 0.003, 7: -0.002})) + buf = io.BytesIO() + wb.save(buf) + + records = ek.parse_outlet_sheet(buf.getvalue()) + + assert len(records) == 2 + assert records[0]["grs_name"].startswith("ГРС-1") + assert records[0]["volume_requested_mln_m3"] == 0.006 + assert records[0]["address_extracted"] is not None + assert records[1]["grs_name"].startswith("ГРС-1") # forward-fill через № п/п layout + assert records[1]["free_capacity_mln_m3"] == -0.002 + + def test_parse_outlet_sheet_volumes_and_meta() -> None: """Объёмы заявок/удовлетворено и тип/группа парсятся из своих колонок.""" records = ek.parse_outlet_sheet(_outlet_xlsx_bytes())