feat(site-finder): ЕЭСК-резервы городских ПС/ТП + тепло из ФГИС ФАС (#2119 B2) #2165
19 changed files with 2090 additions and 12 deletions
|
|
@ -3875,6 +3875,7 @@ def get_parcel_connection_capacity(
|
|||
power_summary=data["power_summary"],
|
||||
water=data["water"],
|
||||
gas=data.get("gas"),
|
||||
heat=data.get("heat"),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -118,3 +118,38 @@ Rp1k43BrIngThpXwdQmGOsj6KS7PuqkKhMvIZVQ9lDcFnNI0ySk3BPsuTVO7yo92
|
|||
03GT/P4nsjwqpCc6XoCpPb42EqyFRHH5GJ9ca1ePkKxs3lApHnW9E4rmIf5tSOnt
|
||||
jISnqoU=
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# GlobalSign GCC R6 AlphaSSL CA 2025 (промежуточный, AIA secure.globalsign.com) —
|
||||
# ri.eias.ru / ri-loader.eias.ru не шлют intermediate. Добавлен 2026-07-02.
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFjTCCA3WgAwIBAgIRAIN9TriekS/nLK07x2kt3CAwDQYJKoZIhvcNAQELBQAw
|
||||
TDEgMB4GA1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjYxEzARBgNVBAoTCkds
|
||||
b2JhbFNpZ24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMjUwNTIxMDIzNjUyWhcN
|
||||
MjcwNTIxMDAwMDAwWjBVMQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2ln
|
||||
biBudi1zYTErMCkGA1UEAxMiR2xvYmFsU2lnbiBHQ0MgUjYgQWxwaGFTU0wgQ0Eg
|
||||
MjAyNTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJ/oiu0Bviq52UUE
|
||||
ADbFWmgu3rC7KDSMoorLN1Wd03McG3Z1aP71DlPCE33838r72Dfuj5M9LXfiQLJp
|
||||
Au6MwNExmKOzothw4x0zGf5oBYyrCMGm3fBpLPafwYQ3MchBOWMTbf83rKUPLH48
|
||||
KCJ0MnU8GUl8oA/J81wIvbbKPuNrFf6hvJDccjzc4NyxLz3A89zjV2g5whCg5O0u
|
||||
9YX4Zxk9JHuc/LvllOJO4waAYLjbWBJkz3rV3ts1SmSYnJqmyRTIjXwQgRvhEYqt
|
||||
DbRskt0W7M6cPwCze3GTBN2UHNpHkMs3YmVxku68I0aOQn5+uz//fDROP3z1Z/7I
|
||||
APteRtECAwEAAaOCAV8wggFbMA4GA1UdDwEB/wQEAwIBhjAdBgNVHSUEFjAUBggr
|
||||
BgEFBQcDAQYIKwYBBQUHAwIwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU
|
||||
xbSTj28r3B5Iv7cQMIXO0bK7SC0wHwYDVR0jBBgwFoAUrmwFo5MT4qLn4tcc1sfw
|
||||
f8hnU6AwewYIKwYBBQUHAQEEbzBtMC4GCCsGAQUFBzABhiJodHRwOi8vb2NzcDIu
|
||||
Z2xvYmFsc2lnbi5jb20vcm9vdHI2MDsGCCsGAQUFBzAChi9odHRwOi8vc2VjdXJl
|
||||
Lmdsb2JhbHNpZ24uY29tL2NhY2VydC9yb290LXI2LmNydDA2BgNVHR8ELzAtMCug
|
||||
KaAnhiVodHRwOi8vY3JsLmdsb2JhbHNpZ24uY29tL3Jvb3QtcjYuY3JsMCEGA1Ud
|
||||
IAQaMBgwCAYGZ4EMAQIBMAwGCisGAQQBoDIKAQMwDQYJKoZIhvcNAQELBQADggIB
|
||||
AB/uvBuZf4CiuSahwiXn4geF52roAH+6jxsEPTXTfb7bbeMDXsYgRRsOTNA70ruZ
|
||||
Tnz5DfFMuBhNoFhIFb0qR1izdy6VkdKOqFPNF2dOFI1EcnY9l2ory9mrzHqVbrL4
|
||||
vzUd17FLUVyjTVU7PAv4nxyhnO1GTeT83YlrdRF31NyR6bvZVTEERHmpbWSgeveJ
|
||||
LRtaMzlGWiLZ8IwkH7o6GH3jp/KPtDW4Npu8w64HrRZdN2pqQhi7+YKwfHM7H+2U
|
||||
dM1BGN0sjOWMVbMSB9MtCsleS2Mb7TRZEbOHxECJLLIluQypZr7Pol3+hAqrhyKI
|
||||
k+6y+Da0NeDuWxW59Ku4NvClqW1UFX1SpfNGhzVfp/CH+vPM1tySomx2jE0EnYZu
|
||||
GwVucXPBsp5nUWqUV9+143glVuS7GTg9hFPjNBInn17HbCoIIQIOzj5Vd9bK3A9U
|
||||
GxXNpwenDHEalCsD/4eQYDHPhFE7sNe0D/OXu+FAM02VZkARx37Jp4bDdujvgL9P
|
||||
vZPR3wThvDN1CTU8Bc3xea3yKFAraKcPZLkhReQUAm2VpR+HSJRPlUpYizlF9WkL
|
||||
h3KcAVCBJWvnOkVwxyU5QJMcnwW95JlOtx+9100GL99jHE5rs3gXp7F4bg8H01QT
|
||||
9jVOhBBmQ7nQoXuwI0tqal2QUqZz3eeu62CU7xBwtfYR
|
||||
-----END CERTIFICATE-----
|
||||
|
|
|
|||
|
|
@ -129,6 +129,11 @@ class PowerConnectionPoint(BaseModel):
|
|||
# Пометка для ПС ЕЭСК без опубликованного резерва: свободная мощность у АО «ЕЭСК»
|
||||
# (городская сеть ЕКБ) не раскрывается WFS/xlsx — берётся по запросу. #2119 B1.
|
||||
reserve_note: str | None = None
|
||||
# Единица reserve_mva/installed_capacity_mva: 'МВА' (Россети) или 'МВт'
|
||||
# (ЕЭСК ПС 35-220). district — адм. район ЕКБ (из xlsx ЕЭСК; NULL у Россети).
|
||||
# ADDITIVE (default None). #2119 Phase B2.
|
||||
reserve_unit: str | None = None
|
||||
district: str | None = None
|
||||
|
||||
|
||||
class PowerCapacitySummary(BaseModel):
|
||||
|
|
@ -172,12 +177,33 @@ class GasCapacityBlock(BaseModel):
|
|||
total_free_th_m3_h: float | None
|
||||
|
||||
|
||||
class HeatSystemRow(BaseModel):
|
||||
"""Резерв тепловой мощности одной системы теплоснабжения. #2119 Phase B2."""
|
||||
|
||||
org: str # организация-держатель (ЕТК / Екатеринбургэнерго)
|
||||
system_name: str # имя системы теплоснабжения (напр. «СТ №54»)
|
||||
reserve_gcal_h: float | None # свободная мощность, Гкал/ч (может быть <0 — дефицит)
|
||||
period: str | None # напр. '2026-Q2'
|
||||
|
||||
|
||||
class HeatCapacityBlock(BaseModel):
|
||||
"""Тепловой блок: резервы систем теплоснабжения за последний период. #2119 B2."""
|
||||
|
||||
# Строки heat_system_reserves за MAX(period) ПО КАЖДОЙ организации. Пусто, если
|
||||
# таблица heat_system_reserves ещё не наполнена (weekly-sync, RUN-ON-PROD).
|
||||
systems: list[HeatSystemRow]
|
||||
# Суммарная свободная тепловая мощность, Гкал/ч (None если данных нет).
|
||||
total_reserve_gcal_h: float | None
|
||||
|
||||
|
||||
class ConnectionCapacityResponse(BaseModel):
|
||||
power_points: list[PowerConnectionPoint]
|
||||
power_summary: PowerCapacitySummary
|
||||
water: list[WaterCapacityRow]
|
||||
# Газовый блок аддитивен (Phase B1): None если ключ 'gas' не собран.
|
||||
gas: GasCapacityBlock | None = None
|
||||
# Тепловой блок аддитивен (Phase B2): None если ключ 'heat' не собран.
|
||||
heat: HeatCapacityBlock | None = None
|
||||
|
||||
|
||||
# ── NSPD Risk Zones schemas (issue #94 TIER 3) ───────────────────────────────
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@
|
|||
- water: все строки water_supply_reserves за ПОСЛЕДНИЙ period (city-level).
|
||||
- gas: городские ГРС «Газпром трансгаз Екатеринбург» со свободной мощностью
|
||||
(gas_grs_capacity), агрегация по grs_name + суммарный свободный объём. #2119 B1.
|
||||
- heat: резервы тепловой мощности систем теплоснабжения (heat_system_reserves)
|
||||
за последний период ПО КАЖДОЙ организации + суммарный свободный объём. #2119 B2.
|
||||
|
||||
Источник координат участка — общий ``_get_parcel_wkt`` (cad_parcels_geom → квартал
|
||||
fallback). Радиус по умолчанию 3000 м (cap 10000).
|
||||
|
|
@ -42,7 +44,8 @@ def get_connection_capacity(
|
|||
radius_m: радиус поиска ЦП, м (caller клампит 1..10000).
|
||||
|
||||
Returns:
|
||||
{"power_points": [...], "power_summary": {...}, "water": [...]}
|
||||
{"power_points": [...], "power_summary": {...}, "water": [...],
|
||||
"gas": {...}|None, "heat": {...}|None}
|
||||
|
||||
Raises:
|
||||
ValueError: участок не найден в БД (нет geom).
|
||||
|
|
@ -60,12 +63,14 @@ def get_connection_capacity(
|
|||
power_summary = _build_power_summary(power_points)
|
||||
water = _query_water_latest(db)
|
||||
gas = _query_gas_city_grs(db)
|
||||
heat = _query_heat_latest(db)
|
||||
|
||||
return {
|
||||
"power_points": power_points,
|
||||
"power_summary": power_summary,
|
||||
"water": water,
|
||||
"gas": gas,
|
||||
"heat": heat,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -76,7 +81,7 @@ def _query_power_points(db: Session, parcel_wkt: str, radius_m: int) -> list[dic
|
|||
text("""
|
||||
SELECT sc_name, dzo_name, voltage_class, load_index,
|
||||
installed_capacity_mva, current_load_mva, reserve_mva,
|
||||
reserve_asof,
|
||||
reserve_unit, district, reserve_asof,
|
||||
ST_Distance(
|
||||
geom::geography,
|
||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||||
|
|
@ -110,6 +115,10 @@ def _query_power_points(db: Session, parcel_wkt: str, radius_m: int) -> list[dic
|
|||
"installed_capacity_mva": _num(r["installed_capacity_mva"]),
|
||||
"current_load_mva": _num(r["current_load_mva"]),
|
||||
"reserve_mva": reserve_mva,
|
||||
# Единица резерва: 'МВА' (Россети, default) или 'МВт' (ЕЭСК ПС 35-220).
|
||||
# district — адм. район ЕКБ из xlsx ЕЭСК (NULL у Россети-строк). #2119 B2.
|
||||
"reserve_unit": r["reserve_unit"],
|
||||
"district": r["district"],
|
||||
"reserve_asof": r["reserve_asof"].isoformat() if r["reserve_asof"] else None,
|
||||
"reserve_note": _reserve_note(r["dzo_name"], reserve_mva),
|
||||
"distance_m": round(float(r["distance_m"]), 1),
|
||||
|
|
@ -256,6 +265,64 @@ def _query_gas_city_grs(db: Session) -> dict:
|
|||
}
|
||||
|
||||
|
||||
def _query_heat_latest(db: Session) -> dict:
|
||||
"""Резервы тепловой мощности систем за ПОСЛЕДНИЙ период ПО КАЖДОЙ организации.
|
||||
|
||||
Строки heat_system_reserves за MAX(period) PER org (коррелированный подзапрос —
|
||||
как в _query_water_latest: одна организация может опубликовать свежий квартал
|
||||
раньше другой, глобальный MAX молча выкинул бы отстающую). period IS NULL
|
||||
исключаем; MAX(period) лексикографически совпадает с хронологией для 'YYYY-Qn'.
|
||||
Сортировка org, system_name. Graceful при отсутствии таблицы (как gas-блок).
|
||||
|
||||
Returns: {"systems": [{org, system_name, reserve_gcal_h, period}],
|
||||
"total_reserve_gcal_h": float|None}.
|
||||
"""
|
||||
try:
|
||||
rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT h.org, h.system_name, h.reserve_gcal_h, h.period
|
||||
FROM heat_system_reserves h
|
||||
WHERE h.period IS NOT NULL
|
||||
AND h.period = (
|
||||
SELECT MAX(h2.period) FROM heat_system_reserves h2
|
||||
WHERE h2.period IS NOT NULL
|
||||
AND h2.org = h.org
|
||||
)
|
||||
ORDER BY h.org, h.system_name
|
||||
""")
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
except Exception as e:
|
||||
# Таблица может не существовать до применения миграции 182 (schema-first).
|
||||
# Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт.
|
||||
logger.warning("heat_system_reserves недоступна (миграция 182 не применена?): %s", e)
|
||||
return {"systems": [], "total_reserve_gcal_h": None}
|
||||
|
||||
systems: list[dict] = []
|
||||
total_reserve = 0.0
|
||||
has_reserve = False
|
||||
for r in rows:
|
||||
reserve = _num(r["reserve_gcal_h"])
|
||||
if reserve is not None:
|
||||
total_reserve += reserve
|
||||
has_reserve = True
|
||||
systems.append(
|
||||
{
|
||||
"org": r["org"],
|
||||
"system_name": r["system_name"],
|
||||
"reserve_gcal_h": reserve,
|
||||
"period": r["period"],
|
||||
}
|
||||
)
|
||||
return {
|
||||
"systems": systems,
|
||||
"total_reserve_gcal_h": round(total_reserve, 3) if has_reserve else None,
|
||||
}
|
||||
|
||||
|
||||
def _num(value: object) -> float | None:
|
||||
"""NUMERIC (Decimal) из БД → float | None."""
|
||||
return float(value) if value is not None else None # type: ignore[arg-type]
|
||||
|
|
|
|||
432
backend/app/services/site_finder/eesk_reserve_loader.py
Normal file
432
backend/app/services/site_finder/eesk_reserve_loader.py
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
"""Загрузчик резервов свободной мощности АО «ЕЭСК» из xlsx-раскрытия (#2119 B2).
|
||||
|
||||
Хаб eesk.ru «Наличие объёма свободной для технологического присоединения
|
||||
мощности» — статический HTML со ссылками на xlsx двух серий:
|
||||
(а) ПС 35-220 кВ («резерв мощности по центрам питания», текст ссылки БЕЗ «ниже»
|
||||
/ содержит «35-220») → апдейт ``power_supply_centers`` (резерв в МВт!);
|
||||
(б) ТП/РП «ниже 35» кВ (текст ссылки содержит «ниже 35»)
|
||||
→ UPSERT ``power_tp_rp_reserves``.
|
||||
|
||||
Свежайший файл каждой серии выбирается по max(YYYY, N-квартал) из ТЕКСТА ссылки
|
||||
(«…N квартал(а) YYYY…»). period='YYYY-QN', reserve_asof = последний день квартала.
|
||||
|
||||
Источник — обычный SSL, БЕЗ гео-блока, но лоадер всё равно RUN-ON-PROD рядом с
|
||||
rosseti/gas/vodokanal (единый weekly-воркер). httpx с явным таймаутом, per-row
|
||||
SAVEPOINT при UPSERT (битая строка не валит батч).
|
||||
|
||||
Числовой парс/дата — переиспользуют ``parse_reserve_number`` / ``parse_asof_date``
|
||||
из ``rosseti_reserve_loader``; матч ЦП — общий ``normalize_sc_name``.
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from datetime import date
|
||||
|
||||
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_reserve_loader import (
|
||||
parse_asof_date, # noqa: F401 — переэкспорт для соседей/тестов симметрии
|
||||
parse_reserve_number,
|
||||
)
|
||||
from app.services.site_finder.rosseti_wfs_loader import normalize_sc_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Хаб раскрытия (статический HTML). ЕЭСК — латиница, punycode не требуется.
|
||||
HUB_URL = (
|
||||
"https://www.eesk.ru/Raskritie_informacii/standarty_raskrytia_info/"
|
||||
"Ob_osnovnih_potrebitelskih_harakteristik/"
|
||||
"Informacija_o_tehnicheskom_sostojanii_se/"
|
||||
"Nalichie_obema_svobodnoj_dlja_tehnologic"
|
||||
)
|
||||
|
||||
_HTTP_TIMEOUT = 60
|
||||
|
||||
# Ссылки на xlsx-файлы в загрузках сайта: /upload/site1/document_file/<hash>.xls[x].
|
||||
# Захватываем href + видимый ТЕКСТ ссылки (в тексте — серия и квартал/год).
|
||||
_LINK_RE = re.compile(
|
||||
r'<a\b[^>]*href=["\'](?P<href>[^"\']*/upload/site1/document_file/'
|
||||
r'[^"\']+?\.xlsx?)["\'][^>]*>(?P<text>.*?)</a>',
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
# «N квартал[а] YYYY» в тексте ссылки → (N, YYYY). Слово «квартал» в любой форме.
|
||||
_QUARTER_RE = re.compile(r"(\d)\s*квартал\w*\s*(\d{4})", re.IGNORECASE)
|
||||
|
||||
_BASE = "https://www.eesk.ru"
|
||||
|
||||
# Последний день квартала (месяц, день) по номеру квартала.
|
||||
_QUARTER_LAST_DAY: dict[int, tuple[int, int]] = {
|
||||
1: (3, 31),
|
||||
2: (6, 30),
|
||||
3: (9, 30),
|
||||
4: (12, 31),
|
||||
}
|
||||
|
||||
|
||||
def _strip_tags(html_fragment: str) -> str:
|
||||
"""Убирает html-теги/entity-мусор из текста ссылки → плоская строка."""
|
||||
text_only = re.sub(r"<[^>]+>", " ", html_fragment)
|
||||
text_only = text_only.replace(" ", " ").replace("\xa0", " ")
|
||||
return re.sub(r"\s+", " ", text_only).strip()
|
||||
|
||||
|
||||
def _classify_series(link_text_low: str) -> str | None:
|
||||
"""Классифицирует ссылку по её тексту: 'ps_35_220' | 'tp_rp_sub35' | None.
|
||||
|
||||
Различаем серии ТОЛЬКО по тексту ссылки:
|
||||
(a) ПС 35-220 — содержит «35-220» ИЛИ «резерв мощности по центрам питания»,
|
||||
при этом НЕ содержит «ниже» (иначе это серия б);
|
||||
(b) ТП/РП <35 — содержит «ниже 35».
|
||||
"""
|
||||
has_below = "ниже 35" in link_text_low or "ниже35" in link_text_low
|
||||
if has_below:
|
||||
return "tp_rp_sub35"
|
||||
if "35-220" in link_text_low or "по центрам питания" in link_text_low:
|
||||
return "ps_35_220"
|
||||
return None
|
||||
|
||||
|
||||
def _quarter_from_text(link_text: str) -> tuple[int, int] | None:
|
||||
"""Извлекает (year, quarter) из текста ссылки «…N квартал(а) YYYY…». Нет → None."""
|
||||
m = _QUARTER_RE.search(link_text)
|
||||
if not m:
|
||||
return None
|
||||
quarter = int(m.group(1))
|
||||
year = int(m.group(2))
|
||||
if quarter not in _QUARTER_LAST_DAY:
|
||||
return None
|
||||
return (year, quarter)
|
||||
|
||||
|
||||
def quarter_period(year: int, quarter: int) -> str:
|
||||
"""(2026, 2) → '2026-Q2'."""
|
||||
return f"{year}-Q{quarter}"
|
||||
|
||||
|
||||
def quarter_last_day(year: int, quarter: int) -> date:
|
||||
"""Последний календарный день квартала → date (для reserve_asof)."""
|
||||
month, day = _QUARTER_LAST_DAY[quarter]
|
||||
return date(year, month, day)
|
||||
|
||||
|
||||
def fetch_hub_links() -> dict[str, dict]:
|
||||
"""Скрейпит хаб → СВЕЖАЙШИЙ xlsx каждой серии (ps_35_220 + tp_rp_sub35).
|
||||
|
||||
RUN-ON-PROD. GET с follow_redirects, regex по <a href …upload…xlsx>ТЕКСТ</a>,
|
||||
классификация серии + квартал по ТЕКСТУ ссылки, выбор max(year, quarter).
|
||||
|
||||
Returns: {series: {"url": str, "year": int, "quarter": int}} — только найденные.
|
||||
"""
|
||||
resp = httpx.get(HUB_URL, timeout=_HTTP_TIMEOUT, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
return select_freshest_links(resp.text)
|
||||
|
||||
|
||||
def select_freshest_links(html: str) -> dict[str, dict]:
|
||||
"""Из HTML-хаба выбирает свежайшую ссылку каждой серии (чистая, тестируемая).
|
||||
|
||||
Свежайший = max((year, quarter)) среди ссылок одной серии. Ссылки без
|
||||
распознанного квартала игнорируются (нельзя датировать).
|
||||
"""
|
||||
best: dict[str, dict] = {}
|
||||
n_links = 0
|
||||
for m in _LINK_RE.finditer(html):
|
||||
n_links += 1
|
||||
link_text = _strip_tags(m.group("text"))
|
||||
series = _classify_series(link_text.lower())
|
||||
if series is None:
|
||||
continue
|
||||
yq = _quarter_from_text(link_text)
|
||||
if yq is None:
|
||||
continue
|
||||
year, quarter = yq
|
||||
href = m.group("href")
|
||||
url = href if href.startswith("http") else _BASE + href
|
||||
prev = best.get(series)
|
||||
if prev is None or (year, quarter) > (prev["year"], prev["quarter"]):
|
||||
best[series] = {"url": url, "year": year, "quarter": quarter}
|
||||
|
||||
logger.info(
|
||||
"eesk hub links: ps_35_220=%r tp_rp_sub35=%r (from %d xlsx links)",
|
||||
best.get("ps_35_220"),
|
||||
best.get("tp_rp_sub35"),
|
||||
n_links,
|
||||
)
|
||||
return best
|
||||
|
||||
|
||||
def _cell(row: tuple, idx: int) -> object:
|
||||
"""Безопасно достаёт ячейку row по 0-based индексу (None если за границей)."""
|
||||
return row[idx] if idx < len(row) else None
|
||||
|
||||
|
||||
def _pct_share_to_percent(value: object) -> float | None:
|
||||
"""Доля загрузки (0.41) → проценты (41.0). Уже-проценты (>1) не трогаем.
|
||||
|
||||
В xlsx ЕЭСК степень загрузки хранится ДОЛЕЙ (0..1). Храним в процентах.
|
||||
None/мусор → None.
|
||||
"""
|
||||
num = parse_reserve_number(value)
|
||||
if num is None:
|
||||
return None
|
||||
# Доля 0..1 → *100. Значения >1 считаем уже процентами (защита от смешанного ввода).
|
||||
return round(num * 100.0, 2) if -1.0 <= num <= 1.0 else num
|
||||
|
||||
|
||||
def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) -> dict[str, int]:
|
||||
"""Апдейт power_supply_centers резервом ЕЭСК ПС 35-220 из xlsx.
|
||||
|
||||
ВЕРИФИЦИРОВАННАЯ структура (лист ``Лист1``, снята с живого файла): шапка строка
|
||||
4 (1-based), данные строки 7-63 и 65-83, между секциями строки-разделители.
|
||||
Скипаем строки где колонка C (имя ПС) пуста или D (мощность) не парсится числом.
|
||||
Колонки 0-based: A=№, B=адм. район, C=имя ПС, D=макс полная мощность МВА,
|
||||
E=% загрузки ДОЛЯ (0.41→41.0), F=степень МУ250 (в raw), G=свободная МВт.
|
||||
|
||||
Матч к power_supply_centers по normalize_sc_name(C) AND dzo_name ILIKE '%ЕЭСК%'.
|
||||
reserve_mva=G, reserve_unit='МВт', installed_capacity_mva=D, district=B,
|
||||
capacity_source='eesk_35_220', reserve_asof. Unmatched имена — warning-лог.
|
||||
|
||||
Returns: счётчики rows/matched/unmatched.
|
||||
"""
|
||||
wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
|
||||
ws = wb["Лист1"] if "Лист1" in wb.sheetnames else wb.active
|
||||
|
||||
rows_seen = 0
|
||||
matched = 0
|
||||
unmatched = 0
|
||||
unmatched_names: list[str] = []
|
||||
|
||||
try:
|
||||
# min_row=7 (1-based) → данные с 7-й строки; секции 7-63 и 65-83 идут подряд,
|
||||
# строки-разделители отсеиваются проверкой C пусто / D не число.
|
||||
for row in ws.iter_rows(min_row=7, values_only=True):
|
||||
sc_name = _cell(row, 2) # C
|
||||
installed = parse_reserve_number(_cell(row, 3)) # D
|
||||
# Строка-разделитель между секциями: нет имени ПС ИЛИ мощность не число.
|
||||
if sc_name is None or not str(sc_name).strip() or installed is None:
|
||||
continue
|
||||
rows_seen += 1
|
||||
|
||||
district = _cell(row, 1) # B
|
||||
load_pct = _pct_share_to_percent(_cell(row, 4)) # E (доля → %)
|
||||
reserve = parse_reserve_number(_cell(row, 6)) # G (свободная МВт)
|
||||
name_norm = normalize_sc_name(str(sc_name))
|
||||
|
||||
params = {
|
||||
"installed": installed,
|
||||
"reserve": reserve,
|
||||
"asof": reserve_asof,
|
||||
"district": str(district).strip() if district else None,
|
||||
"load_pct": load_pct,
|
||||
"name_norm": name_norm,
|
||||
}
|
||||
|
||||
try:
|
||||
with db.begin_nested(): # SAVEPOINT — битая строка не валит батч
|
||||
res = db.execute(
|
||||
text("""
|
||||
UPDATE power_supply_centers
|
||||
SET reserve_mva = :reserve,
|
||||
reserve_unit = 'МВт',
|
||||
installed_capacity_mva = :installed,
|
||||
district = :district,
|
||||
load_index = COALESCE(
|
||||
load_index,
|
||||
CAST(:load_pct AS text)
|
||||
),
|
||||
capacity_source = 'eesk_35_220',
|
||||
reserve_asof = :asof
|
||||
WHERE sc_name_norm = :name_norm
|
||||
AND dzo_name ILIKE '%ЕЭСК%'
|
||||
"""),
|
||||
params,
|
||||
)
|
||||
if res.rowcount and res.rowcount > 0:
|
||||
matched += res.rowcount
|
||||
else:
|
||||
unmatched += 1
|
||||
if len(unmatched_names) < 50:
|
||||
unmatched_names.append(str(sc_name).strip())
|
||||
except Exception as e:
|
||||
logger.warning("eesk ps_35_220 update failed for %r: %s", sc_name, e)
|
||||
unmatched += 1
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("load_ps_35_220: outer tx rolled back: %s", e)
|
||||
raise
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
if unmatched_names:
|
||||
logger.warning(
|
||||
"eesk ps_35_220 unmatched (%d) sc_name список: %s",
|
||||
unmatched,
|
||||
unmatched_names,
|
||||
)
|
||||
logger.info(
|
||||
"load_ps_35_220 done: rows=%d matched=%d unmatched=%d",
|
||||
rows_seen,
|
||||
matched,
|
||||
unmatched,
|
||||
)
|
||||
return {"rows": rows_seen, "matched": matched, "unmatched": unmatched}
|
||||
|
||||
|
||||
def load_tp_rp_sub35(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) -> dict[str, int]:
|
||||
"""UPSERT резервов ЕЭСК ТП/РП «ниже 35» кВ в power_tp_rp_reserves из xlsx.
|
||||
|
||||
ВЕРИФИЦИРОВАННАЯ структура (лист ``Публикация``): строка 1 — title, строка 3 —
|
||||
шапка, ДАННЫЕ С СТРОКИ 4 (~2014 строк). Колонки 0-based: A=номер ТП/РП
|
||||
(«10003»), B=предельно допустимая загрузка МВт, C=загрузка по 4 замерам МВт,
|
||||
D=текущий резерв МВт.
|
||||
|
||||
UPSERT: name=«ТП/РП <A>», name_norm=str(A).strip().lower(),
|
||||
municipality='Екатеринбург', voltage_kv=NULL, installed=B, current=C,
|
||||
reserve=D, reserve_asof. ON CONFLICT (name_norm, municipality, voltage_kv) DO
|
||||
UPDATE. БЕЗ кВА-санитайза. Per-row SAVEPOINT.
|
||||
|
||||
Returns: счётчики rows/inserted/updated/skipped.
|
||||
"""
|
||||
wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
|
||||
ws = wb["Публикация"] if "Публикация" in wb.sheetnames else wb.active
|
||||
|
||||
rows_seen = 0
|
||||
inserted = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
|
||||
try:
|
||||
# min_row=4 (1-based) → данные с 4-й строки; 1 title, 3 шапка выше.
|
||||
for row in ws.iter_rows(min_row=4, values_only=True):
|
||||
num = _cell(row, 0) # A
|
||||
if num is None or not str(num).strip():
|
||||
continue
|
||||
# Номер ТП/РП бывает float из Excel (10003.0) — нормализуем к целому виду.
|
||||
num_raw = num
|
||||
if isinstance(num, float) and num.is_integer():
|
||||
num_raw = int(num)
|
||||
num_str = str(num_raw).strip()
|
||||
rows_seen += 1
|
||||
|
||||
installed = parse_reserve_number(_cell(row, 1)) # B
|
||||
current = parse_reserve_number(_cell(row, 2)) # C
|
||||
reserve = parse_reserve_number(_cell(row, 3)) # D
|
||||
|
||||
params = {
|
||||
"name": f"ТП/РП {num_str}",
|
||||
"name_norm": num_str.lower(),
|
||||
"municipality": "Екатеринбург",
|
||||
"installed": installed,
|
||||
"current": current,
|
||||
"reserve": reserve,
|
||||
"asof": reserve_asof,
|
||||
}
|
||||
|
||||
try:
|
||||
with db.begin_nested(): # SAVEPOINT — битая строка не валит батч
|
||||
result = db.execute(
|
||||
# voltage_kv намеренно NULL: у ЕЭСК-серии «ниже 35» отдельного
|
||||
# класса напряжения нет; settlement=municipality (одна колонка).
|
||||
text("""
|
||||
INSERT INTO power_tp_rp_reserves
|
||||
(name, name_norm, municipality, settlement,
|
||||
voltage_kv, installed_capacity_mva, current_load_mva,
|
||||
reserve_mva, reserve_asof, fetched_at)
|
||||
VALUES (
|
||||
:name, :name_norm, :municipality, :municipality,
|
||||
NULL, :installed, :current,
|
||||
:reserve, :asof, NOW()
|
||||
)
|
||||
ON CONFLICT (name_norm, municipality, voltage_kv) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
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("eesk tp_rp_sub35 upsert failed for %r: %s", num_str, e)
|
||||
skipped += 1
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("load_tp_rp_sub35: outer tx rolled back: %s", e)
|
||||
raise
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
result_dict = {
|
||||
"rows": rows_seen,
|
||||
"inserted": inserted,
|
||||
"updated": updated,
|
||||
"skipped": skipped,
|
||||
}
|
||||
logger.info("load_tp_rp_sub35 done: %s", result_dict)
|
||||
return result_dict
|
||||
|
||||
|
||||
def load_eesk_reserves(db: Session | None = None) -> dict[str, dict]:
|
||||
"""Полный прогон ЕЭСК: discovery ссылок + скачивание + загрузка ПС и ТП/РП.
|
||||
|
||||
RUN-ON-PROD. Owns-session как соседи (rosseti/gas/vodokanal). Каждый шаг
|
||||
graceful: сбой одного файла не валит другой.
|
||||
Returns: {"ps": {...}, "tp_rp": {...}} со счётчиками (или {"error": ...}).
|
||||
"""
|
||||
owns_session = db is None
|
||||
if db is None:
|
||||
db = SessionLocal()
|
||||
|
||||
out: dict[str, dict] = {}
|
||||
try:
|
||||
links = fetch_hub_links()
|
||||
with httpx.Client(timeout=_HTTP_TIMEOUT, follow_redirects=True) as client:
|
||||
ps = links.get("ps_35_220")
|
||||
if ps:
|
||||
try:
|
||||
r = client.get(ps["url"])
|
||||
r.raise_for_status()
|
||||
asof = quarter_last_day(ps["year"], ps["quarter"])
|
||||
out["ps"] = load_ps_35_220(db, r.content, asof)
|
||||
out["ps"]["period"] = quarter_period(ps["year"], ps["quarter"])
|
||||
except Exception as e:
|
||||
logger.exception("load_eesk_reserves: ps file failed: %s", e)
|
||||
out["ps"] = {"error": str(e)}
|
||||
else:
|
||||
out["ps"] = {"error": "no ps_35_220 link found"}
|
||||
|
||||
tp = links.get("tp_rp_sub35")
|
||||
if tp:
|
||||
try:
|
||||
r = client.get(tp["url"])
|
||||
r.raise_for_status()
|
||||
asof = quarter_last_day(tp["year"], tp["quarter"])
|
||||
out["tp_rp"] = load_tp_rp_sub35(db, r.content, asof)
|
||||
out["tp_rp"]["period"] = quarter_period(tp["year"], tp["quarter"])
|
||||
except Exception as e:
|
||||
logger.exception("load_eesk_reserves: tp_rp file failed: %s", e)
|
||||
out["tp_rp"] = {"error": str(e)}
|
||||
else:
|
||||
out["tp_rp"] = {"error": "no tp_rp_sub35 link found"}
|
||||
finally:
|
||||
if owns_session:
|
||||
db.close()
|
||||
|
||||
logger.info("load_eesk_reserves done: %s", out)
|
||||
return out
|
||||
427
backend/app/services/site_finder/eias_heat_loader.py
Normal file
427
backend/app/services/site_finder/eias_heat_loader.py
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
"""Загрузчик резервов тепловой мощности из ФГИС ФАС (ri.eias.ru) (#2119 Phase B2).
|
||||
|
||||
Источник — реестр раскрытия информации ФАС по сфере теплоснабжения (ri.eias.ru,
|
||||
reg=2644). Данные лежат за 3 GET-переходами:
|
||||
|
||||
1. Карточка организации (``PublicDisclosureInfoOrg.aspx``) — HTML-грид со списком
|
||||
публикаций форм F_W_Q_14 / F_W_Q_4_6; из строк грида собираем ссылки
|
||||
``TemplatePrinter.aspx?reg=RU.5.66&guid=<GUID>&id=<pubId>`` + текст периода
|
||||
«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 Гкал/час).
|
||||
|
||||
Точная сетка листа НЕ снята разведкой → парсинг DEFENSIVE: итерируем ВСЕ строки,
|
||||
строка-кандидат = текстовая ячейка матчит «СТ №N | систем | зона» И в той же строке
|
||||
есть парсибельное число; берём ПРАВЕЙШЕЕ число как резерв. Все ячейки листа целиком
|
||||
→ ``raw`` jsonb (будущие колонки не теряются). Ноль распознанных систем → warning-лог
|
||||
(первые 20 строк) + 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
|
||||
|
||||
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).
|
||||
_PUB_HREF_RE = re.compile(
|
||||
r'href=["\'](?P<href>[^"\']*TemplatePrinter\.aspx[^"\']*)["\']',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_GUID_RE = re.compile(r"[?&]guid=([^&\"'\s]+)", re.IGNORECASE)
|
||||
_PUBID_RE = re.compile(r"[?&]id=(\d+)", re.IGNORECASE)
|
||||
|
||||
# «N квартал YYYY» в тексте строки грида → (year, quarter).
|
||||
_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,
|
||||
)
|
||||
|
||||
# Строка-кандидат листа: текстовая ячейка про систему теплоснабжения / зону / СТ №N.
|
||||
_SYSTEM_MARKER_RE = re.compile(r"СТ\s*№\s*\d+|систем|зона", re.IGNORECASE)
|
||||
|
||||
# Разбивка 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)) и
|
||||
возвращаем ВСЕ публикации именно этого квартала (их бывает несколько — по ЦС).
|
||||
Строки без ссылки ИЛИ без распознанного периода игнорируются.
|
||||
"""
|
||||
pubs: list[dict] = []
|
||||
for chunk in _TR_SPLIT_RE.split(card_html):
|
||||
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 = [p for p in pubs if (p["year"], p["quarter"]) == freshest]
|
||||
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 _rightmost_number(cells: list[object]) -> float | None:
|
||||
"""Правейшее парсибельное число в строке (резерв). Нет чисел → None."""
|
||||
for value in reversed(cells):
|
||||
num = parse_reserve_number(value)
|
||||
if num is not None:
|
||||
return num
|
||||
return None
|
||||
|
||||
|
||||
def _first_system_text(cells: list[object]) -> str | None:
|
||||
"""Левейшая текстовая ячейка, матчащая систему/зону/СТ №N. Нет → None."""
|
||||
for value in cells:
|
||||
if value is None:
|
||||
continue
|
||||
s = str(value).strip()
|
||||
if s and _SYSTEM_MARKER_RE.search(s):
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
def parse_heat_sheet(xlsx_bytes: bytes) -> tuple[list[dict], list[list[object]]]:
|
||||
"""Парсит лист ``ТП`` формы 14 → (systems, all_cells).
|
||||
|
||||
DEFENSIVE (точная сетка не снята): итерируем ВСЕ строки листа. Строка-кандидат =
|
||||
есть текстовая ячейка про систему («СТ №N | систем | зона») И в той же строке
|
||||
есть парсибельное число → system_name = текст (strip), reserve_gcal_h = ПРАВЕЙШЕЕ
|
||||
число строки. Строки-заголовки (без чисел) пропускаются.
|
||||
|
||||
``all_cells`` — ВСЕ ячейки листа (список строк-списков) для сохранения в raw.
|
||||
Ноль распознанных систем → warning-лог с первыми 20 строками (в ``load_*``).
|
||||
"""
|
||||
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
|
||||
|
||||
systems: list[dict] = []
|
||||
all_cells: list[list[object]] = []
|
||||
try:
|
||||
for row in ws.iter_rows(values_only=True):
|
||||
cells = list(row)
|
||||
all_cells.append(cells)
|
||||
|
||||
system_name = _first_system_text(cells)
|
||||
if system_name is None:
|
||||
continue # не строка про систему (или заголовок) → скип
|
||||
reserve = _rightmost_number(cells)
|
||||
if reserve is None:
|
||||
continue # строка-заголовок системы без числа → скип
|
||||
systems.append({"system_name": system_name, "reserve_gcal_h": reserve})
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
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
|
||||
|
|
@ -90,9 +90,11 @@ _HEADER_MARKERS = ("наименование", "проектн")
|
|||
# сопредельные: Курганская, Челябинская и т.д. — они отсекаются region-фильтром).
|
||||
_SVERDLOVSK_TOKEN = "свердлов"
|
||||
|
||||
# Суффикс «(выход №N)» в имени ГРС → отдельный output_name. «(5)» (только цифры) —
|
||||
# НЕ выход (это доп. нумерация станции), остаётся в имени.
|
||||
_OUTPUT_SUFFIX_RE = re.compile(r"\s*\((выход\s*№?\s*\d+)\)\s*$", re.IGNORECASE)
|
||||
# Фрагмент «(выход №N)» в имени ГРС → отдельный output_name. «(5)» (только цифры) —
|
||||
# НЕ выход (это доп. нумерация станции), остаётся в имени. В живых данных фрагмент
|
||||
# стоит НЕ только в конце, но и В СЕРЕДИНЕ имени («ГРС-1 Свердловск (выход №1) (5)»)
|
||||
# → search по всему имени (не $-якорь), фрагмент вырезаем, остаток склеиваем. #2119 B1.
|
||||
_OUTPUT_SUFFIX_RE = re.compile(r"\((выход\s*№?\s*\d+)\)", re.IGNORECASE)
|
||||
|
||||
# FULL-строка: idx имени=2, проектная=3, загрузка=4, объём ТУ=5, свободная=6,
|
||||
# свободная %=7, уведомление(основание/срок/параметры)=8,9,10.
|
||||
|
|
@ -163,14 +165,19 @@ def _parse_pct(value: str | None) -> float | None:
|
|||
def _split_grs_name(raw_name: str) -> tuple[str, str | None]:
|
||||
"""Имя ячейки → (grs_name без выхода, output_name|None).
|
||||
|
||||
«ГРС Арти (выход №2)» → («ГРС Арти», «выход №2»); «ГРС Арамиль (5)» → без
|
||||
изменений (цифра в скобках — НЕ выход, часть имени).
|
||||
«ГРС Арти (выход №2)» → («ГРС Арти», «выход №2»); фрагмент «(выход №N)» бывает
|
||||
и В СЕРЕДИНЕ имени («ГРС-1 Свердловск (выход №1) (5)» → («ГРС-1 Свердловск (5)»,
|
||||
«выход №1»)) — вырезаем его, остаток склеиваем и нормализуем двойные пробелы.
|
||||
«ГРС Арамиль (5)» → без изменений (цифра в скобках — НЕ выход, часть имени).
|
||||
"""
|
||||
m = _OUTPUT_SUFFIX_RE.search(raw_name)
|
||||
if not m:
|
||||
return raw_name, None
|
||||
output = _MULTISPACE_RE.sub(" ", m.group(1)).strip()
|
||||
grs_name = raw_name[: m.start()].strip()
|
||||
# Вырезаем фрагмент из ЛЮБОГО места имени и склеиваем остаток (нормализуя
|
||||
# двойные пробелы, оставшиеся на месте выреза в середине).
|
||||
grs_name = raw_name[: m.start()] + raw_name[m.end() :]
|
||||
grs_name = _MULTISPACE_RE.sub(" ", grs_name).strip()
|
||||
return grs_name, output
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -310,6 +310,24 @@ def build_beat_schedule() -> dict:
|
|||
"schedule": _parse_cron("0 5 * * tue"), # 05:00 МСК, вторник
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
# ЕЭСК-резервы (Forgejo #2119 Фаза B2): ПС 35-220 (МВт) + ТП/РП «ниже 35» из
|
||||
# xlsx-раскрытия eesk.ru. Обычный SSL БЕЗ гео-блока, но держим в том же вторник-
|
||||
# кластере рядом с rosseti/gas/vodokanal. 05:30 МСК — через 30 мин после газа
|
||||
# (разные хосты, не бьём одновременно), вне monday-нагрузки.
|
||||
schedule["eesk-reserves-sync-weekly"] = {
|
||||
"task": "tasks.connection_capacity_sync.sync_eesk_reserves",
|
||||
"schedule": _parse_cron("30 5 * * tue"), # 05:30 МСК, вторник
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
# Тепло-резервы (Forgejo #2119 Фаза B2): резерв мощности источников тепла из
|
||||
# реестра раскрытия ФАС (ri.eias.ru, ЕТК + Екатеринбургэнерго). Источник ГЕО-
|
||||
# БЛОКИРУЕТ non-RU IP + российский УЦ → RUN-ON-PROD. 06:00 МСК — через 30 мин
|
||||
# после ЕЭСК (разные хосты), в том же вторник-кластере вне monday-нагрузки.
|
||||
schedule["heat-reserves-sync-weekly"] = {
|
||||
"task": "tasks.connection_capacity_sync.sync_heat_reserves",
|
||||
"schedule": _parse_cron("0 6 * * tue"), # 06:00 МСК, вторник
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
# #105 Phase 4: ЕКБ РНС/РВЭ — ежемесячно 1-го числа в 05:00 МСК.
|
||||
# Celery conf.timezone=Europe/Moscow → crontab трактуется в МСК (#1233).
|
||||
|
|
|
|||
|
|
@ -117,3 +117,54 @@ def sync_gas_grs_capacity() -> dict:
|
|||
logger.exception("sync_gas_grs_capacity failed: %s", e)
|
||||
_log_breadcrumb("gas_grs_sync", "error", f"{type(e).__name__}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="tasks.connection_capacity_sync.sync_eesk_reserves",
|
||||
queue="celery",
|
||||
)
|
||||
def sync_eesk_reserves() -> dict:
|
||||
"""Резервы АО «ЕЭСК» (xlsx-раскрытие) → power_supply_centers (ПС 35-220, МВт) +
|
||||
power_tp_rp_reserves (ТП/РП «ниже 35»). #2119 Фаза B2.
|
||||
|
||||
RUN-ON-PROD рядом с rosseti/gas/vodokanal (единый weekly-кластер). discovery
|
||||
свежайшего xlsx + загрузка ПС и ТП/РП — каждый файл graceful (сбой одного не
|
||||
валит другой; лоадер сам изолирует per-row SAVEPOINT-ами).
|
||||
"""
|
||||
_log_breadcrumb("eesk_reserves_sync", "info", "task started")
|
||||
try:
|
||||
from app.services.site_finder.eesk_reserve_loader import load_eesk_reserves
|
||||
|
||||
result = load_eesk_reserves()
|
||||
logger.info("sync_eesk_reserves done: %s", result)
|
||||
_log_breadcrumb("eesk_reserves_sync", "info", f"done: {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.exception("sync_eesk_reserves failed: %s", e)
|
||||
_log_breadcrumb("eesk_reserves_sync", "error", f"{type(e).__name__}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="tasks.connection_capacity_sync.sync_heat_reserves",
|
||||
queue="celery",
|
||||
)
|
||||
def sync_heat_reserves() -> dict:
|
||||
"""Резервы тепловой мощности ФАС (ri.eias.ru) → heat_system_reserves. #2119 B2.
|
||||
|
||||
RUN-ON-PROD (гео-блок non-RU + российский УЦ). По всем организациям-держателям
|
||||
(ЕТК, Екатеринбургэнерго): карточка ФАС → публикации свежайшего квартала →
|
||||
выгрузки xlsx → UPSERT. Каждая организация graceful (падение одной не валит вторую).
|
||||
"""
|
||||
_log_breadcrumb("heat_reserves_sync", "info", "task started")
|
||||
try:
|
||||
from app.services.site_finder.eias_heat_loader import load_heat_reserves
|
||||
|
||||
result = load_heat_reserves()
|
||||
logger.info("sync_heat_reserves done: %s", result)
|
||||
_log_breadcrumb("heat_reserves_sync", "info", f"done: {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.exception("sync_heat_reserves failed: %s", e)
|
||||
_log_breadcrumb("heat_reserves_sync", "error", f"{type(e).__name__}: {e}")
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ def _power_point(
|
|||
reserve: float | None = 15.0,
|
||||
load_index: str | None = "open",
|
||||
distance_m: float = 850.0,
|
||||
reserve_unit: str | None = "МВА",
|
||||
district: str | None = None,
|
||||
reserve_note: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"name": name,
|
||||
|
|
@ -30,16 +33,36 @@ def _power_point(
|
|||
"installed_capacity_mva": 40.0,
|
||||
"current_load_mva": 25.0,
|
||||
"reserve_mva": reserve,
|
||||
"reserve_unit": reserve_unit,
|
||||
"district": district,
|
||||
"reserve_asof": "2026-06-30",
|
||||
"reserve_note": reserve_note,
|
||||
"distance_m": distance_m,
|
||||
"lat": 56.78,
|
||||
"lon": 60.61,
|
||||
}
|
||||
|
||||
|
||||
def _heat_block(
|
||||
systems: list[dict[str, Any]] | None = None,
|
||||
total: float | None = 3.26,
|
||||
) -> dict[str, Any]:
|
||||
if systems is None:
|
||||
systems = [
|
||||
{
|
||||
"org": "ЕТК",
|
||||
"system_name": "СТ №54",
|
||||
"reserve_gcal_h": 3.26,
|
||||
"period": "2026-Q2",
|
||||
}
|
||||
]
|
||||
return {"systems": systems, "total_reserve_gcal_h": total}
|
||||
|
||||
|
||||
def _make_response(
|
||||
power_points: list[dict[str, Any]] | None = None,
|
||||
water: list[dict[str, Any]] | None = None,
|
||||
heat: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if power_points is None:
|
||||
power_points = [_power_point()]
|
||||
|
|
@ -66,6 +89,7 @@ def _make_response(
|
|||
"nearest_with_reserve": nearest,
|
||||
},
|
||||
"water": water,
|
||||
"heat": heat if heat is not None else _heat_block(),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -155,3 +179,105 @@ def test_connection_capacity_radius_too_large_422() -> None:
|
|||
client = TestClient(app)
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity?radius_m=20000")
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
# ── Phase B2: heat-блок + reserve_unit/district + reserve_note ────────────────
|
||||
|
||||
|
||||
def test_connection_capacity_heat_block_per_org() -> None:
|
||||
"""Heat-блок: строки за последний период per-org + суммарный резерв. #2119 B2."""
|
||||
heat = _heat_block(
|
||||
systems=[
|
||||
{"org": "ЕТК", "system_name": "СТ №54", "reserve_gcal_h": 3.26, "period": "2026-Q2"},
|
||||
# Другая организация может публиковать свежий квартал раньше/позже —
|
||||
# per-org MAX(period) сервиса это учитывает (тут просто передаём готовое).
|
||||
{
|
||||
"org": "Екатеринбургэнерго",
|
||||
"system_name": "ТЭЦ Зона-1",
|
||||
"reserve_gcal_h": -5.0, # дефицит — знак сохраняется
|
||||
"period": "2026-Q1",
|
||||
},
|
||||
],
|
||||
total=-1.74,
|
||||
)
|
||||
with patch(
|
||||
"app.api.v1.parcels.get_connection_capacity",
|
||||
return_value=_make_response(heat=heat),
|
||||
):
|
||||
client = TestClient(app)
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert len(body["heat"]["systems"]) == 2
|
||||
assert body["heat"]["systems"][0]["org"] == "ЕТК"
|
||||
assert body["heat"]["systems"][0]["reserve_gcal_h"] == pytest.approx(3.26)
|
||||
# дефицит (отрицательный резерв) сохраняет знак.
|
||||
assert body["heat"]["systems"][1]["reserve_gcal_h"] == pytest.approx(-5.0)
|
||||
assert body["heat"]["systems"][1]["period"] == "2026-Q1"
|
||||
assert body["heat"]["total_reserve_gcal_h"] == pytest.approx(-1.74)
|
||||
|
||||
|
||||
def test_connection_capacity_heat_graceful_absent() -> None:
|
||||
"""Ключ 'heat' отсутствует (таблица не наполнена / миграция не применена) → null."""
|
||||
resp = _make_response()
|
||||
del resp["heat"]
|
||||
with patch(
|
||||
"app.api.v1.parcels.get_connection_capacity",
|
||||
return_value=resp,
|
||||
):
|
||||
client = TestClient(app)
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["heat"] is None
|
||||
|
||||
|
||||
def test_connection_capacity_heat_empty_systems() -> None:
|
||||
"""Таблица есть, но систем нет → пустой блок, total=null (graceful degrade)."""
|
||||
with patch(
|
||||
"app.api.v1.parcels.get_connection_capacity",
|
||||
return_value=_make_response(heat=_heat_block(systems=[], total=None)),
|
||||
):
|
||||
client = TestClient(app)
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["heat"]["systems"] == []
|
||||
assert body["heat"]["total_reserve_gcal_h"] is None
|
||||
|
||||
|
||||
def test_connection_capacity_power_reserve_unit_and_district() -> None:
|
||||
"""power_points пробрасывает reserve_unit ('МВт' у ЕЭСК) + district. #2119 B2."""
|
||||
pt = _power_point(
|
||||
name="ПС 110/10 Свердловск",
|
||||
reserve_unit="МВт",
|
||||
district="Октябрьский",
|
||||
)
|
||||
with patch(
|
||||
"app.api.v1.parcels.get_connection_capacity",
|
||||
return_value=_make_response(power_points=[pt]),
|
||||
):
|
||||
client = TestClient(app)
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
p0 = r.json()["power_points"][0]
|
||||
assert p0["reserve_unit"] == "МВт"
|
||||
assert p0["district"] == "Октябрьский"
|
||||
|
||||
|
||||
def test_connection_capacity_reserve_note_null_when_reserve_filled() -> None:
|
||||
"""ЕЭСК теперь с числами → reserve_note NULL когда reserve_mva заполнен. #2119 B2."""
|
||||
# Заполненный резерв → сервис отдаёт reserve_note=None (даже для ЕЭСК-строк).
|
||||
pt = _power_point(reserve=12.5, reserve_note=None)
|
||||
with patch(
|
||||
"app.api.v1.parcels.get_connection_capacity",
|
||||
return_value=_make_response(power_points=[pt]),
|
||||
):
|
||||
client = TestClient(app)
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["power_points"][0]["reserve_note"] is None
|
||||
|
|
|
|||
277
backend/tests/test_eesk_reserve_loader.py
Normal file
277
backend/tests/test_eesk_reserve_loader.py
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
"""Unit-тесты загрузчика резервов АО «ЕЭСК» (#2119 Phase B2).
|
||||
|
||||
Pure / mock-based — без реальной сети и БД. Покрывает:
|
||||
- select_freshest_links: 2 серии (ПС 35-220 / ТП «ниже 35»), выбор свежайшего
|
||||
квартала из HTML-фрагмента; классификация по тексту ссылки.
|
||||
- _classify_series / _quarter_from_text / quarter_period / quarter_last_day.
|
||||
- _pct_share_to_percent: доля 0.41 → 41.0.
|
||||
- load_ps_35_220: шапка r4, данные r7+/r65+, строка-разделитель, доля→41.0,
|
||||
матч ЕЭСК-only (dzo_name ILIKE %ЕЭСК%) / анматч.
|
||||
- load_tp_rp_sub35: данные с r4, name «ТП/РП <A>», municipality='Екатеринбург'.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
import openpyxl
|
||||
|
||||
from app.services.site_finder import eesk_reserve_loader as ee
|
||||
from app.services.site_finder import rosseti_wfs_loader as rw
|
||||
|
||||
# ── select_freshest_links / классификация серий ──────────────────────────────
|
||||
|
||||
|
||||
def _link(href: str, text: str) -> str:
|
||||
return f'<a href="{href}">{text}</a>'
|
||||
|
||||
|
||||
def test_select_freshest_links_two_series_quarters() -> None:
|
||||
"""Из HTML-хаба выбирается свежайший квартал каждой серии; серии различаются."""
|
||||
base = "/upload/site1/document_file/"
|
||||
html = "".join(
|
||||
[
|
||||
_link(
|
||||
base + "aaa.xlsx",
|
||||
"Резерв мощности по центрам питания ПС 35-220 кВ 1 квартал 2026",
|
||||
),
|
||||
_link(
|
||||
base + "bbb.xlsx",
|
||||
"Резерв мощности по центрам питания ПС 35-220 кВ 2 квартала 2026",
|
||||
),
|
||||
_link(
|
||||
base + "ccc.xls",
|
||||
"Наличие свободной мощности ТП и РП ниже 35 кВ 1 квартал 2026",
|
||||
),
|
||||
_link(
|
||||
base + "ddd.xls",
|
||||
"Наличие свободной мощности ТП и РП ниже 35 кВ 2 квартала 2026",
|
||||
),
|
||||
]
|
||||
)
|
||||
links = ee.select_freshest_links(html)
|
||||
|
||||
assert set(links) == {"ps_35_220", "tp_rp_sub35"}
|
||||
# Свежайший ПС — 2 квартал 2026 (bbb), абсолютный URL достроен.
|
||||
assert links["ps_35_220"]["url"].endswith("bbb.xlsx")
|
||||
assert links["ps_35_220"]["url"].startswith("https://www.eesk.ru")
|
||||
assert (links["ps_35_220"]["year"], links["ps_35_220"]["quarter"]) == (2026, 2)
|
||||
# Свежайший ТП — 2 квартал 2026 (ddd).
|
||||
assert links["tp_rp_sub35"]["url"].endswith("ddd.xls")
|
||||
assert (links["tp_rp_sub35"]["year"], links["tp_rp_sub35"]["quarter"]) == (2026, 2)
|
||||
|
||||
|
||||
def test_select_freshest_picks_latest_year_over_quarter() -> None:
|
||||
"""4 квартал 2025 старее, чем 1 квартал 2026 — берём 2026."""
|
||||
base = "/upload/site1/document_file/"
|
||||
html = "".join(
|
||||
[
|
||||
_link(base + "old.xlsx", "Резерв по центрам питания 35-220 4 квартал 2025"),
|
||||
_link(base + "new.xlsx", "Резерв по центрам питания 35-220 1 квартал 2026"),
|
||||
]
|
||||
)
|
||||
links = ee.select_freshest_links(html)
|
||||
assert links["ps_35_220"]["url"].endswith("new.xlsx")
|
||||
assert (links["ps_35_220"]["year"], links["ps_35_220"]["quarter"]) == (2026, 1)
|
||||
|
||||
|
||||
def test_classify_series_below_wins_over_cp_phrase() -> None:
|
||||
"""«ниже 35» классифицирует как ТП даже если рядом «по центрам питания»."""
|
||||
assert ee._classify_series("резерв по центрам питания ниже 35 кв") == "tp_rp_sub35"
|
||||
assert ee._classify_series("резерв мощности по центрам питания 35-220") == "ps_35_220"
|
||||
assert ee._classify_series("что-то постороннее") is None
|
||||
|
||||
|
||||
def test_quarter_from_text_and_helpers() -> None:
|
||||
assert ee._quarter_from_text("… 3 квартал 2025 …") == (2025, 3)
|
||||
assert ee._quarter_from_text("… 2 квартала 2026 …") == (2026, 2)
|
||||
assert ee._quarter_from_text("без квартала 2026") is None
|
||||
assert ee.quarter_period(2026, 2) == "2026-Q2"
|
||||
assert ee.quarter_last_day(2026, 2).isoformat() == "2026-06-30"
|
||||
assert ee.quarter_last_day(2026, 1).isoformat() == "2026-03-31"
|
||||
assert ee.quarter_last_day(2025, 4).isoformat() == "2025-12-31"
|
||||
|
||||
|
||||
def test_pct_share_to_percent() -> None:
|
||||
"""Доля 0.41 → 41.0; уже-проценты не трогаем; мусор → None."""
|
||||
assert ee._pct_share_to_percent(0.41) == 41.0
|
||||
assert ee._pct_share_to_percent("0,41") == 41.0
|
||||
assert ee._pct_share_to_percent(1.0) == 100.0
|
||||
assert ee._pct_share_to_percent(41.0) == 41.0 # >1 → уже проценты
|
||||
assert ee._pct_share_to_percent("н/д") is None
|
||||
|
||||
|
||||
# ── Fake DB session (SAVEPOINT + execute), как в test_connection_capacity ────
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(self, scalar_value: Any = True, rowcount: int = 1) -> None:
|
||||
self._scalar = scalar_value
|
||||
self.rowcount = rowcount
|
||||
|
||||
def scalar(self) -> Any:
|
||||
return self._scalar
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""Мини-заглушка Session: собирает execute-параметры, поддерживает begin_nested."""
|
||||
|
||||
def __init__(self, scalar_value: Any = True, rowcount: int = 1) -> None:
|
||||
self.calls: list[tuple[str, Any]] = []
|
||||
self._scalar = scalar_value
|
||||
self._rowcount = rowcount
|
||||
self.committed = False
|
||||
|
||||
@contextmanager
|
||||
def begin_nested(self): # type: ignore[no-untyped-def]
|
||||
yield
|
||||
|
||||
def execute(self, stmt: Any, params: Any = None) -> _FakeResult:
|
||||
self.calls.append((str(stmt), params))
|
||||
return _FakeResult(self._scalar, self._rowcount)
|
||||
|
||||
def commit(self) -> None:
|
||||
self.committed = True
|
||||
|
||||
def rollback(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# ── load_ps_35_220: r4 шапка, данные r7+/r65+, разделитель, доля→41.0 ─────────
|
||||
|
||||
|
||||
def _build_ps_workbook() -> bytes:
|
||||
"""xlsx ПС 35-220: лист «Лист1», шапка r4, данные с r7, строка-разделитель.
|
||||
|
||||
Колонки 0-based: A=№, B=адм.район, C=имя ПС, D=макс полн. МВА, E=доля загрузки,
|
||||
F=степень МУ250, G=свободная МВт.
|
||||
"""
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Лист1"
|
||||
# Строки 1-6 — заголовки/шапка (r4 — реальная шапка), должны игнорироваться.
|
||||
for r in range(1, 7):
|
||||
ws.cell(row=r, column=1, value=f"header-{r}")
|
||||
# Строка 7 — первая строка данных.
|
||||
ws.append([1, "Ленинский", "ПС 110/10 Уктус", 40.0, 0.41, "МУ250", 15.0])
|
||||
# Строка 8 — вторая ПС.
|
||||
ws.append([2, "Кировский", "ПС 35/6 Изоплит", 16.0, 0.60, "МУ250", 4.0])
|
||||
# Строка 9 — РАЗДЕЛИТЕЛЬ секций: C пусто И D не число → скип.
|
||||
ws.append([None, None, None, None, None, None, None])
|
||||
# Строка 10 — ещё одна ПС (эмулирует секцию r65+).
|
||||
ws.append([3, "Октябрьский", "ПС 110/10 Сибирь", 25.0, 0.50, "МУ250", 8.0])
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_load_ps_35_220_parse_and_match() -> None:
|
||||
"""Данные с r7, разделитель пропущен, доля→%, матч по sc_name_norm."""
|
||||
from datetime import date
|
||||
|
||||
db = _FakeSession(scalar_value=None, rowcount=1) # UPDATE matched 1 row
|
||||
result = ee.load_ps_35_220(db, _build_ps_workbook(), date(2026, 6, 30))
|
||||
|
||||
# 3 строки данных (разделитель отсеян), все смэтчились.
|
||||
assert result["rows"] == 3, result
|
||||
assert result["matched"] == 3
|
||||
assert result["unmatched"] == 0
|
||||
|
||||
first = db.calls[0][1]
|
||||
assert first["name_norm"] == rw.normalize_sc_name("Уктус") == "уктус"
|
||||
assert first["installed"] == 40.0
|
||||
assert first["reserve"] == 15.0
|
||||
assert first["district"] == "Ленинский"
|
||||
assert first["load_pct"] == 41.0 # доля 0.41 → 41.0%
|
||||
assert first["asof"] == date(2026, 6, 30)
|
||||
|
||||
|
||||
def test_load_ps_35_220_unmatched_counted() -> None:
|
||||
"""ПС без совпадения (rowcount=0 — напр. не ЕЭСК) → unmatched, не падаем."""
|
||||
from datetime import date
|
||||
|
||||
db = _FakeSession(scalar_value=None, rowcount=0) # UPDATE matched 0 rows
|
||||
result = ee.load_ps_35_220(db, _build_ps_workbook(), date(2026, 6, 30))
|
||||
assert result["rows"] == 3
|
||||
assert result["matched"] == 0
|
||||
assert result["unmatched"] == 3
|
||||
|
||||
|
||||
def test_load_ps_35_220_sql_scopes_to_eesk() -> None:
|
||||
"""UPDATE ограничен dzo_name ILIKE '%ЕЭСК%' — не затирает Россети-строки."""
|
||||
from datetime import date
|
||||
|
||||
db = _FakeSession(scalar_value=None, rowcount=1)
|
||||
ee.load_ps_35_220(db, _build_ps_workbook(), date(2026, 6, 30))
|
||||
sql = db.calls[0][0]
|
||||
assert "ILIKE '%ЕЭСК%'" in sql
|
||||
assert "capacity_source = 'eesk_35_220'" in sql
|
||||
assert "reserve_unit = 'МВт'" in sql
|
||||
|
||||
|
||||
# ── load_tp_rp_sub35: данные с r4, name «ТП/РП <A>», municipality ────────────
|
||||
|
||||
|
||||
def _build_tp_workbook() -> bytes:
|
||||
"""xlsx ТП/РП «ниже 35»: лист «Публикация», r1 title, r3 шапка, данные r4+.
|
||||
|
||||
Колонки 0-based: A=номер, B=предельно доп. МВт, C=загрузка МВт, D=резерв МВт.
|
||||
"""
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Публикация"
|
||||
ws.cell(row=1, column=1, value="Заголовок публикации") # r1 title
|
||||
# r2 пустая, r3 — шапка.
|
||||
ws.cell(row=3, column=1, value="№ ТП/РП")
|
||||
ws.cell(row=3, column=2, value="предельно допустимая, МВт")
|
||||
# Строка 4+ — данные.
|
||||
ws.cell(row=4, column=1, value=10003) # int-номер (Excel часто float)
|
||||
ws.cell(row=4, column=2, value=1.6)
|
||||
ws.cell(row=4, column=3, value=1.0)
|
||||
ws.cell(row=4, column=4, value=0.6)
|
||||
ws.cell(row=5, column=1, value="10004") # уже строка
|
||||
ws.cell(row=5, column=2, value=2.5)
|
||||
ws.cell(row=5, column=3, value=2.0)
|
||||
ws.cell(row=5, column=4, value=0.5)
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_load_tp_rp_sub35_parse() -> None:
|
||||
"""Данные с r4; name «ТП/РП 10003», name_norm='10003', municipality='Екатеринбург'."""
|
||||
from datetime import date
|
||||
|
||||
db = _FakeSession(scalar_value=True, rowcount=1)
|
||||
result = ee.load_tp_rp_sub35(db, _build_tp_workbook(), date(2026, 6, 30))
|
||||
|
||||
assert result["rows"] == 2, result
|
||||
assert result["inserted"] == 2
|
||||
|
||||
first = db.calls[0][1]
|
||||
# float-номер 10003.0 из Excel нормализован к «10003» (без .0).
|
||||
assert first["name"] == "ТП/РП 10003"
|
||||
assert first["name_norm"] == "10003"
|
||||
assert first["municipality"] == "Екатеринбург"
|
||||
assert first["installed"] == 1.6
|
||||
assert first["current"] == 1.0
|
||||
assert first["reserve"] == 0.6
|
||||
assert first["asof"] == date(2026, 6, 30)
|
||||
|
||||
second = db.calls[1][1]
|
||||
assert second["name"] == "ТП/РП 10004"
|
||||
assert second["name_norm"] == "10004"
|
||||
|
||||
|
||||
def test_load_tp_rp_sub35_voltage_null_no_kva_sanitize() -> None:
|
||||
"""voltage_kv NULL в SQL; installed НЕ делится на 1000 (без кВА-санитайза)."""
|
||||
from datetime import date
|
||||
|
||||
db = _FakeSession(scalar_value=True, rowcount=1)
|
||||
ee.load_tp_rp_sub35(db, _build_tp_workbook(), date(2026, 6, 30))
|
||||
sql = db.calls[0][0]
|
||||
assert "voltage_kv" in sql and "NULL" in sql
|
||||
# installed сохранён как есть (1.6), без деления.
|
||||
assert db.calls[0][1]["installed"] == 1.6
|
||||
278
backend/tests/test_eias_heat_loader.py
Normal file
278
backend/tests/test_eias_heat_loader.py
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
"""Unit-тесты загрузчика тепло-резервов ФАС (ri.eias.ru) (#2119 Phase B2).
|
||||
|
||||
Pure / mock-based — без реальной сети и БД. Покрывает:
|
||||
- extract_publications: (guid, id, период) из синтетического HTML грида + выбор
|
||||
свежайшего квартала (несколько публикаций одного квартала → все);
|
||||
- extract_tpl_xlsx: base64 → ZIP → член 'tpl' (генерим zip в памяти с xlsx-членом);
|
||||
- parse_heat_sheet: детект «СТ №54 … 3.26», скип заголовков, правейшее число;
|
||||
- _upsert_heat_rows: счётчики parsed/inserted/updated (mock-DB как в eesk-тестах).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import zipfile
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
import openpyxl
|
||||
|
||||
from app.services.site_finder import eias_heat_loader as eh
|
||||
|
||||
# ── extract_publications: (guid, id, период) + свежайший квартал ──────────────
|
||||
|
||||
|
||||
def _pub_row(guid: str, pub_id: str, period_text: str) -> str:
|
||||
"""<tr> строки грида: ссылка на TemplatePrinter (guid+id) + текст периода."""
|
||||
href = f"/Discl/TemplatePrinter.aspx?reg=RU.5.66&guid={guid}&id={pub_id}"
|
||||
return f'<tr><td><a href="{href}">выгрузить</a></td><td>{period_text}</td></tr>'
|
||||
|
||||
|
||||
def test_extract_publications_freshest_quarter_all_pubs() -> None:
|
||||
"""Берём ВСЕ публикации свежайшего квартала; старее — отбрасываем."""
|
||||
html = (
|
||||
"<table>"
|
||||
+ "".join(
|
||||
[
|
||||
_pub_row("g-old", "101", "1 квартал 2026"),
|
||||
_pub_row("g-new-a", "201", "2 квартал 2026"), # свежайший, ЦС A
|
||||
_pub_row("g-new-b", "202", "2 квартала 2026"), # свежайший, ЦС B
|
||||
]
|
||||
)
|
||||
+ "</table>"
|
||||
)
|
||||
|
||||
pubs = eh.extract_publications(html)
|
||||
|
||||
assert len(pubs) == 2
|
||||
guids = {p["guid"] for p in pubs}
|
||||
assert guids == {"g-new-a", "g-new-b"}
|
||||
assert all((p["year"], p["quarter"]) == (2026, 2) for p in pubs)
|
||||
by_guid = {p["guid"]: p for p in pubs}
|
||||
assert by_guid["g-new-a"]["id"] == "201"
|
||||
assert by_guid["g-new-b"]["id"] == "202"
|
||||
|
||||
|
||||
def test_extract_publications_year_beats_quarter() -> None:
|
||||
"""4 квартал 2025 старее 1 квартала 2026 — берём 2026."""
|
||||
html = (
|
||||
"<table>"
|
||||
+ "".join(
|
||||
[
|
||||
_pub_row("g-2025q4", "10", "4 квартал 2025"),
|
||||
_pub_row("g-2026q1", "20", "1 квартал 2026"),
|
||||
]
|
||||
)
|
||||
+ "</table>"
|
||||
)
|
||||
|
||||
pubs = eh.extract_publications(html)
|
||||
|
||||
assert len(pubs) == 1
|
||||
assert pubs[0]["guid"] == "g-2026q1"
|
||||
assert (pubs[0]["year"], pubs[0]["quarter"]) == (2026, 1)
|
||||
|
||||
|
||||
def test_extract_publications_skips_rows_without_period_or_link() -> None:
|
||||
"""Строка без ссылки ИЛИ без квартала игнорируется; нет валидных → []."""
|
||||
html = (
|
||||
"<table>"
|
||||
"<tr><td>шапка без ссылки 2 квартал 2026</td></tr>"
|
||||
+ _pub_row("g-noperiod", "99", "без указания периода")
|
||||
+ "</table>"
|
||||
)
|
||||
assert eh.extract_publications(html) == []
|
||||
|
||||
|
||||
def test_quarter_from_text_helper() -> None:
|
||||
assert eh._quarter_from_text("… 3 квартал 2025 …") == (2025, 3)
|
||||
assert eh._quarter_from_text("… 2 квартала 2026 …") == (2026, 2)
|
||||
assert eh._quarter_from_text("5 квартал 2026") is None # квартал вне 1..4
|
||||
assert eh._quarter_from_text("без квартала 2026") is None
|
||||
|
||||
|
||||
# ── extract_tpl_xlsx: base64 → ZIP → член 'tpl' ──────────────────────────────
|
||||
|
||||
|
||||
def _make_tpl_zip_html(xlsx_bytes: bytes, member: str = "tpl") -> str:
|
||||
"""Строит HTML TemplatePrinter: zip с членом <member>=xlsx → base64 в data-file."""
|
||||
zbuf = io.BytesIO()
|
||||
with zipfile.ZipFile(zbuf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr(member, xlsx_bytes)
|
||||
zf.writestr("meta.xml", b"<meta/>") # посторонний член — должен игнорироваться
|
||||
b64 = base64.b64encode(zbuf.getvalue()).decode("ascii")
|
||||
return (
|
||||
"<html><body>"
|
||||
f'<div id="dataFile" data-filename="f.zip" data-file="{b64}"></div>'
|
||||
"</body></html>"
|
||||
)
|
||||
|
||||
|
||||
def _heat_xlsx_bytes() -> bytes:
|
||||
"""XLSX с листом 'ТП': строка-заголовок + СТ №54 → 3.26 Гкал/час."""
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "ТП"
|
||||
ws.append(["Резерв мощности источников тепловой энергии", None, None]) # заголовок, без числа
|
||||
ws.append(["№ п/п", "Наименование", "Гкал/час"]) # шапка — без системного маркера
|
||||
ws.append(["СТ №54", "источник", 3.26]) # система + правейшее число 3.26
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_extract_tpl_xlsx_roundtrip() -> None:
|
||||
"""base64 → zip → член 'tpl' извлекается и открывается openpyxl."""
|
||||
html = _make_tpl_zip_html(_heat_xlsx_bytes())
|
||||
out = eh.extract_tpl_xlsx(html)
|
||||
assert out is not None
|
||||
wb = openpyxl.load_workbook(io.BytesIO(out))
|
||||
assert "ТП" in wb.sheetnames
|
||||
|
||||
|
||||
def test_extract_tpl_xlsx_missing_member_returns_none() -> None:
|
||||
"""ZIP без члена 'tpl' → None (graceful, не падаем)."""
|
||||
html = _make_tpl_zip_html(_heat_xlsx_bytes(), member="other")
|
||||
assert eh.extract_tpl_xlsx(html) is None
|
||||
|
||||
|
||||
def test_extract_tpl_xlsx_no_datafile_div_returns_none() -> None:
|
||||
"""Нет <div id=dataFile> → None."""
|
||||
assert eh.extract_tpl_xlsx("<html><body>пусто</body></html>") is None
|
||||
|
||||
|
||||
def test_extract_tpl_xlsx_bad_zip_returns_none() -> None:
|
||||
"""data-file — валидный base64, но не ZIP → None (graceful)."""
|
||||
b64 = base64.b64encode(b"not a zip at all").decode("ascii")
|
||||
html = f'<div id="dataFile" data-file="{b64}"></div>'
|
||||
assert eh.extract_tpl_xlsx(html) is None
|
||||
|
||||
|
||||
# ── parse_heat_sheet: СТ №54 → 3.26, скип заголовков, правейшее число ─────────
|
||||
|
||||
|
||||
def test_parse_heat_sheet_detects_system_and_rightmost_number() -> None:
|
||||
"""«СТ №54 … 3.26» распознан; строки-заголовки без чисел скипнуты."""
|
||||
systems, all_cells = eh.parse_heat_sheet(_heat_xlsx_bytes())
|
||||
|
||||
assert len(systems) == 1
|
||||
assert systems[0]["system_name"] == "СТ №54"
|
||||
assert systems[0]["reserve_gcal_h"] == 3.26
|
||||
# all_cells содержит ВЕСЬ лист (3 строки) для raw.
|
||||
assert len(all_cells) == 3
|
||||
|
||||
|
||||
def test_parse_heat_sheet_rightmost_number_wins() -> None:
|
||||
"""Если в строке несколько чисел — берётся правейшее."""
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "ТП"
|
||||
ws.append(["зона Центральная", 100.0, 12.5]) # правейшее = 12.5 (резерв)
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
|
||||
systems, _ = eh.parse_heat_sheet(buf.getvalue())
|
||||
assert len(systems) == 1
|
||||
assert systems[0]["system_name"] == "зона Центральная"
|
||||
assert systems[0]["reserve_gcal_h"] == 12.5
|
||||
|
||||
|
||||
def test_parse_heat_sheet_zero_systems() -> None:
|
||||
"""Лист без системных строк → systems пуст, но all_cells собран."""
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "ТП"
|
||||
ws.append(["Совершенно посторонний заголовок", None])
|
||||
ws.append(["ещё строка", 5.0]) # число есть, но нет системного маркера → скип
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
|
||||
systems, all_cells = eh.parse_heat_sheet(buf.getvalue())
|
||||
assert systems == []
|
||||
assert len(all_cells) == 2
|
||||
|
||||
|
||||
# ── Fake DB (SAVEPOINT + execute), как в test_eesk_reserve_loader.py ──────────
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(self, scalar_value: Any = True) -> None:
|
||||
self._scalar = scalar_value
|
||||
|
||||
def scalar(self) -> Any:
|
||||
return self._scalar
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""Мини-заглушка Session: собирает execute-параметры, поддерживает begin_nested."""
|
||||
|
||||
def __init__(self, scalar_value: Any = True) -> None:
|
||||
self.calls: list[tuple[str, Any]] = []
|
||||
self._scalar = scalar_value
|
||||
self.committed = False
|
||||
|
||||
@contextmanager
|
||||
def begin_nested(self): # type: ignore[no-untyped-def]
|
||||
yield
|
||||
|
||||
def execute(self, stmt: Any, params: Any = None) -> _FakeResult:
|
||||
self.calls.append((str(stmt), params))
|
||||
return _FakeResult(self._scalar)
|
||||
|
||||
def commit(self) -> None:
|
||||
self.committed = True
|
||||
|
||||
def rollback(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def test_upsert_heat_rows_counters_and_params() -> None:
|
||||
"""parsed/inserted счётчики; params корректны; raw содержит cells; CAST jsonb."""
|
||||
db = _FakeSession(scalar_value=True) # xmax=0 → insert
|
||||
systems = [
|
||||
{"system_name": "СТ №54", "reserve_gcal_h": 3.26},
|
||||
{"system_name": "зона Юг", "reserve_gcal_h": -1.2}, # дефицит: знак сохраняется
|
||||
]
|
||||
all_cells = [["СТ №54", "источник", 3.26], ["зона Юг", None, -1.2]]
|
||||
|
||||
counts = eh._upsert_heat_rows(db, "ЕТК", "2026-Q2", "guid-xyz", systems, all_cells)
|
||||
|
||||
assert counts == {"parsed": 2, "inserted": 2, "updated": 0, "skipped": 0}
|
||||
assert len(db.calls) == 2
|
||||
|
||||
sql, first = db.calls[0]
|
||||
assert first["org"] == "ЕТК"
|
||||
assert first["system_name"] == "СТ №54"
|
||||
assert first["reserve"] == 3.26
|
||||
assert first["period"] == "2026-Q2"
|
||||
assert first["source_guid"] == "guid-xyz"
|
||||
# raw — JSON-строка с ключом cells (весь лист), CAST в jsonb в SQL.
|
||||
assert '"cells"' in first["raw"]
|
||||
assert "CAST(:raw AS jsonb)" in sql
|
||||
assert "ON CONFLICT (org, sphere, system_name, period)" in sql
|
||||
# negative reserve (дефицит) прокинут знаком.
|
||||
assert db.calls[1][1]["reserve"] == -1.2
|
||||
|
||||
|
||||
def test_upsert_heat_rows_update_counter() -> None:
|
||||
"""scalar()=False (xmax≠0) → строка учтена как update, не insert."""
|
||||
db = _FakeSession(scalar_value=False)
|
||||
systems = [{"system_name": "СТ №1", "reserve_gcal_h": 1.0}]
|
||||
counts = eh._upsert_heat_rows(
|
||||
db, "Екатеринбургэнерго", "2026-Q2", "g1", systems, [["СТ №1", 1.0]]
|
||||
)
|
||||
assert counts == {"parsed": 1, "inserted": 0, "updated": 1, "skipped": 0}
|
||||
|
||||
|
||||
def test_upsert_heat_rows_skip_on_error() -> None:
|
||||
"""execute бросает → строка skipped, счётчик не растёт в inserted/updated."""
|
||||
|
||||
class _RaisingSession(_FakeSession):
|
||||
def execute(self, stmt: Any, params: Any = None) -> _FakeResult:
|
||||
raise RuntimeError("db boom")
|
||||
|
||||
db = _RaisingSession()
|
||||
systems = [{"system_name": "СТ №9", "reserve_gcal_h": 2.0}]
|
||||
counts = eh._upsert_heat_rows(db, "ЕТК", "2026-Q2", "g", systems, [["СТ №9", 2.0]])
|
||||
assert counts == {"parsed": 1, "inserted": 0, "updated": 0, "skipped": 1}
|
||||
|
|
@ -173,6 +173,37 @@ def test_parse_no_header_row_returns_empty() -> None:
|
|||
assert gg.parse_grs_table(html) == []
|
||||
|
||||
|
||||
# ── _split_grs_name: «(выход №N)» в конце И в середине имени ───────────────────
|
||||
|
||||
|
||||
def test_split_grs_name_suffix_at_end() -> None:
|
||||
"""«(выход №N)» в конце → output отдельно, grs_name без суффикса."""
|
||||
assert gg._split_grs_name("ГРС Арти (выход №2)") == ("ГРС Арти", "выход №2")
|
||||
|
||||
|
||||
def test_split_grs_name_digit_suffix_not_output() -> None:
|
||||
"""«(5)» (только цифры) — НЕ выход, остаётся в имени."""
|
||||
assert gg._split_grs_name("ГРС Арамиль (5)") == ("ГРС Арамиль (5)", None)
|
||||
|
||||
|
||||
def test_split_grs_name_output_in_middle() -> None:
|
||||
"""Фрагмент «(выход №1)» В СЕРЕДИНЕ имени вырезается, остаток склеивается.
|
||||
|
||||
Живые данные: «ГРС-1 Свердловск (выход №1) (5)» — «(выход №1)» стоит перед
|
||||
станционным «(5)». Ранее $-якорь матчил только конец → выход терялся, а «(5)»
|
||||
ошибочно попадал бы под выход-логику. Теперь search по всему имени.
|
||||
"""
|
||||
grs_name, output = gg._split_grs_name("ГРС-1 Свердловск (выход №1) (5)")
|
||||
assert output == "выход №1"
|
||||
# «(5)» остаётся; двойной пробел на месте выреза нормализован.
|
||||
assert grs_name == "ГРС-1 Свердловск (5)"
|
||||
|
||||
|
||||
def test_split_grs_name_no_output() -> None:
|
||||
"""Имя без «(выход …)» → без изменений, output=None."""
|
||||
assert gg._split_grs_name("ГРС Кольцово") == ("ГРС Кольцово", None)
|
||||
|
||||
|
||||
# ── _passes_region_filter ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -303,6 +334,55 @@ def test_query_gas_city_grs_missing_table_degrades() -> None:
|
|||
assert out == {"city_grs": [], "total_free_th_m3_h": None}
|
||||
|
||||
|
||||
# ── _query_heat_latest: per-org период + суммарный резерв + graceful ───────────
|
||||
|
||||
|
||||
def test_query_heat_latest_aggregation_per_org() -> None:
|
||||
"""Строки heat_system_reserves маппятся в systems + суммарный резерв (знак сохр.)."""
|
||||
rows = [
|
||||
{"org": "ЕТК", "system_name": "СТ №54", "reserve_gcal_h": 3.26, "period": "2026-Q2"},
|
||||
{
|
||||
"org": "Екатеринбургэнерго",
|
||||
"system_name": "ТЭЦ Зона-1",
|
||||
"reserve_gcal_h": -5.0, # дефицит
|
||||
"period": "2026-Q1",
|
||||
},
|
||||
]
|
||||
db = _FakeSession(mappings_rows=rows)
|
||||
out = ccl._query_heat_latest(db) # type: ignore[arg-type]
|
||||
assert len(out["systems"]) == 2
|
||||
assert out["systems"][0]["org"] == "ЕТК"
|
||||
assert out["systems"][0]["reserve_gcal_h"] == 3.26
|
||||
assert out["systems"][1]["reserve_gcal_h"] == -5.0 # знак дефицита сохранён
|
||||
assert out["total_reserve_gcal_h"] == -1.74 # 3.26 + (-5.0), round(…, 3)
|
||||
|
||||
|
||||
def test_query_heat_latest_null_reserve_excluded_from_total() -> None:
|
||||
"""Система с reserve_gcal_h=NULL входит в systems, но не в total."""
|
||||
rows = [
|
||||
{"org": "ЕТК", "system_name": "СТ №1", "reserve_gcal_h": 2.0, "period": "2026-Q2"},
|
||||
{"org": "ЕТК", "system_name": "СТ №2", "reserve_gcal_h": None, "period": "2026-Q2"},
|
||||
]
|
||||
db = _FakeSession(mappings_rows=rows)
|
||||
out = ccl._query_heat_latest(db) # type: ignore[arg-type]
|
||||
assert len(out["systems"]) == 2
|
||||
assert out["systems"][1]["reserve_gcal_h"] is None
|
||||
assert out["total_reserve_gcal_h"] == 2.0
|
||||
|
||||
|
||||
def test_query_heat_latest_empty_table() -> None:
|
||||
db = _FakeSession(mappings_rows=[])
|
||||
out = ccl._query_heat_latest(db) # type: ignore[arg-type]
|
||||
assert out == {"systems": [], "total_reserve_gcal_h": None}
|
||||
|
||||
|
||||
def test_query_heat_latest_missing_table_degrades() -> None:
|
||||
"""Таблица не существует (миграция 182 не применена) → пустой блок, не падаем."""
|
||||
db = _FakeSession(execute_raises=RuntimeError('relation "heat_system_reserves" does not exist'))
|
||||
out = ccl._query_heat_latest(db) # type: ignore[arg-type]
|
||||
assert out == {"systems": [], "total_reserve_gcal_h": None}
|
||||
|
||||
|
||||
# ── _reserve_note: пометка ЕЭСК ───────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
74
data/sql/182_eesk_heat_reserves.sql
Normal file
74
data/sql/182_eesk_heat_reserves.sql
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
-- 182_eesk_heat_reserves.sql
|
||||
-- Forgejo #2119 Phase B2 — АО «ЕЭСК» (Екатеринбургская электросетевая компания):
|
||||
-- (а) резерв мощности по ЦП ПС 35-220 кВ → апдейт power_supply_centers (МВт);
|
||||
-- (б) резерв ТП/РП напряжением ниже 35 кВ → UPSERT power_tp_rp_reserves;
|
||||
-- (в) heat_system_reserves — задел под теплоснабжение (Gcal/ч), city-level.
|
||||
--
|
||||
-- Источник: eesk.ru «Наличие объёма свободной для технологического присоединения
|
||||
-- мощности» — статический HTML-хаб со ссылками на xlsx (обычный SSL, БЕЗ гео-блока,
|
||||
-- но лоадер всё равно RUN-ON-PROD рядом с rosseti/gas/vodokanal).
|
||||
--
|
||||
-- ЕЭСК резервы у ЦП измеряются в МВт (не МВА как у Россетей) → добавляем
|
||||
-- reserve_unit на power_supply_centers (default 'МВА' для старых строк Россетей,
|
||||
-- лоадер ЕЭСК проставляет 'МВт'). district — адм. район ЕКБ из xlsx ЕЭСК.
|
||||
--
|
||||
-- Refresh: weekly via tasks.connection_capacity_sync (отдельный воркер).
|
||||
-- Порядок применения: миграция ПЕРЕД деплоем кода (schema-first, sql.md).
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── power_supply_centers: единица резерва + адм. район ────────────────────────
|
||||
-- reserve_unit: у Россетей резерв в МВА, у ЕЭСК ПС 35-220 — в МВт. default 'МВА'
|
||||
-- сохраняет семантику существующих строк (заполнены Россетями), лоадер ЕЭСК
|
||||
-- явно проставляет 'МВт'. district — административный район Екатеринбурга (колонка
|
||||
-- B файла ЕЭСК); у Россети-строк остаётся NULL.
|
||||
ALTER TABLE power_supply_centers
|
||||
ADD COLUMN IF NOT EXISTS reserve_unit TEXT DEFAULT 'МВА';
|
||||
|
||||
ALTER TABLE power_supply_centers
|
||||
ADD COLUMN IF NOT EXISTS district TEXT;
|
||||
|
||||
COMMENT ON COLUMN power_supply_centers.reserve_unit IS
|
||||
'Единица reserve_mva/installed_capacity_mva: ''МВА'' (Россети, default) или '
|
||||
'''МВт'' (ЕЭСК ПС 35-220, capacity_source=''eesk_35_220''). Forgejo #2119 B2.';
|
||||
|
||||
COMMENT ON COLUMN power_supply_centers.district IS
|
||||
'Административный район Екатеринбурга (из xlsx ЕЭСК ПС 35-220). NULL у '
|
||||
'Россети-строк. Forgejo #2119 Phase B2.';
|
||||
|
||||
-- ── heat_system_reserves ──────────────────────────────────────────────────────
|
||||
-- Резервы тепловой мощности систем теплоснабжения (Gcal/ч), city-level, без geom.
|
||||
-- sphere зафиксирована 'heat' (задел под другие сферы раскрытия одной таблицей).
|
||||
-- reserve_gcal_h может быть отрицательным (дефицит) — знак сохраняем. raw jsonb —
|
||||
-- сырая строка источника целиком (будущие колонки не теряются).
|
||||
CREATE TABLE IF NOT EXISTS heat_system_reserves (
|
||||
id SERIAL PRIMARY KEY,
|
||||
org TEXT NOT NULL, -- организация-держатель (АО «ЕЭСК» и др.)
|
||||
sphere TEXT NOT NULL DEFAULT 'heat',
|
||||
system_name TEXT NOT NULL, -- имя системы теплоснабжения
|
||||
reserve_gcal_h NUMERIC, -- свободная мощность, Гкал/ч (может быть <0)
|
||||
applications_note TEXT, -- примечание о заявках на подключение
|
||||
period TEXT, -- напр. '2026-Q2'
|
||||
source_guid TEXT, -- хэш/идентификатор исходного файла
|
||||
raw JSONB, -- сырая строка источника целиком
|
||||
fetched_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
-- NULLS NOT DISTINCT: period может быть NULL при неудачном разборе даты
|
||||
-- (те же грабли, что #140 / 180_connection_capacity.sql water_supply_reserves).
|
||||
UNIQUE NULLS NOT DISTINCT (org, sphere, system_name, period)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_heat_system_reserves_period
|
||||
ON heat_system_reserves (period);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_heat_system_reserves_sphere
|
||||
ON heat_system_reserves (sphere);
|
||||
|
||||
COMMENT ON TABLE heat_system_reserves IS
|
||||
'Резервы тепловой мощности систем теплоснабжения (Гкал/ч, city-level, без geom). '
|
||||
'reserve_gcal_h может быть отрицательным (дефицит). Forgejo #2119 Phase B2.';
|
||||
|
||||
COMMENT ON COLUMN heat_system_reserves.reserve_gcal_h IS
|
||||
'Свободная (резервная) тепловая мощность, Гкал/ч. Отрицательное = дефицит.';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -46,11 +46,15 @@ function PowerPopup({ point }: { point: PowerConnectionPoint }) {
|
|||
const color = loadIndexColor(point.load_index);
|
||||
|
||||
const voltage = point.voltage_class?.trim() || null;
|
||||
// reserve_unit — единица ТОЛЬКО для строки резерва: у ЕЭСК установленная
|
||||
// мощность в файле раскрытия дана в МВА, а свободная — в МВт (разные строки
|
||||
// источника), поэтому unit НЕ распространяем на установленную/загрузку.
|
||||
const installed = formatMva(point.installed_capacity_mva);
|
||||
const load = formatMva(point.current_load_mva);
|
||||
const reserve = formatMva(point.reserve_mva);
|
||||
const reserve = formatMva(point.reserve_mva, point.reserve_unit);
|
||||
const reserveNegative = powerPointReserveIsNegative(point);
|
||||
const distance = formatMeters(point.distance_m);
|
||||
const district = point.district?.trim() || null;
|
||||
|
||||
return (
|
||||
<Popup>
|
||||
|
|
@ -80,6 +84,11 @@ function PowerPopup({ point }: { point: PowerConnectionPoint }) {
|
|||
<div style={{ marginBottom: 4 }}>
|
||||
<strong>{point.name}</strong>
|
||||
</div>
|
||||
{district && (
|
||||
<div style={{ color: "#374151", marginBottom: 2 }}>
|
||||
Район: {district}
|
||||
</div>
|
||||
)}
|
||||
{voltage && (
|
||||
<div style={{ color: "#374151", marginBottom: 2 }}>
|
||||
Напряжение: {voltage} кВ
|
||||
|
|
|
|||
|
|
@ -15,18 +15,21 @@
|
|||
* бэка не отработали, показываем нейтральный «загружаются», а не «нет данных».
|
||||
*/
|
||||
|
||||
import { Info, Zap, Droplets, Flame } from "lucide-react";
|
||||
import { Info, Zap, Droplets, Flame, Thermometer } from "lucide-react";
|
||||
|
||||
import type {
|
||||
ConnectionCapacityResponse,
|
||||
GasCapacityBlock,
|
||||
GasGrsRow,
|
||||
HeatCapacityBlock,
|
||||
HeatSystemRow,
|
||||
WaterCapacityRow,
|
||||
} from "@/hooks/useConnectionCapacity";
|
||||
import { CONNECTION_CAPACITY_RADIUS_M } from "@/hooks/useConnectionCapacity";
|
||||
import {
|
||||
LOAD_INDEX_BADGE,
|
||||
classifyLoadIndex,
|
||||
formatGcalHour,
|
||||
formatMeters,
|
||||
formatMva,
|
||||
formatThousandM3Day,
|
||||
|
|
@ -106,13 +109,15 @@ function PowerRow({ data }: { data: ConnectionCapacityResponse }) {
|
|||
const humanIdx = humanizeLoadIndex(nearest.load_index);
|
||||
|
||||
const voltage = nearest.voltage_class?.trim() || null;
|
||||
const reserve = formatMva(nearest.reserve_mva);
|
||||
const reserve = formatMva(nearest.reserve_mva, nearest.reserve_unit);
|
||||
const distance = formatMeters(nearest.distance_m);
|
||||
const district = nearest.district?.trim() || null;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (voltage) parts.push(`${voltage} кВ`);
|
||||
if (reserve) parts.push(`резерв ${reserve}`);
|
||||
if (humanIdx) parts.push(humanIdx);
|
||||
if (district) parts.push(`район ${district}`);
|
||||
if (distance) parts.push(distance);
|
||||
|
||||
return (
|
||||
|
|
@ -356,6 +361,114 @@ function GasBlock({ gas }: { gas: GasCapacityBlock | null | undefined }) {
|
|||
);
|
||||
}
|
||||
|
||||
// ── Тепло ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function HeatSystemLine({ row }: { row: HeatSystemRow }) {
|
||||
const reserveNum = toFiniteNumber(row.reserve_gcal_h);
|
||||
const reserveStr = formatGcalHour(row.reserve_gcal_h);
|
||||
const isDepleted = reserveNum !== null && reserveNum <= 0;
|
||||
const badge = isDepleted ? RESERVE_BADGE.depleted : RESERVE_BADGE.reserve;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, color: "var(--fg-primary)" }}>
|
||||
<strong>{row.org}</strong> · {row.system_name}: резерв{" "}
|
||||
{reserveStr ? (
|
||||
<strong
|
||||
style={{
|
||||
color: isDepleted ? "var(--danger)" : "var(--fg-primary)",
|
||||
}}
|
||||
>
|
||||
{reserveStr}
|
||||
</strong>
|
||||
) : (
|
||||
<span style={{ color: "var(--fg-tertiary)" }}>—</span>
|
||||
)}
|
||||
</span>
|
||||
{reserveNum !== null && (
|
||||
<StatusBadge label={badge.label} bg={badge.bg} fg={badge.fg} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeatBlock({ heat }: { heat: HeatCapacityBlock | null | undefined }) {
|
||||
if (!heat || heat.systems.length === 0) {
|
||||
return (
|
||||
<div style={{ fontSize: 13, color: "var(--fg-secondary)" }}>
|
||||
Данные о резервах теплоснабжения загружаются.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const head = heat.systems.slice(0, 5);
|
||||
const rest = heat.systems.slice(5);
|
||||
|
||||
const totalNum = toFiniteNumber(heat.total_reserve_gcal_h);
|
||||
const totalStr = formatGcalHour(heat.total_reserve_gcal_h);
|
||||
|
||||
// Источник-подпись: период первой системы (первый ненулевой).
|
||||
const period = heat.systems.find((s) => s.period)?.period?.trim() || null;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{head.map((row, i) => (
|
||||
<HeatSystemLine
|
||||
key={`heat-${row.org}-${row.system_name}-${i}`}
|
||||
row={row}
|
||||
/>
|
||||
))}
|
||||
|
||||
{rest.length > 0 && (
|
||||
<details>
|
||||
<summary
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--accent)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
+ {pluralizeSystems(rest.length)}
|
||||
</summary>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
marginTop: 6,
|
||||
}}
|
||||
>
|
||||
{rest.map((row, i) => (
|
||||
<HeatSystemLine
|
||||
key={`heat-rest-${row.org}-${row.system_name}-${i}`}
|
||||
row={row}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{totalNum !== null && totalStr && (
|
||||
<span style={{ fontSize: 13, color: "var(--fg-primary)" }}>
|
||||
Суммарно: <strong>{totalStr}</strong>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span style={{ fontSize: 12, color: "var(--fg-tertiary)" }}>
|
||||
Раскрытие ФАС (ЕТК, Екатеринбургэнерго)
|
||||
{period ? ` · ${period}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Card ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
const SUB_HEADER: React.CSSProperties = {
|
||||
|
|
@ -417,6 +530,15 @@ export function ResourceReservesCard({ data }: Props) {
|
|||
<GasBlock gas={data.gas} />
|
||||
</div>
|
||||
|
||||
{/* Тепло */}
|
||||
<div>
|
||||
<div style={SUB_HEADER}>
|
||||
<Thermometer size={14} strokeWidth={1.5} aria-hidden="true" />
|
||||
Тепло
|
||||
</div>
|
||||
<HeatBlock heat={data.heat} />
|
||||
</div>
|
||||
|
||||
{/* Вода / канализация */}
|
||||
<div>
|
||||
<div style={SUB_HEADER}>
|
||||
|
|
|
|||
|
|
@ -94,15 +94,19 @@ export function humanizeLoadIndex(
|
|||
// ── Форматирование чисел ───────────────────────────────────────────────────────
|
||||
|
||||
// МВА с одним знаком после запятой (резерв/мощность ЦП). Приводит строку→число.
|
||||
// unit — необязательный override единицы измерения (бэк отдаёт reserve_unit;
|
||||
// напр. «кВт» вместо «МВА» для отдельных ЦП). null/undefined → дефолт «МВА».
|
||||
export function formatMva(
|
||||
v: number | string | null | undefined,
|
||||
unit?: string | null,
|
||||
): string | null {
|
||||
const n = toFiniteNumber(v);
|
||||
if (n === null) return null;
|
||||
const u = unit?.trim() || "МВА";
|
||||
return `${n.toLocaleString("ru-RU", {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
})} МВА`;
|
||||
})} ${u}`;
|
||||
}
|
||||
|
||||
// тыс. м³/сут (резерв воды/канализации). Может быть ОТРИЦАТЕЛЬНЫМ (дефицит) —
|
||||
|
|
@ -138,6 +142,19 @@ export function formatThousandM3Hour(
|
|||
})} тыс. м³/ч`;
|
||||
}
|
||||
|
||||
// Гкал/ч (резерв тепловой мощности системы теплоснабжения). Может быть
|
||||
// ОТРИЦАТЕЛЬНЫМ (система исчерпана) — знак сохраняем. Приводит строку→число.
|
||||
export function formatGcalHour(
|
||||
v: number | string | null | undefined,
|
||||
): string | null {
|
||||
const n = toFiniteNumber(v);
|
||||
if (n === null) return null;
|
||||
return `${n.toLocaleString("ru-RU", {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
})} Гкал/ч`;
|
||||
}
|
||||
|
||||
// ── Вода: выбор «городской» системы + группировка ──────────────────────────────
|
||||
//
|
||||
// Городская система ЦСВ/ЦСК выделяется в карточке отдельной строкой: сначала по
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ export type PowerCapacitySummary =
|
|||
export type WaterCapacityRow = components["schemas"]["WaterCapacityRow"];
|
||||
export type GasCapacityBlock = components["schemas"]["GasCapacityBlock"];
|
||||
export type GasGrsRow = components["schemas"]["GasGrsRow"];
|
||||
export type HeatCapacityBlock = components["schemas"]["HeatCapacityBlock"];
|
||||
export type HeatSystemRow = components["schemas"]["HeatSystemRow"];
|
||||
|
||||
/**
|
||||
* Радиус поиска центров питания (ЦП, ПС 35/110 кВ) вокруг участка. Дефолт 3 км —
|
||||
|
|
|
|||
|
|
@ -3457,6 +3457,7 @@ export interface components {
|
|||
/** Water */
|
||||
water: components["schemas"]["WaterCapacityRow"][];
|
||||
gas?: components["schemas"]["GasCapacityBlock"] | null;
|
||||
heat?: components["schemas"]["HeatCapacityBlock"] | null;
|
||||
};
|
||||
/** ConnectionPointsResponse */
|
||||
ConnectionPointsResponse: {
|
||||
|
|
@ -3934,6 +3935,30 @@ export interface components {
|
|||
*/
|
||||
include_risks: boolean;
|
||||
};
|
||||
/**
|
||||
* HeatCapacityBlock
|
||||
* @description Тепловой блок: резервы систем теплоснабжения за последний период. #2119 B2.
|
||||
*/
|
||||
HeatCapacityBlock: {
|
||||
/** Systems */
|
||||
systems: components["schemas"]["HeatSystemRow"][];
|
||||
/** Total Reserve Gcal H */
|
||||
total_reserve_gcal_h: number | null;
|
||||
};
|
||||
/**
|
||||
* HeatSystemRow
|
||||
* @description Резерв тепловой мощности одной системы теплоснабжения. #2119 Phase B2.
|
||||
*/
|
||||
HeatSystemRow: {
|
||||
/** Org */
|
||||
org: string;
|
||||
/** System Name */
|
||||
system_name: string;
|
||||
/** Reserve Gcal H */
|
||||
reserve_gcal_h: number | null;
|
||||
/** Period */
|
||||
period: string | null;
|
||||
};
|
||||
/**
|
||||
* HouseTypeCatalog
|
||||
* @description Stage 3b (#1965) — каталог типовых домов, отдаваемый ``GET /concepts/house-types``.
|
||||
|
|
@ -4743,6 +4768,10 @@ export interface components {
|
|||
lon: number | null;
|
||||
/** Reserve Note */
|
||||
reserve_note?: string | null;
|
||||
/** Reserve Unit */
|
||||
reserve_unit?: string | null;
|
||||
/** District */
|
||||
district?: string | null;
|
||||
};
|
||||
/** RecommendBucket */
|
||||
RecommendBucket: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue