fix(vodokanal): column-aligned parse by header labels, not blind indices
Прод-баг #2126: water_supply_reserves содержал мусор — резерв = № п/п формы (1.1, 1.3, 2.18…), а рубрики анкеты («Количество поданных…»), «Итого:» и шапки попадали в поле system_name. Итог §3 показывал «водоснабжение 1,1 / водоотведение 3,0 тыс. м³/сут» (номера строк, не резервы). Корень: extract_water_rows брал «первую текстовую ячейку» как имя и «первое число строки» как резерв. РЕАЛЬНАЯ сетка (снята с прода 2026-07-03 через docker exec тем же путём, что лоадер) устроена иначе, причём две страницы имеют РАЗНЫЙ layout: - водоснабжение: 6 колонок, каждый ЦСВ = 4 строки показателей; резерв — строка, где col2 = «Резерв мощности централизованной системы…», значение в col4; - водоотведение: 5 колонок, имя системы — отдельная строка-заголовок («Централизованная система…»/«Транспортировка сточных вод…»), резерв в col3. Переписал парсинг column-aligned по подписям шапки и метке показателя: детект layout по строке-шапке, резерв ТОЛЬКО из строки «Резерв мощности…» (+ защита ед.изм. «тыс…м3/сут»), заявки/отказы — из своих рубрик. Шапки, «Итого:», легенда отфильтрованы. Нули («Отсутствие резерва мощности», 0,0) сохраняются честно (0.0 + note), как в eias_heat_loader после того же класса бага. Дедуп байт-в-байт одинаковых имён суффиксом « (N)» (защита UNIQUE), warning при 0 записей. Прод-verify нового парсера на живом docx: water → 33 ЦСВ (ЗФС,ГСВ −168,5; Изоплит 0,0; Шабровский 1,03; ПТВ 73,34), sewerage → 6 систем (Южный бассейн −296,85; Северный 2,02). Ни один резерв больше не равен № п/п. Тесты: фикстуры из РЕАЛЬНОЙ сетки обоих layout (шапка + ЦСВ + рубрики + «Итого:» + легенда + ноль-резерв); ассерты — резервы из правильной колонки, мусор отфильтрован, нули сохранены; end-to-end через load_water_reserves_from_docx. 41 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
5fb5675871
commit
5d34bb7833
2 changed files with 490 additions and 71 deletions
|
|
@ -1,4 +1,4 @@
|
|||
"""Загрузчик резервов ЦСВ/ЦСК Водоканала ЕКБ из docx-раскрытия (#2119).
|
||||
"""Загрузчик резервов ЦСВ/ЦСК Водоканала ЕКБ из docx-раскрытия (#2119, фикс #2126).
|
||||
|
||||
Две страницы водоканалекб.рф (водоснабжение / водоотведение) публикуют docx с
|
||||
таблицами свободной мощности ЦСВ (центр. система водоснабжения) / ЦСК (центр.
|
||||
|
|
@ -7,14 +7,37 @@
|
|||
Источник ГЕО-БЛОКИРУЕТ non-RU IP → РАБОТАЕТ НА ПРОДЕ (Celery / docker exec).
|
||||
Punycode-host обязателен (водоканалекб.рф → xn--80aadbki6adhshb.xn--p1ai).
|
||||
|
||||
CRITICAL: колонка имени системы использует ВЕРТИКАЛЬНЫЙ MERGE (vMerge) — значение
|
||||
задано в первой строке блока, дальше идут <w:vMerge/> без restart → forward-fill.
|
||||
Колонка примечания тоже мержится. Парсим таблицы через stdlib (zipfile +
|
||||
ElementTree по word/document.xml) — это надёжнее python-docx для vMerge-детекта
|
||||
и тривиально тестируется на синтетическом xml-фрагменте.
|
||||
Парсим таблицы через stdlib (zipfile + ElementTree по word/document.xml) — это
|
||||
надёжнее python-docx для vMerge-детекта и тривиально тестируется на синтетическом
|
||||
xml-фрагменте. Резерв бывает ОТРИЦАТЕЛЬНЫМ (дефицит, напр. −168,5) — знак сохраняем,
|
||||
запятая-десятичный → float. period инферится из имени файла (1kv.-2026 → '2026-Q1').
|
||||
|
||||
Резерв бывает ОТРИЦАТЕЛЬНЫМ (дефицит, напр. −168,5) — знак сохраняем, запятая-
|
||||
десятичный → float. period инферится из имени файла (1kv.-2026 → '2026-Q1').
|
||||
РЕАЛЬНАЯ СЕТКА (снята с прода 2026-07-03, обе страницы — 1 таблица, но РАЗНЫЙ layout).
|
||||
Парсинг COLUMN-ALIGNED по ПОДПИСЯМ ШАПКИ и метке показателя (НЕ по индексам вслепую):
|
||||
|
||||
* Страница «водоснабжение» (system_kind='water') — 6 колонок, «длинный» layout:
|
||||
каждый ЦСВ занимает 4 СТРОКИ (по строке на показатель). Шапка: col2=='Наименование
|
||||
показателя'. col0='№ ЦСВ' (номер п/п: 1.1, 2.1…2.26, ПТВ), col1='Название ЦСВ'
|
||||
(имя системы, повторяется в каждой строке блока), col2=показатель, col3=ед.изм,
|
||||
col4=значение периода, col5=примечание. РЕЗЕРВ = строка, где col2 начинается с
|
||||
«Резерв мощности централизованной системы…» И col3 — «тыс…м3/сут» → число из col4.
|
||||
Заявки/отказы — строки «Количество поданных…» / «…отказе в подключении».
|
||||
Живые резервы: ЗФС,ГСВ = −168,5; Изоплит = 0,0; Шабровский = 1,03; ПТВ = 73,34.
|
||||
* Страница «водоотведение» (system_kind='sewerage') — 5 колонок, «блочный» layout:
|
||||
имя системы — ОТДЕЛЬНАЯ строка-заголовок (только col0, напр. «Централизованная
|
||||
система водоотведения "Южный бассейн канализования"»), под ней строка-шапка
|
||||
(col0='№ п/п') и 4 строки показателей (col0 = 1..4 ИЛИ пусто). РЕЗЕРВ = строка,
|
||||
где col1 начинается с «Резерв мощности централизованной системы…» И col2 —
|
||||
«тыс…м3/сут» → число из col3. Имя системы forward-fill-ится из последней
|
||||
строки-заголовка. Живые резервы: Южный бассейн = −296,85; Северный = 2,02.
|
||||
|
||||
ЛОВУШКА (прод-баг #2126, был мусор 28+6 строк): старый парсер брал «первое число
|
||||
строки» как резерв и «первую текстовую ячейку» как имя. На проде это давало № п/п
|
||||
(1.1, 2.18…) в поле резерва и рубрики анкеты («Количество поданных…», «Итого:»,
|
||||
служебные шапки «№ ЦСВ»/«№ п/п») как «системы». Column-aligned парс по метке
|
||||
показателя их не видит: строки-рубрики, «Итого:», шапки и легенда отфильтрованы;
|
||||
резерв читается ТОЛЬКО из строки «Резерв мощности…». НУЛИ («Отсутствие резерва
|
||||
мощности», 0,0) сохраняем честно (0.0 + note), как в eias_heat_loader.
|
||||
"""
|
||||
|
||||
import io
|
||||
|
|
@ -53,6 +76,27 @@ _PERIOD_RE = re.compile(r"(\d)\s*(?:kv|кв)\w*[.\s-]*(\d{4})", re.IGNORECASE)
|
|||
|
||||
_NODATA_TOKENS = frozenset({"", "н/д", "нд", "n/a", "-", "—", "–", "нет данных", "х", "x"})
|
||||
|
||||
# Метки показателей формы раскрытия (нормализуем casefold + схлопнутые пробелы перед
|
||||
# сравнением: в docx встречаются \xa0 и двойные пробелы). Резерв — по префиксу, т.к.
|
||||
# хвост различается («…холодного водоснабжения» / «…водоотведения»).
|
||||
_RESERVE_LABEL_PREFIX = "резерв мощности централизованной системы"
|
||||
_REQUESTED_LABEL = "количество поданных и зарегистрированных заявок на подключение"
|
||||
_REFUSED_LABEL_PREFIX = "количество заявок на подключение, по которым принято решение об отказе"
|
||||
# Ед.изм. резерва: «тыс. м3/сут» / «тыс.м3/сут» (пробелы схлопнуты) — защита от того,
|
||||
# чтобы «Резерв…» из чужой формы с иной размерностью не попал в тыс.м³/сут.
|
||||
_RESERVE_UNIT_MARKER = "тыс"
|
||||
# Шапки таблицы и служебные строки — не данные.
|
||||
_HEADER_MARKERS = frozenset({"№ п/п", "№ цсв", "наименование показателя", "название цсв"})
|
||||
_TOTAL_MARKER = "итого"
|
||||
# Строка-заголовок системы в «блочном» layout водоотведения: только col0 заполнен и
|
||||
# начинается с одного из этих префиксов (сама система, а не показатель/шапка).
|
||||
_SYSTEM_TITLE_PREFIXES = (
|
||||
"централизованная система",
|
||||
"транспортировка сточных вод",
|
||||
)
|
||||
# Легенда в конце водоснабжения: «ЦСВ – централизованная система…» / «ПТВ – …».
|
||||
_LEGEND_MARKER = " – "
|
||||
|
||||
|
||||
def parse_water_number(value: str | None) -> float | None:
|
||||
"""Числовая ячейка резерва тыс. м³/сут → float. Сохраняет знак (дефицит <0).
|
||||
|
|
@ -140,69 +184,179 @@ def parse_docx_table_rows(document_xml: bytes) -> list[list[str]]:
|
|||
return matrix
|
||||
|
||||
|
||||
def _looks_numeric(s: str) -> bool:
|
||||
"""Строка похожа на число (для отсева заголовочных строк)?"""
|
||||
return parse_water_number(s) is not None
|
||||
def _norm(s: str | None) -> str:
|
||||
"""Ячейку → casefold + схлопнутые пробелы (\xa0/двойные) для сравнения с метками."""
|
||||
if not s:
|
||||
return ""
|
||||
return re.sub(r"\s+", " ", s.replace("\xa0", " ")).strip().casefold()
|
||||
|
||||
|
||||
def _cell(row: list[str], idx: int) -> str:
|
||||
"""Ячейка row[idx] со .strip() или '' если колонки нет."""
|
||||
return row[idx].strip() if idx < len(row) and row[idx] else ""
|
||||
|
||||
|
||||
def _is_header_row(row: list[str]) -> bool:
|
||||
"""Строка-шапка таблицы (№ п/п / Наименование показателя / …)?"""
|
||||
return any(_norm(c) in _HEADER_MARKERS for c in row)
|
||||
|
||||
|
||||
def _is_total_row(row: list[str]) -> bool:
|
||||
"""Строка «Итого:» (агрегат по всем системам — не резерв конкретной ЦСВ)."""
|
||||
return _norm(_cell(row, 0)).startswith(_TOTAL_MARKER)
|
||||
|
||||
|
||||
def _row_metric(row: list[str], label_col: int) -> str:
|
||||
"""Метка показателя строки (нормализованная) из колонки label_col."""
|
||||
return _norm(_cell(row, label_col))
|
||||
|
||||
|
||||
def _is_reserve_metric(metric: str) -> bool:
|
||||
return metric.startswith(_RESERVE_LABEL_PREFIX)
|
||||
|
||||
|
||||
def _is_requested_metric(metric: str) -> bool:
|
||||
return metric == _REQUESTED_LABEL
|
||||
|
||||
|
||||
def _is_refused_metric(metric: str) -> bool:
|
||||
return metric.startswith(_REFUSED_LABEL_PREFIX)
|
||||
|
||||
|
||||
def _system_title(row: list[str]) -> str | None:
|
||||
"""Строка-заголовок системы «блочного» layout (водоотведение): имя или None.
|
||||
|
||||
Заполнена ТОЛЬКО col0, текст начинается с «Централизованная система…» /
|
||||
«Транспортировка сточных вод…». Возвращает имя системы (col0.strip()).
|
||||
"""
|
||||
if any(_cell(row, i) for i in range(1, len(row))):
|
||||
return None # не одиночный заголовок — заполнены и другие колонки
|
||||
text0 = _cell(row, 0)
|
||||
norm0 = _norm(text0)
|
||||
if any(norm0.startswith(p) for p in _SYSTEM_TITLE_PREFIXES):
|
||||
return text0
|
||||
return None
|
||||
|
||||
|
||||
def _detect_layout(matrix: list[list[str]]) -> tuple[str, int, int, int]:
|
||||
"""По шапке определяет layout → (kind, name_col, label_col, value_col).
|
||||
|
||||
kind='water' : шапка с col2=='Наименование показателя', имя ЦСВ в col1,
|
||||
показатель col2, значение col4.
|
||||
kind='block' : шапка с col1=='Наименование показателя' (водоотведение),
|
||||
имя системы — отдельная строка-заголовок, значение col3.
|
||||
Не нашли шапку → ('', -1, -1, -1) (graceful).
|
||||
"""
|
||||
for row in matrix:
|
||||
if _norm(_cell(row, 2)) == "наименование показателя":
|
||||
return ("water", 1, 2, 4)
|
||||
if _norm(_cell(row, 1)) == "наименование показателя":
|
||||
return ("block", -1, 1, 3)
|
||||
return ("", -1, -1, -1)
|
||||
|
||||
|
||||
def _flush_group(
|
||||
name: str,
|
||||
reserve_cell: str | None,
|
||||
note_cell: str,
|
||||
requested: int | None,
|
||||
refused: int | None,
|
||||
records: list[dict],
|
||||
) -> None:
|
||||
"""Собирает запись ЦСВ/ЦСК из накопленной группы строк, если есть имя + резерв."""
|
||||
if not name or reserve_cell is None:
|
||||
return
|
||||
reserve = parse_water_number(reserve_cell)
|
||||
# note: явное примечание строки резерва (напр. «Отсутствие резерва мощности»)
|
||||
# честно фиксирует ноль-резерв; иначе None.
|
||||
note = note_cell.strip() or None
|
||||
records.append(
|
||||
{
|
||||
"system_name": name,
|
||||
"reserve_thousand_m3_day": reserve,
|
||||
"connections_requested": requested,
|
||||
"connections_refused": refused,
|
||||
"note": note,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def extract_water_rows(matrix: list[list[str]]) -> list[dict]:
|
||||
"""Из forward-filled матрицы вытаскивает записи ЦСВ/ЦСК.
|
||||
"""Из матрицы docx → записи ЦСВ/ЦСК, COLUMN-ALIGNED по метке показателя.
|
||||
|
||||
Эвристика колонок (структура docx стабильна, но защищаемся): первая непустая
|
||||
текстовая колонка = system_name; первая числовая колонка = reserve; далее
|
||||
ищем заявки/отказы (целые) если есть; note — последняя длинная текстовая.
|
||||
Заголовочные строки (без числового резерва) пропускаются.
|
||||
Определяет layout по шапке (см. ``_detect_layout``): «water» — имя ЦСВ в col1,
|
||||
«block» (водоотведение) — имя в отдельной строке-заголовке. Затем группирует
|
||||
строки показателей по системе и берёт резерв ТОЛЬКО из строки «Резерв мощности
|
||||
централизованной системы…» (col — value_col), заявки/отказы — из строк
|
||||
«Количество поданных…» / «…отказе…». Ноль-резерв (0,0 + «Отсутствие резерва
|
||||
мощности») сохраняется честно. Шапки, «Итого:», легенда — отфильтрованы.
|
||||
|
||||
Не распознан layout → [] (graceful, лог наверху).
|
||||
"""
|
||||
kind, name_col, label_col, value_col = _detect_layout(matrix)
|
||||
if not kind:
|
||||
return []
|
||||
|
||||
records: list[dict] = []
|
||||
# Накопитель текущей группы (одна система = один блок строк показателей).
|
||||
cur_name = ""
|
||||
cur_reserve: str | None = None
|
||||
cur_note = ""
|
||||
cur_requested: int | None = None
|
||||
cur_refused: int | None = None
|
||||
|
||||
def flush() -> None:
|
||||
nonlocal cur_name, cur_reserve, cur_note, cur_requested, cur_refused
|
||||
_flush_group(cur_name, cur_reserve, cur_note, cur_requested, cur_refused, records)
|
||||
cur_name, cur_reserve, cur_note = "", None, ""
|
||||
cur_requested, cur_refused = None, None
|
||||
|
||||
for row in matrix:
|
||||
if not any(c.strip() for c in row):
|
||||
continue
|
||||
# system_name — первая непустая текстовая (не число) ячейка.
|
||||
name = ""
|
||||
reserve_idx = -1
|
||||
for idx, cell in enumerate(row):
|
||||
cell = cell.strip()
|
||||
if not cell:
|
||||
if _is_header_row(row):
|
||||
continue
|
||||
if _is_total_row(row):
|
||||
flush() # «Итого:» закрывает предыдущую систему, само не данные
|
||||
continue
|
||||
# Легенда в конце водоснабжения («ЦСВ – централизованная система…»).
|
||||
if _norm(_cell(row, 0)).startswith(("цсв ", "птв ")) and _LEGEND_MARKER in _cell(row, 0):
|
||||
flush()
|
||||
continue
|
||||
|
||||
if kind == "block":
|
||||
title = _system_title(row)
|
||||
if title is not None:
|
||||
flush() # новая система-заголовок
|
||||
cur_name = title
|
||||
continue
|
||||
if not name and not _looks_numeric(cell):
|
||||
name = cell
|
||||
if reserve_idx < 0 and _looks_numeric(cell):
|
||||
reserve_idx = idx
|
||||
if not name or reserve_idx < 0:
|
||||
continue # заголовок / служебная строка без резерва
|
||||
|
||||
reserve = parse_water_number(row[reserve_idx])
|
||||
metric = _row_metric(row, label_col)
|
||||
if kind == "water":
|
||||
# Имя ЦСВ повторяется в каждой строке блока (col1). Смена имени → новая
|
||||
# система: сбрасываем накопитель.
|
||||
row_name = _cell(row, name_col)
|
||||
if row_name and row_name != cur_name:
|
||||
flush()
|
||||
cur_name = row_name
|
||||
|
||||
# заявки/отказы — целочисленные значения ПОСЛЕ колонки резерва.
|
||||
requested: int | None = None
|
||||
refused: int | None = None
|
||||
ints_after: list[int] = []
|
||||
for cell in row[reserve_idx + 1 :]:
|
||||
n = parse_water_number(cell)
|
||||
if n is not None and float(n).is_integer() and n >= 0:
|
||||
ints_after.append(int(n))
|
||||
if len(ints_after) >= 1:
|
||||
requested = ints_after[0]
|
||||
if len(ints_after) >= 2:
|
||||
refused = ints_after[1]
|
||||
value = _cell(row, value_col)
|
||||
if _is_reserve_metric(metric):
|
||||
# Защита размерности: у резерва ед.изм. — «тыс…м3/сут» (col label+1).
|
||||
unit = _norm(_cell(row, label_col + 1))
|
||||
if _RESERVE_UNIT_MARKER in unit:
|
||||
cur_reserve = value
|
||||
cur_note = _cell(row, value_col + 1) # Примечание строки резерва
|
||||
elif _is_requested_metric(metric):
|
||||
n = parse_water_number(value)
|
||||
if n is not None and float(n).is_integer():
|
||||
cur_requested = int(n)
|
||||
elif _is_refused_metric(metric):
|
||||
n = parse_water_number(value)
|
||||
if n is not None and float(n).is_integer():
|
||||
cur_refused = int(n)
|
||||
|
||||
# note — последняя текстовая ячейка (не имя, не число).
|
||||
note = ""
|
||||
for cell in reversed(row):
|
||||
cell = cell.strip()
|
||||
if cell and not _looks_numeric(cell) and cell != name:
|
||||
note = cell
|
||||
break
|
||||
|
||||
records.append(
|
||||
{
|
||||
"system_name": name,
|
||||
"reserve_thousand_m3_day": reserve,
|
||||
"connections_requested": requested,
|
||||
"connections_refused": refused,
|
||||
"note": note or None,
|
||||
}
|
||||
)
|
||||
flush() # последняя система
|
||||
return records
|
||||
|
||||
|
||||
|
|
@ -225,6 +379,22 @@ def fetch_docx_link(page_url: str) -> str:
|
|||
return best if best.startswith("http") else _BASE + best
|
||||
|
||||
|
||||
def _dedupe_names(records: list[dict]) -> list[dict]:
|
||||
"""Разводит БАЙТ-В-БАЙТ одинаковые system_name суффиксом « (N)».
|
||||
|
||||
UNIQUE (system_kind, system_name, period) схлопнул бы две разные системы с
|
||||
одинаковым именем в одну (с потерей резерва). Имена ЦСВ на проде обычно уникальны
|
||||
(well-суффиксы), но защищаемся — как в eias_heat_loader.
|
||||
"""
|
||||
seen: dict[str, int] = {}
|
||||
for rec in records:
|
||||
name = rec["system_name"]
|
||||
seen[name] = seen.get(name, 0) + 1
|
||||
if seen[name] > 1:
|
||||
rec["system_name"] = f"{name} ({seen[name]})"
|
||||
return records
|
||||
|
||||
|
||||
def _upsert_water_rows(
|
||||
db: Session,
|
||||
system_kind: str,
|
||||
|
|
@ -295,8 +465,15 @@ def load_water_reserves_from_docx(
|
|||
with zipfile.ZipFile(io.BytesIO(docx_bytes)) as zf:
|
||||
document_xml = zf.read("word/document.xml")
|
||||
matrix = parse_docx_table_rows(document_xml)
|
||||
records = extract_water_rows(matrix)
|
||||
records = _dedupe_names(extract_water_rows(matrix))
|
||||
period = infer_period(source_url)
|
||||
if not records:
|
||||
logger.warning(
|
||||
"water_reserves[%s]: 0 записей распознано (layout не найден или пустая "
|
||||
"таблица); первые 6 строк матрицы: %s",
|
||||
system_kind,
|
||||
matrix[:6],
|
||||
)
|
||||
|
||||
try:
|
||||
counts = _upsert_water_rows(db, system_kind, records, period, source_url)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ Pure / mock-based — без реальной сети и БД. Покрывае
|
|||
- parse_reserve_number / parse_water_number: запятая, знак (дефицит <0), «н/д».
|
||||
- infer_period: «1kv.-2026» → '2026-Q1'.
|
||||
- parse_docx_table_rows: forward-fill вертикального merge (vMerge) на xml-фрагменте.
|
||||
- extract_water_rows: извлечение ЦСВ-записей из forward-filled матрицы.
|
||||
- extract_water_rows: column-aligned извлечение ЦСВ/ЦСК из РЕАЛЬНОЙ сетки прода —
|
||||
оба layout (water 6-колонок «длинный» / sewerage 5-колонок «блочный»); резерв из
|
||||
строки «Резерв мощности…», мусор (№ п/п, рубрики, «Итого:», шапки) отфильтрован,
|
||||
нули сохранены — регрессия прод-бага #2126.
|
||||
- load_tp_rp_reserves: row-offset (данные с строки 9) на синтетическом workbook.
|
||||
- load_cp_reserves: row-offset (данные с строки 8) + матч по sc_name_norm.
|
||||
"""
|
||||
|
|
@ -230,18 +233,195 @@ def test_docx_empty_no_table() -> None:
|
|||
assert vk.parse_docx_table_rows(xml) == []
|
||||
|
||||
|
||||
def test_extract_water_rows_from_matrix() -> None:
|
||||
"""Из forward-filled матрицы: имя (текст), резерв (первое число, знак сохранён)."""
|
||||
matrix = [
|
||||
["система", "резерв", "примечание"], # заголовок (без числа) — пропуск
|
||||
["ЗФС, ГСВ", "−168,5", "дефицит"],
|
||||
["Головные, ЮАС", "42,0", "запас"],
|
||||
# ── extract_water_rows: РЕАЛЬНАЯ сетка прода (регрессия #2126) ────────────────
|
||||
#
|
||||
# Обе матрицы — сокращённые копии РЕАЛЬНОЙ сетки, снятой с прода 2026-07-03
|
||||
# (dlya-sajta-vodosnabzhenie-1kv.-2026v1.docx / vodootvedenie). Прод-баг #2126:
|
||||
# старый парсер брал «первое число строки» как резерв → в БД попадали № п/п
|
||||
# (1.1, 2.18…) вместо тыс.м³/сут, а рубрики анкеты / «Итого:» / шапки — как «системы».
|
||||
# Ниже фикстуры специально несут: шапку, рубрики (заявки/исполненные/отказы),
|
||||
# строку «Итого:», строку-легенду, ноль-резерв с «Отсутствие резерва мощности».
|
||||
|
||||
# Наименования показателей формы (точь-в-точь с прода).
|
||||
_M_REQ = "Количество поданных и зарегистрированных заявок на подключение"
|
||||
_M_DONE = "Количество исполненных заявок на подключение"
|
||||
_M_REFUSED = "Количество заявок на подключение, по которым принято решение об отказе в подключении"
|
||||
_M_RESERVE_W = "Резерв мощности централизованной системы холодного водоснабжения"
|
||||
_M_RESERVE_S = "Резерв мощности централизованной системы водоотведения"
|
||||
|
||||
|
||||
def _water_block(
|
||||
num: str, name: str, req: str, refused: str, reserve: str, note: str
|
||||
) -> list[list[str]]:
|
||||
"""4 строки показателей одного ЦСВ водоснабжения (layout «water», 6 колонок)."""
|
||||
return [
|
||||
[num, name, _M_REQ, "шт", req, ""],
|
||||
[num, name, _M_DONE, "шт", "0", ""],
|
||||
[num, name, _M_REFUSED, "шт", refused, ""],
|
||||
[num, name, _M_RESERVE_W, "тыс. м3/сут", reserve, note],
|
||||
]
|
||||
recs = vk.extract_water_rows(matrix)
|
||||
assert len(recs) == 2
|
||||
assert recs[0]["system_name"] == "ЗФС, ГСВ"
|
||||
assert recs[0]["reserve_thousand_m3_day"] == -168.5
|
||||
assert recs[1]["reserve_thousand_m3_day"] == 42.0
|
||||
|
||||
|
||||
def _real_water_matrix() -> list[list[str]]:
|
||||
"""РЕАЛЬНАЯ сетка водоснабжения: шапка + 3 ЦСВ + ПТВ + «Итого:» + легенда."""
|
||||
return [
|
||||
["1 квартал 2026г."],
|
||||
["Информация о наличии\xa0(отсутствии) технической возможности…"],
|
||||
# шапка (col2 == 'Наименование показателя' → детект layout 'water')
|
||||
[
|
||||
"№ ЦСВ",
|
||||
"Название ЦСВ",
|
||||
"Наименование показателя",
|
||||
"Ед. изм.",
|
||||
"Период 01.01.2026 -31.03.2026",
|
||||
"Примечание",
|
||||
],
|
||||
# ЗФС,ГСВ: резерв −168,5 (дефицит), 49 заявок; № п/п '1.1.' НЕ должен стать резервом
|
||||
*_water_block("1.1.", 'ЦСВ "ЗФС, ГСВ"', "49", "0", "-168,5", ""),
|
||||
["", "", "", "", "", ""], # разделитель-пустышка
|
||||
# Изоплит: ноль-резерв «Отсутствие резерва мощности» — сохраняем 0.0 + note
|
||||
*_water_block("1.3.", 'ЦСВ "Изоплит"', "0", "0", "0,0", "Отсутствие резерва мощности"),
|
||||
["", "", "", "", "", ""],
|
||||
# Шабровский: положительный резерв 1,03
|
||||
*_water_block("2.10.", 'ЦСВ "пос. Шабровский"', "0", "0", "1,03", ""),
|
||||
["", "", "", "", "", ""],
|
||||
# ПТВ (№ ЦСВ = 'ПТВ', не числовой) — резерв 73,34, реальный крупный запас
|
||||
*_water_block("ПТВ", "ПТВ", "0", "0", "73,34", ""),
|
||||
["", "", "", ""],
|
||||
# «Итого:» — агрегат по всем системам, НЕ резерв конкретной ЦСВ
|
||||
["Итого:", _M_REQ, "шт", "65", ""],
|
||||
["Итого:", _M_DONE, "шт", "41", ""],
|
||||
["Итого:", _M_REFUSED, "шт", "0", ""],
|
||||
# легенда в конце
|
||||
["ЦСВ – централизованная система водоснабжения", "", "", ""],
|
||||
["ПТВ – производственно-техническое водоснабжение", "", "", ""],
|
||||
]
|
||||
|
||||
|
||||
def _sewer_block(title: str, req: str, reserve: str, note: str) -> list[list[str]]:
|
||||
"""Строка-заголовок системы + шапка + 4 показателя (layout «block», 5 колонок)."""
|
||||
return [
|
||||
[title], # одиночная строка-заголовок системы (только col0)
|
||||
["№ п/п", "Наименование показателя", "Единица измерения", "Период", "Примечание"],
|
||||
["1", _M_REQ, "шт.", req, ""],
|
||||
["2", _M_DONE, "шт.", "0", ""],
|
||||
["3", _M_REFUSED, "шт.", "0", ""],
|
||||
["4", _M_RESERVE_S, "тыс.м3/сут", reserve, note],
|
||||
]
|
||||
|
||||
|
||||
def _real_sewer_matrix() -> list[list[str]]:
|
||||
"""РЕАЛЬНАЯ сетка водоотведения: 3 системы-блока + «Итого:»."""
|
||||
return [
|
||||
["1 квартал 2026г."],
|
||||
["Информация о наличии\xa0(отсутствии) технической возможности…"],
|
||||
# Южный: дефицит −296,85 (крупнейший), 62 заявки; '4' в col0 НЕ должен стать резервом
|
||||
*_sewer_block(
|
||||
'Централизованная система водоотведения "Южный бассейн канализования"',
|
||||
"62",
|
||||
"-296,85",
|
||||
"Отсутствие резерва мощности",
|
||||
),
|
||||
# Северный: положительный 2,02
|
||||
*_sewer_block(
|
||||
'Централизованная система водоотведения "Северный бассейн канализования"',
|
||||
"3",
|
||||
"2,02",
|
||||
"",
|
||||
),
|
||||
# Транспортировка: ноль-резерв honestly сохраняем
|
||||
*_sewer_block(
|
||||
'Транспортировка сточных вод ЦСВО "Бассейн канализования "Чусовское озеро"',
|
||||
"0",
|
||||
"0",
|
||||
"Отсутствие резерва мощности",
|
||||
),
|
||||
["", "", "", "", ""],
|
||||
["Итого:", _M_REQ, "шт.", "65", ""],
|
||||
["Итого:", _M_DONE, "шт.", "37", ""],
|
||||
["Итого:", _M_REFUSED, "шт.", "0", ""],
|
||||
]
|
||||
|
||||
|
||||
def test_vodokanal_water_column_aligned_real_grid() -> None:
|
||||
"""Layout 'water': резерв из строки «Резерв мощности…», а НЕ № п/п; заявки верны."""
|
||||
recs = vk.extract_water_rows(_real_water_matrix())
|
||||
by_name = {r["system_name"]: r for r in recs}
|
||||
|
||||
# ровно 4 системы (ЗФС, Изоплит, Шабровский, ПТВ) — ни одной рубрики/«Итого»/легенды
|
||||
assert len(recs) == 4
|
||||
# РЕАЛЬНЫЕ резервы из правильной колонки (тыс.м³/сут), знак дефицита сохранён
|
||||
assert by_name['ЦСВ "ЗФС, ГСВ"']["reserve_thousand_m3_day"] == -168.5
|
||||
assert by_name['ЦСВ "ЗФС, ГСВ"']["connections_requested"] == 49
|
||||
assert by_name["ПТВ"]["reserve_thousand_m3_day"] == 73.34
|
||||
assert by_name['ЦСВ "пос. Шабровский"']["reserve_thousand_m3_day"] == 1.03
|
||||
# ни один резерв не равен № п/п (1.1 / 1.3 / 2.10) — прод-баг #2126
|
||||
reserves = {r["reserve_thousand_m3_day"] for r in recs}
|
||||
assert 1.1 not in reserves
|
||||
assert 2.1 not in reserves
|
||||
|
||||
|
||||
def test_vodokanal_water_zero_reserve_preserved_with_note() -> None:
|
||||
"""Ноль-резерв («Отсутствие резерва мощности») сохраняется честно: 0.0 + note."""
|
||||
recs = vk.extract_water_rows(_real_water_matrix())
|
||||
izoplit = next(r for r in recs if r["system_name"] == 'ЦСВ "Изоплит"')
|
||||
assert izoplit["reserve_thousand_m3_day"] == 0.0 # НЕ None, НЕ отброшен
|
||||
assert izoplit["note"] == "Отсутствие резерва мощности"
|
||||
|
||||
|
||||
def test_vodokanal_water_filters_rubrics_totals_legend() -> None:
|
||||
"""Рубрики анкеты, «Итого:», шапка, легенда НЕ распознаются как системы."""
|
||||
recs = vk.extract_water_rows(_real_water_matrix())
|
||||
names = {r["system_name"] for r in recs}
|
||||
assert not any("Количество" in n for n in names) # рубрики заявок/отказов
|
||||
assert not any(n.startswith("Итого") for n in names)
|
||||
assert "Название ЦСВ" not in names and "№ ЦСВ" not in names # шапка
|
||||
assert not any(n.startswith("ЦСВ –") or n.startswith("ПТВ –") for n in names) # легенда
|
||||
|
||||
|
||||
def test_vodokanal_sewer_block_layout_real_grid() -> None:
|
||||
"""Layout 'block' (водоотведение): имя — строка-заголовок, резерв из «Резерв…»."""
|
||||
recs = vk.extract_water_rows(_real_sewer_matrix())
|
||||
by_name = {r["system_name"]: r for r in recs}
|
||||
|
||||
assert len(recs) == 3 # 3 системы, «Итого:»/шапки отфильтрованы
|
||||
yuzhny = 'Централизованная система водоотведения "Южный бассейн канализования"'
|
||||
assert by_name[yuzhny]["reserve_thousand_m3_day"] == -296.85 # крупнейший дефицит
|
||||
assert by_name[yuzhny]["connections_requested"] == 62
|
||||
severny = 'Централизованная система водоотведения "Северный бассейн канализования"'
|
||||
assert by_name[severny]["reserve_thousand_m3_day"] == 2.02
|
||||
# '4' (№ п/п строки резерва) не попал в резерв
|
||||
assert 4.0 not in {r["reserve_thousand_m3_day"] for r in recs}
|
||||
|
||||
|
||||
def test_vodokanal_sewer_zero_reserve_preserved() -> None:
|
||||
"""Ноль-резерв транспортировки сохранён (0.0 + note), не отброшен."""
|
||||
recs = vk.extract_water_rows(_real_sewer_matrix())
|
||||
transp = next(r for r in recs if r["system_name"].startswith("Транспортировка"))
|
||||
assert transp["reserve_thousand_m3_day"] == 0.0
|
||||
assert transp["note"] == "Отсутствие резерва мощности"
|
||||
|
||||
|
||||
def test_vodokanal_water_no_header_returns_empty() -> None:
|
||||
"""Нет строки-шапки (layout не распознан) → [] (graceful, warning наверху)."""
|
||||
matrix = [
|
||||
["мусор", "без шапки"],
|
||||
["1.1.", 'ЦСВ "X"', "какой-то показатель", "шт", "5", ""],
|
||||
]
|
||||
assert vk.extract_water_rows(matrix) == []
|
||||
|
||||
|
||||
def test_vodokanal_dedupe_names_suffixes_byte_identical() -> None:
|
||||
"""БАЙТ-В-БАЙТ одинаковые имена разводятся суффиксом « (N)» (защита UNIQUE)."""
|
||||
recs = [
|
||||
{"system_name": "ЦСВ X", "reserve_thousand_m3_day": 1.0},
|
||||
{"system_name": "ЦСВ X", "reserve_thousand_m3_day": -2.0},
|
||||
{"system_name": "ЦСВ Y", "reserve_thousand_m3_day": 0.0},
|
||||
]
|
||||
out = vk._dedupe_names(recs)
|
||||
assert [r["system_name"] for r in out] == ["ЦСВ X", "ЦСВ X (2)", "ЦСВ Y"]
|
||||
# первое вхождение — без суффикса, его резерв 1.0 НЕ теряется
|
||||
assert out[0]["reserve_thousand_m3_day"] == 1.0
|
||||
|
||||
|
||||
# ── Fake DB session для upsert-тестов (SAVEPOINT + execute) ───────────────────
|
||||
|
|
@ -280,6 +460,68 @@ class _FakeSession:
|
|||
pass
|
||||
|
||||
|
||||
# ── load_water_reserves_from_docx: end-to-end (parse → dedupe → upsert) ────────
|
||||
|
||||
|
||||
def _matrix_to_docx(matrix: list[list[str]]) -> bytes:
|
||||
"""Матрица текстов → байты .docx (zip с word/document.xml, одна таблица)."""
|
||||
import zipfile as _zip
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
trs = []
|
||||
for row in matrix:
|
||||
tcs = "".join(
|
||||
f"<w:tc><w:p><w:r><w:t xml:space='preserve'>{escape(c)}</w:t></w:r></w:p></w:tc>"
|
||||
for c in row
|
||||
)
|
||||
trs.append(f"<w:tr>{tcs}</w:tr>")
|
||||
document = (
|
||||
f'<w:document xmlns:w="{_W}"><w:body><w:tbl>{"".join(trs)}</w:tbl></w:body></w:document>'
|
||||
)
|
||||
buf = io.BytesIO()
|
||||
with _zip.ZipFile(buf, "w", _zip.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("word/document.xml", document.encode("utf-8"))
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_vodokanal_load_from_docx_end_to_end() -> None:
|
||||
"""Полный путь water: docx → 4 записи, реальные резервы прокинуты в upsert-params."""
|
||||
db = _FakeSession(scalar_value=True, rowcount=1)
|
||||
docx = _matrix_to_docx(_real_water_matrix())
|
||||
|
||||
result = vk.load_water_reserves_from_docx(
|
||||
db, "water", docx, source_url="https://x/dlya-sajta-1kv.-2026.docx"
|
||||
)
|
||||
|
||||
assert result["records"] == 4
|
||||
assert result["inserted"] == 4
|
||||
assert db.committed
|
||||
# период инферится из имени файла источника
|
||||
assert db.calls[0][1]["period"] == "2026-Q1"
|
||||
by_name = {c[1]["system_name"]: c[1] for c in db.calls}
|
||||
# РЕАЛЬНЫЙ резерв ЗФС,ГСВ (−168,5), НЕ № п/п 1.1 — прод-баг #2126
|
||||
assert by_name['ЦСВ "ЗФС, ГСВ"']["reserve"] == -168.5
|
||||
assert by_name['ЦСВ "ЗФС, ГСВ"']["requested"] == 49
|
||||
assert by_name["ПТВ"]["reserve"] == 73.34
|
||||
# ноль-резерв Изоплита сохранён (0.0 + note), не отброшен и не None
|
||||
assert by_name['ЦСВ "Изоплит"']["reserve"] == 0.0
|
||||
assert by_name['ЦСВ "Изоплит"']["note"] == "Отсутствие резерва мощности"
|
||||
|
||||
|
||||
def test_vodokanal_load_from_docx_sewer_layout() -> None:
|
||||
"""Полный путь sewerage (block layout): 3 системы, крупнейший дефицит −296,85."""
|
||||
db = _FakeSession(scalar_value=True, rowcount=1)
|
||||
docx = _matrix_to_docx(_real_sewer_matrix())
|
||||
|
||||
result = vk.load_water_reserves_from_docx(db, "sewerage", docx, source_url="x-3kv-2025.docx")
|
||||
|
||||
assert result["records"] == 3
|
||||
by_name = {c[1]["system_name"]: c[1] for c in db.calls}
|
||||
yuzhny = 'Централизованная система водоотведения "Южный бассейн канализования"'
|
||||
assert by_name[yuzhny]["reserve"] == -296.85
|
||||
assert db.calls[0][1]["period"] == "2025-Q3"
|
||||
|
||||
|
||||
# ── load_tp_rp_reserves: row-offset (данные с строки 9) на синтетическом xlsx ──
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue