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
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:
parent
af70d50f0d
commit
9e6176c486
2 changed files with 134 additions and 45 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue