pcp: создание плана новой цепочки из вкладки «Планы» (база/без истории, окно из двух ЗРВ)

Оператор берёт за основу прошлый план (предыстория) или начинает цепочку с нуля,
набирает окно из двух реальных ЗРВ и создаёт план-кандидат; сразу показывается прогноз
будущей цепочки от него. Рабочим (якорем) план становится после ручной отправки (promote)
и приёмки — отдельного механизма якорения не вводим.

pcp-tgu-service:
- сырые ЗРВ: PlanCalculationService.rawWindows + PlanProjectionService.rawZrvWindows,
  GET /api/plans/{spacecraftId}/zrv (исходные окна снапшота, не cooked-проекция);
- CreateChainPlanUseCase + POST /api/plans/new-chain: опциональная база (клон включений
  в окно) либо пустая миссия без истории; прогноз цепочки через projectAfterWindow;
- ZrvWindowMatcher (старт+КПП одной зоны, конец — другой); assertDraftSlotStillValid
  переведён на него — единая проверка для replace/fork-forward и окна из двух ЗРВ;
- тесты: CreateChainPlanUseCaseTest (8), ZrvWindowMatcherTest (7), правки promote-тестов.

pcp-mission-planing-service:
- POST /api/missions/blank — пустая миссия для сценария «без истории» (+ 2 теста).

pcp-tgu-ops-ui:
- API fetchZrvWindows/createChainPlan; диалог «Создать план» во вкладке «Планы»
  (выбор базы, стартовой и конечной ЗРВ) с показом спрогнозированной цепочки и ручной
  отправкой; подсветка ЗРВ выбранного КА на таймлайне;
- «Целевая ЗРВ» убрана из редактора (он остаётся под replace-правку); удалён ставший
  мёртвым editorForkForward; createChainWindows + тесты.
