fix(ekgas): label-детект сетки формы 6
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m51s
CI / backend-tests (pull_request) Successful in 14m5s

This commit is contained in:
bot-backend 2026-07-03 07:35:55 +05:00
parent af70d50f0d
commit 9e6176c486
2 changed files with 134 additions and 45 deletions

View file

@ -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

View file

@ -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())