feat(site-finder): резервы мощности для ТП — электро+вода на карте §3 (#2119 Фаза A)
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Successful in 1m17s
CI / openapi-codegen-check (pull_request) Successful in 2m6s
CI / backend-tests (pull_request) Successful in 14m28s

Наполнение карты точками подключения с ХАРАКТЕРИСТИКАМИ (свободная мощность)
из верифицированных источников (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:
Light1YT 2026-07-02 12:45:55 +05:00
parent 4a7ce91db1
commit e285cbe33a
19 changed files with 3054 additions and 1 deletions

View file

@ -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: радиус поиска ЦП, 10010000 м (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"})

View file

@ -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) ───────────────────────────────

View 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]

View 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

View 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

View 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

View file

@ -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"] = {

View file

@ -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"

View 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

View 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

View 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

View 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;

View file

@ -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;
}

View file

@ -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={{

View file

@ -0,0 +1,355 @@
"use client";
/**
* ResourceReservesCard «Ресурсные резервы» для §3 «Сети» (#connection-capacity).
*
* Показывает свободную мощность для технологического присоединения (ТП) из
* ОБЯЗАТЕЛЬНОГО раскрытия сетевых организаций:
* Электричество ближайший центр питания (ЦП, ПС 35/110 кВ) Россетей со
* свободной мощностью + бейдж статуса по индексу загрузки (open/limited/closed).
* Вода/канализация резервы ЦСВ/ЦСК Водоканала: городская система отдельной
* строкой (ОТРИЦАТЕЛЬНЫЙ резерв = дефицит, красный бейдж), посёлковые
* свёрнуты в details.
*
* Числа приводятся через toFiniteNumber (Pydantic Decimalstr). Пока лоадеры
* бэка не отработали, показываем нейтральный «загружаются», а не «нет данных».
*/
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>
);
}

View file

@ -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}
/>

View 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;
}

View 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,
});
}

View file

@ -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: радиус поиска ЦП, 10010000 м (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?: {