"""Загрузчик резервов ЦСВ/ЦСК Водоканала ЕКБ из docx-раскрытия (#2119).
Две страницы водоканалекб.рф (водоснабжение / водоотведение) публикуют docx с
таблицами свободной мощности ЦСВ (центр. система водоснабжения) / ЦСК (центр.
система водоотведения). UPSERT-ит в ``water_supply_reserves``.
Источник ГЕО-БЛОКИРУЕТ 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-фрагменте.
Резерв бывает ОТРИЦАТЕЛЬНЫМ (дефицит, напр. −168,5) — знак сохраняем, запятая-
десятичный → float. period инферится из имени файла (1kv.-2026 → '2026-Q1').
"""
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"})
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 _looks_numeric(s: str) -> bool:
"""Строка похожа на число (для отсева заголовочных строк)?"""
return parse_water_number(s) is not None
def extract_water_rows(matrix: list[list[str]]) -> list[dict]:
"""Из forward-filled матрицы вытаскивает записи ЦСВ/ЦСК.
Эвристика колонок (структура docx стабильна, но защищаемся): первая непустая
текстовая колонка = system_name; первая числовая колонка = reserve; далее
ищем заявки/отказы (целые) если есть; note — последняя длинная текстовая.
Заголовочные строки (без числового резерва) пропускаются.
"""
records: list[dict] = []
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:
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])
# заявки/отказы — целочисленные значения ПОСЛЕ колонки резерва.
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]
# 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,
}
)
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 _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 = extract_water_rows(matrix)
period = infer_period(source_url)
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