Городские числа вместо «по запросу» + четвёртый ресурс (тепло) в карточке §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.
199 lines
7.9 KiB
TypeScript
199 lines
7.9 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|