diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 7af52fbe..2dbedb41 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -26,6 +26,7 @@ from app.schemas.parcel import ( CompetitorsParkingResponse, CompetitorsRequest, CompetitorsResponse, + ConnectionCapacityResponse, ConnectionPointsResponse, OpportunityParcel, ParcelBboxResponse, @@ -64,6 +65,7 @@ from app.services.site_finder.cadastre_fetch import ( fetch_status as _fetch_status, ) from app.services.site_finder.competitors import get_competitors, get_competitors_parking +from app.services.site_finder.connection_capacity_lookup import get_connection_capacity from app.services.site_finder.custom_pois import ( get_overlaps_for_scoring as _get_custom_poi_overlaps, ) @@ -3839,6 +3841,42 @@ def get_parcel_utility_infrastructure( ) +@router.get( + "/{cad_num}/connection-capacity", + response_model=ConnectionCapacityResponse, + summary="Точки подключения со свободной мощностью — электро + вода (Forgejo #2119)", +) +def get_parcel_connection_capacity( + cad_num: str, + db: Annotated[Session, Depends(get_db)], + radius_m: Annotated[int, Query(ge=100, le=10000)] = 3000, +) -> ConnectionCapacityResponse: + """Центры питания со свободной мощностью + резервы воды вблизи участка (ПТИЦА §3). + + Верифицированные open-data источники (наполняются weekly-sync, RUN-ON-PROD): + - power_supply_centers: ЦП (ПС 35/110 кВ) Россети WFS + резервы xlsx. + - water_supply_reserves: ЦСВ/ЦСК Водоканала ЕКБ (city-level, последний период). + + power_points — ЦП в радиусе от центроида участка (ST_DWithin geography). + water — все строки за ПОСЛЕДНИЙ период (город-уровень, показывается целиком). + + Query params: + radius_m: радиус поиска ЦП, 100–10000 м (default 3000). + + Если участок не найден в БД (нет geom) → 404. + """ + try: + data = get_connection_capacity(db, cad_num, radius_m) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + return ConnectionCapacityResponse( + power_points=data["power_points"], + power_summary=data["power_summary"], + water=data["water"], + ) + + _ORS_BASE = "https://api.openrouteservice.org/v2/isochrones" _ORS_VALID_MODES = frozenset({"foot-walking", "cycling-regular", "driving-car"}) diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 5f81c2d5..01e1d4f9 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -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) ─────────────────────────────── diff --git a/backend/app/services/site_finder/connection_capacity_lookup.py b/backend/app/services/site_finder/connection_capacity_lookup.py new file mode 100644 index 00000000..17d21a3d --- /dev/null +++ b/backend/app/services/site_finder/connection_capacity_lookup.py @@ -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] diff --git a/backend/app/services/site_finder/rosseti_reserve_loader.py b/backend/app/services/site_finder/rosseti_reserve_loader.py new file mode 100644 index 00000000..c977c66f --- /dev/null +++ b/backend/app/services/site_finder/rosseti_reserve_loader.py @@ -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 diff --git a/backend/app/services/site_finder/rosseti_wfs_loader.py b/backend/app/services/site_finder/rosseti_wfs_loader.py new file mode 100644 index 00000000..5c7d27f4 --- /dev/null +++ b/backend/app/services/site_finder/rosseti_wfs_loader.py @@ -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 diff --git a/backend/app/services/site_finder/vodokanal_reserve_loader.py b/backend/app/services/site_finder/vodokanal_reserve_loader.py new file mode 100644 index 00000000..41d14a45 --- /dev/null +++ b/backend/app/services/site_finder/vodokanal_reserve_loader.py @@ -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) — значение +задано в первой строке блока, дальше идут без 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: + """Весь текст ячейки таблицы () — конкатенация всех .""" + 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'. + + — начало блока (значение здесь). + (без val или val='continue') — продолжение (forward-fill сверху). + Нет — обычная ячейка. + """ + 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 diff --git a/backend/app/workers/beat_schedule.py b/backend/app/workers/beat_schedule.py index 557842b6..7aff2c84 100644 --- a/backend/app/workers/beat_schedule.py +++ b/backend/app/workers/beat_schedule.py @@ -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"] = { diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index 08d56989..e09657b3 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -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" diff --git a/backend/app/workers/tasks/connection_capacity_sync.py b/backend/app/workers/tasks/connection_capacity_sync.py new file mode 100644 index 00000000..87fd1095 --- /dev/null +++ b/backend/app/workers/tasks/connection_capacity_sync.py @@ -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 diff --git a/backend/tests/api/v1/test_parcel_connection_capacity.py b/backend/tests/api/v1/test_parcel_connection_capacity.py new file mode 100644 index 00000000..fac36579 --- /dev/null +++ b/backend/tests/api/v1/test_parcel_connection_capacity.py @@ -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 diff --git a/backend/tests/test_connection_capacity_loaders.py b/backend/tests/test_connection_capacity_loaders.py new file mode 100644 index 00000000..66efb3fe --- /dev/null +++ b/backend/tests/test_connection_capacity_loaders.py @@ -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 одной ячейки с опциональным vMerge.""" + tc_pr = "" + if vmerge == "restart": + tc_pr = '' + elif vmerge == "continue": + tc_pr = "" + return f"{tc_pr}{text}" + + +def _doc_xml(rows: list[str]) -> bytes: + """Строит минимальный document.xml с одной таблицей из строк-.""" + trs = "".join(f"{r}" for r in rows) + xml = f'{trs}' + 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''.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 diff --git a/data/sql/180_connection_capacity.sql b/data/sql/180_connection_capacity.sql new file mode 100644 index 00000000..22721632 --- /dev/null +++ b/data/sql/180_connection_capacity.sql @@ -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; diff --git a/frontend/src/components/site-finder/PowerConnectionPointsLayer.tsx b/frontend/src/components/site-finder/PowerConnectionPointsLayer.tsx new file mode 100644 index 00000000..4373b95a --- /dev/null +++ b/frontend/src/components/site-finder/PowerConnectionPointsLayer.tsx @@ -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 ( + +
+
+ + Центр питания (Россети) +
+
+ {point.name} +
+ {voltage && ( +
+ Напряжение: {voltage} кВ +
+ )} + {installed && ( +
+ Установленная мощность: {installed} +
+ )} + {load && ( +
+ Загрузка: {load} +
+ )} + {reserve && ( +
+ Резерв для ТП:{" "} + + {reserve} + +
+ )} + {badge && ( + + {badge.label} + + )} + {distance && ( +
+ До участка: {distance} +
+ )} +
+ Россети{point.reserve_asof ? ` · срез ${point.reserve_asof}` : ""} +
+
+ Координаты ЦП ~±100 м (округление источника) +
+
+
+ ); +} + +// ── Layer ────────────────────────────────────────────────────────────────────── + +interface Props { + points: PowerConnectionPoint[]; +} + +export function PowerConnectionPointsLayer({ points }: Props) { + return ( + + {points.map((point, idx) => { + const latLon = pointLatLon(point); + if (!latLon) return null; + const color = loadIndexColor(point.load_index); + return ( + + + + ); + })} + + ); +} + +// Число рисуемых точек (с валидными координатами) — для легенды/счётчика. +export function countMappablePowerPoints( + points: PowerConnectionPoint[] | undefined, +): number { + if (!points) return 0; + return points.filter((p) => pointLatLon(p) !== null).length; +} diff --git a/frontend/src/components/site-finder/SiteMap.tsx b/frontend/src/components/site-finder/SiteMap.tsx index 43a1a70f..489c8e85 100644 --- a/frontend/src/components/site-finder/SiteMap.tsx +++ b/frontend/src/components/site-finder/SiteMap.tsx @@ -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 (
{/* Map */} @@ -618,6 +637,13 @@ export function SiteMap({ /> )} + {/* Центры питания Россетей со свободной мощностью для ТП + (#connection-capacity). Отдельный слой поверх OSM: цвет по индексу + загрузки, отличается от оранжевых точек НСПД/инж.сетей OSM. */} + {hasPowerPoints && ( + + )} + {/* Custom POI markers — topmost layer */} {customPois && customPois.length > 0 && ( )} + {/* Легенда слоя центров питания (#connection-capacity). Слой не тумблерный + (небольшое число точек), поэтому подпись рендерим в легенде под картой: + цвет маркера кодирует индекс загрузки ЦП. */} + {hasPowerPoints && ( +
+ + Центры питания (резерв): {powerPointMappable} + + {( + [ + ["open", "открыт для ТП"], + ["limited", "ограничен"], + ["closed", "закрыт"], + ] as const + ).map(([idx, label]) => ( + + + {label} + + ))} + + + нет данных + +
+ )} + {!data.geom_geojson && (

+ {label} + + ); +} + +// ── Электричество ────────────────────────────────────────────────────────────── + +function PowerRow({ data }: { data: ConnectionCapacityResponse }) { + const nearest = data.power_summary.nearest_with_reserve; + + if (!nearest) { + return ( +

