Газовая вертикаль точек подключения: - Миграция 181: gas_grs_capacity (ГРС с выходами; UNIQUE NULLS NOT DISTINCT на (grs_name_norm, output_name) — грабли #140). - gazprom_grs_loader: bs4-парсер инлайн HTML-таблицы «техническая возможность» ГТЕ (~316 ГРС; гео-блок → RUN-ON-PROD): header-driven маппинг колонок, rowspan forward-fill имени (допущение col-0 задокументировано), числа через общий parse_reserve_number, SAVEPOINT per-row, unknown-колонки → raw jsonb. - Celery sync_gas_grs_capacity + beat вт 05:00 MSK. - Endpoint connection-capacity: gas.city_grs (городские ГРС Свердловск/ Екатеринбург, агрегация по выходам) + gas.total_free_th_m3_h; graceful при отсутствии таблицы (schema-first окно). reserve_note у ПС ЕЭСК без опубликованного резерва: «Резерв — по запросу в АО «ЕЭСК» (город)». - Схемы GasGrsRow/GasCapacityBlock/reserve_note аддитивны; api-types +31/-0. - Фронт: блок «ГАЗ» в карточке резервов (по-ГРС «свободно X из Y тыс. м³/ч», бейджи резерв/исчерпана, расшивка, N выходов, суммарная строка) + серая reserve_note-строка в попапе ЦП. Числа через toFiniteNumber. 57 backend-тестов зелёные; deep-review ✅ APPROVE (2 minor-нота учтены).
454 lines
14 KiB
TypeScript
454 lines
14 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* ResourceReservesCard — «Ресурсные резервы» для §3 «Сети» (#connection-capacity).
|
||
*
|
||
* Показывает свободную мощность для технологического присоединения (ТП) из
|
||
* ОБЯЗАТЕЛЬНОГО раскрытия сетевых организаций:
|
||
* • Электричество — ближайший центр питания (ЦП, ПС 35/110 кВ) Россетей со
|
||
* свободной мощностью + бейдж статуса по индексу загрузки (open/limited/closed).
|
||
* • Вода/канализация — резервы ЦСВ/ЦСК Водоканала: городская система отдельной
|
||
* строкой (ОТРИЦАТЕЛЬНЫЙ резерв = дефицит, красный бейдж), посёлковые —
|
||
* свёрнуты в details.
|
||
*
|
||
* Числа приводятся через toFiniteNumber (Pydantic Decimal→str). Пока лоадеры
|
||
* бэка не отработали, показываем нейтральный «загружаются», а не «нет данных».
|
||
*/
|
||
|
||
import { Info, Zap, Droplets, Flame } from "lucide-react";
|
||
|
||
import type {
|
||
ConnectionCapacityResponse,
|
||
GasCapacityBlock,
|
||
GasGrsRow,
|
||
WaterCapacityRow,
|
||
} from "@/hooks/useConnectionCapacity";
|
||
import { CONNECTION_CAPACITY_RADIUS_M } from "@/hooks/useConnectionCapacity";
|
||
import {
|
||
LOAD_INDEX_BADGE,
|
||
classifyLoadIndex,
|
||
formatMeters,
|
||
formatMva,
|
||
formatThousandM3Day,
|
||
formatThousandM3Hour,
|
||
humanizeLoadIndex,
|
||
pickCitySystem,
|
||
pluralizeSystems,
|
||
toFiniteNumber,
|
||
} from "@/components/site-finder/connection-capacity";
|
||
|
||
const RADIUS_KM = CONNECTION_CAPACITY_RADIUS_M / 1000;
|
||
|
||
// Semantic-бейдж (резерв/дефицит) — token-палитра карточек (не карта).
|
||
const RESERVE_BADGE = {
|
||
reserve: {
|
||
label: "резерв",
|
||
bg: "var(--success-soft)",
|
||
fg: "var(--success)",
|
||
},
|
||
deficit: {
|
||
label: "дефицит",
|
||
bg: "var(--danger-soft)",
|
||
fg: "var(--danger)",
|
||
},
|
||
depleted: {
|
||
label: "исчерпана",
|
||
bg: "var(--danger-soft)",
|
||
fg: "var(--danger)",
|
||
},
|
||
} as const;
|
||
|
||
function StatusBadge({
|
||
label,
|
||
bg,
|
||
fg,
|
||
border,
|
||
}: {
|
||
label: string;
|
||
bg: string;
|
||
fg: string;
|
||
border?: string;
|
||
}) {
|
||
return (
|
||
<span
|
||
style={{
|
||
display: "inline-block",
|
||
padding: "1px 8px",
|
||
borderRadius: 6,
|
||
fontSize: 12,
|
||
fontWeight: 500,
|
||
color: fg,
|
||
background: bg,
|
||
border: border ? `1px solid ${border}` : undefined,
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{label}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// ── Электричество ──────────────────────────────────────────────────────────────
|
||
|
||
function PowerRow({ data }: { data: ConnectionCapacityResponse }) {
|
||
const nearest = data.power_summary.nearest_with_reserve;
|
||
|
||
if (!nearest) {
|
||
return (
|
||
<div style={{ fontSize: 13, color: "var(--fg-secondary)" }}>
|
||
Данные о резервах ПС загружаются.
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const idx = classifyLoadIndex(nearest.load_index);
|
||
const badge = idx ? LOAD_INDEX_BADGE[idx] : null;
|
||
const humanIdx = humanizeLoadIndex(nearest.load_index);
|
||
|
||
const voltage = nearest.voltage_class?.trim() || null;
|
||
const reserve = formatMva(nearest.reserve_mva);
|
||
const distance = formatMeters(nearest.distance_m);
|
||
|
||
const parts: string[] = [];
|
||
if (voltage) parts.push(`${voltage} кВ`);
|
||
if (reserve) parts.push(`резерв ${reserve}`);
|
||
if (humanIdx) parts.push(humanIdx);
|
||
if (distance) parts.push(distance);
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "flex-start",
|
||
gap: 8,
|
||
flexWrap: "wrap",
|
||
}}
|
||
>
|
||
<span style={{ fontSize: 13, color: "var(--fg-primary)" }}>
|
||
Ближайший центр питания: <strong>{nearest.name}</strong>
|
||
{parts.length > 0 ? ` · ${parts.join(" · ")}` : ""}
|
||
</span>
|
||
{badge && (
|
||
<StatusBadge
|
||
label={badge.label}
|
||
bg={badge.bg}
|
||
fg={badge.fg}
|
||
border={badge.border}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Вода / канализация ─────────────────────────────────────────────────────────
|
||
|
||
function WaterSystemLine({
|
||
label,
|
||
row,
|
||
}: {
|
||
label: string;
|
||
row: WaterCapacityRow;
|
||
}) {
|
||
const reserveNum = toFiniteNumber(row.reserve_thousand_m3_day);
|
||
const reserveStr = formatThousandM3Day(row.reserve_thousand_m3_day);
|
||
const isDeficit = reserveNum !== null && reserveNum < 0;
|
||
const badge = isDeficit ? RESERVE_BADGE.deficit : RESERVE_BADGE.reserve;
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 4,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
flexWrap: "wrap",
|
||
}}
|
||
>
|
||
<span style={{ fontSize: 13, color: "var(--fg-primary)" }}>
|
||
{label}:{" "}
|
||
{reserveStr ? (
|
||
<strong
|
||
style={{
|
||
color: isDeficit ? "var(--danger)" : "var(--fg-primary)",
|
||
}}
|
||
>
|
||
{reserveStr}
|
||
</strong>
|
||
) : (
|
||
<span style={{ color: "var(--fg-tertiary)" }}>—</span>
|
||
)}
|
||
</span>
|
||
{reserveNum !== null && (
|
||
<StatusBadge label={badge.label} bg={badge.bg} fg={badge.fg} />
|
||
)}
|
||
</div>
|
||
{row.note && (
|
||
<span style={{ fontSize: 12, color: "var(--fg-secondary)" }}>
|
||
{row.note}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function WaterBlock({ rows }: { rows: WaterCapacityRow[] }) {
|
||
if (rows.length === 0) {
|
||
return (
|
||
<div style={{ fontSize: 13, color: "var(--fg-secondary)" }}>
|
||
Данные о резервах водоснабжения загружаются.
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Разделяем водоснабжение (ЦСВ, system_kind="water") и водоотведение
|
||
// (ЦСК, system_kind="sewerage") — значения из бэка (vodokanal_reserve_loader).
|
||
const supply = rows.filter((r) => r.system_kind === "water");
|
||
const sewage = rows.filter((r) => r.system_kind === "sewerage");
|
||
const other = rows.filter(
|
||
(r) => r.system_kind !== "water" && r.system_kind !== "sewerage",
|
||
);
|
||
|
||
const citySupply = pickCitySystem(supply);
|
||
const citySewage = pickCitySystem(sewage);
|
||
|
||
// Остальные (посёлковые) системы — свернуть в details. Городские строки уже
|
||
// показаны отдельно, поэтому исключаем их из «прочих».
|
||
const rest = [
|
||
...(citySupply ? supply.filter((r) => r !== citySupply) : supply),
|
||
...(citySewage ? sewage.filter((r) => r !== citySewage) : sewage),
|
||
...other,
|
||
];
|
||
|
||
// Источник-подпись: период из данных (первый ненулевой).
|
||
const period = rows.find((r) => r.period)?.period?.trim() || null;
|
||
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||
{citySupply && (
|
||
<WaterSystemLine label="Водоснабжение (город)" row={citySupply} />
|
||
)}
|
||
{citySewage && (
|
||
<WaterSystemLine label="Водоотведение (город)" row={citySewage} />
|
||
)}
|
||
|
||
{rest.length > 0 && (
|
||
<details>
|
||
<summary
|
||
style={{
|
||
fontSize: 12,
|
||
color: "var(--accent)",
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
+ {pluralizeSystems(rest.length)}
|
||
</summary>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 6,
|
||
marginTop: 6,
|
||
}}
|
||
>
|
||
{rest.map((r, i) => (
|
||
<WaterSystemLine
|
||
key={`${r.system_kind}-${r.system_name}-${i}`}
|
||
label={r.system_name || "Система"}
|
||
row={r}
|
||
/>
|
||
))}
|
||
</div>
|
||
</details>
|
||
)}
|
||
|
||
<span style={{ fontSize: 12, color: "var(--fg-tertiary)" }}>
|
||
Раскрытие МУП «Водоканал»{period ? ` · ${period}` : ""}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Газ ────────────────────────────────────────────────────────────────────────
|
||
|
||
function GasGrsLine({ row }: { row: GasGrsRow }) {
|
||
const freeNum = toFiniteNumber(row.free_capacity_th_m3_h);
|
||
const freeStr = formatThousandM3Hour(row.free_capacity_th_m3_h);
|
||
const designStr = formatThousandM3Hour(row.design_capacity_th_m3_h);
|
||
const isDepleted = freeNum !== null && freeNum <= 0;
|
||
const badge = isDepleted ? RESERVE_BADGE.depleted : RESERVE_BADGE.reserve;
|
||
const upgrade = row.upgrade_due?.trim() || null;
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
flexWrap: "wrap",
|
||
}}
|
||
>
|
||
<span style={{ fontSize: 13, color: "var(--fg-primary)" }}>
|
||
<strong>{row.grs_name}</strong>: свободно{" "}
|
||
{freeStr ? (
|
||
<strong
|
||
style={{
|
||
color: isDepleted ? "var(--danger)" : "var(--fg-primary)",
|
||
}}
|
||
>
|
||
{freeStr}
|
||
</strong>
|
||
) : (
|
||
<span style={{ color: "var(--fg-tertiary)" }}>—</span>
|
||
)}
|
||
{designStr ? ` из ${designStr}` : ""}
|
||
{row.outputs_count > 1 && (
|
||
<span style={{ color: "var(--fg-tertiary)" }}>
|
||
{" "}
|
||
({row.outputs_count} выхода)
|
||
</span>
|
||
)}
|
||
{upgrade && (
|
||
<span style={{ color: "var(--fg-tertiary)" }}>
|
||
{" "}
|
||
· расшивка: {upgrade}
|
||
</span>
|
||
)}
|
||
</span>
|
||
<StatusBadge label={badge.label} bg={badge.bg} fg={badge.fg} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function GasBlock({ gas }: { gas: GasCapacityBlock | null | undefined }) {
|
||
if (!gas || gas.city_grs.length === 0) {
|
||
return (
|
||
<div style={{ fontSize: 13, color: "var(--fg-secondary)" }}>
|
||
Данные о загрузке ГРС загружаются.
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const totalNum = toFiniteNumber(gas.total_free_th_m3_h);
|
||
const totalStr = formatThousandM3Hour(gas.total_free_th_m3_h);
|
||
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||
{gas.city_grs.map((row, i) => (
|
||
<GasGrsLine key={`grs-${row.grs_name}-${i}`} row={row} />
|
||
))}
|
||
|
||
{totalNum !== null && totalStr && (
|
||
<span style={{ fontSize: 13, color: "var(--fg-primary)" }}>
|
||
Суммарно по городским ГРС: <strong>{totalStr}</strong>
|
||
</span>
|
||
)}
|
||
|
||
<span style={{ fontSize: 12, color: "var(--fg-tertiary)" }}>
|
||
Газпром трансгаз Екатеринбург · техвозможность ГРС
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Card ───────────────────────────────────────────────────────────────────────
|
||
|
||
const SUB_HEADER: React.CSSProperties = {
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.04em",
|
||
color: "var(--fg-tertiary)",
|
||
marginBottom: 6,
|
||
};
|
||
|
||
interface Props {
|
||
data: ConnectionCapacityResponse;
|
||
}
|
||
|
||
export function ResourceReservesCard({ data }: Props) {
|
||
return (
|
||
<div
|
||
style={{
|
||
border: "1px solid var(--border-card)",
|
||
borderRadius: 12,
|
||
background: "var(--bg-card)",
|
||
padding: 20,
|
||
marginBottom: 16,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 16,
|
||
}}
|
||
>
|
||
<h3
|
||
style={{
|
||
margin: 0,
|
||
fontSize: 14,
|
||
fontWeight: 600,
|
||
color: "var(--fg-primary)",
|
||
}}
|
||
>
|
||
Ресурсные резервы для подключения (ТП)
|
||
</h3>
|
||
|
||
{/* Электричество */}
|
||
<div>
|
||
<div style={SUB_HEADER}>
|
||
<Zap size={14} strokeWidth={1.5} aria-hidden="true" />
|
||
Электричество
|
||
</div>
|
||
<PowerRow data={data} />
|
||
</div>
|
||
|
||
{/* Газ */}
|
||
<div>
|
||
<div style={SUB_HEADER}>
|
||
<Flame size={14} strokeWidth={1.5} aria-hidden="true" />
|
||
Газ
|
||
</div>
|
||
<GasBlock gas={data.gas} />
|
||
</div>
|
||
|
||
{/* Вода / канализация */}
|
||
<div>
|
||
<div style={SUB_HEADER}>
|
||
<Droplets size={14} strokeWidth={1.5} aria-hidden="true" />
|
||
Вода и водоотведение
|
||
</div>
|
||
<WaterBlock rows={data.water} />
|
||
</div>
|
||
|
||
{/* Caveat */}
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "flex-start",
|
||
gap: 6,
|
||
fontSize: 12,
|
||
color: "var(--fg-tertiary)",
|
||
borderTop: "1px solid var(--border-soft)",
|
||
paddingTop: 12,
|
||
}}
|
||
>
|
||
<Info
|
||
size={13}
|
||
strokeWidth={1.5}
|
||
aria-hidden="true"
|
||
style={{ flexShrink: 0, marginTop: 1 }}
|
||
/>
|
||
<span>
|
||
Резервы — из обязательного раскрытия сетевых организаций в радиусе{" "}
|
||
{RADIUS_KM} км; для ТП требуется запрос технических условий (ТУ).
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|