fix(ekgas): label-детект сетки формы 6 — живой файл сдвинут (#2119 B2) #2248

Merged
bot-backend merged 1 commit from fix/ekgas-header-detect into main 2026-07-03 02:51:04 +00:00
2 changed files with 134 additions and 45 deletions

View file

@ -69,17 +69,45 @@ _HTTP_TIMEOUT = 60
# Целевой лист XLSX (единственный) — берём активный, если имя не совпало. # Целевой лист XLSX (единственный) — берём активный, если имя не совпало.
# 1-indexed строки листа: подписи колонок = row 10, нумерация 1..7 = row 11, данные с 12. # 1-indexed строки листа: подписи колонок = row 10, нумерация 1..7 = row 11, данные с 12.
_HEADER_ROW = 10 # строка подписей («Точка входа»/«Точка выхода»/…) # Сетка НЕ хардкодится по номерам строк/колонок: живой файл за май 2026 имеет
_DATA_START_ROW = 12 # первая строка данных (после нумерации row 11) # header на row 11 (не 10 из разведки) и колонку «№ п/п» слева (данные в col 2-8,
# сдвиг +1) — первый прод-прогон распарсил 5 строк вместо 1037. Урок воды/тепла:
# опорные строки и колонки ищем ПО ПОДПИСЯМ (_find_layout), скан первых N строк.
_LAYOUT_SCAN_ROWS = 25
# Колонки формы 6 (0-indexed для списка ячеек строки). # Колонки формы 6 (0-indexed для списка ячеек строки).
_COL_ENTRY = 0 # «Точка входа» (ГРС, block-marker → forward-fill) # Подписи колонок header-строки → внутренние ключи (substring-матч, lower).
_COL_OUTLET = 1 # «Точка выхода» (свободный текст) _COLUMN_LABELS: dict[str, str] = {
_COL_TYPE = 2 # тип потребителя (котельная/ТЭЦ/…) "точка входа": "entry",
_COL_GROUP = 3 # группа потребления "точка выхода": "outlet",
_COL_REQUESTED = 4 # объём заявок, млн. м³/мес "наименование потребителя": "consumer_type",
_COL_APPROVED = 5 # удовлетворено, млн. м³/мес "номер группы": "group",
_COL_FREE = 6 # свободная мощность, млн. м³/мес (float ИЛИ «0.000*») "поступивш": "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*» (и вариации с запятой/пробелами) в колонке свободной мощности = НЕ ноль, а # «0.000*» (и вариации с запятой/пробелами) в колонке свободной мощности = НЕ ноль, а
# «требуется гидравлический расчёт» → free_capacity_mln_m3=NULL, needs_calc=True. # «требуется гидравлический расчёт» → free_capacity_mln_m3=NULL, needs_calc=True.
@ -292,13 +320,19 @@ def extract_address(outlet_name: str) -> str | None:
return address or 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 ГРС). """Строки данных → записи с forward-fill «Точки входа» (block-marker ГРС).
«Точка входа» (col0) заполнена ТОЛЬКО в первой строке блока ГРС протягиваем «Точка входа» заполнена ТОЛЬКО в первой строке блока ГРС протягиваем
последнее непустое значение вниз (5 блоков). Строки до первого block-marker (нет последнее непустое значение вниз (5 блоков). Индексы колонок из
ГРС) и single-cell строки (сноска «*» в конце листа) пропускаются. _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] = [] records: list[dict] = []
current_grs: str | None = None current_grs: str | None = None
for cells in rows: 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)] non_empty = [c for c in cells if _clean_text(c)]
if len(non_empty) <= 1: if len(non_empty) <= 1:
continue continue
entry = _clean_text(cells[_COL_ENTRY]) if len(cells) > _COL_ENTRY else "" entry = _clean_text(_cell(cells, "entry"))
if entry: if entry:
current_grs = 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: if not current_grs or not outlet:
continue # строка до первого block-marker ИЛИ без точки выхода → скип 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) free, needs_calc = _parse_free_capacity(free_raw)
records.append( records.append(
{ {
"grs_name": current_grs, "grs_name": current_grs,
"outlet_name": outlet, "outlet_name": outlet,
"consumer_type": _clean_text(cells[_COL_TYPE]) or None "consumer_type": _clean_text(_cell(cells, "consumer_type")) or None,
if len(cells) > _COL_TYPE "consumption_group": _clean_text(_cell(cells, "group")) or None,
else None, "volume_requested_mln_m3": parse_reserve_number(_cell(cells, "requested")),
"consumption_group": _clean_text(cells[_COL_GROUP]) or None "volume_approved_mln_m3": parse_reserve_number(_cell(cells, "approved")),
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
),
"free_capacity_mln_m3": free, "free_capacity_mln_m3": free,
"free_capacity_needs_calc": needs_calc, "free_capacity_needs_calc": needs_calc,
"address_extracted": extract_address(outlet), "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]: def parse_outlet_sheet(xlsx_bytes: bytes) -> list[dict]:
"""Парсит лист формы 6 → записи точек выхода (forward-fill ГРС + дедуп имён). """Парсит лист формы 6 → записи точек выхода (forward-fill ГРС + дедуп имён).
РЕАЛЬНАЯ сетка (снята с прода): подписи row 10 (1-indexed), нумерация 1..7 row 11, Сетка детектится ПО ПОДПИСЯМ (_find_layout): живой файл за май 2026 header
данные с row 12; col1=ГРС (block-marker, forward-fill), col2=точка выхода, row 11, слева колонка « п/п», данные с row 13 (разведка видела r10/r12 без
col3=тип, col4=группа, col5=заявки, col6=удовлетворено, col7=свободная мощность « п/п» формат плавает). Свободная мощность: float ИЛИ «0.000*» needs_calc.
(float ИЛИ «0.000*» needs_calc). Single-cell сноска про «*» в конце листа скип. Single-cell сноска про «*» в конце листа скип. Сетка не распознана 0 записей
+ warning (не мусор урок vodokanal).
Каждая запись: {grs_name, outlet_name, consumer_type, consumption_group, Каждая запись: {grs_name, outlet_name, consumer_type, consumption_group,
volume_requested_mln_m3, volume_approved_mln_m3, free_capacity_mln_m3, 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) wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
ws = wb.active ws = wb.active
try: try:
data_rows: list[list[object]] = [] all_rows: list[list[object]] = [list(row) for row in ws.iter_rows(values_only=True)]
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))
finally: finally:
wb.close() 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) records = _dedup_outlet_names(records)
logger.info("ekgas: распознано точек выхода=%d", len(records)) logger.info("ekgas: распознано точек выхода=%d", len(records))
return records return records

View file

@ -162,13 +162,13 @@ def _outlet_xlsx_bytes() -> bytes:
ws.append( ws.append(
_pad( _pad(
{ {
0: "Точка входа", 0: "Точка входа в газораспределительную сеть",
1: "Точка выхода", 1: "Точка выхода из газораспределительной сети",
2: "Тип потребителя", 2: "Наименование потребителя",
3: "Группа потребления", 3: "Номер группы газопотребления/ транзит",
4: "Объём заявок, млн.куб.м", 4: "Объемы газа в соответствии с поступившими заявками, млн.куб.м",
5: "Удовлетворено, млн.куб.м", 5: "Объемы газа в соответствии с удовлетворенными заявками, млн.куб.м",
6: "Свободная мощность, млн.куб.м", 6: "Свободная мощность газораспределительной сети, млн.куб.м",
} }
) )
) )
@ -255,6 +255,63 @@ def test_parse_outlet_sheet_forward_fill_and_markers() -> None:
assert r0["raw_cells"][0] == "ГРС Арамиль" 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: def test_parse_outlet_sheet_volumes_and_meta() -> None:
"""Объёмы заявок/удовлетворено и тип/группа парсятся из своих колонок.""" """Объёмы заявок/удовлетворено и тип/группа парсятся из своих колонок."""
records = ek.parse_outlet_sheet(_outlet_xlsx_bytes()) records = ek.parse_outlet_sheet(_outlet_xlsx_bytes())