517 lines
26 KiB
Python
517 lines
26 KiB
Python
"""Загрузчик резервов тепловой мощности из ФГИС ФАС (ri.eias.ru) (#2119 Phase B2).
|
||
|
||
Источник — реестр раскрытия информации ФАС по сфере теплоснабжения (ri.eias.ru,
|
||
reg=2644). Данные лежат за 3 GET-переходами:
|
||
|
||
1. Карточка организации (``PublicDisclosureInfoOrg.aspx``) — HTML-грид (DevExpress)
|
||
со списком публикаций форм F_W_Q_14 / F_W_Q_4_6; из строк грида собираем ссылки
|
||
``TemplatePrinter.aspx?reg=RU.5.66&guid=<GUID>&id=<pubId>`` + текст периода.
|
||
На проде ссылка живёт НЕ в href (там ``href="#"``), а в JS
|
||
``onclick="openTemplateDialog('https://ri-loader.eias.ru/TemplatePrinter.aspx?…', …)"``,
|
||
амперсанды бывают HTML-энкожены (``&``) → чанк строки перед парсом
|
||
прогоняем через ``html.unescape``. Период — «N кв. YYYY года» (реже «N квартал
|
||
YYYY»). Берём ВСЕ публикации свежайшего квартала (max(year, quarter)) — их может
|
||
быть несколько (по одной на центральную систему / ЦС).
|
||
2. TemplatePrinter (ri-loader.eias.ru) — HTML с ``<div id="dataFile"
|
||
data-file="<base64>">``; base64 → ZIP (in-memory) → член ``tpl`` (XLSX).
|
||
3. Лист ``ТП`` формы 14 — «Резерв мощности источников тепловой энергии»
|
||
(живой пример: СТ №54 → 3.26 Гкал/час).
|
||
|
||
Реальная сетка листа снята с прода (raw jsonb, все 4 публикации единообразны) —
|
||
парсинг COLUMN-ALIGNED, а НЕ «правейшее число в строке»:
|
||
|
||
* Строка-заголовок систем: ``col4 == 'Централизованная система'``; ИМЕНА систем
|
||
лежат в колонках ``j ≥ 6`` (текст: «СТ № 56», «СТ № 54 » с trailing-space, «Локальные
|
||
зоны теплоснабжения МУП…»). В хвосте той же строки бывают СЛУЖЕБНЫЕ ЧИСЛА (шаблон
|
||
ЕИАС) — отсекаем их требованием «имя содержит хотя бы одну букву».
|
||
* Строка резерва (№5): ``col3.strip() == '5'`` И/ИЛИ ``col4`` начинается с «Резерв
|
||
мощности источников тепловой энергии» — значения Гкал/ч стоят в ТЕХ ЖЕ колонках
|
||
``j``, что и имена систем. В хвосте — длинный текст-примечание «Указывается резерв…»
|
||
и служебные числа: они не в колонках систем и отсекаются сами.
|
||
* ЛОВУШКА ``Flag_Row_Size``: последняя КОЛОНКА листа (col25) — служебные числа-высоты
|
||
строк шаблона ЕИАС (74, 52, 15 у рубрик анкеты). Старый «правейшее число строки»
|
||
хватал именно их и распознавал рубрики анкеты («Количество заявок…», «Причины
|
||
отказа…», кнопку «Добавить систему») как системы. Column-aligned парс их не видит.
|
||
|
||
Все ячейки листа целиком → ``raw`` jsonb (будущие колонки не теряются). Нет строки
|
||
систем ИЛИ строки резерва → warning-лог + parsed=0, НЕ падаем.
|
||
|
||
Источник ГЕО-БЛОКИРУЕТ non-RU IP + шлёт российский УЦ без intermediate → загрузчик
|
||
РАБОТАЕТ НА ПРОДЕ (Celery weekly / manual docker exec), SSL через общий
|
||
``_ru_ssl_context`` (бандл содержит GlobalSign-intermediate для eias.ru). httpx с
|
||
явным таймаутом, per-row SAVEPOINT при UPSERT.
|
||
|
||
Числовой парс переиспользует ``parse_reserve_number`` из rosseti_reserve_loader.
|
||
"""
|
||
|
||
import base64
|
||
import binascii
|
||
import io
|
||
import json
|
||
import logging
|
||
import re
|
||
import zipfile
|
||
from html import unescape
|
||
|
||
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.gazprom_grs_loader import _ru_ssl_context
|
||
from app.services.site_finder.rosseti_reserve_loader import parse_reserve_number
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Реестр ФАС по сфере «теплоснабжение», регион Свердловская область (reg=2644).
|
||
_REG = "2644"
|
||
_REG_TEMPLATE = "RU.5.66"
|
||
|
||
# Организации-держатели: (человекочитаемое имя, orgId в реестре ФАС).
|
||
ORGS: list[tuple[str, int]] = [
|
||
("ЕТК", 30814352),
|
||
("Екатеринбургэнерго", 26479450),
|
||
]
|
||
|
||
# Карточка организации: грид с публикациями форм 14 / 4_6 сферы WARM, раздел QUARTER.
|
||
_CARD_URL = "https://ri.eias.ru/Discl/PublicDisclosureInfoOrg.aspx"
|
||
_CARD_FORMS = "F_W_Q_14;F_W_Q_4_6;"
|
||
|
||
# Выгрузка публикации: HTML с base64-ZIP в data-file. Хост loader-домена.
|
||
_TEMPLATE_URL = "https://ri-loader.eias.ru/TemplatePrinter.aspx"
|
||
|
||
_HTTP_TIMEOUT = 60
|
||
|
||
# Лист формы 14 с резервом мощности источников тепла.
|
||
_HEAT_SHEET = "ТП"
|
||
|
||
# Ссылка на публикацию в HTML грида карточки: захватываем guid + id (pubId).
|
||
# TemplatePrinter.aspx?reg=RU.5.66&guid=<GUID>&id=<pubId> (порядок параметров может
|
||
# варьироваться — берём каждый независимым lookahead-регэкспом на href).
|
||
# URL живёт либо в старом href="/Discl/TemplatePrinter.aspx?...", либо (реальная
|
||
# разметка прода) в JS onclick="openTemplateDialog('https://ri-loader.eias.ru/
|
||
# TemplatePrinter.aspx?...', ...)" при href="#" — поэтому ищем URL где угодно в чанке,
|
||
# останавливаясь на кавычках / скобках / пробелах (границы JS-строки и href-атрибута).
|
||
_PUB_HREF_RE = re.compile(
|
||
r"(?P<href>(?:https?://[^\"'()\s]+)?/?[^\"'()\s]*TemplatePrinter\.aspx[^\"'()\s]*)",
|
||
re.IGNORECASE,
|
||
)
|
||
_GUID_RE = re.compile(r"[?&]guid=([^&\"'\s]+)", re.IGNORECASE)
|
||
_PUBID_RE = re.compile(r"[?&]id=(\d+)", re.IGNORECASE)
|
||
|
||
# Период в тексте строки грида → (year, quarter). Реальная разметка прода —
|
||
# «1 кв. 2026 года»; встречаются также «N квартал YYYY» / «N квартала YYYY» / «N кв YYYY».
|
||
_QUARTER_RE = re.compile(r"(\d)\s*кв(?:артал\w*|\.)?\s*(\d{4})", re.IGNORECASE)
|
||
|
||
# base64-payload файла: <div id="dataFile" ... data-file="<base64>">.
|
||
_DATAFILE_RE = re.compile(
|
||
r'<div\b[^>]*id=["\']dataFile["\'][^>]*data-file=["\'](?P<b64>[^"\']+)["\']',
|
||
re.IGNORECASE | re.DOTALL,
|
||
)
|
||
|
||
# Заголовок листа со строкой систем: в этой строке col4 == этому значению, а имена
|
||
# систем стоят в колонках j ≥ 6 (см. docstring модуля, п.3).
|
||
_SYSTEMS_HEADER = "Централизованная система"
|
||
|
||
# Строка резерва мощности (№5 формы 14): col3 == '5' И/ИЛИ col4 начинается с этого.
|
||
_RESERVE_ROW_NUM = "5"
|
||
_RESERVE_ROW_PREFIX = "Резерв мощности источников тепловой энергии"
|
||
|
||
# Первая колонка листа, где могут стоять имена систем (col0..col5 — служебные/№/ед.изм.).
|
||
_SYSTEM_COL_START = 6
|
||
|
||
# Имя системы обязано содержать хотя бы одну букву (кириллица/латиница) — иначе это
|
||
# служебное число из хвоста строки-заголовка (Flag_Row_Size шаблона ЕИАС).
|
||
_HAS_LETTER_RE = re.compile(r"[А-Яа-яЁёA-Za-z]")
|
||
|
||
# Разбивка HTML грида на строки (<tr>) — публикации живут построчно, период и
|
||
# ссылка в одной строке. Достаточно грубого split по <tr, полноценный DOM не нужен.
|
||
_TR_SPLIT_RE = re.compile(r"<tr\b", re.IGNORECASE)
|
||
|
||
|
||
def _quarter_from_text(row_text: str) -> tuple[int, int] | None:
|
||
"""«…N квартал YYYY…» → (year, quarter). Квартал вне 1..4 / нет совпадения → None."""
|
||
m = _QUARTER_RE.search(row_text)
|
||
if not m:
|
||
return None
|
||
quarter = int(m.group(1))
|
||
year = int(m.group(2))
|
||
if quarter not in (1, 2, 3, 4):
|
||
return None
|
||
return (year, quarter)
|
||
|
||
|
||
def build_card_url(org_id: int) -> str:
|
||
"""URL карточки организации в реестре ФАС (грид публикаций форм 14 / 4_6)."""
|
||
return (
|
||
f"{_CARD_URL}?reg={_REG}&orgId={org_id}" f"&sphere=WARM&razdel=QUARTER&form={_CARD_FORMS}"
|
||
)
|
||
|
||
|
||
def build_template_url(guid: str, pub_id: str) -> str:
|
||
"""URL выгрузки публикации (TemplatePrinter, base64-ZIP в data-file)."""
|
||
return f"{_TEMPLATE_URL}?reg={_REG_TEMPLATE}&guid={guid}&id={pub_id}"
|
||
|
||
|
||
def extract_publications(card_html: str) -> list[dict]:
|
||
"""Из HTML карточки → публикации свежайшего квартала: [{guid, id, year, quarter}].
|
||
|
||
По каждой <tr>-строке грида ищем ссылку на TemplatePrinter (guid+id) И период
|
||
«N квартал YYYY» в тексте той же строки. Собираем max((year, quarter)) и
|
||
возвращаем ВСЕ публикации именно этого квартала (их бывает несколько — по ЦС).
|
||
Строки без ссылки ИЛИ без распознанного периода игнорируются. Одна публикация
|
||
встречается в нескольких строках грида (по видам деятельности: передача/сбыт) —
|
||
дедупим по (guid, id), иначе один и тот же ZIP качается многократно.
|
||
"""
|
||
pubs: list[dict] = []
|
||
for raw_chunk in _TR_SPLIT_RE.split(card_html):
|
||
# Атрибуты грида могут содержать HTML-энтити (`&guid=` вместо `&guid=`) —
|
||
# раскрываем до поиска, иначе `[?&]guid=` / `[?&]id=` не сматчатся.
|
||
chunk = unescape(raw_chunk)
|
||
href_m = _PUB_HREF_RE.search(chunk)
|
||
if not href_m:
|
||
continue
|
||
href = href_m.group("href")
|
||
guid_m = _GUID_RE.search(href)
|
||
pubid_m = _PUBID_RE.search(href)
|
||
if not guid_m or not pubid_m:
|
||
continue
|
||
yq = _quarter_from_text(chunk)
|
||
if yq is None:
|
||
continue
|
||
year, quarter = yq
|
||
pubs.append(
|
||
{"guid": guid_m.group(1), "id": pubid_m.group(1), "year": year, "quarter": quarter}
|
||
)
|
||
|
||
if not pubs:
|
||
logger.warning("eias_heat: публикации не найдены в карточке (0 строк с guid+период)")
|
||
return []
|
||
|
||
freshest = max((p["year"], p["quarter"]) for p in pubs)
|
||
latest: list[dict] = []
|
||
seen: set[tuple[str, str]] = set()
|
||
for p in pubs:
|
||
if (p["year"], p["quarter"]) != freshest or (p["guid"], p["id"]) in seen:
|
||
continue
|
||
seen.add((p["guid"], p["id"]))
|
||
latest.append(p)
|
||
logger.info(
|
||
"eias_heat: публикаций всего=%d, свежайший квартал=%d-Q%d → %d публикаций",
|
||
len(pubs),
|
||
freshest[0],
|
||
freshest[1],
|
||
len(latest),
|
||
)
|
||
return latest
|
||
|
||
|
||
def extract_tpl_xlsx(template_html: str) -> bytes | None:
|
||
"""HTML выгрузки → байты XLSX (член ``tpl`` из base64-ZIP в data-file). None — нет.
|
||
|
||
``<div id="dataFile" data-file="<base64>">`` → base64decode → ZIP → член ``tpl``.
|
||
Битый base64 / отсутствие члена ``tpl`` → warning-лог + None (шаг graceful).
|
||
"""
|
||
m = _DATAFILE_RE.search(template_html)
|
||
if not m:
|
||
logger.warning("eias_heat: <div id=dataFile data-file> не найден в TemplatePrinter HTML")
|
||
return None
|
||
try:
|
||
zip_bytes = base64.b64decode(m.group("b64"), validate=False)
|
||
except (binascii.Error, ValueError) as e:
|
||
logger.warning("eias_heat: base64-декод data-file упал: %s", e)
|
||
return None
|
||
|
||
try:
|
||
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
|
||
names = zf.namelist()
|
||
member = next((n for n in names if n == "tpl" or n.rsplit("/", 1)[-1] == "tpl"), None)
|
||
if member is None:
|
||
logger.warning("eias_heat: член 'tpl' не найден в ZIP (члены: %s)", names[:20])
|
||
return None
|
||
return zf.read(member)
|
||
except zipfile.BadZipFile as e:
|
||
logger.warning("eias_heat: data-file не является валидным ZIP: %s", e)
|
||
return None
|
||
|
||
|
||
def _cell_str(value: object) -> str:
|
||
"""Ячейка → strip-строка (None → '')."""
|
||
return "" if value is None else str(value).strip()
|
||
|
||
|
||
def _is_systems_header(cells: list[object]) -> bool:
|
||
"""Строка-заголовок систем: col4 == 'Централизованная система'."""
|
||
return len(cells) > 4 and _cell_str(cells[4]) == _SYSTEMS_HEADER
|
||
|
||
|
||
def _is_reserve_row(cells: list[object]) -> bool:
|
||
"""Строка резерва (№5): col3.strip() == '5' И/ИЛИ col4 начинается с «Резерв мощности…»."""
|
||
if len(cells) <= 4:
|
||
return False
|
||
return _cell_str(cells[3]) == _RESERVE_ROW_NUM or _cell_str(cells[4]).startswith(
|
||
_RESERVE_ROW_PREFIX
|
||
)
|
||
|
||
|
||
def _system_columns(header_cells: list[object]) -> list[tuple[int, str]]:
|
||
"""Из строки-заголовка → пары (col_index, system_name) для j ≥ 6.
|
||
|
||
Имя обязано быть текстовым и содержать хотя бы одну букву — иначе это служебное
|
||
число из хвоста строки (Flag_Row_Size шаблона ЕИАС), а не система.
|
||
"""
|
||
result: list[tuple[int, str]] = []
|
||
for j in range(_SYSTEM_COL_START, len(header_cells)):
|
||
value = header_cells[j]
|
||
if value is None:
|
||
continue
|
||
name = str(value).strip()
|
||
if name and _HAS_LETTER_RE.search(name):
|
||
result.append((j, name))
|
||
return result
|
||
|
||
|
||
def parse_heat_sheet(xlsx_bytes: bytes) -> tuple[list[dict], list[list[object]]]:
|
||
"""Парсит лист ``ТП`` формы 14 → (systems, all_cells).
|
||
|
||
COLUMN-ALIGNED по РЕАЛЬНОЙ сетке (не «правейшее число в строке»):
|
||
|
||
1. Строка-заголовок систем — ``col4 == 'Централизованная система'``; имена систем
|
||
берём из колонок ``j ≥ 6`` (текст, содержит букву — служебные числа хвоста
|
||
отсеиваются), имя .strip() (у части систем trailing-space).
|
||
2. Строка резерва (№5) — ``col3 == '5'`` И/ИЛИ ``col4`` начинается с «Резерв
|
||
мощности источников тепловой энергии»; резерв каждой системы читаем из ТОЙ ЖЕ
|
||
колонки ``j``, что и её имя. Нулевой резерв (0.0) — честный дефицит, СОХРАНЯЕМ;
|
||
непарсибельная ячейка (текст/пусто) — систему пропускаем.
|
||
|
||
Нет строки систем ИЛИ строки резерва → warning-лог + ([], all_cells) (graceful).
|
||
Служебная колонка 25 ``Flag_Row_Size`` (числа-высоты строк) в парс не попадает —
|
||
именно она заставляла старый парсер хватать рубрики анкеты как «системы».
|
||
|
||
``all_cells`` — ВСЕ ячейки листа (список строк-списков) для сохранения в raw.
|
||
"""
|
||
wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
|
||
ws = wb[_HEAT_SHEET] if _HEAT_SHEET in wb.sheetnames else wb.active
|
||
|
||
all_cells: list[list[object]] = []
|
||
header_cells: list[object] | None = None
|
||
reserve_cells: list[object] | None = None
|
||
try:
|
||
for row in ws.iter_rows(values_only=True):
|
||
cells = list(row)
|
||
all_cells.append(cells)
|
||
if header_cells is None and _is_systems_header(cells):
|
||
header_cells = cells
|
||
elif reserve_cells is None and _is_reserve_row(cells):
|
||
reserve_cells = cells
|
||
finally:
|
||
wb.close()
|
||
|
||
if header_cells is None or reserve_cells is None:
|
||
logger.warning(
|
||
"eias_heat: сетка не распознана (header=%s, reserve=%s) — 0 систем",
|
||
header_cells is not None,
|
||
reserve_cells is not None,
|
||
)
|
||
return [], all_cells
|
||
|
||
systems: list[dict] = []
|
||
name_counts: dict[str, int] = {}
|
||
for j, name in _system_columns(header_cells):
|
||
reserve = parse_reserve_number(reserve_cells[j] if j < len(reserve_cells) else None)
|
||
if reserve is None:
|
||
continue # непарсибельная ячейка резерва (текст/пусто) → систему скипаем
|
||
# Разные системы могут носить БАЙТ-В-БАЙТ одинаковое имя в соседних колонках
|
||
# (живой пример ЕЭ: две «Локальные зоны … МУП "Екатеринбургэнерго"» с 62.198
|
||
# и 0). UPSERT по (org, sphere, system_name, period) схлопнул бы их с потерей
|
||
# данных — вторым и далее вхождениям добавляем порядковый суффикс « (N)».
|
||
name_counts[name] = name_counts.get(name, 0) + 1
|
||
if name_counts[name] > 1:
|
||
name = f"{name} ({name_counts[name]})"
|
||
systems.append({"system_name": name, "reserve_gcal_h": reserve})
|
||
|
||
return systems, all_cells
|
||
|
||
|
||
def _cells_to_jsonable(all_cells: list[list[object]]) -> list[list[object]]:
|
||
"""Ячейки листа → JSON-совместимый вид (datetime/прочее → str, числа/None as-is)."""
|
||
out: list[list[object]] = []
|
||
for row in all_cells:
|
||
json_row: list[object] = []
|
||
for value in row:
|
||
if value is None or isinstance(value, bool | int | float | str):
|
||
json_row.append(value)
|
||
else:
|
||
json_row.append(str(value))
|
||
out.append(json_row)
|
||
return out
|
||
|
||
|
||
def _upsert_heat_rows(
|
||
db: Session,
|
||
org: str,
|
||
period: str,
|
||
source_guid: str,
|
||
systems: list[dict],
|
||
all_cells: list[list[object]],
|
||
) -> dict[str, int]:
|
||
"""UPSERT распознанных систем публикации в heat_system_reserves. Per-row SAVEPOINT.
|
||
|
||
raw = {cells: <весь лист>} сохраняется у КАЖДОЙ строки публикации (одна выгрузка =
|
||
один лист). ON CONFLICT (org, sphere, system_name, period) DO UPDATE.
|
||
Returns: счётчики parsed/inserted/updated/skipped.
|
||
"""
|
||
raw_json = json.dumps({"cells": _cells_to_jsonable(all_cells)}, ensure_ascii=False)
|
||
inserted = 0
|
||
updated = 0
|
||
skipped = 0
|
||
for sysrec in systems:
|
||
params = {
|
||
"org": org,
|
||
"system_name": sysrec["system_name"],
|
||
"reserve": sysrec["reserve_gcal_h"],
|
||
"period": period,
|
||
"source_guid": source_guid,
|
||
"raw": raw_json,
|
||
}
|
||
try:
|
||
with db.begin_nested(): # SAVEPOINT — битая строка не валит батч
|
||
result = db.execute(
|
||
text("""
|
||
INSERT INTO heat_system_reserves
|
||
(org, sphere, system_name, reserve_gcal_h,
|
||
period, source_guid, raw, fetched_at)
|
||
VALUES (
|
||
:org, 'heat', :system_name, :reserve,
|
||
:period, :source_guid, CAST(:raw AS jsonb), NOW()
|
||
)
|
||
ON CONFLICT (org, sphere, system_name, period) DO UPDATE
|
||
SET reserve_gcal_h = EXCLUDED.reserve_gcal_h,
|
||
source_guid = EXCLUDED.source_guid,
|
||
raw = EXCLUDED.raw,
|
||
fetched_at = NOW()
|
||
RETURNING (xmax = 0) AS is_insert
|
||
"""),
|
||
params,
|
||
).scalar()
|
||
if result:
|
||
inserted += 1
|
||
else:
|
||
updated += 1
|
||
except Exception as e:
|
||
logger.warning("eias_heat upsert failed for %r/%r: %s", org, sysrec["system_name"], e)
|
||
skipped += 1
|
||
return {
|
||
"parsed": len(systems),
|
||
"inserted": inserted,
|
||
"updated": updated,
|
||
"skipped": skipped,
|
||
}
|
||
|
||
|
||
def fetch_card_html(org_id: int) -> str:
|
||
"""Тянет HTML карточки организации. RUN-ON-PROD (гео-блок + российский УЦ)."""
|
||
resp = httpx.get(
|
||
build_card_url(org_id),
|
||
timeout=_HTTP_TIMEOUT,
|
||
follow_redirects=True,
|
||
verify=_ru_ssl_context(),
|
||
)
|
||
resp.raise_for_status()
|
||
return resp.text
|
||
|
||
|
||
def fetch_template_html(client: httpx.Client, guid: str, pub_id: str) -> str:
|
||
"""Тянет HTML выгрузки публикации (base64-ZIP в data-file). RUN-ON-PROD."""
|
||
resp = client.get(build_template_url(guid, pub_id))
|
||
resp.raise_for_status()
|
||
return resp.text
|
||
|
||
|
||
def load_org_reserves(db: Session, org: str, org_id: int) -> dict[str, int]:
|
||
"""Полный прогон одной организации: карточка → публикации → выгрузки → UPSERT.
|
||
|
||
Аккумулирует счётчики по всем публикациям свежайшего квартала (по ЦС).
|
||
Ноль распознанных систем во всех публикациях → warning-лог с первыми 20 строками
|
||
последнего листа, parsed=0, НЕ падаем.
|
||
"""
|
||
counts = {"parsed": 0, "inserted": 0, "updated": 0, "skipped": 0, "publications": 0}
|
||
|
||
card_html = fetch_card_html(org_id)
|
||
pubs = extract_publications(card_html)
|
||
if not pubs:
|
||
logger.warning("eias_heat[%s]: нет публикаций свежайшего квартала", org)
|
||
return counts
|
||
|
||
period = f"{pubs[0]['year']}-Q{pubs[0]['quarter']}"
|
||
last_cells: list[list[object]] = []
|
||
|
||
with httpx.Client(
|
||
timeout=_HTTP_TIMEOUT, follow_redirects=True, verify=_ru_ssl_context()
|
||
) as client:
|
||
for pub in pubs:
|
||
try:
|
||
template_html = fetch_template_html(client, pub["guid"], pub["id"])
|
||
xlsx_bytes = extract_tpl_xlsx(template_html)
|
||
if xlsx_bytes is None:
|
||
continue
|
||
systems, all_cells = parse_heat_sheet(xlsx_bytes)
|
||
last_cells = all_cells
|
||
counts["publications"] += 1
|
||
if not systems:
|
||
continue
|
||
sub = _upsert_heat_rows(db, org, period, pub["guid"], systems, all_cells)
|
||
counts["parsed"] += sub["parsed"]
|
||
counts["inserted"] += sub["inserted"]
|
||
counts["updated"] += sub["updated"]
|
||
counts["skipped"] += sub["skipped"]
|
||
except Exception as e:
|
||
logger.exception("eias_heat[%s]: публикация guid=%s упала: %s", org, pub["guid"], e)
|
||
|
||
if counts["parsed"] == 0:
|
||
logger.warning(
|
||
"eias_heat[%s]: 0 систем распознано (period=%s); первые 20 строк листа: %s",
|
||
org,
|
||
period,
|
||
last_cells[:20],
|
||
)
|
||
|
||
counts["period"] = period # type: ignore[assignment]
|
||
logger.info("eias_heat[%s] done: %s", org, counts)
|
||
return counts
|
||
|
||
|
||
def load_heat_reserves(db: Session | None = None) -> dict[str, dict]:
|
||
"""Полный прогон тепло-резервов ФАС по всем организациям (ЕТК, Екатеринбургэнерго).
|
||
|
||
RUN-ON-PROD. Owns-session как соседи (rosseti/gas/vodokanal/eesk). Каждая
|
||
организация graceful: падение одной не валит вторую. Коммит один в конце
|
||
(per-row SAVEPOINT уже изолирует битые строки).
|
||
|
||
Returns: {org_name: {parsed, inserted, updated, skipped, publications, period}}
|
||
или {org_name: {"error": ...}} при падении организации.
|
||
"""
|
||
owns_session = db is None
|
||
if db is None:
|
||
db = SessionLocal()
|
||
|
||
out: dict[str, dict] = {}
|
||
try:
|
||
for org, org_id in ORGS:
|
||
try:
|
||
out[org] = load_org_reserves(db, org, org_id)
|
||
except Exception as e:
|
||
logger.exception("load_heat_reserves: org %s failed: %s", org, e)
|
||
out[org] = {"error": str(e)}
|
||
db.commit()
|
||
except Exception as e:
|
||
db.rollback()
|
||
logger.exception("load_heat_reserves: outer tx rolled back: %s", e)
|
||
raise
|
||
finally:
|
||
if owns_session:
|
||
db.close()
|
||
|
||
logger.info("load_heat_reserves done: %s", out)
|
||
return out
|