gendesign/frontend/src/components/site-finder/connection-capacity.ts
Light1YT 372856c4cc
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Successful in 1m10s
CI / openapi-codegen-check (pull_request) Successful in 2m0s
CI / backend-tests (pull_request) Successful in 14m36s
feat(site-finder): ЕЭСК-резервы городских ПС/ТП + тепло из ФГИС ФАС (#2119 B2)
Городские числа вместо «по запросу» + четвёртый ресурс (тепло) в карточке §3.

- Миграция 182: power_supply_centers +reserve_unit ('МВА'/'МВт') +district;
  heat_system_reserves (UNIQUE NULLS NOT DISTINCT).
- eesk_reserve_loader (RUN-ON-PROD): хаб раскрытия eesk.ru → свежайшие xlsx по
  (год, квартал) из текста ссылки; ПС 35-220 (структура снята: r7+, доля
  0.41→41.0, свободная МВт) → UPDATE 70 городских ПС (только dzo ЕЭСК;
  «ФСК ЕЭС» не матчится — проверено ревью); ТП/РП ниже 35 (r4+, ~2014 строк
  города) → power_tp_rp_reserves. ДОПУЩЕНИЕ: % загрузки в источнике — доля
  (0..1); если ЕЭСК перейдёт на целые проценты, load_pct тихо ×100.
- eias_heat_loader (RUN-ON-PROD, SSL: в CA-бандл добавлен GlobalSign-интермедиат
  — ri.eias.ru не шлёт цепочку): карточка орг (ЕТК 30814352, Екатеринбургэнерго
  26479450) → публикации свежайшего квартала → TemplatePrinter base64-ZIP → tpl
  XLSX → defensive-парс «СТ №N»+правейшее число → heat_system_reserves.
- Celery вт 05:30/06:00; endpoint: power_points +reserve_unit/district,
  heat-блок (per-org MAX(period), graceful); схемы аддитивны, api-types +29/-0.
- Фронт: блок «ТЕПЛО» (бейджи резерв/исчерпана, свёртка >5, суммарно Гкал/ч);
  попап ПС: район + unit ТОЛЬКО на строке резерва (у ЕЭСК installed МВА,
  резерв МВт — разные единицы источника).
- B1-хвост: «(выход №N)» матчится в середине имени ГРС.

64+ тестов зелёные; deep-review  APPROVE.
2026-07-02 21:26:39 +05:00

199 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* connection-capacity — общие хелперы для §3 «Сети»: ресурсные резервы (ЦП
* Россетей + Водоканал). Используются и карточкой «Ресурсные резервы»
* (Section2NetworksUtilities), и слоем центров питания на карте
* (PowerConnectionPointsLayer) — чтобы цвета/подписи/приведение чисел были
* идентичны в обоих местах.
*/
import type {
PowerConnectionPoint,
WaterCapacityRow,
} from "@/hooks/useConnectionCapacity";
// ── Coercion (Pydantic Decimal → строка) ──────────────────────────────────────
//
// NUMERIC-поля БД сериализуются Pydantic v2 как СТРОКИ («12.5»), хотя OpenAPI-тип
// объявлен number | null. `typeof === "number"`-гварды молча роняют такое поле
// (см. урок MarketLayers.toFiniteNumber). Приводим руками. Локальная копия по
// образцу MarketLayers — намеренно, чтобы не тянуть map-модуль в неграфические
// места.
export function toFiniteNumber(
v: number | string | null | undefined,
): number | null {
if (v === null || v === undefined) return null;
const n = typeof v === "string" ? Number(v) : v;
return Number.isFinite(n) ? n : null;
}
// ── Индекс загрузки центра питания (load_index) ────────────────────────────────
export type LoadIndex = "open" | "limited" | "closed";
export function classifyLoadIndex(
raw: string | null | undefined,
): LoadIndex | null {
switch (raw) {
case "open":
case "limited":
case "closed":
return raw;
default:
return null;
}
}
// Бейдж статуса ТП по индексу загрузки. Цвета — semantic-палитра карточек §3
// (совпадает с SEVERITY_COLOR в Pipeline24moBlock; для карты — документированное
// token-hex исключение popup'ов).
export const LOAD_INDEX_BADGE: Record<
LoadIndex,
{ label: string; bg: string; fg: string; border: string }
> = {
open: {
label: "открыт для ТП",
bg: "#dcfce7",
fg: "#15803d",
border: "#86efac",
},
limited: {
label: "ограничен",
bg: "#fef9c3",
fg: "#a16207",
border: "#fde68a",
},
closed: {
label: "закрыт",
bg: "#fee2e2",
fg: "#b91c1c",
border: "#fca5a5",
},
};
// Цвет маркера ЦП на карте по индексу загрузки (null → нейтральный серый).
export const LOAD_INDEX_MARKER_COLOR: Record<LoadIndex, string> = {
open: "#15803d",
limited: "#a16207",
closed: "#b91c1c",
};
export const LOAD_INDEX_NULL_COLOR = "#6b7280";
export function loadIndexColor(raw: string | null | undefined): string {
const idx = classifyLoadIndex(raw);
return idx ? LOAD_INDEX_MARKER_COLOR[idx] : LOAD_INDEX_NULL_COLOR;
}
export function humanizeLoadIndex(
raw: string | null | undefined,
): string | null {
const idx = classifyLoadIndex(raw);
return idx ? LOAD_INDEX_BADGE[idx].label : null;
}
// ── Форматирование чисел ───────────────────────────────────────────────────────
// МВА с одним знаком после запятой (резерв/мощность ЦП). Приводит строку→число.
// unit — необязательный override единицы измерения (бэк отдаёт reserve_unit;
// напр. «кВт» вместо «МВА» для отдельных ЦП). null/undefined → дефолт «МВА».
export function formatMva(
v: number | string | null | undefined,
unit?: string | null,
): string | null {
const n = toFiniteNumber(v);
if (n === null) return null;
const u = unit?.trim() || "МВА";
return `${n.toLocaleString("ru-RU", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})} ${u}`;
}
// тыс. м³/сут (резерв воды/канализации). Может быть ОТРИЦАТЕЛЬНЫМ (дефицит) —
// знак сохраняем (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")} м`;
}
// тыс. м³/ч (свободная/проектная мощность ГРС). Приводит строку→число (Decimal→str).
export function formatThousandM3Hour(
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 formatGcalHour(
v: number | string | null | undefined,
): string | null {
const n = toFiniteNumber(v);
if (n === null) return null;
return `${n.toLocaleString("ru-RU", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})} Гкал/ч`;
}
// ── Вода: выбор «городской» системы + группировка ──────────────────────────────
//
// Городская система ЦСВ/ЦСК выделяется в карточке отдельной строкой: сначала по
// имени (содержит «ЗФС» — Западная фильтровальная станция ЕКБ), иначе — самая
// крупная по |резерв| (крупнейшая система города). Остальные сворачиваются.
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;
}