408 lines
18 KiB
Python
408 lines
18 KiB
Python
"""Загрузчик резервов свободной мощности Россети Урал из xlsx-раскрытия (#2119).
|
||
|
||
Два xlsx-файла раскрытия информации ФАС (rosseti-ural.ru):
|
||
(а) «свободная мощность по ЦП» → апдейт power_supply_centers
|
||
(б) «свободная мощность для ТП по ПС и РП <35» → UPSERT power_tp_rp_reserves
|
||
|
||
Источник ГЕО-БЛОКИРУЕТ non-RU IP → РАБОТАЕТ НА ПРОДЕ (Celery / docker exec).
|
||
|
||
Структуры xlsx ВЕРИФИЦИРОВАНЫ (см. docstring-и функций): фиксированные offset'ы
|
||
строк заголовков/нумерации/данных. openpyxl read_only для памяти на 10k+ строках.
|
||
Матч ЦП-резерва к power_supply_centers — по ОБЩЕМУ нормализатору normalize_sc_name.
|
||
"""
|
||
|
||
import io
|
||
import logging
|
||
import re
|
||
from datetime import date
|
||
from urllib.parse import unquote
|
||
|
||
import httpx
|
||
from openpyxl import load_workbook
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.db import SessionLocal
|
||
from app.services.site_finder.rosseti_wfs_loader import normalize_sc_name
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Punycode-host не требуется — rosseti-ural.ru латиница.
|
||
DISCLOSURE_URL = "https://rosseti-ural.ru/company/disclosure/monopoly/characteristic/bandwidth/"
|
||
|
||
_HTTP_TIMEOUT = 60
|
||
|
||
# «н/д» / «-» / пусто → None (нет данных).
|
||
_NODATA_TOKENS = frozenset({"", "н/д", "нд", "n/a", "na", "-", "—", "–", "нет данных"})
|
||
|
||
# Дата актуальности «на 30.06.2026» из имени файла или заголовка листа.
|
||
_ASOF_RE = re.compile(r"на\s+(\d{1,2})[.\-/](\d{1,2})[.\-/](\d{4})", re.IGNORECASE)
|
||
# Дата в имени файла для выбора «свежайшего» (DD.MM.YYYY / DD-MM-YYYY / YYYY-MM-DD).
|
||
_FILE_DATE_RE = re.compile(
|
||
r"(\d{1,2})[.\-](\d{1,2})[.\-](\d{4})|(\d{4})[.\-](\d{1,2})[.\-](\d{1,2})"
|
||
)
|
||
|
||
# Порог кВА-санитайза: у ТП установленная мощность в МВА физически <2.5;
|
||
# значение выше — почти наверняка забито в кВА (÷1000). РП не трогаем.
|
||
_TP_KVA_THRESHOLD_MVA = 2.5
|
||
|
||
|
||
def parse_reserve_number(value: object) -> float | None:
|
||
"""Парсит числовую ячейку резерва/мощности xlsx → float.
|
||
|
||
«н/д»/пусто → None. Запятая-десятичный → точка. Сохраняет знак
|
||
(резервы бывают отрицательными = дефицит). Нечисловой мусор → None.
|
||
"""
|
||
if value is None:
|
||
return None
|
||
if isinstance(value, int | float):
|
||
return float(value)
|
||
s = str(value).strip().lower()
|
||
if s in _NODATA_TOKENS:
|
||
return None
|
||
# Убираем пробелы-разделители тысяч (в т.ч. неразрывные) и нормализуем запятую.
|
||
s = s.replace("\xa0", "").replace(" ", "").replace(",", ".")
|
||
# Оставляем ведущий минус + цифры + точку.
|
||
m = re.match(r"^-?\d+(?:\.\d+)?$", s)
|
||
if not m:
|
||
return None
|
||
try:
|
||
return float(s)
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def parse_asof_date(*sources: str | None) -> date | None:
|
||
"""Ищет дату «на DD.MM.YYYY» в переданных строках (имя файла / заголовок листа).
|
||
|
||
Возвращает первую найденную date. None — если нигде нет.
|
||
"""
|
||
for src in sources:
|
||
if not src:
|
||
continue
|
||
m = _ASOF_RE.search(src)
|
||
if m:
|
||
day, month, year = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||
try:
|
||
return date(year, month, day)
|
||
except ValueError:
|
||
continue
|
||
return None
|
||
|
||
|
||
def _file_date_key(filename: str) -> tuple[int, int, int]:
|
||
"""Ключ сортировки «свежести» по дате в имени файла. Нет даты → (0,0,0)."""
|
||
m = _FILE_DATE_RE.search(filename)
|
||
if not m:
|
||
return (0, 0, 0)
|
||
if m.group(1): # DD-MM-YYYY
|
||
return (int(m.group(3)), int(m.group(2)), int(m.group(1)))
|
||
return (int(m.group(4)), int(m.group(5)), int(m.group(6))) # YYYY-MM-DD
|
||
|
||
|
||
def sanitize_tp_capacity_mva(installed: float | None, is_rp: bool) -> tuple[float | None, bool]:
|
||
"""кВА-санитайз установленной мощности ТП.
|
||
|
||
Часть строк ошибочно забивает кВА в колонку МВА. Если это ТП (не РП) и
|
||
installed > порога (2.5 МВА физически невозможно для ТП) — считаем что это
|
||
кВА и делим на 1000. Возвращает (значение_МВА, был_ли_санитайз).
|
||
РП не трогаем (у РП мощность легитимно бывает высокой).
|
||
"""
|
||
if installed is None or is_rp:
|
||
return installed, False
|
||
if installed > _TP_KVA_THRESHOLD_MVA:
|
||
return installed / 1000.0, True
|
||
return installed, False
|
||
|
||
|
||
def fetch_disclosure_links() -> dict[str, str]:
|
||
"""Скрейпит страницу раскрытия → ссылки на СВЕЖАЙШИЕ xlsx (ЦП + ТП/РП).
|
||
|
||
RUN-ON-PROD (гео-блок). GET с follow_redirects, regex по href .xlsx, фильтр
|
||
Свердловск-related, выбор LATEST по дате-в-имени для двух типов файлов.
|
||
|
||
Returns: {"cp": url|'', "tp_rp": url|''} — пустые если не нашлось.
|
||
"""
|
||
resp = httpx.get(DISCLOSURE_URL, timeout=_HTTP_TIMEOUT, follow_redirects=True)
|
||
resp.raise_for_status()
|
||
html = resp.text
|
||
|
||
# href на .xlsx (относительные и абсолютные).
|
||
hrefs = re.findall(r'href=["\']([^"\']+?\.xlsx)["\']', html, re.IGNORECASE)
|
||
base = "https://rosseti-ural.ru"
|
||
|
||
# ВАЖНО (прод-инцидент 2026-07-02): href'ы URL-энкоженные (кириллица как
|
||
# %D0%A1...) — фильтр по СЫРОЙ строке никогда не матчился → 'no cp link
|
||
# found'. Фильтруем/классифицируем/датируем по ДЕКОДИРОВАННОМУ имени, а
|
||
# скачиваем по сырому href (энкоженный URL корректен для httpx).
|
||
cp_candidates: list[tuple[str, str]] = [] # (url, decoded_low)
|
||
tp_candidates: list[tuple[str, str]] = []
|
||
for href in hrefs:
|
||
decoded = unquote(href).lower()
|
||
# Свердловск-фильтр: «Свердловской области» (ЦП) / «Свердловэнерго» (ТП/РП).
|
||
if "свердлов" not in decoded and "sverdl" not in decoded:
|
||
continue
|
||
url = href if href.startswith("http") else base + href
|
||
if "по цп" in decoded:
|
||
cp_candidates.append((url, decoded))
|
||
elif "для тп" in decoded and ("рп" in decoded or "35" in decoded):
|
||
tp_candidates.append((url, decoded))
|
||
|
||
def _latest(cands: list[tuple[str, str]]) -> str:
|
||
# Дата «на DD.MM.YYYY» — в декодированном имени файла.
|
||
return max(cands, key=lambda c: _file_date_key(c[1]))[0] if cands else ""
|
||
|
||
result = {"cp": _latest(cp_candidates), "tp_rp": _latest(tp_candidates)}
|
||
logger.info(
|
||
"rosseti disclosure links: cp=%r tp_rp=%r (from %d hrefs)",
|
||
result["cp"],
|
||
result["tp_rp"],
|
||
len(hrefs),
|
||
)
|
||
return result
|
||
|
||
|
||
def _cell(row: tuple, idx: int) -> object:
|
||
"""Безопасно достаёт ячейку row по 0-based индексу (None если за границей)."""
|
||
return row[idx] if idx < len(row) else None
|
||
|
||
|
||
def load_cp_reserves(db: Session, xlsx_bytes: bytes, filename: str = "") -> dict[str, int]:
|
||
"""Апдейт power_supply_centers резервами по ЦП из xlsx-раскрытия.
|
||
|
||
ВЕРИФИЦИРОВАННАЯ структура ЦП-файла: заголовки строки 5-6, строка 7 =
|
||
нумерация столбцов, ДАННЫЕ С СТРОКИ 8. Колонки (1-based Excel → 0-based idx):
|
||
B(1)=ЦП name, C(2)=ПО (баланс), E(4)=municipality, F(5)=voltage class,
|
||
G(6)=installed МВА, H(7)=current load МВА, I(8)=reserve МВА.
|
||
Матч к power_supply_centers по sc_name_norm (тот же нормализатор!) →
|
||
installed/current/reserve/capacity_source='cp_35_110'. Unmatched → лог count.
|
||
|
||
Returns: счётчики rows/matched/unmatched.
|
||
"""
|
||
wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
|
||
ws = wb.active
|
||
asof = parse_asof_date(filename, ws.title if ws is not None else None)
|
||
|
||
rows_seen = 0
|
||
matched = 0
|
||
unmatched = 0
|
||
unmatched_names: list[str] = []
|
||
|
||
try:
|
||
# min_row=8 → данные с 8-й строки (1-based). iter_rows отдаёт кортежи values.
|
||
for row in ws.iter_rows(min_row=8, values_only=True):
|
||
cp_name = _cell(row, 1) # B
|
||
if cp_name is None or not str(cp_name).strip():
|
||
continue
|
||
rows_seen += 1
|
||
|
||
name_norm = normalize_sc_name(str(cp_name))
|
||
installed = parse_reserve_number(_cell(row, 6)) # G
|
||
current = parse_reserve_number(_cell(row, 7)) # H
|
||
reserve = parse_reserve_number(_cell(row, 8)) # I
|
||
municipality = _cell(row, 4) # E
|
||
|
||
try:
|
||
with db.begin_nested(): # SAVEPOINT — битая строка не валит батч
|
||
res = db.execute(
|
||
text("""
|
||
UPDATE power_supply_centers
|
||
SET installed_capacity_mva = :installed,
|
||
current_load_mva = :current,
|
||
reserve_mva = :reserve,
|
||
reserve_asof = :asof,
|
||
capacity_source = 'cp_35_110',
|
||
municipality = COALESCE(municipality, :municipality)
|
||
WHERE sc_name_norm = :name_norm
|
||
"""),
|
||
{
|
||
"installed": installed,
|
||
"current": current,
|
||
"reserve": reserve,
|
||
"asof": asof,
|
||
"municipality": (str(municipality).strip() if municipality else None),
|
||
"name_norm": name_norm,
|
||
},
|
||
)
|
||
if res.rowcount and res.rowcount > 0:
|
||
matched += res.rowcount
|
||
else:
|
||
unmatched += 1
|
||
if len(unmatched_names) < 20:
|
||
unmatched_names.append(str(cp_name).strip())
|
||
except Exception as e:
|
||
logger.warning("cp_reserve update failed for %r: %s", cp_name, e)
|
||
unmatched += 1
|
||
|
||
db.commit()
|
||
except Exception as e:
|
||
db.rollback()
|
||
logger.exception("load_cp_reserves: outer tx rolled back: %s", e)
|
||
raise
|
||
finally:
|
||
wb.close()
|
||
|
||
logger.info(
|
||
"load_cp_reserves done: rows=%d matched=%d unmatched=%d (sample unmatched=%s)",
|
||
rows_seen,
|
||
matched,
|
||
unmatched,
|
||
unmatched_names[:10],
|
||
)
|
||
return {"rows": rows_seen, "matched": matched, "unmatched": unmatched}
|
||
|
||
|
||
def _is_rp(name: str) -> bool:
|
||
"""Строка про РП (распределительный пункт), а не ТП? Влияет на кВА-санитайз."""
|
||
return bool(re.search(r"\bрп\b|рп[\s-]?\d", name.lower()))
|
||
|
||
|
||
def load_tp_rp_reserves(db: Session, xlsx_bytes: bytes, filename: str = "") -> dict[str, int]:
|
||
"""UPSERT резервов ТП/РП <35 кВ в power_tp_rp_reserves из xlsx-раскрытия.
|
||
|
||
ВЕРИФИЦИРОВАННАЯ структура: заголовки строки 6-7, нумерация строка 8,
|
||
ДАННЫЕ С СТРОКИ 9 (~10 741 строк). Колонки (0-based idx):
|
||
0=name (ТП-721…), 1=РЭС, 2=region, 3=municipality/settlement,
|
||
4=voltage kV, 5=installed МВА, 6=load МВА, 7=reserve МВА.
|
||
кВА-санитайз: у ТП (не РП) installed > 2.5 → делим на 1000 (лог count).
|
||
«н/д» → NULL. UPSERT по UNIQUE(name_norm, municipality, voltage_kv).
|
||
Per-row SAVEPOINT. Returns: счётчики.
|
||
"""
|
||
wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
|
||
ws = wb.active
|
||
asof = parse_asof_date(filename, ws.title if ws is not None else None)
|
||
|
||
rows_seen = 0
|
||
inserted = 0
|
||
updated = 0
|
||
skipped = 0
|
||
kva_sanitized = 0
|
||
|
||
try:
|
||
for row in ws.iter_rows(min_row=9, values_only=True):
|
||
name = _cell(row, 0)
|
||
if name is None or not str(name).strip():
|
||
continue
|
||
name_str = str(name).strip()
|
||
rows_seen += 1
|
||
|
||
res_unit = _cell(row, 1)
|
||
municipality = _cell(row, 3)
|
||
voltage_kv = _cell(row, 4)
|
||
installed = parse_reserve_number(_cell(row, 5))
|
||
current = parse_reserve_number(_cell(row, 6))
|
||
reserve = parse_reserve_number(_cell(row, 7))
|
||
|
||
installed, sanitized = sanitize_tp_capacity_mva(installed, _is_rp(name_str))
|
||
if sanitized:
|
||
kva_sanitized += 1
|
||
|
||
params = {
|
||
"name": name_str,
|
||
"name_norm": normalize_sc_name(name_str),
|
||
"res_unit": str(res_unit).strip() if res_unit else None,
|
||
"municipality": str(municipality).strip() if municipality else None,
|
||
"voltage_kv": str(voltage_kv).strip() if voltage_kv else None,
|
||
"installed": installed,
|
||
"current": current,
|
||
"reserve": reserve,
|
||
"asof": asof,
|
||
}
|
||
|
||
try:
|
||
with db.begin_nested(): # SAVEPOINT — битая строка не валит батч
|
||
result = db.execute(
|
||
# settlement намеренно = municipality: в xlsx Россетей это ОДНА
|
||
# колонка «МО/населённый пункт»; отдельного settlement источник
|
||
# не даёт (колонка в схеме — задел под Фазу B/геокод).
|
||
text("""
|
||
INSERT INTO power_tp_rp_reserves
|
||
(name, name_norm, res_unit, municipality, settlement,
|
||
voltage_kv, installed_capacity_mva, current_load_mva,
|
||
reserve_mva, reserve_asof, fetched_at)
|
||
VALUES (
|
||
:name, :name_norm, :res_unit, :municipality, :municipality,
|
||
:voltage_kv, :installed, :current,
|
||
:reserve, :asof, NOW()
|
||
)
|
||
ON CONFLICT (name_norm, municipality, voltage_kv) DO UPDATE
|
||
SET name = EXCLUDED.name,
|
||
res_unit = EXCLUDED.res_unit,
|
||
installed_capacity_mva = EXCLUDED.installed_capacity_mva,
|
||
current_load_mva = EXCLUDED.current_load_mva,
|
||
reserve_mva = EXCLUDED.reserve_mva,
|
||
reserve_asof = EXCLUDED.reserve_asof,
|
||
fetched_at = NOW()
|
||
RETURNING (xmax = 0) AS is_insert
|
||
"""),
|
||
params,
|
||
).scalar()
|
||
if result:
|
||
inserted += 1
|
||
else:
|
||
updated += 1
|
||
except Exception as e:
|
||
logger.warning("tp_rp_reserve upsert failed for %r: %s", name_str, e)
|
||
skipped += 1
|
||
|
||
db.commit()
|
||
except Exception as e:
|
||
db.rollback()
|
||
logger.exception("load_tp_rp_reserves: outer tx rolled back: %s", e)
|
||
raise
|
||
finally:
|
||
wb.close()
|
||
|
||
result_dict = {
|
||
"rows": rows_seen,
|
||
"inserted": inserted,
|
||
"updated": updated,
|
||
"skipped": skipped,
|
||
"kva_sanitized": kva_sanitized,
|
||
}
|
||
logger.info("load_tp_rp_reserves done: %s", result_dict)
|
||
return result_dict
|
||
|
||
|
||
def load_all_reserves(db: Session | None = None) -> dict[str, dict]:
|
||
"""Полный прогон: discovery ссылок + скачивание + загрузка ЦП и ТП/РП резервов.
|
||
|
||
RUN-ON-PROD. Каждый шаг graceful: сбой одного файла не валит другой.
|
||
Returns: {"cp": {...}, "tp_rp": {...}} со счётчиками (или {"error": ...}).
|
||
"""
|
||
owns_session = db is None
|
||
if db is None:
|
||
db = SessionLocal()
|
||
|
||
out: dict[str, dict] = {}
|
||
try:
|
||
links = fetch_disclosure_links()
|
||
with httpx.Client(timeout=_HTTP_TIMEOUT, follow_redirects=True) as client:
|
||
if links.get("cp"):
|
||
try:
|
||
r = client.get(links["cp"])
|
||
r.raise_for_status()
|
||
# unquote: дата «на DD.MM.YYYY» ищется в ДЕКОДИРОВАННОМ имени.
|
||
out["cp"] = load_cp_reserves(db, r.content, unquote(links["cp"]))
|
||
except Exception as e:
|
||
logger.exception("load_all_reserves: cp file failed: %s", e)
|
||
out["cp"] = {"error": str(e)}
|
||
else:
|
||
out["cp"] = {"error": "no cp link found"}
|
||
|
||
if links.get("tp_rp"):
|
||
try:
|
||
r = client.get(links["tp_rp"])
|
||
r.raise_for_status()
|
||
out["tp_rp"] = load_tp_rp_reserves(db, r.content, unquote(links["tp_rp"]))
|
||
except Exception as e:
|
||
logger.exception("load_all_reserves: tp_rp file failed: %s", e)
|
||
out["tp_rp"] = {"error": str(e)}
|
||
else:
|
||
out["tp_rp"] = {"error": "no tp_rp link found"}
|
||
finally:
|
||
if owns_session:
|
||
db.close()
|
||
|
||
logger.info("load_all_reserves done: %s", out)
|
||
return out
|