diff --git a/backend/app/services/site_finder/vodokanal_reserve_loader.py b/backend/app/services/site_finder/vodokanal_reserve_loader.py
index 41d14a45..cff79c18 100644
--- a/backend/app/services/site_finder/vodokanal_reserve_loader.py
+++ b/backend/app/services/site_finder/vodokanal_reserve_loader.py
@@ -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) — значение
-задано в первой строке блока, дальше идут без 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)
diff --git a/backend/tests/test_connection_capacity_loaders.py b/backend/tests/test_connection_capacity_loaders.py
index a4b22b54..ff550bae 100644
--- a/backend/tests/test_connection_capacity_loaders.py
+++ b/backend/tests/test_connection_capacity_loaders.py
@@ -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"{escape(c)}"
+ for c in row
+ )
+ trs.append(f"{tcs}")
+ document = (
+ f'{"".join(trs)}'
+ )
+ 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 ──