feat(site-finder): резервы мощности для ТП — электро+вода на карте §3 (#2119 Фаза A)
Наполнение карты точками подключения с ХАРАКТЕРИСТИКАМИ (свободная мощность) из верифицированных источников (research #2119). Все источники гео-блокируют не-RU IP — лоадеры выполняются на проде (Celery weekly + ручной docker exec). Backend: - Миграция 180: power_supply_centers (ПС 35-220кВ, точки + резерв МВА), power_tp_rp_reserves (10.7k ТП/РП 6-10кВ, геокод — Фаза B), water_supply_reserves (ЦСВ/ЦСК Водоканала). UNIQUE NULLS NOT DISTINCT на nullable-ключах (грабли #140). - rosseti_wfs_loader: открытый WFS портал-тп.рф (punycode) → 488 ПС Свердл обл, индекс загрузки 256184/6/8 → open/limited/closed, voltage из имени. - rosseti_reserve_loader: раскрытие ПП№24 (xlsx) — ЦП 35-110кВ (строка 8+) матчится к WFS-точкам по нормализованному имени; ТП/РП <35кВ (строка 9+), кВА-санити (>2.5 МВА для ТП → ÷1000), «н/д»→NULL, SAVEPOINT per-row. - vodokanal_reserve_loader: DOCX-раскрытие ПП№6 (stdlib parse, vMerge forward-fill), отрицательные резервы (город: дефицит) сохраняются со знаком. - Endpoint GET /{cad}/connection-capacity: ПС в радиусе (ST_DWithin geography) + summary + вода за последний период per-system_kind. Celery: вт 04:00/04:30. Frontend (§3): - Карточка «Ресурсные резервы»: ближайший ЦП с резервом МВА + статус-бейдж; вода/канализация с красным «дефицит» при отрицательном резерве + примечание. - Слой «Центры питания (резерв)» на карте: цвет по индексу загрузки (зелёный/янтарь/красный), попап с напряжением/мощностью/загрузкой/резервом, легенда под картой. Числа через toFiniteNumber (Decimal→str coercion). api-types.ts перегенерён офлайн (+131/-0, аддитивно). 39 тестов зелёные. Deep-review: ⚠️ MINOR → все 3 замечания исправлены (NULLS NOT DISTINCT, per-kind MAX(period), settlement-комментарий).
This commit is contained in:
parent
4a7ce91db1
commit
e285cbe33a
19 changed files with 3054 additions and 1 deletions
|
|
@ -26,6 +26,7 @@ from app.schemas.parcel import (
|
|||
CompetitorsParkingResponse,
|
||||
CompetitorsRequest,
|
||||
CompetitorsResponse,
|
||||
ConnectionCapacityResponse,
|
||||
ConnectionPointsResponse,
|
||||
OpportunityParcel,
|
||||
ParcelBboxResponse,
|
||||
|
|
@ -64,6 +65,7 @@ from app.services.site_finder.cadastre_fetch import (
|
|||
fetch_status as _fetch_status,
|
||||
)
|
||||
from app.services.site_finder.competitors import get_competitors, get_competitors_parking
|
||||
from app.services.site_finder.connection_capacity_lookup import get_connection_capacity
|
||||
from app.services.site_finder.custom_pois import (
|
||||
get_overlaps_for_scoring as _get_custom_poi_overlaps,
|
||||
)
|
||||
|
|
@ -3839,6 +3841,42 @@ def get_parcel_utility_infrastructure(
|
|||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{cad_num}/connection-capacity",
|
||||
response_model=ConnectionCapacityResponse,
|
||||
summary="Точки подключения со свободной мощностью — электро + вода (Forgejo #2119)",
|
||||
)
|
||||
def get_parcel_connection_capacity(
|
||||
cad_num: str,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
radius_m: Annotated[int, Query(ge=100, le=10000)] = 3000,
|
||||
) -> ConnectionCapacityResponse:
|
||||
"""Центры питания со свободной мощностью + резервы воды вблизи участка (ПТИЦА §3).
|
||||
|
||||
Верифицированные open-data источники (наполняются weekly-sync, RUN-ON-PROD):
|
||||
- power_supply_centers: ЦП (ПС 35/110 кВ) Россети WFS + резервы xlsx.
|
||||
- water_supply_reserves: ЦСВ/ЦСК Водоканала ЕКБ (city-level, последний период).
|
||||
|
||||
power_points — ЦП в радиусе от центроида участка (ST_DWithin geography).
|
||||
water — все строки за ПОСЛЕДНИЙ период (город-уровень, показывается целиком).
|
||||
|
||||
Query params:
|
||||
radius_m: радиус поиска ЦП, 100–10000 м (default 3000).
|
||||
|
||||
Если участок не найден в БД (нет geom) → 404.
|
||||
"""
|
||||
try:
|
||||
data = get_connection_capacity(db, cad_num, radius_m)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
return ConnectionCapacityResponse(
|
||||
power_points=data["power_points"],
|
||||
power_summary=data["power_summary"],
|
||||
water=data["water"],
|
||||
)
|
||||
|
||||
|
||||
_ORS_BASE = "https://api.openrouteservice.org/v2/isochrones"
|
||||
_ORS_VALID_MODES = frozenset({"foot-walking", "cycling-regular", "driving-car"})
|
||||
|
||||
|
|
|
|||
|
|
@ -107,6 +107,53 @@ class UtilityInfrastructureResponse(BaseModel):
|
|||
summary: UtilityInfrastructureSummary
|
||||
|
||||
|
||||
# ── Connection-capacity schemas (Forgejo #2119 Phase A) ──────────────────────
|
||||
# «Точки подключения СО СВОБОДНОЙ МОЩНОСТЬЮ» для ПТИЦА §3: центры питания
|
||||
# (power_supply_centers) с резервом + city-level резервы воды (water_supply_reserves).
|
||||
|
||||
|
||||
class PowerConnectionPoint(BaseModel):
|
||||
"""Центр питания (ЦП, ПС 35/110 кВ) вблизи участка со свободной мощностью."""
|
||||
|
||||
name: str
|
||||
dzo_name: str | None
|
||||
voltage_class: str | None # напр. '110/35/10'
|
||||
load_index: str | None # 'open'|'limited'|'closed'|None
|
||||
installed_capacity_mva: float | None
|
||||
current_load_mva: float | None
|
||||
reserve_mva: float | None # свободная мощность, МВА
|
||||
reserve_asof: str | None # ISO-дата актуальности резерва
|
||||
distance_m: float
|
||||
lat: float | None
|
||||
lon: float | None
|
||||
|
||||
|
||||
class PowerCapacitySummary(BaseModel):
|
||||
"""Сводка по электрике: ближайший ЦП с резервом + count по индексу загрузки."""
|
||||
|
||||
total_power_points: int
|
||||
# Карта индекс загрузки ('open'|'limited'|'closed'|'unknown') → count.
|
||||
by_load_index: dict[str, int]
|
||||
# Ближайший ЦП с положительным резервом (или null, если резерв нигде не задан).
|
||||
nearest_with_reserve: PowerConnectionPoint | None
|
||||
|
||||
|
||||
class WaterCapacityRow(BaseModel):
|
||||
"""Строка резерва ЦСВ/ЦСК Водоканала (city-level) за последний период."""
|
||||
|
||||
system_kind: str # 'water'|'sewerage'
|
||||
system_name: str # ЦСВ/ЦСК, напр. «ЗФС, ГСВ»
|
||||
reserve_thousand_m3_day: float | None # может быть отрицательным (дефицит)
|
||||
note: str | None
|
||||
period: str | None
|
||||
|
||||
|
||||
class ConnectionCapacityResponse(BaseModel):
|
||||
power_points: list[PowerConnectionPoint]
|
||||
power_summary: PowerCapacitySummary
|
||||
water: list[WaterCapacityRow]
|
||||
|
||||
|
||||
# ── NSPD Risk Zones schemas (issue #94 TIER 3) ───────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
173
backend/app/services/site_finder/connection_capacity_lookup.py
Normal file
173
backend/app/services/site_finder/connection_capacity_lookup.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"""Резолвер «точки подключения СО СВОБОДНОЙ МОЩНОСТЬЮ» для ПТИЦА §3 (#2119).
|
||||
|
||||
Собирает по участку:
|
||||
- power_points: центры питания (power_supply_centers) в радиусе от центроида
|
||||
участка (ST_DWithin geography) с классом напряжения / индексом загрузки /
|
||||
резервом свободной мощности.
|
||||
- power_summary: ближайший ЦП с резервом + count по load_index.
|
||||
- water: все строки water_supply_reserves за ПОСЛЕДНИЙ period (city-level).
|
||||
|
||||
Источник координат участка — общий ``_get_parcel_wkt`` (cad_parcels_geom → квартал
|
||||
fallback). Радиус по умолчанию 3000 м (cap 10000).
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_RADIUS_M = 3000
|
||||
_MAX_RADIUS_M = 10000
|
||||
|
||||
|
||||
def get_connection_capacity(
|
||||
db: Session,
|
||||
cad_num: str,
|
||||
radius_m: int = _DEFAULT_RADIUS_M,
|
||||
) -> dict:
|
||||
"""Точки подключения со свободной мощностью вблизи участка.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy session.
|
||||
cad_num: кадастровый номер участка.
|
||||
radius_m: радиус поиска ЦП, м (caller клампит 1..10000).
|
||||
|
||||
Returns:
|
||||
{"power_points": [...], "power_summary": {...}, "water": [...]}
|
||||
|
||||
Raises:
|
||||
ValueError: участок не найден в БД (нет geom).
|
||||
"""
|
||||
# Lazy import — избегаем циклического импорта на module-load.
|
||||
from app.services.site_finder.quarter_dump_lookup import _get_parcel_wkt
|
||||
|
||||
radius_m = max(1, min(int(radius_m), _MAX_RADIUS_M))
|
||||
|
||||
parcel_wkt = _get_parcel_wkt(db, cad_num)
|
||||
if parcel_wkt is None:
|
||||
raise ValueError(f"Участок {cad_num!r} не найден в БД")
|
||||
|
||||
power_points = _query_power_points(db, parcel_wkt, radius_m)
|
||||
power_summary = _build_power_summary(power_points)
|
||||
water = _query_water_latest(db)
|
||||
|
||||
return {
|
||||
"power_points": power_points,
|
||||
"power_summary": power_summary,
|
||||
"water": water,
|
||||
}
|
||||
|
||||
|
||||
def _query_power_points(db: Session, parcel_wkt: str, radius_m: int) -> list[dict]:
|
||||
"""Центры питания в радиусе от центроида участка (ST_DWithin geography)."""
|
||||
rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT sc_name, dzo_name, voltage_class, load_index,
|
||||
installed_capacity_mva, current_load_mva, reserve_mva,
|
||||
reserve_asof,
|
||||
ST_Distance(
|
||||
geom::geography,
|
||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||||
) AS distance_m,
|
||||
ST_Y(geom) AS lat,
|
||||
ST_X(geom) AS lon
|
||||
FROM power_supply_centers
|
||||
WHERE geom IS NOT NULL
|
||||
AND ST_DWithin(
|
||||
geom::geography,
|
||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||||
CAST(:radius_m AS float)
|
||||
)
|
||||
ORDER BY distance_m ASC
|
||||
"""),
|
||||
{"wkt": parcel_wkt, "radius_m": radius_m},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
points: list[dict] = []
|
||||
for r in rows:
|
||||
points.append(
|
||||
{
|
||||
"name": r["sc_name"],
|
||||
"dzo_name": r["dzo_name"],
|
||||
"voltage_class": r["voltage_class"],
|
||||
"load_index": r["load_index"],
|
||||
"installed_capacity_mva": _num(r["installed_capacity_mva"]),
|
||||
"current_load_mva": _num(r["current_load_mva"]),
|
||||
"reserve_mva": _num(r["reserve_mva"]),
|
||||
"reserve_asof": r["reserve_asof"].isoformat() if r["reserve_asof"] else None,
|
||||
"distance_m": round(float(r["distance_m"]), 1),
|
||||
"lat": round(float(r["lat"]), 6) if r["lat"] is not None else None,
|
||||
"lon": round(float(r["lon"]), 6) if r["lon"] is not None else None,
|
||||
}
|
||||
)
|
||||
return points
|
||||
|
||||
|
||||
def _build_power_summary(points: list[dict]) -> dict:
|
||||
"""Сводка: ближайший ЦП с положительным резервом + count по load_index."""
|
||||
by_load_index: dict[str, int] = {}
|
||||
for p in points:
|
||||
li = p["load_index"] or "unknown"
|
||||
by_load_index[li] = by_load_index.get(li, 0) + 1
|
||||
|
||||
# points уже отсортированы по distance ASC → первый с reserve_mva>0 = ближайший.
|
||||
nearest_with_reserve = next(
|
||||
(p for p in points if p["reserve_mva"] is not None and p["reserve_mva"] > 0),
|
||||
None,
|
||||
)
|
||||
|
||||
return {
|
||||
"total_power_points": len(points),
|
||||
"by_load_index": by_load_index,
|
||||
"nearest_with_reserve": nearest_with_reserve,
|
||||
}
|
||||
|
||||
|
||||
def _query_water_latest(db: Session) -> list[dict]:
|
||||
"""Все строки water_supply_reserves за ПОСЛЕДНИЙ period ПО КАЖДОМУ system_kind.
|
||||
|
||||
«Последний» — max(period) лексикографически ('2026-Q1' < '2026-Q2' < …),
|
||||
что для формата 'YYYY-Qn' совпадает с хронологией. period IS NULL исключаем.
|
||||
MAX(period) берём per-system_kind (коррелированный подзапрос): Водоканал может
|
||||
опубликовать водоотведение на квартал позже водоснабжения — глобальный MAX
|
||||
молча выкинул бы отстающий вид целиком.
|
||||
"""
|
||||
rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT w.system_kind, w.system_name, w.reserve_thousand_m3_day,
|
||||
w.note, w.period
|
||||
FROM water_supply_reserves w
|
||||
WHERE w.period IS NOT NULL
|
||||
AND w.period = (
|
||||
SELECT MAX(w2.period) FROM water_supply_reserves w2
|
||||
WHERE w2.period IS NOT NULL
|
||||
AND w2.system_kind = w.system_kind
|
||||
)
|
||||
ORDER BY w.system_kind, w.system_name
|
||||
""")
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"system_kind": r["system_kind"],
|
||||
"system_name": r["system_name"],
|
||||
"reserve_thousand_m3_day": _num(r["reserve_thousand_m3_day"]),
|
||||
"note": r["note"],
|
||||
"period": r["period"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _num(value: object) -> float | None:
|
||||
"""NUMERIC (Decimal) из БД → float | None."""
|
||||
return float(value) if value is not None else None # type: ignore[arg-type]
|
||||
403
backend/app/services/site_finder/rosseti_reserve_loader.py
Normal file
403
backend/app/services/site_finder/rosseti_reserve_loader.py
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
"""Загрузчик резервов свободной мощности Россети Урал из xlsx-раскрытия (#2119).
|
||||
|
||||
Два xlsx-файла раскрытия информации ФАС (rosseti-ural.ru):
|
||||
(а) «свободная мощность по ЦП» → апдейт power_supply_centers
|
||||
(б) «свободная мощность для ТП по ПС и РП <35» → UPSERT power_tp_rp_reserves
|
||||
|
||||
Источник ГЕО-БЛОКИРУЕТ non-RU IP → РАБОТАЕТ НА ПРОДЕ (Celery / docker exec).
|
||||
|
||||
Структуры xlsx ВЕРИФИЦИРОВАНЫ (см. docstring-и функций): фиксированные offset'ы
|
||||
строк заголовков/нумерации/данных. openpyxl read_only для памяти на 10k+ строках.
|
||||
Матч ЦП-резерва к power_supply_centers — по ОБЩЕМУ нормализатору normalize_sc_name.
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from datetime import date
|
||||
|
||||
import httpx
|
||||
from openpyxl import load_workbook
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.services.site_finder.rosseti_wfs_loader import normalize_sc_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Punycode-host не требуется — rosseti-ural.ru латиница.
|
||||
DISCLOSURE_URL = "https://rosseti-ural.ru/company/disclosure/monopoly/characteristic/bandwidth/"
|
||||
|
||||
_HTTP_TIMEOUT = 60
|
||||
|
||||
# «н/д» / «-» / пусто → None (нет данных).
|
||||
_NODATA_TOKENS = frozenset({"", "н/д", "нд", "n/a", "na", "-", "—", "–", "нет данных"})
|
||||
|
||||
# Дата актуальности «на 30.06.2026» из имени файла или заголовка листа.
|
||||
_ASOF_RE = re.compile(r"на\s+(\d{1,2})[.\-/](\d{1,2})[.\-/](\d{4})", re.IGNORECASE)
|
||||
# Дата в имени файла для выбора «свежайшего» (DD.MM.YYYY / DD-MM-YYYY / YYYY-MM-DD).
|
||||
_FILE_DATE_RE = re.compile(
|
||||
r"(\d{1,2})[.\-](\d{1,2})[.\-](\d{4})|(\d{4})[.\-](\d{1,2})[.\-](\d{1,2})"
|
||||
)
|
||||
|
||||
# Порог кВА-санитайза: у ТП установленная мощность в МВА физически <2.5;
|
||||
# значение выше — почти наверняка забито в кВА (÷1000). РП не трогаем.
|
||||
_TP_KVA_THRESHOLD_MVA = 2.5
|
||||
|
||||
|
||||
def parse_reserve_number(value: object) -> float | None:
|
||||
"""Парсит числовую ячейку резерва/мощности xlsx → float.
|
||||
|
||||
«н/д»/пусто → None. Запятая-десятичный → точка. Сохраняет знак
|
||||
(резервы бывают отрицательными = дефицит). Нечисловой мусор → None.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, int | float):
|
||||
return float(value)
|
||||
s = str(value).strip().lower()
|
||||
if s in _NODATA_TOKENS:
|
||||
return None
|
||||
# Убираем пробелы-разделители тысяч (в т.ч. неразрывные) и нормализуем запятую.
|
||||
s = s.replace("\xa0", "").replace(" ", "").replace(",", ".")
|
||||
# Оставляем ведущий минус + цифры + точку.
|
||||
m = re.match(r"^-?\d+(?:\.\d+)?$", s)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_asof_date(*sources: str | None) -> date | None:
|
||||
"""Ищет дату «на DD.MM.YYYY» в переданных строках (имя файла / заголовок листа).
|
||||
|
||||
Возвращает первую найденную date. None — если нигде нет.
|
||||
"""
|
||||
for src in sources:
|
||||
if not src:
|
||||
continue
|
||||
m = _ASOF_RE.search(src)
|
||||
if m:
|
||||
day, month, year = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||||
try:
|
||||
return date(year, month, day)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _file_date_key(filename: str) -> tuple[int, int, int]:
|
||||
"""Ключ сортировки «свежести» по дате в имени файла. Нет даты → (0,0,0)."""
|
||||
m = _FILE_DATE_RE.search(filename)
|
||||
if not m:
|
||||
return (0, 0, 0)
|
||||
if m.group(1): # DD-MM-YYYY
|
||||
return (int(m.group(3)), int(m.group(2)), int(m.group(1)))
|
||||
return (int(m.group(4)), int(m.group(5)), int(m.group(6))) # YYYY-MM-DD
|
||||
|
||||
|
||||
def sanitize_tp_capacity_mva(installed: float | None, is_rp: bool) -> tuple[float | None, bool]:
|
||||
"""кВА-санитайз установленной мощности ТП.
|
||||
|
||||
Часть строк ошибочно забивает кВА в колонку МВА. Если это ТП (не РП) и
|
||||
installed > порога (2.5 МВА физически невозможно для ТП) — считаем что это
|
||||
кВА и делим на 1000. Возвращает (значение_МВА, был_ли_санитайз).
|
||||
РП не трогаем (у РП мощность легитимно бывает высокой).
|
||||
"""
|
||||
if installed is None or is_rp:
|
||||
return installed, False
|
||||
if installed > _TP_KVA_THRESHOLD_MVA:
|
||||
return installed / 1000.0, True
|
||||
return installed, False
|
||||
|
||||
|
||||
def fetch_disclosure_links() -> dict[str, str]:
|
||||
"""Скрейпит страницу раскрытия → ссылки на СВЕЖАЙШИЕ xlsx (ЦП + ТП/РП).
|
||||
|
||||
RUN-ON-PROD (гео-блок). GET с follow_redirects, regex по href .xlsx, фильтр
|
||||
Свердловск-related, выбор LATEST по дате-в-имени для двух типов файлов.
|
||||
|
||||
Returns: {"cp": url|'', "tp_rp": url|''} — пустые если не нашлось.
|
||||
"""
|
||||
resp = httpx.get(DISCLOSURE_URL, timeout=_HTTP_TIMEOUT, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
html = resp.text
|
||||
|
||||
# href на .xlsx (относительные и абсолютные).
|
||||
hrefs = re.findall(r'href=["\']([^"\']+?\.xlsx)["\']', html, re.IGNORECASE)
|
||||
base = "https://rosseti-ural.ru"
|
||||
|
||||
cp_candidates: list[str] = []
|
||||
tp_candidates: list[str] = []
|
||||
for href in hrefs:
|
||||
low = href.lower()
|
||||
# Свердловск-related фильтр (файлы бывают по разным филиалам).
|
||||
if "свердл" not in low and "sverdl" not in low and "свэс" not in low:
|
||||
# Если в href нет региона — пропускаем осторожно (может быть общий файл),
|
||||
# но для нашей задачи берём только явно свердловские.
|
||||
continue
|
||||
url = href if href.startswith("http") else base + href
|
||||
if "по цп" in low or "_цп" in low or "cp" in low:
|
||||
cp_candidates.append(url)
|
||||
if "тп" in low and ("рп" in low or "35" in low):
|
||||
tp_candidates.append(url)
|
||||
|
||||
def _latest(cands: list[str]) -> str:
|
||||
return max(cands, key=_file_date_key) if cands else ""
|
||||
|
||||
result = {"cp": _latest(cp_candidates), "tp_rp": _latest(tp_candidates)}
|
||||
logger.info(
|
||||
"rosseti disclosure links: cp=%r tp_rp=%r (from %d hrefs)",
|
||||
result["cp"],
|
||||
result["tp_rp"],
|
||||
len(hrefs),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _cell(row: tuple, idx: int) -> object:
|
||||
"""Безопасно достаёт ячейку row по 0-based индексу (None если за границей)."""
|
||||
return row[idx] if idx < len(row) else None
|
||||
|
||||
|
||||
def load_cp_reserves(db: Session, xlsx_bytes: bytes, filename: str = "") -> dict[str, int]:
|
||||
"""Апдейт power_supply_centers резервами по ЦП из xlsx-раскрытия.
|
||||
|
||||
ВЕРИФИЦИРОВАННАЯ структура ЦП-файла: заголовки строки 5-6, строка 7 =
|
||||
нумерация столбцов, ДАННЫЕ С СТРОКИ 8. Колонки (1-based Excel → 0-based idx):
|
||||
B(1)=ЦП name, C(2)=ПО (баланс), E(4)=municipality, F(5)=voltage class,
|
||||
G(6)=installed МВА, H(7)=current load МВА, I(8)=reserve МВА.
|
||||
Матч к power_supply_centers по sc_name_norm (тот же нормализатор!) →
|
||||
installed/current/reserve/capacity_source='cp_35_110'. Unmatched → лог count.
|
||||
|
||||
Returns: счётчики rows/matched/unmatched.
|
||||
"""
|
||||
wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
|
||||
ws = wb.active
|
||||
asof = parse_asof_date(filename, ws.title if ws is not None else None)
|
||||
|
||||
rows_seen = 0
|
||||
matched = 0
|
||||
unmatched = 0
|
||||
unmatched_names: list[str] = []
|
||||
|
||||
try:
|
||||
# min_row=8 → данные с 8-й строки (1-based). iter_rows отдаёт кортежи values.
|
||||
for row in ws.iter_rows(min_row=8, values_only=True):
|
||||
cp_name = _cell(row, 1) # B
|
||||
if cp_name is None or not str(cp_name).strip():
|
||||
continue
|
||||
rows_seen += 1
|
||||
|
||||
name_norm = normalize_sc_name(str(cp_name))
|
||||
installed = parse_reserve_number(_cell(row, 6)) # G
|
||||
current = parse_reserve_number(_cell(row, 7)) # H
|
||||
reserve = parse_reserve_number(_cell(row, 8)) # I
|
||||
municipality = _cell(row, 4) # E
|
||||
|
||||
try:
|
||||
with db.begin_nested(): # SAVEPOINT — битая строка не валит батч
|
||||
res = db.execute(
|
||||
text("""
|
||||
UPDATE power_supply_centers
|
||||
SET installed_capacity_mva = :installed,
|
||||
current_load_mva = :current,
|
||||
reserve_mva = :reserve,
|
||||
reserve_asof = :asof,
|
||||
capacity_source = 'cp_35_110',
|
||||
municipality = COALESCE(municipality, :municipality)
|
||||
WHERE sc_name_norm = :name_norm
|
||||
"""),
|
||||
{
|
||||
"installed": installed,
|
||||
"current": current,
|
||||
"reserve": reserve,
|
||||
"asof": asof,
|
||||
"municipality": (str(municipality).strip() if municipality else None),
|
||||
"name_norm": name_norm,
|
||||
},
|
||||
)
|
||||
if res.rowcount and res.rowcount > 0:
|
||||
matched += res.rowcount
|
||||
else:
|
||||
unmatched += 1
|
||||
if len(unmatched_names) < 20:
|
||||
unmatched_names.append(str(cp_name).strip())
|
||||
except Exception as e:
|
||||
logger.warning("cp_reserve update failed for %r: %s", cp_name, e)
|
||||
unmatched += 1
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("load_cp_reserves: outer tx rolled back: %s", e)
|
||||
raise
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
logger.info(
|
||||
"load_cp_reserves done: rows=%d matched=%d unmatched=%d (sample unmatched=%s)",
|
||||
rows_seen,
|
||||
matched,
|
||||
unmatched,
|
||||
unmatched_names[:10],
|
||||
)
|
||||
return {"rows": rows_seen, "matched": matched, "unmatched": unmatched}
|
||||
|
||||
|
||||
def _is_rp(name: str) -> bool:
|
||||
"""Строка про РП (распределительный пункт), а не ТП? Влияет на кВА-санитайз."""
|
||||
return bool(re.search(r"\bрп\b|рп[\s-]?\d", name.lower()))
|
||||
|
||||
|
||||
def load_tp_rp_reserves(db: Session, xlsx_bytes: bytes, filename: str = "") -> dict[str, int]:
|
||||
"""UPSERT резервов ТП/РП <35 кВ в power_tp_rp_reserves из xlsx-раскрытия.
|
||||
|
||||
ВЕРИФИЦИРОВАННАЯ структура: заголовки строки 6-7, нумерация строка 8,
|
||||
ДАННЫЕ С СТРОКИ 9 (~10 741 строк). Колонки (0-based idx):
|
||||
0=name (ТП-721…), 1=РЭС, 2=region, 3=municipality/settlement,
|
||||
4=voltage kV, 5=installed МВА, 6=load МВА, 7=reserve МВА.
|
||||
кВА-санитайз: у ТП (не РП) installed > 2.5 → делим на 1000 (лог count).
|
||||
«н/д» → NULL. UPSERT по UNIQUE(name_norm, municipality, voltage_kv).
|
||||
Per-row SAVEPOINT. Returns: счётчики.
|
||||
"""
|
||||
wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True)
|
||||
ws = wb.active
|
||||
asof = parse_asof_date(filename, ws.title if ws is not None else None)
|
||||
|
||||
rows_seen = 0
|
||||
inserted = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
kva_sanitized = 0
|
||||
|
||||
try:
|
||||
for row in ws.iter_rows(min_row=9, values_only=True):
|
||||
name = _cell(row, 0)
|
||||
if name is None or not str(name).strip():
|
||||
continue
|
||||
name_str = str(name).strip()
|
||||
rows_seen += 1
|
||||
|
||||
res_unit = _cell(row, 1)
|
||||
municipality = _cell(row, 3)
|
||||
voltage_kv = _cell(row, 4)
|
||||
installed = parse_reserve_number(_cell(row, 5))
|
||||
current = parse_reserve_number(_cell(row, 6))
|
||||
reserve = parse_reserve_number(_cell(row, 7))
|
||||
|
||||
installed, sanitized = sanitize_tp_capacity_mva(installed, _is_rp(name_str))
|
||||
if sanitized:
|
||||
kva_sanitized += 1
|
||||
|
||||
params = {
|
||||
"name": name_str,
|
||||
"name_norm": normalize_sc_name(name_str),
|
||||
"res_unit": str(res_unit).strip() if res_unit else None,
|
||||
"municipality": str(municipality).strip() if municipality else None,
|
||||
"voltage_kv": str(voltage_kv).strip() if voltage_kv else None,
|
||||
"installed": installed,
|
||||
"current": current,
|
||||
"reserve": reserve,
|
||||
"asof": asof,
|
||||
}
|
||||
|
||||
try:
|
||||
with db.begin_nested(): # SAVEPOINT — битая строка не валит батч
|
||||
result = db.execute(
|
||||
# settlement намеренно = municipality: в xlsx Россетей это ОДНА
|
||||
# колонка «МО/населённый пункт»; отдельного settlement источник
|
||||
# не даёт (колонка в схеме — задел под Фазу B/геокод).
|
||||
text("""
|
||||
INSERT INTO power_tp_rp_reserves
|
||||
(name, name_norm, res_unit, municipality, settlement,
|
||||
voltage_kv, installed_capacity_mva, current_load_mva,
|
||||
reserve_mva, reserve_asof, fetched_at)
|
||||
VALUES (
|
||||
:name, :name_norm, :res_unit, :municipality, :municipality,
|
||||
:voltage_kv, :installed, :current,
|
||||
:reserve, :asof, NOW()
|
||||
)
|
||||
ON CONFLICT (name_norm, municipality, voltage_kv) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
res_unit = EXCLUDED.res_unit,
|
||||
installed_capacity_mva = EXCLUDED.installed_capacity_mva,
|
||||
current_load_mva = EXCLUDED.current_load_mva,
|
||||
reserve_mva = EXCLUDED.reserve_mva,
|
||||
reserve_asof = EXCLUDED.reserve_asof,
|
||||
fetched_at = NOW()
|
||||
RETURNING (xmax = 0) AS is_insert
|
||||
"""),
|
||||
params,
|
||||
).scalar()
|
||||
if result:
|
||||
inserted += 1
|
||||
else:
|
||||
updated += 1
|
||||
except Exception as e:
|
||||
logger.warning("tp_rp_reserve upsert failed for %r: %s", name_str, e)
|
||||
skipped += 1
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("load_tp_rp_reserves: outer tx rolled back: %s", e)
|
||||
raise
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
result_dict = {
|
||||
"rows": rows_seen,
|
||||
"inserted": inserted,
|
||||
"updated": updated,
|
||||
"skipped": skipped,
|
||||
"kva_sanitized": kva_sanitized,
|
||||
}
|
||||
logger.info("load_tp_rp_reserves done: %s", result_dict)
|
||||
return result_dict
|
||||
|
||||
|
||||
def load_all_reserves(db: Session | None = None) -> dict[str, dict]:
|
||||
"""Полный прогон: discovery ссылок + скачивание + загрузка ЦП и ТП/РП резервов.
|
||||
|
||||
RUN-ON-PROD. Каждый шаг graceful: сбой одного файла не валит другой.
|
||||
Returns: {"cp": {...}, "tp_rp": {...}} со счётчиками (или {"error": ...}).
|
||||
"""
|
||||
owns_session = db is None
|
||||
if db is None:
|
||||
db = SessionLocal()
|
||||
|
||||
out: dict[str, dict] = {}
|
||||
try:
|
||||
links = fetch_disclosure_links()
|
||||
with httpx.Client(timeout=_HTTP_TIMEOUT, follow_redirects=True) as client:
|
||||
if links.get("cp"):
|
||||
try:
|
||||
r = client.get(links["cp"])
|
||||
r.raise_for_status()
|
||||
out["cp"] = load_cp_reserves(db, r.content, links["cp"])
|
||||
except Exception as e:
|
||||
logger.exception("load_all_reserves: cp file failed: %s", e)
|
||||
out["cp"] = {"error": str(e)}
|
||||
else:
|
||||
out["cp"] = {"error": "no cp link found"}
|
||||
|
||||
if links.get("tp_rp"):
|
||||
try:
|
||||
r = client.get(links["tp_rp"])
|
||||
r.raise_for_status()
|
||||
out["tp_rp"] = load_tp_rp_reserves(db, r.content, links["tp_rp"])
|
||||
except Exception as e:
|
||||
logger.exception("load_all_reserves: tp_rp file failed: %s", e)
|
||||
out["tp_rp"] = {"error": str(e)}
|
||||
else:
|
||||
out["tp_rp"] = {"error": "no tp_rp link found"}
|
||||
finally:
|
||||
if owns_session:
|
||||
db.close()
|
||||
|
||||
logger.info("load_all_reserves done: %s", out)
|
||||
return out
|
||||
269
backend/app/services/site_finder/rosseti_wfs_loader.py
Normal file
269
backend/app/services/site_finder/rosseti_wfs_loader.py
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
"""Загрузчик центров питания (ЦП) Россети из WFS — свободная мощность §3 (#2119).
|
||||
|
||||
No-B2B верифицированный источник: WFS геосервера портала-тп.рф отдаёт ~488
|
||||
центров питания (ПС 35/110 кВ) по Свердловской области с координатами (WGS84,
|
||||
округлены ~100 м), классом напряжения и индексом загрузки (открыт/ограничен/
|
||||
закрыт для техприсоединения). UPSERT-ит в ``power_supply_centers``.
|
||||
|
||||
Источник ГЕО-БЛОКИРУЕТ non-RU IP → загрузчик РАБОТАЕТ НА ПРОДЕ (Celery weekly /
|
||||
manual docker exec). Punycode-host ОБЯЗАТЕЛЕН — кириллический алиас портал-тп.рф
|
||||
не резолвится корректно из curl/httpx.
|
||||
|
||||
Структура зеркалит utility_infrastructure_loader: httpx с явным таймаутом,
|
||||
per-row SAVEPOINT при UPSERT (битая фича не валит weekly-sync).
|
||||
|
||||
Также экспортирует ``normalize_sc_name`` — общий нормализатор имён ЦП, которым
|
||||
пользуется и ``rosseti_reserve_loader`` (матч xlsx-резерва по имени), и endpoint.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Punycode-host ОБЯЗАТЕЛЕН (кириллический алиас портал-тп.рф не для curl/httpx).
|
||||
# xn----7sb7akeedqd.xn--p1ai == портал-тп.рф.
|
||||
WFS_BASE_URL = "https://xn----7sb7akeedqd.xn--p1ai/geoserver/wfs"
|
||||
|
||||
# Слой центров питания (полный вид с резервами/напряжением).
|
||||
_WFS_TYPE_NAME = "gisosslabs:sc_points_fullview"
|
||||
|
||||
# CQL-фильтр по региону — только Свердловская область.
|
||||
_WFS_CQL_FILTER = "rg_code='SverdlovskOblast'"
|
||||
|
||||
# Таймаут WFS-запроса (сек). ~488 фич — с запасом.
|
||||
_WFS_TIMEOUT = 60
|
||||
|
||||
# sc_indexload_id (справочник Россети) → индекс загрузки ЦП. Неизвестный id →
|
||||
# None (сырое значение остаётся в raw). Верифицированные id из справочника:
|
||||
_INDEXLOAD_MAP: dict[int, str] = {
|
||||
256184: "open", # открыт для техприсоединения (есть резерв)
|
||||
256186: "limited", # ограниченно
|
||||
256188: "closed", # закрыт (резерва нет)
|
||||
}
|
||||
|
||||
# «ПС»/«подстанция» + класс напряжения-префикс — срезаем при нормализации имени.
|
||||
# Оба компонента опциональны, но хотя бы один должен присутствовать (иначе матч
|
||||
# пустой и sub ничего не делает). Примеры:
|
||||
# «ПС 110/35/10 Нижне-Исетская» → «нижне-исетская»
|
||||
# «ПС Уктус» → «уктус» (только «ПС», без напряжения)
|
||||
# «110/10 Уктус» → «уктус» (только напряжение)
|
||||
_VOLTAGE_PREFIX_RE = re.compile(
|
||||
r"^\s*(?:(?:пс|подстанци\w*)\s*)?(?:[\d/.,]+\s*(?:кв)?\s*)?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Класс напряжения внутри имени ЦП, напр. «110/35/10» или «110/10».
|
||||
_VOLTAGE_CLASS_RE = re.compile(r"\b(\d{1,3}(?:[/.]\d{1,3}){1,3})\b")
|
||||
_MULTISPACE_RE = re.compile(r"\s+")
|
||||
|
||||
|
||||
def normalize_sc_name(name: str | None) -> str:
|
||||
"""Нормализует имя ЦП для матча xlsx-резерва с WFS-фичей.
|
||||
|
||||
Правила: lower, ё→е, срез «ПС»/подстанция + класс напряжения-префикса,
|
||||
удаление кавычек, схлопывание тире/пробелов. Пусто → ''.
|
||||
|
||||
Examples:
|
||||
«ПС 110/35/10 Нижне-Исетская» → «нижне-исетская»
|
||||
«Нижне — Исетская» → «нижне-исетская»
|
||||
«ПС "Уктус"» → «уктус»
|
||||
"""
|
||||
if not name:
|
||||
return ""
|
||||
s = name.strip().lower().replace("ё", "е")
|
||||
s = s.replace("«", "").replace("»", "").replace('"', "").replace("'", "")
|
||||
# Срез voltage/«ПС»-префикса в начале имени (не трогаем цифры внутри имени).
|
||||
s = _VOLTAGE_PREFIX_RE.sub("", s, count=1)
|
||||
# Схлопываем все виды тире (—, –, -) в одиночный дефис, пробелы вокруг него убираем.
|
||||
s = re.sub(r"\s*[—–-]\s*", "-", s)
|
||||
s = _MULTISPACE_RE.sub(" ", s).strip()
|
||||
return s
|
||||
|
||||
|
||||
def parse_voltage_class(name: str | None) -> str | None:
|
||||
"""Извлекает класс напряжения из текста имени ЦП («ПС 110/35/10 …» → '110/35/10').
|
||||
|
||||
Возвращает первую последовательность вида «NNN/NNN[/NNN…]». None — если нет.
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
m = _VOLTAGE_CLASS_RE.search(name)
|
||||
if not m:
|
||||
return None
|
||||
return m.group(1).replace(".", "/")
|
||||
|
||||
|
||||
def _stable_external_id(feature: dict, props: dict) -> str:
|
||||
"""Стабильный external_id фичи: feature['id'] или хэш ключевых полей.
|
||||
|
||||
WFS обычно отдаёт стабильный ``feature['id']``; если его нет — детерминированный
|
||||
sha1 по (sc_name, координаты) чтобы UPSERT оставался идемпотентным.
|
||||
"""
|
||||
fid = feature.get("id")
|
||||
if fid:
|
||||
return str(fid)
|
||||
geom = feature.get("geometry") or {}
|
||||
coords = geom.get("coordinates")
|
||||
seed = f"{props.get('sc_name', '')}|{coords}"
|
||||
# sha1 здесь — стабильный дедуп-id фичи, не криптография.
|
||||
return "h:" + hashlib.sha1(seed.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _map_load_index(props: dict) -> str | None:
|
||||
"""sc_indexload_id → 'open'|'limited'|'closed'|None (неизвестный → None)."""
|
||||
raw = props.get("sc_indexload_id")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return _INDEXLOAD_MAP.get(int(raw))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def fetch_power_supply_centers() -> list[dict]:
|
||||
"""Тянет WFS-фичи центров питания Свердловской области (GeoJSON Feature-list).
|
||||
|
||||
RUN-ON-PROD: источник гео-блокирует non-RU IP. httpx с явным таймаутом,
|
||||
verify по умолчанию (валидный TLS у геосервера). Возвращает list feature-dict'ов
|
||||
``{"id", "geometry": {...}, "properties": {...}}``.
|
||||
"""
|
||||
params = {
|
||||
"service": "WFS",
|
||||
"version": "1.1.0",
|
||||
"request": "GetFeature",
|
||||
"typeName": _WFS_TYPE_NAME,
|
||||
"outputFormat": "json",
|
||||
"CQL_FILTER": _WFS_CQL_FILTER,
|
||||
}
|
||||
resp = httpx.get(WFS_BASE_URL, params=params, timeout=_WFS_TIMEOUT)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
features: list[dict] = (data or {}).get("features") or []
|
||||
logger.info("rosseti_wfs: загружено центров питания=%d", len(features))
|
||||
return features
|
||||
|
||||
|
||||
def _point_geom_sql(feature: dict) -> tuple[str, dict] | None:
|
||||
"""SQL-выражение + params для geom из GeoJSON Point. None — если не Point/нет коорд."""
|
||||
geom = feature.get("geometry") or {}
|
||||
if geom.get("type") != "Point":
|
||||
return None
|
||||
coords = geom.get("coordinates")
|
||||
if not coords or len(coords) < 2:
|
||||
return None
|
||||
lon, lat = coords[0], coords[1]
|
||||
if lon is None or lat is None:
|
||||
return None
|
||||
return "ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)", {"lon": lon, "lat": lat}
|
||||
|
||||
|
||||
def load_power_supply_centers(db: Session | None = None) -> dict[str, int]:
|
||||
"""Тянет WFS + UPSERT-ит центры питания в power_supply_centers.
|
||||
|
||||
UPSERT по UNIQUE(source, external_id). Per-row SAVEPOINT — битая фича не валит
|
||||
weekly-sync. ``db`` принимается для совместимости сигнатуры; если None — своя
|
||||
SessionLocal (WFS-fetch + запись в БД sync, как utility_infrastructure_loader).
|
||||
|
||||
Returns: счётчики fetched/inserted/updated/skipped.
|
||||
"""
|
||||
features = fetch_power_supply_centers()
|
||||
owns_session = db is None
|
||||
if db is None:
|
||||
db = SessionLocal()
|
||||
|
||||
inserted = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
fetched = len(features)
|
||||
try:
|
||||
for feature in features:
|
||||
props: dict = feature.get("properties") or {}
|
||||
sc_name = props.get("sc_name")
|
||||
if not sc_name:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
geom_pair = _point_geom_sql(feature)
|
||||
geom_sql = geom_pair[0] if geom_pair else "NULL"
|
||||
geom_params = geom_pair[1] if geom_pair else {}
|
||||
|
||||
params: dict = {
|
||||
"source": "rosseti_wfs",
|
||||
"external_id": _stable_external_id(feature, props),
|
||||
"sc_name": sc_name,
|
||||
"sc_name_norm": normalize_sc_name(sc_name),
|
||||
"dzo_name": props.get("dzo_name"),
|
||||
"org_name": props.get("org_name"),
|
||||
"branch_name": props.get("br_name"),
|
||||
"voltage_class": parse_voltage_class(sc_name),
|
||||
"load_index": _map_load_index(props),
|
||||
"municipality": props.get("municipality"),
|
||||
"place_city": props.get("sc_placecity"),
|
||||
"raw": json.dumps(feature, ensure_ascii=False),
|
||||
**geom_params,
|
||||
}
|
||||
|
||||
try:
|
||||
with db.begin_nested(): # SAVEPOINT — откат только этой фичи
|
||||
result = db.execute(
|
||||
text(f"""
|
||||
INSERT INTO power_supply_centers
|
||||
(source, external_id, sc_name, sc_name_norm, dzo_name,
|
||||
org_name, branch_name, voltage_class, load_index,
|
||||
municipality, place_city, geom, raw, fetched_at)
|
||||
VALUES (
|
||||
:source, :external_id, :sc_name, :sc_name_norm, :dzo_name,
|
||||
:org_name, :branch_name, :voltage_class, :load_index,
|
||||
:municipality, :place_city, {geom_sql},
|
||||
CAST(:raw AS jsonb), NOW()
|
||||
)
|
||||
ON CONFLICT (source, external_id) DO UPDATE
|
||||
SET sc_name = EXCLUDED.sc_name,
|
||||
sc_name_norm = EXCLUDED.sc_name_norm,
|
||||
dzo_name = EXCLUDED.dzo_name,
|
||||
org_name = EXCLUDED.org_name,
|
||||
branch_name = EXCLUDED.branch_name,
|
||||
voltage_class = EXCLUDED.voltage_class,
|
||||
load_index = EXCLUDED.load_index,
|
||||
municipality = EXCLUDED.municipality,
|
||||
place_city = EXCLUDED.place_city,
|
||||
geom = EXCLUDED.geom,
|
||||
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("rosseti_wfs upsert failed for %r: %s", sc_name, e)
|
||||
skipped += 1
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("rosseti_wfs: unexpected error, outer tx rolled back: %s", e)
|
||||
raise
|
||||
finally:
|
||||
if owns_session:
|
||||
db.close()
|
||||
|
||||
result_dict = {
|
||||
"fetched": fetched,
|
||||
"inserted": inserted,
|
||||
"updated": updated,
|
||||
"skipped": skipped,
|
||||
}
|
||||
logger.info("rosseti_wfs load done: %s", result_dict)
|
||||
return result_dict
|
||||
344
backend/app/services/site_finder/vodokanal_reserve_loader.py
Normal file
344
backend/app/services/site_finder/vodokanal_reserve_loader.py
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
"""Загрузчик резервов ЦСВ/ЦСК Водоканала ЕКБ из docx-раскрытия (#2119).
|
||||
|
||||
Две страницы водоканалекб.рф (водоснабжение / водоотведение) публикуют docx с
|
||||
таблицами свободной мощности ЦСВ (центр. система водоснабжения) / ЦСК (центр.
|
||||
система водоотведения). UPSERT-ит в ``water_supply_reserves``.
|
||||
|
||||
Источник ГЕО-БЛОКИРУЕТ non-RU IP → РАБОТАЕТ НА ПРОДЕ (Celery / docker exec).
|
||||
Punycode-host обязателен (водоканалекб.рф → xn--80aadbki6adhshb.xn--p1ai).
|
||||
|
||||
CRITICAL: колонка имени системы использует ВЕРТИКАЛЬНЫЙ MERGE (vMerge) — значение
|
||||
задано в первой строке блока, дальше идут <w:vMerge/> без restart → forward-fill.
|
||||
Колонка примечания тоже мержится. Парсим таблицы через stdlib (zipfile +
|
||||
ElementTree по word/document.xml) — это надёжнее python-docx для vMerge-детекта
|
||||
и тривиально тестируется на синтетическом xml-фрагменте.
|
||||
|
||||
Резерв бывает ОТРИЦАТЕЛЬНЫМ (дефицит, напр. −168,5) — знак сохраняем, запятая-
|
||||
десятичный → float. period инферится из имени файла (1kv.-2026 → '2026-Q1').
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
import zipfile
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# WordprocessingML namespace.
|
||||
_W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
||||
_NS = {"w": _W}
|
||||
|
||||
# Punycode-host обязателен (водоканалекб.рф). xn--80aadbki6adhshb.xn--p1ai.
|
||||
_BASE = "https://xn--80aadbki6adhshb.xn--p1ai"
|
||||
PAGES: dict[str, str] = {
|
||||
"water": f"{_BASE}/info/vodosnabzhenie/",
|
||||
"sewerage": f"{_BASE}/info/vodotvedenie/",
|
||||
}
|
||||
|
||||
_HTTP_TIMEOUT = 60
|
||||
|
||||
# docx-ссылка «для сайта»: dlya-sajta-*.docx.
|
||||
_DOCX_HREF_RE = re.compile(r'href=["\']([^"\']*dlya-sajta-[^"\']*\.docx)["\']', re.IGNORECASE)
|
||||
# Дата в имени файла для выбора свежайшего.
|
||||
_FILE_DATE_RE = re.compile(r"(\d{4})|(\d{1,2})[.\-](\d{4})")
|
||||
# Период вида «1kv.-2026» / «1кв-2026» / «1 квартал 2026».
|
||||
_PERIOD_RE = re.compile(r"(\d)\s*(?:kv|кв)\w*[.\s-]*(\d{4})", re.IGNORECASE)
|
||||
|
||||
_NODATA_TOKENS = frozenset({"", "н/д", "нд", "n/a", "-", "—", "–", "нет данных", "х", "x"})
|
||||
|
||||
|
||||
def parse_water_number(value: str | None) -> float | None:
|
||||
"""Числовая ячейка резерва тыс. м³/сут → float. Сохраняет знак (дефицит <0).
|
||||
|
||||
Запятая-десятичный → точка, минусы всех видов (−/–/-) нормализуются.
|
||||
«н/д»/пусто → None.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip().lower()
|
||||
if s in _NODATA_TOKENS:
|
||||
return None
|
||||
s = s.replace("\xa0", "").replace(" ", "").replace(",", ".")
|
||||
# Нормализуем юникод-минусы к ASCII '-'.
|
||||
s = s.replace("−", "-").replace("–", "-").replace("—", "-")
|
||||
m = re.search(r"-?\d+(?:\.\d+)?", s)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return float(m.group())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def infer_period(filename: str) -> str | None:
|
||||
"""Инферит период отчёта из имени файла: «1kv.-2026» → '2026-Q1'. None — нет."""
|
||||
m = _PERIOD_RE.search(filename)
|
||||
if not m:
|
||||
return None
|
||||
quarter, year = m.group(1), m.group(2)
|
||||
return f"{year}-Q{quarter}"
|
||||
|
||||
|
||||
def _cell_text(tc: ET.Element) -> str:
|
||||
"""Весь текст ячейки таблицы (<w:tc>) — конкатенация всех <w:t>."""
|
||||
parts = [t.text or "" for t in tc.iter(f"{{{_W}}}t")]
|
||||
return "".join(parts).strip()
|
||||
|
||||
|
||||
def _vmerge_state(tc: ET.Element) -> str:
|
||||
"""Состояние вертикального merge ячейки: 'restart' | 'continue' | 'none'.
|
||||
|
||||
<w:vMerge w:val="restart"/> — начало блока (значение здесь).
|
||||
<w:vMerge/> (без val или val='continue') — продолжение (forward-fill сверху).
|
||||
Нет <w:vMerge> — обычная ячейка.
|
||||
"""
|
||||
tc_pr = tc.find(f"{{{_W}}}tcPr")
|
||||
if tc_pr is None:
|
||||
return "none"
|
||||
vmerge = tc_pr.find(f"{{{_W}}}vMerge")
|
||||
if vmerge is None:
|
||||
return "none"
|
||||
val = vmerge.get(f"{{{_W}}}val")
|
||||
return "restart" if val == "restart" else "continue"
|
||||
|
||||
|
||||
def parse_docx_table_rows(document_xml: bytes) -> list[list[str]]:
|
||||
"""Парсит ПЕРВУЮ таблицу document.xml → list строк (list ячеек-строк).
|
||||
|
||||
Forward-fill по вертикальному merge: ячейка в состоянии 'continue' наследует
|
||||
текст ячейки той же колонки из предыдущей строки (system-name + note мержатся).
|
||||
Возвращает матрицу текстов [row][col]. Пусто → [].
|
||||
"""
|
||||
root = ET.fromstring(document_xml)
|
||||
tables = root.iter(f"{{{_W}}}tbl")
|
||||
tbl = next(tables, None)
|
||||
if tbl is None:
|
||||
return []
|
||||
|
||||
matrix: list[list[str]] = []
|
||||
prev_row: list[str] = []
|
||||
for tr in tbl.findall(f"{{{_W}}}tr"):
|
||||
cells = tr.findall(f"{{{_W}}}tc")
|
||||
row: list[str] = []
|
||||
for col_idx, tc in enumerate(cells):
|
||||
state = _vmerge_state(tc)
|
||||
if state == "continue":
|
||||
# forward-fill из той же колонки предыдущей строки.
|
||||
filled = prev_row[col_idx] if col_idx < len(prev_row) else ""
|
||||
row.append(filled)
|
||||
else:
|
||||
row.append(_cell_text(tc))
|
||||
matrix.append(row)
|
||||
prev_row = row
|
||||
return matrix
|
||||
|
||||
|
||||
def _looks_numeric(s: str) -> bool:
|
||||
"""Строка похожа на число (для отсева заголовочных строк)?"""
|
||||
return parse_water_number(s) is not None
|
||||
|
||||
|
||||
def extract_water_rows(matrix: list[list[str]]) -> list[dict]:
|
||||
"""Из forward-filled матрицы вытаскивает записи ЦСВ/ЦСК.
|
||||
|
||||
Эвристика колонок (структура docx стабильна, но защищаемся): первая непустая
|
||||
текстовая колонка = system_name; первая числовая колонка = reserve; далее
|
||||
ищем заявки/отказы (целые) если есть; note — последняя длинная текстовая.
|
||||
Заголовочные строки (без числового резерва) пропускаются.
|
||||
"""
|
||||
records: list[dict] = []
|
||||
for row in matrix:
|
||||
if not any(c.strip() for c in row):
|
||||
continue
|
||||
# system_name — первая непустая текстовая (не число) ячейка.
|
||||
name = ""
|
||||
reserve_idx = -1
|
||||
for idx, cell in enumerate(row):
|
||||
cell = cell.strip()
|
||||
if not cell:
|
||||
continue
|
||||
if not name and not _looks_numeric(cell):
|
||||
name = cell
|
||||
if reserve_idx < 0 and _looks_numeric(cell):
|
||||
reserve_idx = idx
|
||||
if not name or reserve_idx < 0:
|
||||
continue # заголовок / служебная строка без резерва
|
||||
|
||||
reserve = parse_water_number(row[reserve_idx])
|
||||
|
||||
# заявки/отказы — целочисленные значения ПОСЛЕ колонки резерва.
|
||||
requested: int | None = None
|
||||
refused: int | None = None
|
||||
ints_after: list[int] = []
|
||||
for cell in row[reserve_idx + 1 :]:
|
||||
n = parse_water_number(cell)
|
||||
if n is not None and float(n).is_integer() and n >= 0:
|
||||
ints_after.append(int(n))
|
||||
if len(ints_after) >= 1:
|
||||
requested = ints_after[0]
|
||||
if len(ints_after) >= 2:
|
||||
refused = ints_after[1]
|
||||
|
||||
# note — последняя текстовая ячейка (не имя, не число).
|
||||
note = ""
|
||||
for cell in reversed(row):
|
||||
cell = cell.strip()
|
||||
if cell and not _looks_numeric(cell) and cell != name:
|
||||
note = cell
|
||||
break
|
||||
|
||||
records.append(
|
||||
{
|
||||
"system_name": name,
|
||||
"reserve_thousand_m3_day": reserve,
|
||||
"connections_requested": requested,
|
||||
"connections_refused": refused,
|
||||
"note": note or None,
|
||||
}
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def fetch_docx_link(page_url: str) -> str:
|
||||
"""Скрейпит страницу → ссылка на СВЕЖАЙШИЙ dlya-sajta-*.docx. '' если нет.
|
||||
|
||||
RUN-ON-PROD (гео-блок). GET follow_redirects, regex по href.
|
||||
"""
|
||||
resp = httpx.get(page_url, timeout=_HTTP_TIMEOUT, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
hrefs = _DOCX_HREF_RE.findall(resp.text)
|
||||
if not hrefs:
|
||||
return ""
|
||||
|
||||
def _key(h: str) -> tuple:
|
||||
years = [int(y) for grp in _FILE_DATE_RE.findall(h) for y in grp if y]
|
||||
return (max(years) if years else 0, h)
|
||||
|
||||
best = max(hrefs, key=_key)
|
||||
return best if best.startswith("http") else _BASE + best
|
||||
|
||||
|
||||
def _upsert_water_rows(
|
||||
db: Session,
|
||||
system_kind: str,
|
||||
records: list[dict],
|
||||
period: str | None,
|
||||
source_url: str,
|
||||
) -> dict[str, int]:
|
||||
"""UPSERT записей ЦСВ/ЦСК в water_supply_reserves. Per-row SAVEPOINT."""
|
||||
inserted = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
for rec in records:
|
||||
params = {
|
||||
"system_kind": system_kind,
|
||||
"system_name": rec["system_name"],
|
||||
"reserve": rec["reserve_thousand_m3_day"],
|
||||
"requested": rec["connections_requested"],
|
||||
"refused": rec["connections_refused"],
|
||||
"note": rec["note"],
|
||||
"period": period,
|
||||
"source_url": source_url,
|
||||
}
|
||||
try:
|
||||
with db.begin_nested(): # SAVEPOINT — битая строка не валит батч
|
||||
result = db.execute(
|
||||
text("""
|
||||
INSERT INTO water_supply_reserves
|
||||
(system_kind, system_name, reserve_thousand_m3_day,
|
||||
connections_requested, connections_refused, note,
|
||||
period, source_url, fetched_at)
|
||||
VALUES (
|
||||
:system_kind, :system_name, :reserve,
|
||||
:requested, :refused, :note,
|
||||
:period, :source_url, NOW()
|
||||
)
|
||||
ON CONFLICT (system_kind, system_name, period) DO UPDATE
|
||||
SET reserve_thousand_m3_day = EXCLUDED.reserve_thousand_m3_day,
|
||||
connections_requested = EXCLUDED.connections_requested,
|
||||
connections_refused = EXCLUDED.connections_refused,
|
||||
note = EXCLUDED.note,
|
||||
source_url = EXCLUDED.source_url,
|
||||
fetched_at = NOW()
|
||||
RETURNING (xmax = 0) AS is_insert
|
||||
"""),
|
||||
params,
|
||||
).scalar()
|
||||
if result:
|
||||
inserted += 1
|
||||
else:
|
||||
updated += 1
|
||||
except Exception as e:
|
||||
logger.warning("water_reserve upsert failed for %r: %s", rec["system_name"], e)
|
||||
skipped += 1
|
||||
return {"inserted": inserted, "updated": updated, "skipped": skipped}
|
||||
|
||||
|
||||
def load_water_reserves_from_docx(
|
||||
db: Session,
|
||||
system_kind: str,
|
||||
docx_bytes: bytes,
|
||||
source_url: str = "",
|
||||
) -> dict[str, int]:
|
||||
"""Парсит docx-байты → UPSERT ЦСВ/ЦСК в water_supply_reserves.
|
||||
|
||||
Выделено из load_water_reserves для юнит-теста на синтетическом docx.
|
||||
Читает word/document.xml из zip, forward-fill vMerge, извлечение записей.
|
||||
"""
|
||||
with zipfile.ZipFile(io.BytesIO(docx_bytes)) as zf:
|
||||
document_xml = zf.read("word/document.xml")
|
||||
matrix = parse_docx_table_rows(document_xml)
|
||||
records = extract_water_rows(matrix)
|
||||
period = infer_period(source_url)
|
||||
|
||||
try:
|
||||
counts = _upsert_water_rows(db, system_kind, records, period, source_url)
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("load_water_reserves_from_docx: outer tx rolled back: %s", e)
|
||||
raise
|
||||
|
||||
result = {"records": len(records), **counts, "period": period} # type: ignore[dict-item]
|
||||
logger.info("water_reserves[%s] done: %s", system_kind, result)
|
||||
return {k: v for k, v in result.items() if isinstance(v, int)}
|
||||
|
||||
|
||||
def load_water_reserves(db: Session | None = None) -> dict[str, dict]:
|
||||
"""Полный прогон: обе страницы (водоснабжение/водоотведение) → UPSERT.
|
||||
|
||||
RUN-ON-PROD. Каждая страница graceful: сбой одной не валит другую.
|
||||
Returns: {"water": {...}, "sewerage": {...}}.
|
||||
"""
|
||||
owns_session = db is None
|
||||
if db is None:
|
||||
db = SessionLocal()
|
||||
|
||||
out: dict[str, dict] = {}
|
||||
try:
|
||||
with httpx.Client(timeout=_HTTP_TIMEOUT, follow_redirects=True) as client:
|
||||
for kind, page_url in PAGES.items():
|
||||
try:
|
||||
link = fetch_docx_link(page_url)
|
||||
if not link:
|
||||
out[kind] = {"error": "no docx link found"}
|
||||
continue
|
||||
r = client.get(link)
|
||||
r.raise_for_status()
|
||||
out[kind] = load_water_reserves_from_docx(db, kind, r.content, link)
|
||||
except Exception as e:
|
||||
logger.exception("load_water_reserves: %s failed: %s", kind, e)
|
||||
out[kind] = {"error": str(e)}
|
||||
finally:
|
||||
if owns_session:
|
||||
db.close()
|
||||
|
||||
logger.info("load_water_reserves done: %s", out)
|
||||
return out
|
||||
|
|
@ -283,6 +283,25 @@ def build_beat_schedule() -> dict:
|
|||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
# Точки подключения со свободной мощностью (Forgejo #2119 Phase A):
|
||||
# электро — Россети WFS (~488 ЦП) + xlsx-резервы (ЦП + 10.7k ТП/РП);
|
||||
# вода — Водоканал ЕКБ docx (ЦСВ/ЦСК, city-level).
|
||||
# Источники ГЕО-БЛОКИРУЮТ non-RU IP → RUN-ON-PROD (Celery worker на VPS).
|
||||
# Меняются медленно (раскрытие ФАС квартальное) — еженедельно с запасом.
|
||||
# Вторник 04:00 МСК — НАМЕРЕННО вне monday-кластера тяжёлых Overpass/site_finder
|
||||
# рефрешей (не конкурируем за БД/сеть). Электро (WFS+xlsx) и вода (docx) —
|
||||
# разные хосты, ставим с интервалом 30 мин, чтобы не бить оба одновременно.
|
||||
schedule["power-capacity-sync-weekly"] = {
|
||||
"task": "tasks.connection_capacity_sync.sync_power_supply_centers",
|
||||
"schedule": _parse_cron("0 4 * * tue"), # 04:00 МСК, вторник
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
schedule["water-capacity-sync-weekly"] = {
|
||||
"task": "tasks.connection_capacity_sync.sync_water_reserves",
|
||||
"schedule": _parse_cron("30 4 * * tue"), # 04:30 МСК, вторник
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
# #105 Phase 4: ЕКБ РНС/РВЭ — ежемесячно 1-го числа в 05:00 МСК.
|
||||
# Celery conf.timezone=Europe/Moscow → crontab трактуется в МСК (#1233).
|
||||
schedule["ekburg-permits-monthly"] = {
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ celery_app = Celery(
|
|||
"app.workers.tasks.riasurt_sverdl_harvest",
|
||||
"app.workers.tasks.mv_sales_tracker_refresh",
|
||||
"app.workers.tasks.scrape_freshness_check",
|
||||
"app.workers.tasks.connection_capacity_sync",
|
||||
],
|
||||
)
|
||||
celery_app.conf.timezone = "Europe/Moscow"
|
||||
|
|
|
|||
95
backend/app/workers/tasks/connection_capacity_sync.py
Normal file
95
backend/app/workers/tasks/connection_capacity_sync.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"""Celery tasks: еженедельная синхронизация точек подключения со свободной
|
||||
мощностью — электро (Россети WFS + xlsx-резервы) и вода (Водоканал docx). #2119.
|
||||
|
||||
Источники ГЕО-БЛОКИРУЮТ non-RU IP → таски выполняются НА ПРОДЕ (Celery worker
|
||||
на VPS). Persistent breadcrumbs в nspd_geo_log (как utility/poi/noise sync).
|
||||
Import-light: тяжёлые загрузчики импортируются лениво внутри тасков.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.workers.celery_app import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _log_breadcrumb(stage: str, level: str, message: str) -> None:
|
||||
"""Записать строку в nspd_geo_log (job_id=NULL) — диагностика без SSH."""
|
||||
try:
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.execute(
|
||||
text(
|
||||
"INSERT INTO nspd_geo_log (job_id, level, stage, message) "
|
||||
"VALUES (NULL, :lvl, :stage, :msg)"
|
||||
),
|
||||
{"lvl": level, "stage": stage, "msg": message[:500]},
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.warning("connection_capacity breadcrumb failed: %s", e)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="tasks.connection_capacity_sync.sync_power_supply_centers",
|
||||
queue="celery",
|
||||
)
|
||||
def sync_power_supply_centers() -> dict:
|
||||
"""WFS центры питания + xlsx-резервы (ЦП + ТП/РП) → power_supply_centers / ТП-РП.
|
||||
|
||||
RUN-ON-PROD (гео-блок источников). Сначала WFS (координаты + напряжение +
|
||||
индекс загрузки), затем резервы из xlsx-раскрытия. Каждый шаг graceful —
|
||||
сбой резервов не откатывает загруженные WFS-точки.
|
||||
"""
|
||||
_log_breadcrumb("power_capacity_sync", "info", "task started")
|
||||
result: dict = {}
|
||||
try:
|
||||
from app.services.site_finder.rosseti_wfs_loader import load_power_supply_centers
|
||||
|
||||
result["wfs"] = load_power_supply_centers()
|
||||
except Exception as e:
|
||||
logger.exception("sync_power_supply_centers: WFS failed: %s", e)
|
||||
_log_breadcrumb("power_capacity_sync", "error", f"wfs {type(e).__name__}: {e}")
|
||||
result["wfs"] = {"error": str(e)}
|
||||
|
||||
try:
|
||||
from app.services.site_finder.rosseti_reserve_loader import load_all_reserves
|
||||
|
||||
result["reserves"] = load_all_reserves()
|
||||
except Exception as e:
|
||||
logger.exception("sync_power_supply_centers: reserves failed: %s", e)
|
||||
_log_breadcrumb("power_capacity_sync", "error", f"reserves {type(e).__name__}: {e}")
|
||||
result["reserves"] = {"error": str(e)}
|
||||
|
||||
logger.info("sync_power_supply_centers done: %s", result)
|
||||
_log_breadcrumb("power_capacity_sync", "info", f"done: {result}")
|
||||
return result
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="tasks.connection_capacity_sync.sync_water_reserves",
|
||||
queue="celery",
|
||||
)
|
||||
def sync_water_reserves() -> dict:
|
||||
"""Резервы ЦСВ/ЦСК Водоканала ЕКБ (docx) → water_supply_reserves.
|
||||
|
||||
RUN-ON-PROD (гео-блок). Обе страницы (водоснабжение/водоотведение) graceful.
|
||||
"""
|
||||
_log_breadcrumb("water_capacity_sync", "info", "task started")
|
||||
try:
|
||||
from app.services.site_finder.vodokanal_reserve_loader import load_water_reserves
|
||||
|
||||
result = load_water_reserves()
|
||||
logger.info("sync_water_reserves done: %s", result)
|
||||
_log_breadcrumb("water_capacity_sync", "info", f"done: {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.exception("sync_water_reserves failed: %s", e)
|
||||
_log_breadcrumb("water_capacity_sync", "error", f"{type(e).__name__}: {e}")
|
||||
raise
|
||||
157
backend/tests/api/v1/test_parcel_connection_capacity.py
Normal file
157
backend/tests/api/v1/test_parcel_connection_capacity.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"""Тесты для GET /{cad_num}/connection-capacity (Forgejo #2119 Phase A).
|
||||
|
||||
FastAPI TestClient + mock сервиса get_connection_capacity — без Postgres.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
_VALID_CAD = "66:41:0204016:10"
|
||||
|
||||
|
||||
def _power_point(
|
||||
name: str = "ПС 110/10 Уктус",
|
||||
reserve: float | None = 15.0,
|
||||
load_index: str | None = "open",
|
||||
distance_m: float = 850.0,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"name": name,
|
||||
"dzo_name": "Россети Урал",
|
||||
"voltage_class": "110/10",
|
||||
"load_index": load_index,
|
||||
"installed_capacity_mva": 40.0,
|
||||
"current_load_mva": 25.0,
|
||||
"reserve_mva": reserve,
|
||||
"reserve_asof": "2026-06-30",
|
||||
"distance_m": distance_m,
|
||||
"lat": 56.78,
|
||||
"lon": 60.61,
|
||||
}
|
||||
|
||||
|
||||
def _make_response(
|
||||
power_points: list[dict[str, Any]] | None = None,
|
||||
water: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if power_points is None:
|
||||
power_points = [_power_point()]
|
||||
if water is None:
|
||||
water = [
|
||||
{
|
||||
"system_kind": "water",
|
||||
"system_name": "ЗФС, ГСВ",
|
||||
"reserve_thousand_m3_day": -168.5,
|
||||
"note": "дефицит",
|
||||
"period": "2026-Q1",
|
||||
}
|
||||
]
|
||||
by_load_index: dict[str, int] = {}
|
||||
for p in power_points:
|
||||
li = p["load_index"] or "unknown"
|
||||
by_load_index[li] = by_load_index.get(li, 0) + 1
|
||||
nearest = next((p for p in power_points if p["reserve_mva"] and p["reserve_mva"] > 0), None)
|
||||
return {
|
||||
"power_points": power_points,
|
||||
"power_summary": {
|
||||
"total_power_points": len(power_points),
|
||||
"by_load_index": by_load_index,
|
||||
"nearest_with_reserve": nearest,
|
||||
},
|
||||
"water": water,
|
||||
}
|
||||
|
||||
|
||||
def test_connection_capacity_returns_power_and_water() -> None:
|
||||
full = _make_response()
|
||||
with patch(
|
||||
"app.api.v1.parcels.get_connection_capacity",
|
||||
return_value=full,
|
||||
):
|
||||
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["power_points"]) == 1
|
||||
assert body["power_points"][0]["voltage_class"] == "110/10"
|
||||
assert body["power_summary"]["total_power_points"] == 1
|
||||
assert body["power_summary"]["by_load_index"]["open"] == 1
|
||||
assert body["power_summary"]["nearest_with_reserve"]["reserve_mva"] == pytest.approx(15.0)
|
||||
# water: отрицательный резерв (дефицит) сохраняет знак.
|
||||
assert body["water"][0]["reserve_thousand_m3_day"] == pytest.approx(-168.5)
|
||||
assert body["water"][0]["system_name"] == "ЗФС, ГСВ"
|
||||
|
||||
|
||||
def test_connection_capacity_empty() -> None:
|
||||
empty = _make_response(power_points=[], water=[])
|
||||
with patch(
|
||||
"app.api.v1.parcels.get_connection_capacity",
|
||||
return_value=empty,
|
||||
):
|
||||
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["power_points"] == []
|
||||
assert body["power_summary"]["total_power_points"] == 0
|
||||
assert body["power_summary"]["nearest_with_reserve"] is None
|
||||
assert body["water"] == []
|
||||
|
||||
|
||||
def test_connection_capacity_no_reserve_nearest_null() -> None:
|
||||
"""Все ЦП без положительного резерва → nearest_with_reserve=null."""
|
||||
pts = [_power_point(reserve=None, load_index="closed")]
|
||||
with patch(
|
||||
"app.api.v1.parcels.get_connection_capacity",
|
||||
return_value=_make_response(power_points=pts),
|
||||
):
|
||||
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["power_summary"]["nearest_with_reserve"] is None
|
||||
assert body["power_summary"]["by_load_index"]["closed"] == 1
|
||||
|
||||
|
||||
def test_connection_capacity_parcel_not_found_404() -> None:
|
||||
with patch(
|
||||
"app.api.v1.parcels.get_connection_capacity",
|
||||
side_effect=ValueError("Участок '66:41:9999999:1' не найден в БД"),
|
||||
):
|
||||
client = TestClient(app)
|
||||
r = client.get("/api/v1/parcels/66:41:9999999:1/connection-capacity")
|
||||
|
||||
assert r.status_code == 404
|
||||
assert "не найден" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_connection_capacity_passes_radius() -> None:
|
||||
with patch("app.api.v1.parcels.get_connection_capacity") as mock_svc:
|
||||
mock_svc.return_value = _make_response()
|
||||
client = TestClient(app)
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity?radius_m=5000")
|
||||
assert r.status_code == 200
|
||||
call = mock_svc.call_args
|
||||
assert call[0][2] == 5000 or call[1].get("radius_m") == 5000
|
||||
|
||||
|
||||
def test_connection_capacity_radius_too_small_422() -> None:
|
||||
client = TestClient(app)
|
||||
r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity?radius_m=50")
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
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
|
||||
365
backend/tests/test_connection_capacity_loaders.py
Normal file
365
backend/tests/test_connection_capacity_loaders.py
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
"""Unit-тесты загрузчиков «точек подключения со свободной мощностью» (#2119).
|
||||
|
||||
Pure / mock-based — без реальной сети и БД. Покрывает:
|
||||
- normalize_sc_name: «ПС 110/35/10 Нижне-Исетская» ↔ «Нижне-Исетская», ё, тире.
|
||||
- parse_voltage_class: извлечение класса напряжения из имени ЦП.
|
||||
- _map_load_index: sc_indexload_id → open/limited/closed/None.
|
||||
- sanitize_tp_capacity_mva: кВА-санитайз (÷1000 для ТП > порога, РП не трогаем).
|
||||
- parse_reserve_number / parse_water_number: запятая, знак (дефицит <0), «н/д».
|
||||
- infer_period: «1kv.-2026» → '2026-Q1'.
|
||||
- parse_docx_table_rows: forward-fill вертикального merge (vMerge) на xml-фрагменте.
|
||||
- extract_water_rows: извлечение ЦСВ-записей из forward-filled матрицы.
|
||||
- load_tp_rp_reserves: row-offset (данные с строки 9) на синтетическом workbook.
|
||||
- load_cp_reserves: row-offset (данные с строки 8) + матч по sc_name_norm.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
import openpyxl
|
||||
|
||||
from app.services.site_finder import rosseti_reserve_loader as rr
|
||||
from app.services.site_finder import rosseti_wfs_loader as rw
|
||||
from app.services.site_finder import vodokanal_reserve_loader as vk
|
||||
|
||||
# ── normalize_sc_name ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_normalize_strips_ps_and_voltage_prefix() -> None:
|
||||
"""«ПС 110/35/10 Нижне-Исетская» → «нижне-исетская» (совпадает с голым именем)."""
|
||||
a = rw.normalize_sc_name("ПС 110/35/10 Нижне-Исетская")
|
||||
b = rw.normalize_sc_name("Нижне-Исетская")
|
||||
assert a == b == "нижне-исетская"
|
||||
|
||||
|
||||
def test_normalize_yo_to_e() -> None:
|
||||
"""ё → е при нормализации."""
|
||||
assert rw.normalize_sc_name("Берёзовская") == rw.normalize_sc_name("Березовская")
|
||||
assert rw.normalize_sc_name("Берёзовская") == "березовская"
|
||||
|
||||
|
||||
def test_normalize_dash_variants_collapse() -> None:
|
||||
"""Разные тире (—, –, -) и пробелы вокруг схлопываются в один дефис."""
|
||||
assert rw.normalize_sc_name("Нижне — Исетская") == "нижне-исетская"
|
||||
assert rw.normalize_sc_name("Нижне–Исетская") == "нижне-исетская"
|
||||
|
||||
|
||||
def test_normalize_quotes_removed() -> None:
|
||||
"""«ПС "Уктус"» → «уктус» (кавычки и префикс срезаны)."""
|
||||
assert rw.normalize_sc_name('ПС "Уктус"') == "уктус"
|
||||
|
||||
|
||||
def test_normalize_empty() -> None:
|
||||
assert rw.normalize_sc_name(None) == ""
|
||||
assert rw.normalize_sc_name("") == ""
|
||||
|
||||
|
||||
# ── parse_voltage_class ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_voltage_class_triple() -> None:
|
||||
assert rw.parse_voltage_class("ПС 110/35/10 Нижне-Исетская") == "110/35/10"
|
||||
|
||||
|
||||
def test_parse_voltage_class_double() -> None:
|
||||
assert rw.parse_voltage_class("ПС 110/10 Уктус") == "110/10"
|
||||
|
||||
|
||||
def test_parse_voltage_class_none() -> None:
|
||||
assert rw.parse_voltage_class("Уктус") is None
|
||||
assert rw.parse_voltage_class(None) is None
|
||||
|
||||
|
||||
# ── _map_load_index ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_map_load_index_known() -> None:
|
||||
assert rw._map_load_index({"sc_indexload_id": 256184}) == "open"
|
||||
assert rw._map_load_index({"sc_indexload_id": 256186}) == "limited"
|
||||
assert rw._map_load_index({"sc_indexload_id": 256188}) == "closed"
|
||||
|
||||
|
||||
def test_map_load_index_string_id_coerced() -> None:
|
||||
"""id приходит строкой из json — коэрсим в int."""
|
||||
assert rw._map_load_index({"sc_indexload_id": "256184"}) == "open"
|
||||
|
||||
|
||||
def test_map_load_index_unknown_and_missing() -> None:
|
||||
assert rw._map_load_index({"sc_indexload_id": 999999}) is None
|
||||
assert rw._map_load_index({}) is None
|
||||
assert rw._map_load_index({"sc_indexload_id": "мусор"}) is None
|
||||
|
||||
|
||||
# ── sanitize_tp_capacity_mva (кВА-санитайз) ───────────────────────────────────
|
||||
|
||||
|
||||
def test_kva_sanitize_tp_above_threshold_divided() -> None:
|
||||
"""ТП с installed=630 (кВА в колонке МВА) → 0.63 МВА, флаг True."""
|
||||
val, sanitized = rr.sanitize_tp_capacity_mva(630.0, is_rp=False)
|
||||
assert sanitized is True
|
||||
assert val == 0.63
|
||||
|
||||
|
||||
def test_kva_sanitize_tp_below_threshold_kept() -> None:
|
||||
"""ТП с installed=1.6 МВА (правдоподобно) → не трогаем."""
|
||||
val, sanitized = rr.sanitize_tp_capacity_mva(1.6, is_rp=False)
|
||||
assert sanitized is False
|
||||
assert val == 1.6
|
||||
|
||||
|
||||
def test_kva_sanitize_rp_never_divided() -> None:
|
||||
"""РП с высокой мощностью легитимен → не делим даже выше порога."""
|
||||
val, sanitized = rr.sanitize_tp_capacity_mva(10.0, is_rp=True)
|
||||
assert sanitized is False
|
||||
assert val == 10.0
|
||||
|
||||
|
||||
def test_kva_sanitize_none() -> None:
|
||||
val, sanitized = rr.sanitize_tp_capacity_mva(None, is_rp=False)
|
||||
assert val is None and sanitized is False
|
||||
|
||||
|
||||
# ── parse_reserve_number ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_reserve_comma_decimal() -> None:
|
||||
assert rr.parse_reserve_number("1,63") == 1.63
|
||||
|
||||
|
||||
def test_parse_reserve_negative_kept() -> None:
|
||||
"""Отрицательный резерв (дефицит) сохраняет знак."""
|
||||
assert rr.parse_reserve_number("-2,5") == -2.5
|
||||
|
||||
|
||||
def test_parse_reserve_thousands_space() -> None:
|
||||
"""Неразрывный пробел-разделитель тысяч убирается."""
|
||||
assert rr.parse_reserve_number("1\xa0234,5") == 1234.5
|
||||
|
||||
|
||||
def test_parse_reserve_nodata() -> None:
|
||||
assert rr.parse_reserve_number("н/д") is None
|
||||
assert rr.parse_reserve_number("") is None
|
||||
assert rr.parse_reserve_number(None) is None
|
||||
assert rr.parse_reserve_number("текст") is None
|
||||
|
||||
|
||||
def test_parse_reserve_numeric_passthrough() -> None:
|
||||
assert rr.parse_reserve_number(5) == 5.0
|
||||
assert rr.parse_reserve_number(2.5) == 2.5
|
||||
|
||||
|
||||
# ── parse_asof_date ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_asof_date_from_filename() -> None:
|
||||
d = rr.parse_asof_date("резерв по ЦП на 30.06.2026.xlsx")
|
||||
assert d is not None and d.isoformat() == "2026-06-30"
|
||||
|
||||
|
||||
def test_parse_asof_date_none() -> None:
|
||||
assert rr.parse_asof_date("no date here", None) is None
|
||||
|
||||
|
||||
# ── water: parse_water_number / infer_period ──────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_water_negative_unicode_minus() -> None:
|
||||
"""Юникод-минус −168,5 (дефицит) → -168.5."""
|
||||
assert vk.parse_water_number("−168,5") == -168.5
|
||||
|
||||
|
||||
def test_parse_water_nodata() -> None:
|
||||
assert vk.parse_water_number("н/д") is None
|
||||
assert vk.parse_water_number("") is None
|
||||
assert vk.parse_water_number(None) is None
|
||||
|
||||
|
||||
def test_infer_period_from_filename() -> None:
|
||||
assert vk.infer_period("dlya-sajta-1kv.-2026.docx") == "2026-Q1"
|
||||
assert vk.infer_period("dlya-sajta-3кв-2025.docx") == "2025-Q3"
|
||||
|
||||
|
||||
def test_infer_period_none() -> None:
|
||||
assert vk.infer_period("dlya-sajta.docx") is None
|
||||
|
||||
|
||||
# ── DOCX vMerge forward-fill ──────────────────────────────────────────────────
|
||||
|
||||
_W = vk._W
|
||||
|
||||
|
||||
def _tc(text: str = "", vmerge: str | None = None) -> str:
|
||||
"""Строит XML одной ячейки <w:tc> с опциональным vMerge."""
|
||||
tc_pr = ""
|
||||
if vmerge == "restart":
|
||||
tc_pr = '<w:tcPr><w:vMerge w:val="restart"/></w:tcPr>'
|
||||
elif vmerge == "continue":
|
||||
tc_pr = "<w:tcPr><w:vMerge/></w:tcPr>"
|
||||
return f"<w:tc>{tc_pr}<w:p><w:r><w:t>{text}</w:t></w:r></w:p></w:tc>"
|
||||
|
||||
|
||||
def _doc_xml(rows: list[str]) -> bytes:
|
||||
"""Строит минимальный document.xml с одной таблицей из строк-<w:tr>."""
|
||||
trs = "".join(f"<w:tr>{r}</w:tr>" for r in rows)
|
||||
xml = f'<w:document xmlns:w="{_W}"><w:body><w:tbl>{trs}</w:tbl></w:body></w:document>'
|
||||
return xml.encode("utf-8")
|
||||
|
||||
|
||||
def test_docx_vmerge_forward_fill() -> None:
|
||||
"""Ячейка колонки 0 в 'continue'-строках наследует значение из restart-строки."""
|
||||
rows = [
|
||||
_tc("ЗФС, ГСВ", vmerge="restart") + _tc("−168,5") + _tc("примечание"),
|
||||
_tc("", vmerge="continue") + _tc("12,3") + _tc(""),
|
||||
_tc("", vmerge="continue") + _tc("0,0") + _tc(""),
|
||||
]
|
||||
matrix = vk.parse_docx_table_rows(_doc_xml(rows))
|
||||
assert len(matrix) == 3
|
||||
# forward-fill: имя системы протянуто во все три строки блока.
|
||||
assert matrix[0][0] == "ЗФС, ГСВ"
|
||||
assert matrix[1][0] == "ЗФС, ГСВ"
|
||||
assert matrix[2][0] == "ЗФС, ГСВ"
|
||||
# числовая колонка не наследуется.
|
||||
assert matrix[1][1] == "12,3"
|
||||
|
||||
|
||||
def test_docx_empty_no_table() -> None:
|
||||
xml = f'<w:document xmlns:w="{_W}"><w:body/></w:document>'.encode()
|
||||
assert vk.parse_docx_table_rows(xml) == []
|
||||
|
||||
|
||||
def test_extract_water_rows_from_matrix() -> None:
|
||||
"""Из forward-filled матрицы: имя (текст), резерв (первое число, знак сохранён)."""
|
||||
matrix = [
|
||||
["система", "резерв", "примечание"], # заголовок (без числа) — пропуск
|
||||
["ЗФС, ГСВ", "−168,5", "дефицит"],
|
||||
["Головные, ЮАС", "42,0", "запас"],
|
||||
]
|
||||
recs = vk.extract_water_rows(matrix)
|
||||
assert len(recs) == 2
|
||||
assert recs[0]["system_name"] == "ЗФС, ГСВ"
|
||||
assert recs[0]["reserve_thousand_m3_day"] == -168.5
|
||||
assert recs[1]["reserve_thousand_m3_day"] == 42.0
|
||||
|
||||
|
||||
# ── Fake DB session для upsert-тестов (SAVEPOINT + execute) ───────────────────
|
||||
|
||||
|
||||
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_tp_rp_reserves: row-offset (данные с строки 9) на синтетическом xlsx ──
|
||||
|
||||
|
||||
def _build_tp_rp_workbook() -> bytes:
|
||||
"""Строит xlsx с верифицированной раскладкой ТП/РП: данные с СТРОКИ 9."""
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "ТП РП на 30.06.2026"
|
||||
# Строки 1-8 — заголовки/нумерация (шум), должны игнорироваться.
|
||||
for r in range(1, 9):
|
||||
ws.cell(row=r, column=1, value=f"header-{r}")
|
||||
# Строка 9+ — данные. Колонки (1-based): name, РЭС, region, mun, кВ, inst, load, reserve.
|
||||
ws.append(["ТП-721", "Центральный РЭС", "СО", "Екатеринбург", "10", 630.0, 100.0, 530.0])
|
||||
ws.append(["РП-5", "Западный РЭС", "СО", "Екатеринбург", "10", 8.0, 2.0, 6.0])
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_load_tp_rp_row_offset_and_kva() -> None:
|
||||
"""Данные читаются с строки 9; ТП-721 630→0.63 МВА (кВА-санитайз); РП не делится."""
|
||||
db = _FakeSession(scalar_value=True, rowcount=1)
|
||||
result = rr.load_tp_rp_reserves(db, _build_tp_rp_workbook(), "тп рп на 30.06.2026.xlsx")
|
||||
|
||||
assert result["rows"] == 2, result
|
||||
assert result["inserted"] == 2
|
||||
assert result["kva_sanitized"] == 1 # только ТП-721
|
||||
|
||||
# Первый upsert — ТП-721 с санитайзнутой мощностью.
|
||||
tp_params = db.calls[0][1]
|
||||
assert tp_params["name"] == "ТП-721"
|
||||
assert tp_params["installed"] == 0.63 # 630 кВА → 0.63 МВА
|
||||
assert tp_params["reserve"] == 530.0
|
||||
# РП-5 — мощность не тронута (легитимно высокая для РП, но тут 8 < порога всё равно).
|
||||
rp_params = db.calls[1][1]
|
||||
assert rp_params["name"] == "РП-5"
|
||||
assert rp_params["installed"] == 8.0
|
||||
|
||||
|
||||
# ── load_cp_reserves: row-offset (строка 8) + матч по sc_name_norm ─────────────
|
||||
|
||||
|
||||
def _build_cp_workbook() -> bytes:
|
||||
"""Строит xlsx с раскладкой ЦП: данные с СТРОКИ 8.
|
||||
|
||||
Колонки (1-based): A=№, B=ЦП name, C=ПО, D=?, E=municipality, F=voltage,
|
||||
G=installed, H=current, I=reserve.
|
||||
"""
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "ЦП на 30.06.2026"
|
||||
for r in range(1, 8):
|
||||
ws.cell(row=r, column=1, value=f"header-{r}")
|
||||
# Строка 8 — первая строка данных.
|
||||
ws.append([1, "ПС 110/10 Уктус", "ПО-1", "", "Екатеринбург", "110/10", 40.0, 25.0, 15.0])
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_load_cp_reserves_row_offset_and_norm_match() -> None:
|
||||
"""Данные с строки 8; UPDATE по sc_name_norm; matched считает rowcount."""
|
||||
db = _FakeSession(scalar_value=None, rowcount=1) # UPDATE matched 1 row
|
||||
result = rr.load_cp_reserves(db, _build_cp_workbook(), "цп на 30.06.2026.xlsx")
|
||||
|
||||
assert result["rows"] == 1, result
|
||||
assert result["matched"] == 1
|
||||
assert result["unmatched"] == 0
|
||||
|
||||
params = db.calls[0][1]
|
||||
# sc_name_norm совпадает с нормализацией голого имени «Уктус».
|
||||
assert params["name_norm"] == rw.normalize_sc_name("Уктус") == "уктус"
|
||||
assert params["installed"] == 40.0
|
||||
assert params["reserve"] == 15.0
|
||||
assert params["asof"] is not None and params["asof"].isoformat() == "2026-06-30"
|
||||
|
||||
|
||||
def test_load_cp_reserves_unmatched_counted() -> None:
|
||||
"""ЦП без совпадения в power_supply_centers (rowcount=0) → unmatched, не падаем."""
|
||||
db = _FakeSession(scalar_value=None, rowcount=0) # UPDATE matched 0 rows
|
||||
result = rr.load_cp_reserves(db, _build_cp_workbook(), "цп на 30.06.2026.xlsx")
|
||||
assert result["matched"] == 0
|
||||
assert result["unmatched"] == 1
|
||||
143
data/sql/180_connection_capacity.sql
Normal file
143
data/sql/180_connection_capacity.sql
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
-- 180_connection_capacity.sql
|
||||
-- Forgejo #2119 Phase A — «точки подключения СО СВОБОДНОЙ МОЩНОСТЬЮ» для ПТИЦА §3.
|
||||
--
|
||||
-- Три таблицы верифицированных источников свободной мощности (электро + вода):
|
||||
-- power_supply_centers — центры питания (ЦП: ПС 35/110 кВ) из Россети WFS +
|
||||
-- резервы свободной мощности по ЦП (rosseti-ural.ru xlsx).
|
||||
-- power_tp_rp_reserves — резервы ТП/РП напряжением ниже 35 кВ (10k+ строк,
|
||||
-- в основном БЕЗ координат — Phase B геокод).
|
||||
-- water_supply_reserves — резервы ЦСВ/ЦСК Водоканала ЕКБ (docx, city-level).
|
||||
--
|
||||
-- Источники гео-блокируют non-RU IP → загрузчики РАБОТАЮТ НА ПРОДЕ (Celery/manual
|
||||
-- docker exec). Код не должен предполагать локальный сетевой доступ.
|
||||
--
|
||||
-- Refresh: weekly via tasks.connection_capacity_sync (WFS + reserves + water).
|
||||
-- Порядок применения: миграция ПЕРЕД деплоем кода (schema-first, sql.md).
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── power_supply_centers ─────────────────────────────────────────────────────
|
||||
-- ЦП = центр питания (ПС 35/110 кВ). geom из Россети WFS (координаты округлены
|
||||
-- ~100 м — для §3 «есть ли точка рядом» этого достаточно). Резервы свободной
|
||||
-- мощности (installed/current/reserve) допихиваются из xlsx-раскрытия по
|
||||
-- sc_name_norm-матчу. sc_name_norm — нормализованное имя для сопоставления
|
||||
-- (lower, «ПС»/voltage-префикс срезан, ё→е, тире/пробелы схлопнуты).
|
||||
CREATE TABLE IF NOT EXISTS power_supply_centers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
source TEXT NOT NULL, -- 'rosseti_wfs'
|
||||
external_id TEXT, -- feature id или stable hash
|
||||
sc_name TEXT NOT NULL, -- исходное имя ЦП
|
||||
sc_name_norm TEXT NOT NULL, -- нормализованное для матча
|
||||
dzo_name TEXT, -- ДЗО (напр. «Россети Урал»)
|
||||
org_name TEXT,
|
||||
branch_name TEXT, -- филиал (br_name)
|
||||
voltage_class TEXT, -- напр. '110/35/10'
|
||||
load_index TEXT, -- 'open'|'limited'|'closed'|NULL
|
||||
installed_capacity_mva NUMERIC, -- установленная, МВА
|
||||
current_load_mva NUMERIC, -- текущая нагрузка, МВА
|
||||
reserve_mva NUMERIC, -- свободная мощность, МВА
|
||||
reserve_asof DATE, -- дата актуальности резерва
|
||||
capacity_source TEXT, -- 'cp_35_110'|'tp_rp_sub35'|NULL
|
||||
municipality TEXT,
|
||||
place_city TEXT, -- sc_placecity
|
||||
geom GEOMETRY(POINT, 4326),
|
||||
raw JSONB, -- сырая WFS-фича целиком
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
UNIQUE (source, external_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS gist_power_supply_centers_geom
|
||||
ON power_supply_centers USING GIST (geom);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_power_supply_centers_name_norm
|
||||
ON power_supply_centers (sc_name_norm);
|
||||
|
||||
COMMENT ON TABLE power_supply_centers IS
|
||||
'Центры питания (ЦП, ПС 35/110 кВ) Россети + резервы свободной мощности по ЦП. '
|
||||
'Source: Россети WFS (geom) + rosseti-ural.ru xlsx (reserve). '
|
||||
'Refresh: weekly via tasks.connection_capacity_sync. Forgejo #2119 Phase A.';
|
||||
|
||||
COMMENT ON COLUMN power_supply_centers.sc_name_norm IS
|
||||
'Нормализованное имя ЦП для матча с xlsx-резервом (lower, «ПС»/voltage-префикс '
|
||||
'срезан, ё→е, тире/пробелы схлопнуты).';
|
||||
|
||||
COMMENT ON COLUMN power_supply_centers.geom IS
|
||||
'Координаты Россети WFS округлены ~100 м — для §3 «есть ли ЦП рядом» достаточно.';
|
||||
|
||||
-- ── power_tp_rp_reserves ─────────────────────────────────────────────────────
|
||||
-- Резервы ТП/РП напряжением ниже 35 кВ (10 741 строк в верифицированном xlsx).
|
||||
-- В основном БЕЗ координат — geocode_status='pending', гео придёт в Phase B.
|
||||
-- UNIQUE(name_norm, municipality, voltage_kv) — одно и то же имя ТП может
|
||||
-- встречаться в разных МО / на разном напряжении.
|
||||
CREATE TABLE IF NOT EXISTS power_tp_rp_reserves (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL, -- напр. «ТП-721»
|
||||
name_norm TEXT NOT NULL, -- нормализованное для матча
|
||||
res_unit TEXT, -- РЭС
|
||||
municipality TEXT,
|
||||
settlement TEXT,
|
||||
voltage_kv TEXT, -- класс напряжения, кВ
|
||||
installed_capacity_mva NUMERIC, -- установленная, МВА
|
||||
current_load_mva NUMERIC, -- текущая нагрузка, МВА
|
||||
reserve_mva NUMERIC, -- свободная мощность, МВА
|
||||
reserve_asof DATE,
|
||||
geom GEOMETRY(POINT, 4326), -- NULL до Phase B геокода
|
||||
geocode_status TEXT DEFAULT 'pending',
|
||||
raw JSONB,
|
||||
fetched_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
-- NULLS NOT DISTINCT: municipality/voltage_kv бывают NULL (пустые ячейки xlsx);
|
||||
-- без этого ON CONFLICT не матчится и weekly-прогон плодит дубли
|
||||
-- (те же грабли, что 140_domrf_documents_unique_nulls_not_distinct.sql).
|
||||
UNIQUE NULLS NOT DISTINCT (name_norm, municipality, voltage_kv)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS gist_power_tp_rp_reserves_geom
|
||||
ON power_tp_rp_reserves USING GIST (geom);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_power_tp_rp_reserves_name_norm
|
||||
ON power_tp_rp_reserves (name_norm);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_power_tp_rp_reserves_geocode_status
|
||||
ON power_tp_rp_reserves (geocode_status);
|
||||
|
||||
COMMENT ON TABLE power_tp_rp_reserves IS
|
||||
'Резервы свободной мощности ТП/РП <35 кВ (rosseti-ural.ru xlsx, ~10.7k строк). '
|
||||
'geom NULL до Phase B геокода (geocode_status). Forgejo #2119 Phase A.';
|
||||
|
||||
-- ── water_supply_reserves ────────────────────────────────────────────────────
|
||||
-- Резервы ЦСВ (центр. система водоснабжения) / ЦСК (водоотведения) Водоканала
|
||||
-- ЕКБ (docx-раскрытие). Городской уровень (без geom) — фронт показывает ЦСВ
|
||||
-- «ЗФС, ГСВ» как атрибут города. reserve может быть ОТРИЦАТЕЛЬНЫМ (дефицит) —
|
||||
-- знак сохраняем. UNIQUE(system_kind, system_name, period).
|
||||
CREATE TABLE IF NOT EXISTS water_supply_reserves (
|
||||
id SERIAL PRIMARY KEY,
|
||||
system_kind TEXT NOT NULL, -- 'water'|'sewerage'
|
||||
system_name TEXT NOT NULL, -- ЦСВ/ЦСК, напр. «ЗФС, ГСВ»
|
||||
reserve_thousand_m3_day NUMERIC, -- резерв, тыс. м³/сут (может быть <0)
|
||||
connections_requested INT, -- заявок на подключение
|
||||
connections_refused INT, -- отказов
|
||||
note TEXT,
|
||||
period TEXT, -- напр. '2026-Q1'
|
||||
source_url TEXT,
|
||||
fetched_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
-- NULLS NOT DISTINCT: period может быть NULL при неудачном infer_period.
|
||||
UNIQUE NULLS NOT DISTINCT (system_kind, system_name, period)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_water_supply_reserves_period
|
||||
ON water_supply_reserves (period);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_water_supply_reserves_kind
|
||||
ON water_supply_reserves (system_kind);
|
||||
|
||||
COMMENT ON TABLE water_supply_reserves IS
|
||||
'Резервы ЦСВ/ЦСК Водоканала ЕКБ (docx-раскрытие, city-level, без geom). '
|
||||
'reserve_thousand_m3_day может быть отрицательным (дефицит). Forgejo #2119 Phase A.';
|
||||
|
||||
COMMENT ON COLUMN water_supply_reserves.reserve_thousand_m3_day IS
|
||||
'Свободная мощность ЦСВ/ЦСК, тыс. м³/сут. Отрицательное значение = дефицит.';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* PowerConnectionPointsLayer — центры питания (ЦП, ПС 35/110 кВ) Россетей со
|
||||
* свободной мощностью для ТП (#connection-capacity). Отдельный слой поверх карты
|
||||
* §3 «Сети»: CircleMarker per power_point, цвет по индексу загрузки (load_index),
|
||||
* чтобы визуально отличаться от оранжевых точек НСПД / инж.сетей OSM.
|
||||
*
|
||||
* Источник — обязательное раскрытие сетевых организаций (Россети), а НЕ OSM:
|
||||
* это разные слои. Координаты ЦП округлены источником (~±100 м) — предупреждаем
|
||||
* об этом в попапе.
|
||||
*
|
||||
* Цвета/подписи/приведение чисел — из общего модуля connection-capacity.ts
|
||||
* (идентичны карточке «Ресурсные резервы»). Inline token-hex в popup —
|
||||
* документированное исключение raw-Leaflet попапов (ui-tokens.md).
|
||||
*/
|
||||
|
||||
import { CircleMarker, LayerGroup, Popup } from "react-leaflet";
|
||||
|
||||
import type { PowerConnectionPoint } from "@/hooks/useConnectionCapacity";
|
||||
import {
|
||||
LOAD_INDEX_BADGE,
|
||||
classifyLoadIndex,
|
||||
formatMeters,
|
||||
formatMva,
|
||||
loadIndexColor,
|
||||
powerPointReserveIsNegative,
|
||||
toFiniteNumber,
|
||||
} from "@/components/site-finder/connection-capacity";
|
||||
|
||||
// ── Point coordinate guard ─────────────────────────────────────────────────────
|
||||
// lat/lon могут прийти строкой (Decimal→str) — приводим через toFiniteNumber, а
|
||||
// не typeof-гвардом (иначе валидная точка молча не рисуется).
|
||||
function pointLatLon(p: PowerConnectionPoint): [number, number] | null {
|
||||
const lat = toFiniteNumber(p.lat);
|
||||
const lon = toFiniteNumber(p.lon);
|
||||
if (lat === null || lon === null) return null;
|
||||
return [lat, lon];
|
||||
}
|
||||
|
||||
// ── Popup ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function PowerPopup({ point }: { point: PowerConnectionPoint }) {
|
||||
const idx = classifyLoadIndex(point.load_index);
|
||||
const badge = idx ? LOAD_INDEX_BADGE[idx] : null;
|
||||
const color = loadIndexColor(point.load_index);
|
||||
|
||||
const voltage = point.voltage_class?.trim() || null;
|
||||
const installed = formatMva(point.installed_capacity_mva);
|
||||
const load = formatMva(point.current_load_mva);
|
||||
const reserve = formatMva(point.reserve_mva);
|
||||
const reserveNegative = powerPointReserveIsNegative(point);
|
||||
const distance = formatMeters(point.distance_m);
|
||||
|
||||
return (
|
||||
<Popup>
|
||||
<div style={{ fontSize: 12, lineHeight: 1.55, minWidth: 200 }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
marginBottom: 4,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: "50%",
|
||||
background: color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
Центр питания (Россети)
|
||||
</div>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<strong>{point.name}</strong>
|
||||
</div>
|
||||
{voltage && (
|
||||
<div style={{ color: "#374151", marginBottom: 2 }}>
|
||||
Напряжение: {voltage} кВ
|
||||
</div>
|
||||
)}
|
||||
{installed && (
|
||||
<div style={{ color: "#374151", marginBottom: 2 }}>
|
||||
Установленная мощность: {installed}
|
||||
</div>
|
||||
)}
|
||||
{load && (
|
||||
<div style={{ color: "#374151", marginBottom: 2 }}>
|
||||
Загрузка: {load}
|
||||
</div>
|
||||
)}
|
||||
{reserve && (
|
||||
<div
|
||||
style={{
|
||||
color: reserveNegative ? "#b91c1c" : "#374151",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
Резерв для ТП:{" "}
|
||||
<strong style={{ color: reserveNegative ? "#b91c1c" : "#111827" }}>
|
||||
{reserve}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
{badge && (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "1px 8px",
|
||||
borderRadius: 6,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: badge.fg,
|
||||
background: badge.bg,
|
||||
border: `1px solid ${badge.border}`,
|
||||
}}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
)}
|
||||
{distance && (
|
||||
<div style={{ marginTop: 4, color: "#374151" }}>
|
||||
До участка: {distance}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
marginTop: 4,
|
||||
fontSize: 11,
|
||||
color: "#9ca3af",
|
||||
borderTop: "1px solid #f3f4f6",
|
||||
paddingTop: 4,
|
||||
}}
|
||||
>
|
||||
Россети{point.reserve_asof ? ` · срез ${point.reserve_asof}` : ""}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "#9ca3af" }}>
|
||||
Координаты ЦП ~±100 м (округление источника)
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Layer ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
points: PowerConnectionPoint[];
|
||||
}
|
||||
|
||||
export function PowerConnectionPointsLayer({ points }: Props) {
|
||||
return (
|
||||
<LayerGroup>
|
||||
{points.map((point, idx) => {
|
||||
const latLon = pointLatLon(point);
|
||||
if (!latLon) return null;
|
||||
const color = loadIndexColor(point.load_index);
|
||||
return (
|
||||
<CircleMarker
|
||||
key={`power-cp-${point.name}-${idx}`}
|
||||
center={latLon}
|
||||
radius={9}
|
||||
pathOptions={{
|
||||
color: "#ffffff",
|
||||
weight: 2,
|
||||
fillColor: color,
|
||||
fillOpacity: 0.85,
|
||||
}}
|
||||
>
|
||||
<PowerPopup point={point} />
|
||||
</CircleMarker>
|
||||
);
|
||||
})}
|
||||
</LayerGroup>
|
||||
);
|
||||
}
|
||||
|
||||
// Число рисуемых точек (с валидными координатами) — для легенды/счётчика.
|
||||
export function countMappablePowerPoints(
|
||||
points: PowerConnectionPoint[] | undefined,
|
||||
): number {
|
||||
if (!points) return 0;
|
||||
return points.filter((p) => pointLatLon(p) !== null).length;
|
||||
}
|
||||
|
|
@ -52,6 +52,15 @@ import {
|
|||
type UtilityMappable,
|
||||
} from "@/components/site-finder/UtilityInfrastructureLayer";
|
||||
import { UtilityLayerControlPanel } from "@/components/site-finder/UtilityLayerControlPanel";
|
||||
import {
|
||||
PowerConnectionPointsLayer,
|
||||
countMappablePowerPoints,
|
||||
} from "@/components/site-finder/PowerConnectionPointsLayer";
|
||||
import {
|
||||
LOAD_INDEX_MARKER_COLOR,
|
||||
LOAD_INDEX_NULL_COLOR,
|
||||
} from "@/components/site-finder/connection-capacity";
|
||||
import type { PowerConnectionPoint } from "@/hooks/useConnectionCapacity";
|
||||
import { CustomPoiLayer } from "@/components/site-finder/CustomPoiLayer";
|
||||
import { CustomPoiToggleButton } from "@/components/site-finder/CustomPoiToggleButton";
|
||||
import { CustomPoiAddModal } from "@/components/site-finder/CustomPoiAddModal";
|
||||
|
|
@ -143,6 +152,9 @@ interface Props {
|
|||
connectionPoints?: ConnectionPointsResponse;
|
||||
// #1746 — инженерная инфраструктура из OSM (доп. слои на той же карте).
|
||||
utilityInfrastructure?: UtilityInfrastructureResponse;
|
||||
// Центры питания Россетей со свободной мощностью для ТП (#connection-capacity).
|
||||
// Отдельный слой (не OSM): цвет по индексу загрузки.
|
||||
powerConnectionPoints?: PowerConnectionPoint[];
|
||||
customPois?: CustomPoi[];
|
||||
parcelCad?: string;
|
||||
// #999 (958-B4) — рыночные слои (опциональны: SiteMap используется и без них).
|
||||
|
|
@ -188,6 +200,7 @@ export function SiteMap({
|
|||
isochrones,
|
||||
connectionPoints,
|
||||
utilityInfrastructure,
|
||||
powerConnectionPoints,
|
||||
customPois,
|
||||
parcelCad,
|
||||
competitors,
|
||||
|
|
@ -397,6 +410,12 @@ export function SiteMap({
|
|||
);
|
||||
const hasUtilityGeom = utilityMappableCount > 0;
|
||||
|
||||
// Центры питания Россетей со свободной мощностью (#connection-capacity).
|
||||
// Рисуем только точки с валидными координатами (Decimal→str приводим в слое).
|
||||
const powerPointList = powerConnectionPoints ?? [];
|
||||
const powerPointMappable = countMappablePowerPoints(powerPointList);
|
||||
const hasPowerPoints = powerPointMappable > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Map */}
|
||||
|
|
@ -618,6 +637,13 @@ export function SiteMap({
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Центры питания Россетей со свободной мощностью для ТП
|
||||
(#connection-capacity). Отдельный слой поверх OSM: цвет по индексу
|
||||
загрузки, отличается от оранжевых точек НСПД/инж.сетей OSM. */}
|
||||
{hasPowerPoints && (
|
||||
<PowerConnectionPointsLayer points={powerPointList} />
|
||||
)}
|
||||
|
||||
{/* Custom POI markers — topmost layer */}
|
||||
{customPois && customPois.length > 0 && (
|
||||
<CustomPoiLayer
|
||||
|
|
@ -729,6 +755,66 @@ export function SiteMap({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Легенда слоя центров питания (#connection-capacity). Слой не тумблерный
|
||||
(небольшое число точек), поэтому подпись рендерим в легенде под картой:
|
||||
цвет маркера кодирует индекс загрузки ЦП. */}
|
||||
{hasPowerPoints && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
gap: "6px 12px",
|
||||
padding: "10px 4px 0",
|
||||
fontSize: 12,
|
||||
color: "#374151",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600 }}>
|
||||
Центры питания (резерв): {powerPointMappable}
|
||||
</span>
|
||||
{(
|
||||
[
|
||||
["open", "открыт для ТП"],
|
||||
["limited", "ограничен"],
|
||||
["closed", "закрыт"],
|
||||
] as const
|
||||
).map(([idx, label]) => (
|
||||
<span
|
||||
key={idx}
|
||||
style={{ display: "flex", alignItems: "center", gap: 5 }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: "50%",
|
||||
background: LOAD_INDEX_MARKER_COLOR[idx],
|
||||
border: "2px solid #ffffff",
|
||||
boxShadow: "0 0 0 1px #d1d5db",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 5 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: "50%",
|
||||
background: LOAD_INDEX_NULL_COLOR,
|
||||
border: "2px solid #ffffff",
|
||||
boxShadow: "0 0 0 1px #d1d5db",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
нет данных
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!data.geom_geojson && (
|
||||
<p
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,355 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* ResourceReservesCard — «Ресурсные резервы» для §3 «Сети» (#connection-capacity).
|
||||
*
|
||||
* Показывает свободную мощность для технологического присоединения (ТП) из
|
||||
* ОБЯЗАТЕЛЬНОГО раскрытия сетевых организаций:
|
||||
* • Электричество — ближайший центр питания (ЦП, ПС 35/110 кВ) Россетей со
|
||||
* свободной мощностью + бейдж статуса по индексу загрузки (open/limited/closed).
|
||||
* • Вода/канализация — резервы ЦСВ/ЦСК Водоканала: городская система отдельной
|
||||
* строкой (ОТРИЦАТЕЛЬНЫЙ резерв = дефицит, красный бейдж), посёлковые —
|
||||
* свёрнуты в details.
|
||||
*
|
||||
* Числа приводятся через toFiniteNumber (Pydantic Decimal→str). Пока лоадеры
|
||||
* бэка не отработали, показываем нейтральный «загружаются», а не «нет данных».
|
||||
*/
|
||||
|
||||
import { Info, Zap, Droplets } from "lucide-react";
|
||||
|
||||
import type {
|
||||
ConnectionCapacityResponse,
|
||||
WaterCapacityRow,
|
||||
} from "@/hooks/useConnectionCapacity";
|
||||
import { CONNECTION_CAPACITY_RADIUS_M } from "@/hooks/useConnectionCapacity";
|
||||
import {
|
||||
LOAD_INDEX_BADGE,
|
||||
classifyLoadIndex,
|
||||
formatMeters,
|
||||
formatMva,
|
||||
formatThousandM3Day,
|
||||
humanizeLoadIndex,
|
||||
pickCitySystem,
|
||||
pluralizeSystems,
|
||||
toFiniteNumber,
|
||||
} from "@/components/site-finder/connection-capacity";
|
||||
|
||||
const RADIUS_KM = CONNECTION_CAPACITY_RADIUS_M / 1000;
|
||||
|
||||
// Semantic-бейдж (резерв/дефицит) — token-палитра карточек (не карта).
|
||||
const RESERVE_BADGE = {
|
||||
reserve: {
|
||||
label: "резерв",
|
||||
bg: "var(--success-soft)",
|
||||
fg: "var(--success)",
|
||||
},
|
||||
deficit: {
|
||||
label: "дефицит",
|
||||
bg: "var(--danger-soft)",
|
||||
fg: "var(--danger)",
|
||||
},
|
||||
} as const;
|
||||
|
||||
function StatusBadge({
|
||||
label,
|
||||
bg,
|
||||
fg,
|
||||
border,
|
||||
}: {
|
||||
label: string;
|
||||
bg: string;
|
||||
fg: string;
|
||||
border?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "1px 8px",
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: fg,
|
||||
background: bg,
|
||||
border: border ? `1px solid ${border}` : undefined,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Электричество ──────────────────────────────────────────────────────────────
|
||||
|
||||
function PowerRow({ data }: { data: ConnectionCapacityResponse }) {
|
||||
const nearest = data.power_summary.nearest_with_reserve;
|
||||
|
||||
if (!nearest) {
|
||||
return (
|
||||
<div style={{ fontSize: 13, color: "var(--fg-secondary)" }}>
|
||||
Данные о резервах ПС загружаются.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const idx = classifyLoadIndex(nearest.load_index);
|
||||
const badge = idx ? LOAD_INDEX_BADGE[idx] : null;
|
||||
const humanIdx = humanizeLoadIndex(nearest.load_index);
|
||||
|
||||
const voltage = nearest.voltage_class?.trim() || null;
|
||||
const reserve = formatMva(nearest.reserve_mva);
|
||||
const distance = formatMeters(nearest.distance_m);
|
||||
|
||||
const parts: string[] = [];
|
||||
if (voltage) parts.push(`${voltage} кВ`);
|
||||
if (reserve) parts.push(`резерв ${reserve}`);
|
||||
if (humanIdx) parts.push(humanIdx);
|
||||
if (distance) parts.push(distance);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, color: "var(--fg-primary)" }}>
|
||||
Ближайший центр питания: <strong>{nearest.name}</strong>
|
||||
{parts.length > 0 ? ` · ${parts.join(" · ")}` : ""}
|
||||
</span>
|
||||
{badge && (
|
||||
<StatusBadge
|
||||
label={badge.label}
|
||||
bg={badge.bg}
|
||||
fg={badge.fg}
|
||||
border={badge.border}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Вода / канализация ─────────────────────────────────────────────────────────
|
||||
|
||||
function WaterSystemLine({
|
||||
label,
|
||||
row,
|
||||
}: {
|
||||
label: string;
|
||||
row: WaterCapacityRow;
|
||||
}) {
|
||||
const reserveNum = toFiniteNumber(row.reserve_thousand_m3_day);
|
||||
const reserveStr = formatThousandM3Day(row.reserve_thousand_m3_day);
|
||||
const isDeficit = reserveNum !== null && reserveNum < 0;
|
||||
const badge = isDeficit ? RESERVE_BADGE.deficit : RESERVE_BADGE.reserve;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, color: "var(--fg-primary)" }}>
|
||||
{label}:{" "}
|
||||
{reserveStr ? (
|
||||
<strong
|
||||
style={{
|
||||
color: isDeficit ? "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>
|
||||
{row.note && (
|
||||
<span style={{ fontSize: 12, color: "var(--fg-secondary)" }}>
|
||||
{row.note}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WaterBlock({ rows }: { rows: WaterCapacityRow[] }) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div style={{ fontSize: 13, color: "var(--fg-secondary)" }}>
|
||||
Данные о резервах водоснабжения загружаются.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Разделяем водоснабжение (ЦСВ, system_kind="water") и водоотведение
|
||||
// (ЦСК, system_kind="sewerage") — значения из бэка (vodokanal_reserve_loader).
|
||||
const supply = rows.filter((r) => r.system_kind === "water");
|
||||
const sewage = rows.filter((r) => r.system_kind === "sewerage");
|
||||
const other = rows.filter(
|
||||
(r) => r.system_kind !== "water" && r.system_kind !== "sewerage",
|
||||
);
|
||||
|
||||
const citySupply = pickCitySystem(supply);
|
||||
const citySewage = pickCitySystem(sewage);
|
||||
|
||||
// Остальные (посёлковые) системы — свернуть в details. Городские строки уже
|
||||
// показаны отдельно, поэтому исключаем их из «прочих».
|
||||
const rest = [
|
||||
...(citySupply ? supply.filter((r) => r !== citySupply) : supply),
|
||||
...(citySewage ? sewage.filter((r) => r !== citySewage) : sewage),
|
||||
...other,
|
||||
];
|
||||
|
||||
// Источник-подпись: период из данных (первый ненулевой).
|
||||
const period = rows.find((r) => r.period)?.period?.trim() || null;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{citySupply && (
|
||||
<WaterSystemLine label="Водоснабжение (город)" row={citySupply} />
|
||||
)}
|
||||
{citySewage && (
|
||||
<WaterSystemLine label="Водоотведение (город)" row={citySewage} />
|
||||
)}
|
||||
|
||||
{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((r, i) => (
|
||||
<WaterSystemLine
|
||||
key={`${r.system_kind}-${r.system_name}-${i}`}
|
||||
label={r.system_name || "Система"}
|
||||
row={r}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<span style={{ fontSize: 12, color: "var(--fg-tertiary)" }}>
|
||||
Раскрытие МУП «Водоканал»{period ? ` · ${period}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Card ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
const SUB_HEADER: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.04em",
|
||||
color: "var(--fg-tertiary)",
|
||||
marginBottom: 6,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
data: ConnectionCapacityResponse;
|
||||
}
|
||||
|
||||
export function ResourceReservesCard({ data }: Props) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid var(--border-card)",
|
||||
borderRadius: 12,
|
||||
background: "var(--bg-card)",
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: "var(--fg-primary)",
|
||||
}}
|
||||
>
|
||||
Ресурсные резервы для подключения (ТП)
|
||||
</h3>
|
||||
|
||||
{/* Электричество */}
|
||||
<div>
|
||||
<div style={SUB_HEADER}>
|
||||
<Zap size={14} strokeWidth={1.5} aria-hidden="true" />
|
||||
Электричество
|
||||
</div>
|
||||
<PowerRow data={data} />
|
||||
</div>
|
||||
|
||||
{/* Вода / канализация */}
|
||||
<div>
|
||||
<div style={SUB_HEADER}>
|
||||
<Droplets size={14} strokeWidth={1.5} aria-hidden="true" />
|
||||
Вода и водоотведение
|
||||
</div>
|
||||
<WaterBlock rows={data.water} />
|
||||
</div>
|
||||
|
||||
{/* Caveat */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
color: "var(--fg-tertiary)",
|
||||
borderTop: "1px solid var(--border-soft)",
|
||||
paddingTop: 12,
|
||||
}}
|
||||
>
|
||||
<Info
|
||||
size={13}
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
style={{ flexShrink: 0, marginTop: 1 }}
|
||||
/>
|
||||
<span>
|
||||
Резервы — из обязательного раскрытия сетевых организаций в радиусе{" "}
|
||||
{RADIUS_KM} км; для ТП требуется запрос технических условий (ТУ).
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -43,7 +43,12 @@ import {
|
|||
UTILITY_COVERAGE_RADIUS_M,
|
||||
type UtilityInfrastructureResponse,
|
||||
} from "@/hooks/useUtilityInfrastructure";
|
||||
import {
|
||||
useConnectionCapacity,
|
||||
type ConnectionCapacityResponse,
|
||||
} from "@/hooks/useConnectionCapacity";
|
||||
import { buildUtilityTableRows, type UtilityTableRow } from "./utility-table";
|
||||
import { ResourceReservesCard } from "./ResourceReservesCard";
|
||||
import { StageDetails } from "./StageDetails";
|
||||
|
||||
// ── Connection-points map (#1746) ─────────────────────────────────────────────
|
||||
|
|
@ -557,6 +562,7 @@ interface ConnectionPointsMapProps {
|
|||
data: ParcelAnalyzeResponse;
|
||||
connectionPoints: ConnectionPointsResponse | undefined;
|
||||
utilityInfrastructure: UtilityInfrastructureResponse | undefined;
|
||||
connectionCapacity: ConnectionCapacityResponse | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -564,6 +570,7 @@ function ConnectionPointsMap({
|
|||
data,
|
||||
connectionPoints,
|
||||
utilityInfrastructure,
|
||||
connectionCapacity,
|
||||
isLoading,
|
||||
}: ConnectionPointsMapProps) {
|
||||
const hasPoints =
|
||||
|
|
@ -572,7 +579,9 @@ function ConnectionPointsMap({
|
|||
connectionPoints.engineering_structures.length > 0;
|
||||
const hasUtility =
|
||||
!!utilityInfrastructure && utilityInfrastructure.features.length > 0;
|
||||
const hasAnyLayer = hasPoints || hasUtility;
|
||||
const powerPoints = connectionCapacity?.power_points ?? [];
|
||||
const hasPowerPoints = powerPoints.length > 0;
|
||||
const hasAnyLayer = hasPoints || hasUtility || hasPowerPoints;
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
|
|
@ -638,6 +647,7 @@ function ConnectionPointsMap({
|
|||
data={toCpMapData(data)}
|
||||
connectionPoints={hasPoints ? connectionPoints : undefined}
|
||||
utilityInfrastructure={hasUtility ? utilityInfrastructure : undefined}
|
||||
powerConnectionPoints={hasPowerPoints ? powerPoints : undefined}
|
||||
mapHeight={CP_MAP_HEIGHT}
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -680,6 +690,9 @@ export function Section2NetworksUtilities({ cad }: Props) {
|
|||
const { data: connectionPoints, isLoading: cpLoading } = useConnectionPoints(
|
||||
data?.cad_num,
|
||||
);
|
||||
// Ресурсные резервы для ТП (#connection-capacity): ЦП Россетей + Водоканал.
|
||||
// Keyed по cad → TanStack дедуплицирует; данные идут и в карточку, и в слой карты.
|
||||
const { data: connectionCapacity } = useConnectionCapacity(data?.cad_num);
|
||||
|
||||
if (isLoading) return <Section2Skeleton />;
|
||||
|
||||
|
|
@ -803,6 +816,12 @@ export function Section2NetworksUtilities({ cad }: Props) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Карточка «Ресурсные резервы» — свободная мощность для ТП из раскрытия
|
||||
сетевых организаций (ЦП Россетей + Водоканал). Над таблицей сетей. */}
|
||||
{connectionCapacity && (
|
||||
<ResourceReservesCard data={connectionCapacity} />
|
||||
)}
|
||||
|
||||
{/* Счётчики источников — явно разделяем «инж.сети OSM» и «точки
|
||||
подключения НСПД», чтобы числа не путались (#1953). */}
|
||||
{!networksLoading && (
|
||||
|
|
@ -858,6 +877,7 @@ export function Section2NetworksUtilities({ cad }: Props) {
|
|||
data={data}
|
||||
connectionPoints={connectionPoints}
|
||||
utilityInfrastructure={utilityInfrastructure}
|
||||
connectionCapacity={connectionCapacity}
|
||||
isLoading={networksLoading}
|
||||
/>
|
||||
|
||||
|
|
|
|||
170
frontend/src/components/site-finder/connection-capacity.ts
Normal file
170
frontend/src/components/site-finder/connection-capacity.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* connection-capacity — общие хелперы для §3 «Сети»: ресурсные резервы (ЦП
|
||||
* Россетей + Водоканал). Используются и карточкой «Ресурсные резервы»
|
||||
* (Section2NetworksUtilities), и слоем центров питания на карте
|
||||
* (PowerConnectionPointsLayer) — чтобы цвета/подписи/приведение чисел были
|
||||
* идентичны в обоих местах.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PowerConnectionPoint,
|
||||
WaterCapacityRow,
|
||||
} from "@/hooks/useConnectionCapacity";
|
||||
|
||||
// ── Coercion (Pydantic Decimal → строка) ──────────────────────────────────────
|
||||
//
|
||||
// NUMERIC-поля БД сериализуются Pydantic v2 как СТРОКИ («12.5»), хотя OpenAPI-тип
|
||||
// объявлен number | null. `typeof === "number"`-гварды молча роняют такое поле
|
||||
// (см. урок MarketLayers.toFiniteNumber). Приводим руками. Локальная копия по
|
||||
// образцу MarketLayers — намеренно, чтобы не тянуть map-модуль в неграфические
|
||||
// места.
|
||||
export function toFiniteNumber(
|
||||
v: number | string | null | undefined,
|
||||
): number | null {
|
||||
if (v === null || v === undefined) return null;
|
||||
const n = typeof v === "string" ? Number(v) : v;
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
// ── Индекс загрузки центра питания (load_index) ────────────────────────────────
|
||||
|
||||
export type LoadIndex = "open" | "limited" | "closed";
|
||||
|
||||
export function classifyLoadIndex(
|
||||
raw: string | null | undefined,
|
||||
): LoadIndex | null {
|
||||
switch (raw) {
|
||||
case "open":
|
||||
case "limited":
|
||||
case "closed":
|
||||
return raw;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Бейдж статуса ТП по индексу загрузки. Цвета — semantic-палитра карточек §3
|
||||
// (совпадает с SEVERITY_COLOR в Pipeline24moBlock; для карты — документированное
|
||||
// token-hex исключение popup'ов).
|
||||
export const LOAD_INDEX_BADGE: Record<
|
||||
LoadIndex,
|
||||
{ label: string; bg: string; fg: string; border: string }
|
||||
> = {
|
||||
open: {
|
||||
label: "открыт для ТП",
|
||||
bg: "#dcfce7",
|
||||
fg: "#15803d",
|
||||
border: "#86efac",
|
||||
},
|
||||
limited: {
|
||||
label: "ограничен",
|
||||
bg: "#fef9c3",
|
||||
fg: "#a16207",
|
||||
border: "#fde68a",
|
||||
},
|
||||
closed: {
|
||||
label: "закрыт",
|
||||
bg: "#fee2e2",
|
||||
fg: "#b91c1c",
|
||||
border: "#fca5a5",
|
||||
},
|
||||
};
|
||||
|
||||
// Цвет маркера ЦП на карте по индексу загрузки (null → нейтральный серый).
|
||||
export const LOAD_INDEX_MARKER_COLOR: Record<LoadIndex, string> = {
|
||||
open: "#15803d",
|
||||
limited: "#a16207",
|
||||
closed: "#b91c1c",
|
||||
};
|
||||
|
||||
export const LOAD_INDEX_NULL_COLOR = "#6b7280";
|
||||
|
||||
export function loadIndexColor(raw: string | null | undefined): string {
|
||||
const idx = classifyLoadIndex(raw);
|
||||
return idx ? LOAD_INDEX_MARKER_COLOR[idx] : LOAD_INDEX_NULL_COLOR;
|
||||
}
|
||||
|
||||
export function humanizeLoadIndex(
|
||||
raw: string | null | undefined,
|
||||
): string | null {
|
||||
const idx = classifyLoadIndex(raw);
|
||||
return idx ? LOAD_INDEX_BADGE[idx].label : null;
|
||||
}
|
||||
|
||||
// ── Форматирование чисел ───────────────────────────────────────────────────────
|
||||
|
||||
// МВА с одним знаком после запятой (резерв/мощность ЦП). Приводит строку→число.
|
||||
export function formatMva(
|
||||
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,
|
||||
})} МВА`;
|
||||
}
|
||||
|
||||
// тыс. м³/сут (резерв воды/канализации). Может быть ОТРИЦАТЕЛЬНЫМ (дефицит) —
|
||||
// знак сохраняем (Unicode-минус даёт toLocaleString).
|
||||
export function formatThousandM3Day(
|
||||
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,
|
||||
})} тыс. м³/сут`;
|
||||
}
|
||||
|
||||
export function formatMeters(
|
||||
v: number | string | null | undefined,
|
||||
): string | null {
|
||||
const n = toFiniteNumber(v);
|
||||
if (n === null) return null;
|
||||
return `${Math.round(n).toLocaleString("ru-RU")} м`;
|
||||
}
|
||||
|
||||
// ── Вода: выбор «городской» системы + группировка ──────────────────────────────
|
||||
//
|
||||
// Городская система ЦСВ/ЦСК выделяется в карточке отдельной строкой: сначала по
|
||||
// имени (содержит «ЗФС» — Западная фильтровальная станция ЕКБ), иначе — самая
|
||||
// крупная по |резерв| (крупнейшая система города). Остальные сворачиваются.
|
||||
|
||||
export function pickCitySystem(
|
||||
rows: WaterCapacityRow[],
|
||||
): WaterCapacityRow | null {
|
||||
if (rows.length === 0) return null;
|
||||
const byName = rows.find((r) => (r.system_name ?? "").includes("ЗФС"));
|
||||
if (byName) return byName;
|
||||
// Самая крупная по модулю резерва (city-scale система).
|
||||
let best: WaterCapacityRow | null = null;
|
||||
let bestAbs = -Infinity;
|
||||
for (const r of rows) {
|
||||
const n = toFiniteNumber(r.reserve_thousand_m3_day);
|
||||
const abs = n === null ? -Infinity : Math.abs(n);
|
||||
if (abs > bestAbs) {
|
||||
bestAbs = abs;
|
||||
best = r;
|
||||
}
|
||||
}
|
||||
return best ?? rows[0];
|
||||
}
|
||||
|
||||
// Множественное число «система» для «+N посёлковых систем».
|
||||
export function pluralizeSystems(n: number): string {
|
||||
const mod10 = n % 10;
|
||||
const mod100 = n % 100;
|
||||
if (mod10 === 1 && mod100 !== 11) return `${n} система`;
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20))
|
||||
return `${n} системы`;
|
||||
return `${n} систем`;
|
||||
}
|
||||
|
||||
// Ближайший ЦП с резервом → строка-summary для карточки. Порядок нужен, чтобы
|
||||
// НЕ раскрывать null-поля муторным «нет данных».
|
||||
export function powerPointReserveIsNegative(p: PowerConnectionPoint): boolean {
|
||||
const n = toFiniteNumber(p.reserve_mva);
|
||||
return n !== null && n <= 0;
|
||||
}
|
||||
47
frontend/src/hooks/useConnectionCapacity.ts
Normal file
47
frontend/src/hooks/useConnectionCapacity.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { components } from "@/lib/api-types";
|
||||
|
||||
// Reuse generated OpenAPI types (api-types.ts) — НЕ дублировать руками.
|
||||
export type ConnectionCapacityResponse =
|
||||
components["schemas"]["ConnectionCapacityResponse"];
|
||||
export type PowerConnectionPoint =
|
||||
components["schemas"]["PowerConnectionPoint"];
|
||||
export type PowerCapacitySummary =
|
||||
components["schemas"]["PowerCapacitySummary"];
|
||||
export type WaterCapacityRow = components["schemas"]["WaterCapacityRow"];
|
||||
|
||||
/**
|
||||
* Радиус поиска центров питания (ЦП, ПС 35/110 кВ) вокруг участка. Дефолт 3 км —
|
||||
* согласован с бэкендом (GET /connection-capacity?radius_m=…). Держим отдельной
|
||||
* константой, чтобы подпись «в радиусе N км» и запрос не расходились.
|
||||
*/
|
||||
export const CONNECTION_CAPACITY_RADIUS_M = 3000;
|
||||
|
||||
/**
|
||||
* Ресурсные резервы для ТП (#connection-capacity): ближайшие центры питания
|
||||
* Россетей со свободной мощностью (power_points/power_summary) + резервы ЦСВ/ЦСК
|
||||
* Водоканала (water). Источник — обязательное раскрытие сетевых организаций.
|
||||
*
|
||||
* Зеркалит useUtilityInfrastructure/useConnectionPoints: apiFetch, queryKey-массив,
|
||||
* staleTime 5 мин, enabled по cadNum. Сервер — единственный источник истины по
|
||||
* форме ответа (см. ConnectionCapacityResponse в api-types.ts).
|
||||
*/
|
||||
export function useConnectionCapacity(
|
||||
cadNum: string | null | undefined,
|
||||
radiusM: number = CONNECTION_CAPACITY_RADIUS_M,
|
||||
enabled = true,
|
||||
) {
|
||||
return useQuery<ConnectionCapacityResponse>({
|
||||
queryKey: ["connection-capacity", cadNum, radiusM],
|
||||
queryFn: () =>
|
||||
apiFetch<ConnectionCapacityResponse>(
|
||||
`/api/v1/parcels/${encodeURIComponent(cadNum!)}/connection-capacity?radius_m=${radiusM}`,
|
||||
),
|
||||
enabled: !!cadNum && enabled,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
|
@ -437,6 +437,38 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/parcels/{cad_num}/connection-capacity": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Точки подключения со свободной мощностью — электро + вода (Forgejo #2119)
|
||||
* @description Центры питания со свободной мощностью + резервы воды вблизи участка (ПТИЦА §3).
|
||||
*
|
||||
* Верифицированные open-data источники (наполняются weekly-sync, RUN-ON-PROD):
|
||||
* - power_supply_centers: ЦП (ПС 35/110 кВ) Россети WFS + резервы xlsx.
|
||||
* - water_supply_reserves: ЦСВ/ЦСК Водоканала ЕКБ (city-level, последний период).
|
||||
*
|
||||
* power_points — ЦП в радиусе от центроида участка (ST_DWithin geography).
|
||||
* water — все строки за ПОСЛЕДНИЙ период (город-уровень, показывается целиком).
|
||||
*
|
||||
* Query params:
|
||||
* radius_m: радиус поиска ЦП, 100–10000 м (default 3000).
|
||||
*
|
||||
* Если участок не найден в БД (нет geom) → 404.
|
||||
*/
|
||||
get: operations["get_parcel_connection_capacity_api_v1_parcels__cad_num__connection_capacity_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/parcels/{cad_num}/isochrones": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -3417,6 +3449,14 @@ export interface components {
|
|||
*/
|
||||
requested_count?: number | null;
|
||||
};
|
||||
/** ConnectionCapacityResponse */
|
||||
ConnectionCapacityResponse: {
|
||||
/** Power Points */
|
||||
power_points: components["schemas"]["PowerConnectionPoint"][];
|
||||
power_summary: components["schemas"]["PowerCapacitySummary"];
|
||||
/** Water */
|
||||
water: components["schemas"]["WaterCapacityRow"][];
|
||||
};
|
||||
/** ConnectionPointsResponse */
|
||||
ConnectionPointsResponse: {
|
||||
/** Engineering Structures */
|
||||
|
|
@ -4631,6 +4671,48 @@ export interface components {
|
|||
/** Top Poi */
|
||||
top_poi: components["schemas"]["PoiScoreItem"][];
|
||||
};
|
||||
/**
|
||||
* PowerCapacitySummary
|
||||
* @description Сводка по электрике: ближайший ЦП с резервом + count по индексу загрузки.
|
||||
*/
|
||||
PowerCapacitySummary: {
|
||||
/** Total Power Points */
|
||||
total_power_points: number;
|
||||
/** By Load Index */
|
||||
by_load_index: {
|
||||
[key: string]: number;
|
||||
};
|
||||
nearest_with_reserve:
|
||||
components["schemas"]["PowerConnectionPoint"] | null;
|
||||
};
|
||||
/**
|
||||
* PowerConnectionPoint
|
||||
* @description Центр питания (ЦП, ПС 35/110 кВ) вблизи участка со свободной мощностью.
|
||||
*/
|
||||
PowerConnectionPoint: {
|
||||
/** Name */
|
||||
name: string;
|
||||
/** Dzo Name */
|
||||
dzo_name: string | null;
|
||||
/** Voltage Class */
|
||||
voltage_class: string | null;
|
||||
/** Load Index */
|
||||
load_index: string | null;
|
||||
/** Installed Capacity Mva */
|
||||
installed_capacity_mva: number | null;
|
||||
/** Current Load Mva */
|
||||
current_load_mva: number | null;
|
||||
/** Reserve Mva */
|
||||
reserve_mva: number | null;
|
||||
/** Reserve Asof */
|
||||
reserve_asof: string | null;
|
||||
/** Distance M */
|
||||
distance_m: number;
|
||||
/** Lat */
|
||||
lat: number | null;
|
||||
/** Lon */
|
||||
lon: number | null;
|
||||
};
|
||||
/** RecommendBucket */
|
||||
RecommendBucket: {
|
||||
/** Bucket */
|
||||
|
|
@ -5052,6 +5134,22 @@ export interface components {
|
|||
/** Context */
|
||||
ctx?: Record<string, never>;
|
||||
};
|
||||
/**
|
||||
* WaterCapacityRow
|
||||
* @description Строка резерва ЦСВ/ЦСК Водоканала (city-level) за последний период.
|
||||
*/
|
||||
WaterCapacityRow: {
|
||||
/** System Kind */
|
||||
system_kind: string;
|
||||
/** System Name */
|
||||
system_name: string;
|
||||
/** Reserve Thousand M3 Day */
|
||||
reserve_thousand_m3_day: number | null;
|
||||
/** Note */
|
||||
note: string | null;
|
||||
/** Period */
|
||||
period: string | null;
|
||||
};
|
||||
/** WeightProfile */
|
||||
WeightProfile: {
|
||||
/** Profile Name */
|
||||
|
|
@ -5617,6 +5715,39 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
get_parcel_connection_capacity_api_v1_parcels__cad_num__connection_capacity_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
radius_m?: number;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
cad_num: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ConnectionCapacityResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_isochrones_api_v1_parcels__cad_num__isochrones_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue