gendesign/backend/app/services/site_finder/vodokanal_reserve_loader.py
bot-backend 4c23c222f6
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
fix(vodokanal): column-aligned парсинг DOCX — реальные резервы вместо № п/п (#2119/#2126) (#2238)
2026-07-02 22:03:15 +00:00

523 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Загрузчик резервов ЦСВ/ЦСК Водоканала ЕКБ из 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:
"""Весь текст ячейки таблицы (<w:tc>) — конкатенация всех <w:t>."""
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'.
<w:vMerge w:val="restart"/> — начало блока (значение здесь).
<w:vMerge/> (без val или val='continue') — продолжение (forward-fill сверху).
Нет <w:vMerge> — обычная ячейка.
"""
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