This commit is contained in:
Дмитрий Соловьев
2026-06-03 16:25:46 +03:00
parent 057452f19d
commit 9c2d8516bc
26 changed files with 1316 additions and 240 deletions
@@ -0,0 +1,211 @@
import { useEffect, useMemo, useState } from "react";
import {
createChainPlan,
fetchZrvWindows,
promoteDraft,
type CreateChainPlanResponse
} from "../../api/tguPlanApi";
import type { TguPlanUi } from "../../model/timelineTypes";
import { TguApiError } from "../../model/tguTypes";
import { formatUtcDateTime, parseUtc } from "../../utils/utcTime";
import { buildChainPlanRequest, futureZrvOptions, type ZrvOption } from "./createChainWindows";
type CreateChainPlanDialogProps = {
spacecraftId: string;
/** Планы для выбора базы-предыстории (берём persisted этого КА, кроме черновиков/проекции). */
plans: TguPlanUi[];
onClose: () => void;
/** Вызвать после ручной отправки созданного плана (для перезагрузки таймлайна). */
onSent: () => void;
};
/**
* Диалог «Создать план» (вкладка «Планы»): оператор берёт за основу прошлый план (или начинает
* цепочку без истории), набирает окно из двух реальных ЗРВ и создаёт план-кандидат. Сразу
* показывается прогноз будущей цепочки от него; кандидат становится рабочим после ручной отправки
* («Отправить вручную» = promote) и последующей приёмки.
*/
export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onSent }: CreateChainPlanDialogProps) {
const [zrvOptions, setZrvOptions] = useState<ZrvOption[]>([]);
const [zrvLoading, setZrvLoading] = useState(true);
const [zrvError, setZrvError] = useState<string>();
const [basePlanId, setBasePlanId] = useState<string>(""); // "" — без истории
const [startId, setStartId] = useState<string>("");
const [endId, setEndId] = useState<string>("");
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string>();
const [result, setResult] = useState<CreateChainPlanResponse>();
const [sending, setSending] = useState(false);
// Базы-предыстории: persisted планы этого КА (без проекции PLANNED и без черновиков).
const baseOptions = useMemo(
() =>
plans
.filter((plan) => plan.spacecraftId === spacecraftId && plan.status !== "PLANNED" && plan.status !== "DRAFT")
.sort((a, b) => b.startMs - a.startMs),
[plans, spacecraftId]
);
useEffect(() => {
let cancelled = false;
setZrvLoading(true);
setZrvError(undefined);
void fetchZrvWindows(spacecraftId)
.then((windows) => {
if (!cancelled) setZrvOptions(futureZrvOptions(windows, Date.now()));
})
.catch((err) => {
if (!cancelled) setZrvError(err instanceof TguApiError ? err.message : "Не удалось получить ЗРВ КА.");
})
.finally(() => {
if (!cancelled) setZrvLoading(false);
});
return () => {
cancelled = true;
};
}, [spacecraftId]);
const start = zrvOptions.find((option) => option.id === startId);
const end = zrvOptions.find((option) => option.id === endId);
const canCreate = Boolean(start && end) && !creating;
const create = async () => {
if (!start || !end) return;
setCreating(true);
setError(undefined);
try {
const request = buildChainPlanRequest(spacecraftId, basePlanId || null, start, end);
setResult(await createChainPlan(request));
} catch (err) {
setError(
err instanceof TguApiError ? err.message : err instanceof Error ? err.message : "Не удалось создать план."
);
} finally {
setCreating(false);
}
};
const send = async () => {
if (!result) return;
setSending(true);
setError(undefined);
try {
await promoteDraft(result.draftPlanId);
onSent();
onClose();
} catch (err) {
setError(err instanceof TguApiError ? err.message : "Не удалось отправить план.");
} finally {
setSending(false);
}
};
return (
<div className="tgu-chain-dialog__backdrop" role="dialog" aria-modal="true" aria-label="Создать план">
<div className="tgu-chain-dialog">
<div className="tgu-chain-dialog__header">
<h2>Создать план</h2>
<button className="tgu-icon-button" type="button" onClick={onClose} aria-label="Закрыть">
x
</button>
</div>
{!result ? (
<>
<label className="tgu-chain-dialog__field">
<span>Базовое состояние (предыстория)</span>
<select className="tgu-select" value={basePlanId} onChange={(e) => setBasePlanId(e.target.value)}>
<option value="">Без истории (новая цепочка с нуля)</option>
{baseOptions.map((plan) => (
<option key={plan.planId} value={plan.planId}>
{formatUtcDateTime(plan.startMs)} · {plan.kppId} · {plan.status}
</option>
))}
</select>
</label>
{zrvError && <div className="tgu-alert tgu-alert--error">{zrvError}</div>}
{zrvLoading ? (
<div className="tgu-chain-dialog__hint">Загрузка ЗРВ</div>
) : zrvOptions.length === 0 ? (
<div className="tgu-chain-dialog__hint">Нет будущих ЗРВ для этого КА.</div>
) : (
<>
<label className="tgu-chain-dialog__field">
<span>Стартовая ЗРВ (начало окна)</span>
<select className="tgu-select" value={startId} onChange={(e) => setStartId(e.target.value)}>
<option value=""> выберите </option>
{zrvOptions.map((option) => (
<option key={option.id} value={option.id}>
{formatUtcDateTime(option.startMs)} · {option.kppId}
</option>
))}
</select>
</label>
<label className="tgu-chain-dialog__field">
<span>Конечная ЗРВ (конец окна)</span>
<select className="tgu-select" value={endId} onChange={(e) => setEndId(e.target.value)}>
<option value=""> выберите </option>
{zrvOptions
.filter((option) => !start || option.endMs > start.startMs)
.map((option) => (
<option key={option.id} value={option.id}>
{formatUtcDateTime(option.endMs)} · {option.kppId}
</option>
))}
</select>
</label>
</>
)}
{error && <div className="tgu-alert tgu-alert--error">{error}</div>}
<div className="tgu-chain-dialog__actions">
<button className="tgu-button" type="button" onClick={onClose}>
Отмена
</button>
<button className="tgu-button tgu-button--primary" type="button" onClick={create} disabled={!canCreate}>
{creating ? "Создание…" : "Создать"}
</button>
</div>
</>
) : (
<>
<div className="tgu-alert tgu-alert--success">
План-кандидат создан (черновик {result.draftPlanId}). Прогноз будущей цепочки от него:
</div>
<ul className="tgu-chain-dialog__chain">
{result.projectedChain.length === 0 ? (
<li className="tgu-chain-dialog__hint">Цепочка после этого плана пуста (нет дальнейших ЗРВ).</li>
) : (
result.projectedChain.map((plan) => (
<li key={plan.planId}>
{formatUtcDateTime(parseUtc(plan.startTime))} {formatUtcDateTime(parseUtc(plan.endTime))} ·{" "}
{plan.kppId}
</li>
))
)}
</ul>
{error && <div className="tgu-alert tgu-alert--error">{error}</div>}
<div className="tgu-chain-dialog__actions">
<button className="tgu-button" type="button" onClick={onClose}>
Закрыть
</button>
<button className="tgu-button tgu-button--primary" type="button" onClick={send} disabled={sending}>
{sending ? "Отправка…" : "Отправить вручную"}
</button>
</div>
<div className="tgu-chain-dialog__hint">
После отправки план уходит в выдачу; рабочим (якорем цепочки) он станет после приёмки.
</div>
</>
)}
</div>
</div>
);
}
@@ -5,6 +5,8 @@ import type { TguActiveTab, TguPlanUi, TimelineRange } from "../../model/timelin
import { TguEditorTab } from "../tgu-editor/TguEditorTab";
import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab";
import { TguRequestsTab } from "../tgu-requests/TguRequestsTab";
import { fetchZrvWindows, type ZrvWindowResponse } from "../../api/tguPlanApi";
import { CreateChainPlanDialog } from "./CreateChainPlanDialog";
import { TguPlanDetails } from "./TguPlanDetails";
import { TguPlanningLayout } from "./TguPlanningLayout";
import { TguSidebar } from "./TguSidebar";
@@ -36,6 +38,9 @@ export function TguPlanningPage() {
const [selectedSpacecraftId, setSelectedSpacecraftId] = useState<string>();
const [selectedPlanId, setSelectedPlanId] = useState<string>();
const [activeTab, setActiveTab] = useState<TguActiveTab>("timeline");
const [createChainOpen, setCreateChainOpen] = useState(false);
// ЗРВ выбранного КА — подсветка доступных зон на таймлайне (на какие зоны можно опереться).
const [zrvWindows, setZrvWindows] = useState<ZrvWindowResponse[]>([]);
// Вкладки рендерятся в режиме keep-alive: каждую монтируем при первом открытии
// и дальше держим смонтированной (скрытой через display:none), чтобы её
// внутреннее состояние — загруженные заявки, выбор, слои, скролл — не сбрасывалось
@@ -102,6 +107,33 @@ export function TguPlanningPage() {
setVisitedTabs((prev) => (prev.has(activeTab) ? prev : new Set(prev).add(activeTab)));
}, [activeTab]);
// ЗРВ выбранного КА для подсветки на таймлайне. Сбой непрозрачен — просто нет подсветки.
useEffect(() => {
if (!selectedSpacecraftId) {
setZrvWindows([]);
return;
}
let cancelled = false;
void fetchZrvWindows(selectedSpacecraftId)
.then((windows) => {
if (!cancelled) setZrvWindows(windows);
})
.catch(() => {
if (!cancelled) setZrvWindows([]);
});
return () => {
cancelled = true;
};
}, [selectedSpacecraftId]);
const zrvBands = useMemo(() => {
if (!selectedSpacecraftId || zrvWindows.length === 0) return undefined;
return {
spacecraftId: selectedSpacecraftId,
windows: zrvWindows.map((zrv) => ({ startMs: parseUtc(zrv.startTime), endMs: parseUtc(zrv.endTime) }))
};
}, [selectedSpacecraftId, zrvWindows]);
// display:contents для активной вкладки сохраняет grid-раскладку shell
// (внутренний <main> остаётся прямым grid-элементом), display:none скрывает остальные.
const tabStyle = (tab: TguActiveTab) =>
@@ -194,10 +226,22 @@ export function TguPlanningPage() {
}}
/>
<section className="tgu-center">
<div className="tgu-center__actions">
<button
className="tgu-button tgu-button--primary"
type="button"
disabled={!selectedSpacecraftId}
title={selectedSpacecraftId ? "Создать план новой цепочки" : "Сначала выберите КА"}
onClick={() => setCreateChainOpen(true)}
>
Создать план
</button>
</div>
<TguTimeline
rows={rows}
range={appliedRange}
invalidRange={invalidRange}
zrvBands={zrvBands}
selectedPlanId={selectedPlanId}
onSelectPlan={(planId) => {
setSelectedPlanId(planId);
@@ -217,6 +261,14 @@ export function TguPlanningPage() {
onClose={() => setSelectedPlanId(undefined)}
/>
</main>
{createChainOpen && selectedSpacecraftId && (
<CreateChainPlanDialog
spacecraftId={selectedSpacecraftId}
plans={plans}
onClose={() => setCreateChainOpen(false)}
onSent={() => void loadData(appliedRange)}
/>
)}
</div>
{visitedTabs.has("map") && (
@@ -15,15 +15,22 @@ import { platformLabel } from "./tguTimelineMapper";
import { statusStyle } from "./tguStatus";
import { formatUtcDate, formatUtcDateTime, formatUtcTime, parseUtc } from "../../utils/utcTime";
type ZrvBands = {
spacecraftId: string;
windows: { startMs: number; endMs: number }[];
};
type TguTimelineProps = {
rows: TimelineRow[];
range: TimelineRange;
selectedPlanId?: string;
invalidRange?: boolean;
/** ЗРВ выбранного КА — подсветка доступных зон на его строке. */
zrvBands?: ZrvBands;
onSelectPlan: (planId: string) => void;
};
export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelectPlan }: TguTimelineProps) {
export function TguTimeline({ rows, range, selectedPlanId, invalidRange, zrvBands, onSelectPlan }: TguTimelineProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [containerWidth, setContainerWidth] = useState(1200);
const timelineWidth = Math.max(720, containerWidth - TIMELINE_LABEL_WIDTH - 28);
@@ -124,6 +131,26 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelec
<text x="16" y={y + 42} className="tgu-timeline__row-meta">
NORAD {row.platform?.noradId || row.spacecraftId}
</text>
{zrvBands?.spacecraftId === row.spacecraftId &&
zrvBands.windows.map((zrv, zrvIndex) => {
const from = Math.max(zrv.startMs, range.fromMs);
const to = Math.min(zrv.endMs, range.toMs);
if (to <= from) return null;
const bx = TIMELINE_LABEL_WIDTH + timeToX(from, range, timelineWidth);
const bw = Math.max(2, timeToX(to, range, timelineWidth) - timeToX(from, range, timelineWidth));
return (
<rect
key={`zrv-${zrvIndex}`}
className="tgu-timeline__zrv"
x={bx}
y={y + 6}
width={bw}
height={TIMELINE_ROW_HEIGHT - 12}
>
<title>ЗРВ {formatUtcDateTime(zrv.startMs)} {formatUtcDateTime(zrv.endMs)}</title>
</rect>
);
})}
</g>
);
})}
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { buildChainPlanRequest, futureZrvOptions, type ZrvOption } from "./createChainWindows";
import type { ZrvWindowResponse } from "../../api/tguPlanApi";
const NOW = Date.UTC(2026, 5, 3, 12, 0, 0);
function zrv(startIso: string, endIso: string, kppId = "KPP-1"): ZrvWindowResponse {
return { startTime: startIso, endTime: endIso, kppId };
}
describe("futureZrvOptions", () => {
it("keeps only future zones and sorts by start", () => {
const windows = [
zrv("2026-06-03T15:00:00Z", "2026-06-03T15:10:00Z"),
zrv("2026-06-03T11:00:00Z", "2026-06-03T11:10:00Z"), // в прошлом → отбросить
zrv("2026-06-03T13:00:00Z", "2026-06-03T13:10:00Z")
];
const options = futureZrvOptions(windows, NOW);
expect(options.map((o) => o.startMs)).toEqual([
Date.UTC(2026, 5, 3, 13, 0, 0),
Date.UTC(2026, 5, 3, 15, 0, 0)
]);
});
it("produces naive UTC (no zone) start/stop strings", () => {
const [option] = futureZrvOptions([zrv("2026-06-03T13:00:00Z", "2026-06-03T13:10:00Z")], NOW);
expect(option.start).toBe("2026-06-03T13:00:00");
expect(option.stop).toBe("2026-06-03T13:10:00");
expect(option.kppId).toBe("KPP-1");
});
});
describe("buildChainPlanRequest", () => {
const start: ZrvOption = {
id: "a", startMs: Date.UTC(2026, 5, 3, 13, 0, 0), endMs: Date.UTC(2026, 5, 3, 13, 10, 0),
start: "2026-06-03T13:00:00", stop: "2026-06-03T13:10:00", kppId: "KPP-1"
};
const end: ZrvOption = {
id: "b", startMs: Date.UTC(2026, 5, 3, 15, 0, 0), endMs: Date.UTC(2026, 5, 3, 15, 10, 0),
start: "2026-06-03T15:00:00", stop: "2026-06-03T15:10:00", kppId: "KPP-2"
};
it("spans from start zone begin to end zone finish and keeps the start kpp", () => {
const req = buildChainPlanRequest("25544", "base-1", start, end);
expect(req).toEqual({
spacecraftId: "25544",
basePlanId: "base-1",
windowStart: "2026-06-03T13:00:00",
windowStop: "2026-06-03T15:10:00",
windowKppId: "KPP-1"
});
});
it("allows a single-zone window (start == end zone)", () => {
const req = buildChainPlanRequest("25544", null, start, start);
expect(req.windowStart).toBe("2026-06-03T13:00:00");
expect(req.windowStop).toBe("2026-06-03T13:10:00");
expect(req.basePlanId).toBeNull();
});
it("throws when the end zone finishes before the start begins", () => {
expect(() => buildChainPlanRequest("25544", null, end, start)).toThrow();
});
});
@@ -0,0 +1,65 @@
import { parseUtc } from "../../utils/utcTime";
import type { CreateChainPlanRequest, ZrvWindowResponse } from "../../api/tguPlanApi";
/**
* Опция выбора ЗРВ в диалоге «Создать план». Из стартовой зоны берутся начало окна и КПП, из
* конечной — конец окна. `start`/`stop` — naive UTC (ISO без зоны), как ждёт `LocalDateTime` бэка;
* `startMs`/`endMs` — для сортировки/сравнения и подсветки на таймлайне.
*/
export type ZrvOption = {
/** Детерминированный id зоны (для value в select и key). */
id: string;
startMs: number;
endMs: number;
/** Naive UTC начала зоны. */
start: string;
/** Naive UTC конца зоны. */
stop: string;
kppId: string;
};
/** Будущие ЗРВ-зоны (строго позже nowMs), по возрастанию начала. */
export function futureZrvOptions(windows: ZrvWindowResponse[], nowMs: number): ZrvOption[] {
return windows
.map((zrv) => {
const startMs = parseUtc(zrv.startTime);
const endMs = parseUtc(zrv.endTime);
return {
id: `${zrv.kppId}|${zrv.startTime}|${zrv.endTime}`,
startMs,
endMs,
start: toNaiveUtc(startMs),
stop: toNaiveUtc(endMs),
kppId: zrv.kppId
};
})
.filter((option) => option.startMs > nowMs)
.sort((a, b) => a.startMs - b.startMs);
}
/**
* Собирает тело запроса создания плана новой цепочки из выбора оператора. Окно — от начала стартовой
* зоны до конца конечной; `basePlanId` — предыстория (или null для цепочки без истории). Бросает,
* если конец окна не позже начала (конечная зона раньше стартовой).
*/
export function buildChainPlanRequest(
spacecraftId: string,
basePlanId: string | null,
start: ZrvOption,
end: ZrvOption
): CreateChainPlanRequest {
if (end.endMs <= start.startMs) {
throw new Error("Конец окна должен быть позже начала: выберите конечную ЗРВ не раньше стартовой.");
}
return {
spacecraftId,
basePlanId,
windowStart: start.start,
windowStop: end.stop,
windowKppId: start.kppId
};
}
function toNaiveUtc(ms: number): string {
return new Date(ms).toISOString().slice(0, 19);
}