From 5d34bb7833e2740e5583f6d50b05597a35cd11c7 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 02:42:29 +0500 Subject: [PATCH] fix(vodokanal): column-aligned parse by header labels, not blind indices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прод-баг #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 --- .../site_finder/vodokanal_reserve_loader.py | 295 ++++++++++++++---- .../tests/test_connection_capacity_loaders.py | 266 +++++++++++++++- 2 files changed, 490 insertions(+), 71 deletions(-) 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 ──