"""Загрузчик резервов свободной мощности Россети Урал из 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