"""Загрузчик резервов ЦСВ/ЦСК Водоканала ЕКБ из docx-раскрытия (#2119, фикс #2126). Две страницы водоканалекб.рф (водоснабжение / водоотведение) публикуют docx с таблицами свободной мощности ЦСВ (центр. система водоснабжения) / ЦСК (центр. система водоотведения). UPSERT-ит в ``water_supply_reserves``. Источник ГЕО-БЛОКИРУЕТ non-RU IP → РАБОТАЕТ НА ПРОДЕ (Celery / docker exec). Punycode-host обязателен (водоканалекб.рф → xn--80aadbki6adhshb.xn--p1ai). Парсим таблицы через stdlib (zipfile + ElementTree по word/document.xml) — это надёжнее python-docx для vMerge-детекта и тривиально тестируется на синтетическом xml-фрагменте. Резерв бывает ОТРИЦАТЕЛЬНЫМ (дефицит, напр. −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 import logging import re import zipfile from xml.etree import ElementTree as ET import httpx from sqlalchemy import text from sqlalchemy.orm import Session from app.core.db import SessionLocal logger = logging.getLogger(__name__) # WordprocessingML namespace. _W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" _NS = {"w": _W} # Punycode-host обязателен (водоканалекб.рф). xn--80aadbki6adhshb.xn--p1ai. _BASE = "https://xn--80aadbki6adhshb.xn--p1ai" PAGES: dict[str, str] = { "water": f"{_BASE}/info/vodosnabzhenie/", "sewerage": f"{_BASE}/info/vodotvedenie/", } _HTTP_TIMEOUT = 60 # docx-ссылка «для сайта»: dlya-sajta-*.docx. _DOCX_HREF_RE = re.compile(r'href=["\']([^"\']*dlya-sajta-[^"\']*\.docx)["\']', re.IGNORECASE) # Дата в имени файла для выбора свежайшего. _FILE_DATE_RE = re.compile(r"(\d{4})|(\d{1,2})[.\-](\d{4})") # Период вида «1kv.-2026» / «1кв-2026» / «1 квартал 2026». _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). Запятая-десятичный → точка, минусы всех видов (−/–/-) нормализуются. «н/д»/пусто → None. """ if value is None: return None s = str(value).strip().lower() if s in _NODATA_TOKENS: return None s = s.replace("\xa0", "").replace(" ", "").replace(",", ".") # Нормализуем юникод-минусы к ASCII '-'. s = s.replace("−", "-").replace("–", "-").replace("—", "-") m = re.search(r"-?\d+(?:\.\d+)?", s) if not m: return None try: return float(m.group()) except ValueError: return None def infer_period(filename: str) -> str | None: """Инферит период отчёта из имени файла: «1kv.-2026» → '2026-Q1'. None — нет.""" m = _PERIOD_RE.search(filename) if not m: return None quarter, year = m.group(1), m.group(2) return f"{year}-Q{quarter}" def _cell_text(tc: ET.Element) -> str: """Весь текст ячейки таблицы () — конкатенация всех .""" parts = [t.text or "" for t in tc.iter(f"{{{_W}}}t")] return "".join(parts).strip() def _vmerge_state(tc: ET.Element) -> str: """Состояние вертикального merge ячейки: 'restart' | 'continue' | 'none'. — начало блока (значение здесь). (без val или val='continue') — продолжение (forward-fill сверху). Нет — обычная ячейка. """ tc_pr = tc.find(f"{{{_W}}}tcPr") if tc_pr is None: return "none" vmerge = tc_pr.find(f"{{{_W}}}vMerge") if vmerge is None: return "none" val = vmerge.get(f"{{{_W}}}val") return "restart" if val == "restart" else "continue" def parse_docx_table_rows(document_xml: bytes) -> list[list[str]]: """Парсит ПЕРВУЮ таблицу document.xml → list строк (list ячеек-строк). Forward-fill по вертикальному merge: ячейка в состоянии 'continue' наследует текст ячейки той же колонки из предыдущей строки (system-name + note мержатся). Возвращает матрицу текстов [row][col]. Пусто → []. """ root = ET.fromstring(document_xml) tables = root.iter(f"{{{_W}}}tbl") tbl = next(tables, None) if tbl is None: return [] matrix: list[list[str]] = [] prev_row: list[str] = [] for tr in tbl.findall(f"{{{_W}}}tr"): cells = tr.findall(f"{{{_W}}}tc") row: list[str] = [] for col_idx, tc in enumerate(cells): state = _vmerge_state(tc) if state == "continue": # forward-fill из той же колонки предыдущей строки. filled = prev_row[col_idx] if col_idx < len(prev_row) else "" row.append(filled) else: row.append(_cell_text(tc)) matrix.append(row) prev_row = row return matrix 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]: """Из матрицы docx → записи ЦСВ/ЦСК, COLUMN-ALIGNED по метке показателя. Определяет 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 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 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 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) flush() # последняя система return records def fetch_docx_link(page_url: str) -> str: """Скрейпит страницу → ссылка на СВЕЖАЙШИЙ dlya-sajta-*.docx. '' если нет. RUN-ON-PROD (гео-блок). GET follow_redirects, regex по href. """ resp = httpx.get(page_url, timeout=_HTTP_TIMEOUT, follow_redirects=True) resp.raise_for_status() hrefs = _DOCX_HREF_RE.findall(resp.text) if not hrefs: return "" def _key(h: str) -> tuple: years = [int(y) for grp in _FILE_DATE_RE.findall(h) for y in grp if y] return (max(years) if years else 0, h) best = max(hrefs, key=_key) 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, records: list[dict], period: str | None, source_url: str, ) -> dict[str, int]: """UPSERT записей ЦСВ/ЦСК в water_supply_reserves. Per-row SAVEPOINT.""" inserted = 0 updated = 0 skipped = 0 for rec in records: params = { "system_kind": system_kind, "system_name": rec["system_name"], "reserve": rec["reserve_thousand_m3_day"], "requested": rec["connections_requested"], "refused": rec["connections_refused"], "note": rec["note"], "period": period, "source_url": source_url, } try: with db.begin_nested(): # SAVEPOINT — битая строка не валит батч result = db.execute( text(""" INSERT INTO water_supply_reserves (system_kind, system_name, reserve_thousand_m3_day, connections_requested, connections_refused, note, period, source_url, fetched_at) VALUES ( :system_kind, :system_name, :reserve, :requested, :refused, :note, :period, :source_url, NOW() ) ON CONFLICT (system_kind, system_name, period) DO UPDATE SET reserve_thousand_m3_day = EXCLUDED.reserve_thousand_m3_day, connections_requested = EXCLUDED.connections_requested, connections_refused = EXCLUDED.connections_refused, note = EXCLUDED.note, source_url = EXCLUDED.source_url, fetched_at = NOW() RETURNING (xmax = 0) AS is_insert """), params, ).scalar() if result: inserted += 1 else: updated += 1 except Exception as e: logger.warning("water_reserve upsert failed for %r: %s", rec["system_name"], e) skipped += 1 return {"inserted": inserted, "updated": updated, "skipped": skipped} def load_water_reserves_from_docx( db: Session, system_kind: str, docx_bytes: bytes, source_url: str = "", ) -> dict[str, int]: """Парсит docx-байты → UPSERT ЦСВ/ЦСК в water_supply_reserves. Выделено из load_water_reserves для юнит-теста на синтетическом docx. Читает word/document.xml из zip, forward-fill vMerge, извлечение записей. """ with zipfile.ZipFile(io.BytesIO(docx_bytes)) as zf: document_xml = zf.read("word/document.xml") matrix = parse_docx_table_rows(document_xml) 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) db.commit() except Exception as e: db.rollback() logger.exception("load_water_reserves_from_docx: outer tx rolled back: %s", e) raise result = {"records": len(records), **counts, "period": period} # type: ignore[dict-item] logger.info("water_reserves[%s] done: %s", system_kind, result) return {k: v for k, v in result.items() if isinstance(v, int)} def load_water_reserves(db: Session | None = None) -> dict[str, dict]: """Полный прогон: обе страницы (водоснабжение/водоотведение) → UPSERT. RUN-ON-PROD. Каждая страница graceful: сбой одной не валит другую. Returns: {"water": {...}, "sewerage": {...}}. """ owns_session = db is None if db is None: db = SessionLocal() out: dict[str, dict] = {} try: with httpx.Client(timeout=_HTTP_TIMEOUT, follow_redirects=True) as client: for kind, page_url in PAGES.items(): try: link = fetch_docx_link(page_url) if not link: out[kind] = {"error": "no docx link found"} continue r = client.get(link) r.raise_for_status() out[kind] = load_water_reserves_from_docx(db, kind, r.content, link) except Exception as e: logger.exception("load_water_reserves: %s failed: %s", kind, e) out[kind] = {"error": str(e)} finally: if owns_session: db.close() logger.info("load_water_reserves done: %s", out) return out