+ Данные о резервах ПС загружаются. +
+ ); + } + + 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 ( +
+ + Ближайший центр питания: {nearest.name} + {parts.length > 0 ? ` · ${parts.join(" · ")}` : ""} + + {badge && ( + + )} +
+ ); +} + +// ── Вода / канализация ───────────────────────────────────────────────────────── + +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 ( +
+
+ + {label}:{" "} + {reserveStr ? ( + + {reserveStr} + + ) : ( + + )} + + {reserveNum !== null && ( + + )} +
+ {row.note && ( + + {row.note} + + )} +
+ ); +} + +function WaterBlock({ rows }: { rows: WaterCapacityRow[] }) { + if (rows.length === 0) { + return ( +
+ Данные о резервах водоснабжения загружаются. +
+ ); + } + + // Разделяем водоснабжение (ЦСВ, 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 ( +
+ {citySupply && ( + + )} + {citySewage && ( + + )} + + {rest.length > 0 && ( +
+ + + {pluralizeSystems(rest.length)} + +
+ {rest.map((r, i) => ( + + ))} +
+
+ )} + + + Раскрытие МУП «Водоканал»{period ? ` · ${period}` : ""} + +
+ ); +} + +// ── 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 ( +
+

+ Ресурсные резервы для подключения (ТП) +

