From e501dedf78d22b1950ddf4610139732678ae46bc Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 2 Jul 2026 16:44:57 +0000 Subject: [PATCH] =?UTF-8?q?feat(site-finder):=20=D0=95=D0=AD=D0=A1=D0=9A-?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=D0=B5=D1=80=D0=B2=D1=8B=20=D0=B3=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=D1=81=D0=BA=D0=B8=D1=85=20=D0=9F=D0=A1/=D0=A2?= =?UTF-8?q?=D0=9F=20+=20=D1=82=D0=B5=D0=BF=D0=BB=D0=BE=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D0=A4=D0=93=D0=98=D0=A1=20=D0=A4=D0=90=D0=A1=20(#2119=20B2)=20?= =?UTF-8?q?(#2165)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/parcels.py | 1 + .../certs/russian_trusted_ca_bundle.pem | 35 ++ backend/app/schemas/parcel.py | 26 ++ .../site_finder/connection_capacity_lookup.py | 71 ++- .../site_finder/eesk_reserve_loader.py | 432 ++++++++++++++++++ .../services/site_finder/eias_heat_loader.py | 427 +++++++++++++++++ .../site_finder/gazprom_grs_loader.py | 19 +- backend/app/workers/beat_schedule.py | 18 + .../workers/tasks/connection_capacity_sync.py | 51 +++ .../api/v1/test_parcel_connection_capacity.py | 126 +++++ backend/tests/test_eesk_reserve_loader.py | 277 +++++++++++ backend/tests/test_eias_heat_loader.py | 278 +++++++++++ backend/tests/test_gas_grs_loader.py | 80 ++++ data/sql/182_eesk_heat_reserves.sql | 74 +++ .../PowerConnectionPointsLayer.tsx | 11 +- .../analysis/ResourceReservesCard.tsx | 126 ++++- .../site-finder/connection-capacity.ts | 19 +- frontend/src/hooks/useConnectionCapacity.ts | 2 + frontend/src/lib/api-types.ts | 29 ++ 19 files changed, 2090 insertions(+), 12 deletions(-) create mode 100644 backend/app/services/site_finder/eesk_reserve_loader.py create mode 100644 backend/app/services/site_finder/eias_heat_loader.py create mode 100644 backend/tests/test_eesk_reserve_loader.py create mode 100644 backend/tests/test_eias_heat_loader.py create mode 100644 data/sql/182_eesk_heat_reserves.sql diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 447307cc..36b1f7d9 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -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"), ) diff --git a/backend/app/resources/certs/russian_trusted_ca_bundle.pem b/backend/app/resources/certs/russian_trusted_ca_bundle.pem index 977b22ee..c784d85d 100644 --- a/backend/app/resources/certs/russian_trusted_ca_bundle.pem +++ b/backend/app/resources/certs/russian_trusted_ca_bundle.pem @@ -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----- diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 0891406e..18c15083 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -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) ─────────────────────────────── diff --git a/backend/app/services/site_finder/connection_capacity_lookup.py b/backend/app/services/site_finder/connection_capacity_lookup.py index 2f11c71b..91f6f87a 100644 --- a/backend/app/services/site_finder/connection_capacity_lookup.py +++ b/backend/app/services/site_finder/connection_capacity_lookup.py @@ -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] diff --git a/backend/app/services/site_finder/eesk_reserve_loader.py b/backend/app/services/site_finder/eesk_reserve_loader.py new file mode 100644 index 00000000..c396321b --- /dev/null +++ b/backend/app/services/site_finder/eesk_reserve_loader.py @@ -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/.xls[x]. +# Захватываем href + видимый ТЕКСТ ссылки (в тексте — серия и квартал/год). +_LINK_RE = re.compile( + r']*href=["\'](?P[^"\']*/upload/site1/document_file/' + r'[^"\']+?\.xlsx?)["\'][^>]*>(?P.*?)', + 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 по ТЕКСТ, + классификация серии + квартал по ТЕКСТУ ссылки, выбор 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=«ТП/РП », 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 diff --git a/backend/app/services/site_finder/eias_heat_loader.py b/backend/app/services/site_finder/eias_heat_loader.py new file mode 100644 index 00000000..10748885 --- /dev/null +++ b/backend/app/services/site_finder/eias_heat_loader.py @@ -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=&id=`` + текст периода + «N квартал YYYY». Берём ВСЕ публикации свежайшего квартала (max(year, quarter)) — + их может быть несколько (по одной на центральную систему / ЦС). + 2. TemplatePrinter (ri-loader.eias.ru) — HTML с ``
``; 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=&id= (порядок параметров может +# варьироваться — берём каждый независимым lookahead-регэкспом на href). +_PUB_HREF_RE = re.compile( + r'href=["\'](?P[^"\']*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 файла:
. +_DATAFILE_RE = re.compile( + r']*id=["\']dataFile["\'][^>]*data-file=["\'](?P[^"\']+)["\']', + re.IGNORECASE | re.DOTALL, +) + +# Строка-кандидат листа: текстовая ячейка про систему теплоснабжения / зону / СТ №N. +_SYSTEM_MARKER_RE = re.compile(r"СТ\s*№\s*\d+|систем|зона", re.IGNORECASE) + +# Разбивка HTML грида на строки () — публикации живут построчно, период и +# ссылка в одной строке. Достаточно грубого split по 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}]. + + По каждой -строке грида ищем ссылку на 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 — нет. + + ``
`` → base64decode → ZIP → член ``tpl``. + Битый base64 / отсутствие члена ``tpl`` → warning-лог + None (шаг graceful). + """ + m = _DATAFILE_RE.search(template_html) + if not m: + logger.warning("eias_heat:
не найден в 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 diff --git a/backend/app/services/site_finder/gazprom_grs_loader.py b/backend/app/services/site_finder/gazprom_grs_loader.py index cab71d35..1915fbc5 100644 --- a/backend/app/services/site_finder/gazprom_grs_loader.py +++ b/backend/app/services/site_finder/gazprom_grs_loader.py @@ -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 diff --git a/backend/app/workers/beat_schedule.py b/backend/app/workers/beat_schedule.py index 23ad5a2f..649e8964 100644 --- a/backend/app/workers/beat_schedule.py +++ b/backend/app/workers/beat_schedule.py @@ -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). diff --git a/backend/app/workers/tasks/connection_capacity_sync.py b/backend/app/workers/tasks/connection_capacity_sync.py index 33718d56..92c1750b 100644 --- a/backend/app/workers/tasks/connection_capacity_sync.py +++ b/backend/app/workers/tasks/connection_capacity_sync.py @@ -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 diff --git a/backend/tests/api/v1/test_parcel_connection_capacity.py b/backend/tests/api/v1/test_parcel_connection_capacity.py index fac36579..322907d0 100644 --- a/backend/tests/api/v1/test_parcel_connection_capacity.py +++ b/backend/tests/api/v1/test_parcel_connection_capacity.py @@ -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 diff --git a/backend/tests/test_eesk_reserve_loader.py b/backend/tests/test_eesk_reserve_loader.py new file mode 100644 index 00000000..df4dff70 --- /dev/null +++ b/backend/tests/test_eesk_reserve_loader.py @@ -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 «ТП/РП », 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'{text}' + + +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 «ТП/РП », 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 diff --git a/backend/tests/test_eias_heat_loader.py b/backend/tests/test_eias_heat_loader.py new file mode 100644 index 00000000..f35660d2 --- /dev/null +++ b/backend/tests/test_eias_heat_loader.py @@ -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: + """ строки грида: ссылка на TemplatePrinter (guid+id) + текст периода.""" + href = f"/Discl/TemplatePrinter.aspx?reg=RU.5.66&guid={guid}&id={pub_id}" + return f'выгрузить{period_text}' + + +def test_extract_publications_freshest_quarter_all_pubs() -> None: + """Берём ВСЕ публикации свежайшего квартала; старее — отбрасываем.""" + html = ( + "" + + "".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 + ] + ) + + "
" + ) + + 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 = ( + "" + + "".join( + [ + _pub_row("g-2025q4", "10", "4 квартал 2025"), + _pub_row("g-2026q1", "20", "1 квартал 2026"), + ] + ) + + "
" + ) + + 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 = ( + "" + "" + + _pub_row("g-noperiod", "99", "без указания периода") + + "
шапка без ссылки 2 квартал 2026
" + ) + 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 с членом =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"") # посторонний член — должен игнорироваться + b64 = base64.b64encode(zbuf.getvalue()).decode("ascii") + return ( + "" + f'
' + "" + ) + + +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: + """Нет
→ None.""" + assert eh.extract_tpl_xlsx("пусто") 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'
' + 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} diff --git a/backend/tests/test_gas_grs_loader.py b/backend/tests/test_gas_grs_loader.py index f938cc51..934288ec 100644 --- a/backend/tests/test_gas_grs_loader.py +++ b/backend/tests/test_gas_grs_loader.py @@ -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: пометка ЕЭСК ─────────────────────────────────────────────── diff --git a/data/sql/182_eesk_heat_reserves.sql b/data/sql/182_eesk_heat_reserves.sql new file mode 100644 index 00000000..d5bc5fe2 --- /dev/null +++ b/data/sql/182_eesk_heat_reserves.sql @@ -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; diff --git a/frontend/src/components/site-finder/PowerConnectionPointsLayer.tsx b/frontend/src/components/site-finder/PowerConnectionPointsLayer.tsx index 31d44b9f..15f09c51 100644 --- a/frontend/src/components/site-finder/PowerConnectionPointsLayer.tsx +++ b/frontend/src/components/site-finder/PowerConnectionPointsLayer.tsx @@ -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 ( @@ -80,6 +84,11 @@ function PowerPopup({ point }: { point: PowerConnectionPoint }) {
{point.name}
+ {district && ( +
+ Район: {district} +
+ )} {voltage && (
Напряжение: {voltage} кВ diff --git a/frontend/src/components/site-finder/analysis/ResourceReservesCard.tsx b/frontend/src/components/site-finder/analysis/ResourceReservesCard.tsx index c46fceb4..e320b6a5 100644 --- a/frontend/src/components/site-finder/analysis/ResourceReservesCard.tsx +++ b/frontend/src/components/site-finder/analysis/ResourceReservesCard.tsx @@ -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 ( +
+ + {row.org} · {row.system_name}: резерв{" "} + {reserveStr ? ( + + {reserveStr} + + ) : ( + + )} + + {reserveNum !== null && ( + + )} +
+ ); +} + +function HeatBlock({ heat }: { heat: HeatCapacityBlock | null | undefined }) { + if (!heat || heat.systems.length === 0) { + return ( +
+ Данные о резервах теплоснабжения загружаются. +
+ ); + } + + 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 ( +
+ {head.map((row, i) => ( + + ))} + + {rest.length > 0 && ( +
+ + + {pluralizeSystems(rest.length)} + +
+ {rest.map((row, i) => ( + + ))} +
+
+ )} + + {totalNum !== null && totalStr && ( + + Суммарно: {totalStr} + + )} + + + Раскрытие ФАС (ЕТК, Екатеринбургэнерго) + {period ? ` · ${period}` : ""} + +
+ ); +} + // ── Card ─────────────────────────────────────────────────────────────────────── const SUB_HEADER: React.CSSProperties = { @@ -417,6 +530,15 @@ export function ResourceReservesCard({ data }: Props) {
+ {/* Тепло */} +
+
+
+ +
+ {/* Вода / канализация */}
diff --git a/frontend/src/components/site-finder/connection-capacity.ts b/frontend/src/components/site-finder/connection-capacity.ts index 696119cf..068ddfc8 100644 --- a/frontend/src/components/site-finder/connection-capacity.ts +++ b/frontend/src/components/site-finder/connection-capacity.ts @@ -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, + })} Гкал/ч`; +} + // ── Вода: выбор «городской» системы + группировка ────────────────────────────── // // Городская система ЦСВ/ЦСК выделяется в карточке отдельной строкой: сначала по diff --git a/frontend/src/hooks/useConnectionCapacity.ts b/frontend/src/hooks/useConnectionCapacity.ts index 4ed02a8d..c2332f84 100644 --- a/frontend/src/hooks/useConnectionCapacity.ts +++ b/frontend/src/hooks/useConnectionCapacity.ts @@ -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 км — diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index 5d802932..c162fcb5 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -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: {