fix(vodokanal): column-aligned парсинг DOCX — реальные резервы вместо № п/п (#2119/#2126) (#2238)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m48s
Deploy / build-backend (push) Successful in 1m33s
Deploy / deploy (push) Successful in 1m27s

This commit is contained in:
bot-backend 2026-07-02 22:03:15 +00:00
parent e9c8d05e1d
commit 4c23c222f6
2 changed files with 492 additions and 71 deletions

View file

@ -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,29 @@ _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/сут» (пробелы схлопнуты) — защита от того,
# чтобы «Резерв…» из чужой формы с иной размерностью не попал в тыс.м³/сут.
# Оба токена обязательны: одного «тыс» мало — «тыс. м3/час» прошёл бы с чужой
# временной базой (ревью 2026-07-03).
_RESERVE_UNIT_MARKERS = ("тыс", "сут")
# Шапки таблицы и служебные строки — не данные.
_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 +186,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 all(m in unit for m in _RESERVE_UNIT_MARKERS):
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 +381,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 +467,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)

View file

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