+ + {/* Электричество */} +
+
+
+ +
+ + {/* Вода / канализация */} +
+
+
+ +
+ + {/* Caveat */} +
+
+
+ ); +} diff --git a/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx b/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx index 341f593a..d442d3d7 100644 --- a/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx +++ b/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx @@ -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 (
@@ -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 ; @@ -803,6 +816,12 @@ export function Section2NetworksUtilities({ cad }: Props) {
)} + {/* Карточка «Ресурсные резервы» — свободная мощность для ТП из раскрытия + сетевых организаций (ЦП Россетей + Водоканал). Над таблицей сетей. */} + {connectionCapacity && ( + + )} + {/* Счётчики источников — явно разделяем «инж.сети OSM» и «точки подключения НСПД», чтобы числа не путались (#1953). */} {!networksLoading && ( @@ -858,6 +877,7 @@ export function Section2NetworksUtilities({ cad }: Props) { data={data} connectionPoints={connectionPoints} utilityInfrastructure={utilityInfrastructure} + connectionCapacity={connectionCapacity} isLoading={networksLoading} /> diff --git a/frontend/src/components/site-finder/connection-capacity.ts b/frontend/src/components/site-finder/connection-capacity.ts new file mode 100644 index 00000000..b55a454c --- /dev/null +++ b/frontend/src/components/site-finder/connection-capacity.ts @@ -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 = { + 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; +} diff --git a/frontend/src/hooks/useConnectionCapacity.ts b/frontend/src/hooks/useConnectionCapacity.ts new file mode 100644 index 00000000..9979a28b --- /dev/null +++ b/frontend/src/hooks/useConnectionCapacity.ts @@ -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({ + queryKey: ["connection-capacity", cadNum, radiusM], + queryFn: () => + apiFetch( + `/api/v1/parcels/${encodeURIComponent(cadNum!)}/connection-capacity?radius_m=${radiusM}`, + ), + enabled: !!cadNum && enabled, + staleTime: 5 * 60 * 1000, + }); +} diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index 8ed7b644..9e70bba1 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -437,6 +437,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/parcels/{cad_num}/connection-capacity": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Точки подключения со свободной мощностью — электро + вода (Forgejo #2119) + * @description Центры питания со свободной мощностью + резервы воды вблизи участка (ПТИЦА §3). + * + * Верифицированные open-data источники (наполняются weekly-sync, RUN-ON-PROD): + * - power_supply_centers: ЦП (ПС 35/110 кВ) Россети WFS + резервы xlsx. + * - water_supply_reserves: ЦСВ/ЦСК Водоканала ЕКБ (city-level, последний период). + * + * power_points — ЦП в радиусе от центроида участка (ST_DWithin geography). + * water — все строки за ПОСЛЕДНИЙ период (город-уровень, показывается целиком). + * + * Query params: + * radius_m: радиус поиска ЦП, 100–10000 м (default 3000). + * + * Если участок не найден в БД (нет geom) → 404. + */ + get: operations["get_parcel_connection_capacity_api_v1_parcels__cad_num__connection_capacity_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/parcels/{cad_num}/isochrones": { parameters: { query?: never; @@ -3417,6 +3449,14 @@ export interface components { */ requested_count?: number | null; }; + /** ConnectionCapacityResponse */ + ConnectionCapacityResponse: { + /** Power Points */ + power_points: components["schemas"]["PowerConnectionPoint"][]; + power_summary: components["schemas"]["PowerCapacitySummary"]; + /** Water */ + water: components["schemas"]["WaterCapacityRow"][]; + }; /** ConnectionPointsResponse */ ConnectionPointsResponse: { /** Engineering Structures */ @@ -4631,6 +4671,48 @@ export interface components { /** Top Poi */ top_poi: components["schemas"]["PoiScoreItem"][]; }; + /** + * PowerCapacitySummary + * @description Сводка по электрике: ближайший ЦП с резервом + count по индексу загрузки. + */ + PowerCapacitySummary: { + /** Total Power Points */ + total_power_points: number; + /** By Load Index */ + by_load_index: { + [key: string]: number; + }; + nearest_with_reserve: + components["schemas"]["PowerConnectionPoint"] | null; + }; + /** + * PowerConnectionPoint + * @description Центр питания (ЦП, ПС 35/110 кВ) вблизи участка со свободной мощностью. + */ + PowerConnectionPoint: { + /** Name */ + name: string; + /** Dzo Name */ + dzo_name: string | null; + /** Voltage Class */ + voltage_class: string | null; + /** Load Index */ + load_index: string | null; + /** Installed Capacity Mva */ + installed_capacity_mva: number | null; + /** Current Load Mva */ + current_load_mva: number | null; + /** Reserve Mva */ + reserve_mva: number | null; + /** Reserve Asof */ + reserve_asof: string | null; + /** Distance M */ + distance_m: number; + /** Lat */ + lat: number | null; + /** Lon */ + lon: number | null; + }; /** RecommendBucket */ RecommendBucket: { /** Bucket */ @@ -5052,6 +5134,22 @@ export interface components { /** Context */ ctx?: Record; }; + /** + * 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?: {